Move Semantics Advanced
By the end of this lesson you'll be able to write generic code that forwards arguments without a single needless copy — choosing std::move vs std::forward correctly, reading reference-collapsing rules, and relying on copy elision and noexcept moves to make modern C++ fast.
Part of the free C++ course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
What You'll Learn
💡 Real-World Analogy
Think of a courier relay . A package arrives that is either a keepsake the owner still wants back (an lvalue — you must photocopy it and forward the copy) or a disposable parcel nobody will reclaim (an rvalue — you can just hand the original onward). Perfect forwarding is a courier who looks at a label on each package and passes it to the next stop in exactly the same condition it arrived — copy-it-onward for keepsakes, hand-it-onward for disposables. std::forward is that label-reader. std::move , by contrast, is a courier who relabels everything as disposable — useful, but only when you truly own the parcel.
1. Forwarding references vs rvalue references
You already know std::string&& is an rvalue reference: it binds only to temporaries. But the moment && is attached to a deduced template parameter — template <typename T> void f(T&& x) or auto&& — it becomes something different: a forwarding reference (also called a universal reference). A forwarding reference binds to both lvalues and rvalues, and remembers which one it caught. That memory is what makes perfect forwarding possible.
Written as
What it is
Binds to
string&& r
Plain rvalue reference (concrete type)
rvalues only
T&& x (T deduced)
Forwarding reference
lvalues and rvalues
auto&& x
vector<T>&& x
Rvalue reference (T not at top level)
Read this worked example first. It is a perfect-forwarding factory : one template function that copies when handed a keepsake and moves when handed a disposable. Run it and watch which constructor fires for each call.
2. std::forward and perfect forwarding
A forwarding reference parameter has a name, so inside the function it is an lvalue — even if it caught an rvalue. If you pass it straight on, everything becomes a copy. std::forward<T>(x) fixes that: it re-casts x back to its original category. It returns an rvalue only if the caller passed an rvalue; otherwise it leaves an lvalue alone. That conditional cast is the entire difference from std::move , which casts unconditionally.
Your turn. The wrapper below catches its argument with a forwarding reference but passes it on incorrectly. Fill in the blank so the original category is preserved.
3. When to use std::move vs std::forward
The rule is short: use std::move on a plain rvalue reference ( string&& ), and std::forward<T> on a forwarding reference (deduced T&& ). The trap is that a named rvalue reference is itself an lvalue, so if you forget the std::move , you silently copy. The exercise below has exactly that bug waiting — sink the string into the vector without copying it.
4. Reference collapsing — why forwarding works
You can't write a reference to a reference yourself, but template deduction can produce one internally. C++ then collapses it with one rule of thumb: if any & is involved, the result is & ; only && && stays && . So when you pass an lvalue to T&& , T deduces to U& , giving U& && which collapses to a plain lvalue reference. Pass an rvalue and T deduces to U , giving U&& . That asymmetry is precisely how a forwarding reference "remembers" what it caught.
You write
Deduction gives
Collapses to
pass lvalue to T&&
U& &&
U& (lvalue ref)
pass rvalue to T&&
U &&
U&& (rvalue ref)
& & / & && / && &
—
&
&& &&
&&
This example proves the rule at runtime using is_lvalue_reference . The same inspect template deduces a different T depending on what you hand it.
5. Copy elision (RVO/NRVO) & noexcept moves
Copy elision is the compiler skipping a copy or move entirely by constructing the result straight into its destination. Since C++17, returning a temporary (a prvalue) is guaranteed to be elided — so return Heavy("x"); builds the object in the caller with zero moves. That's why return std::move(local); is an anti-pattern: it turns the value into something that can't be elided, blocking the optimisation it was meant to help.
Separately, marking your move constructor noexcept is what lets std::vector move its elements during reallocation instead of copying them. std::move_if_noexcept encodes the rule directly: it hands you an rvalue only when the move can't throw, otherwise an lvalue so a safe copy is made.
Common Errors (and the fix)
- Using std::move instead of std::forward in a template: writing return Inner(std::move(x)); on a forwarding reference forces a move even when the caller passed an lvalue they still need — you've stolen from their object. On a forwarding reference always use std::forward<T>(x) .
- Double-move: forwarding or moving the same variable twice — e.g. g(std::move(x)); h(std::move(x)); — leaves the second call reading a moved-from object. After a move, treat the source as empty; move each value exactly once.
- Forwarding a named rvalue reference without std::move : inside void f(string&& s) , s is an lvalue, so vec.push_back(s) copies . Write vec.push_back(std::move(s)) to actually move.
- return std::move(local); — this disables (N)RVO and is usually slower, not faster. Just return local; and let the compiler elide.
- Forgetting noexcept on the move constructor: without it, std::vector reallocation falls back to copying every element. Mark the move ctor noexcept when it genuinely can't throw.
📋 Quick Reference
Goal
Code
Notes
template<class T> f(T&& x)
T deduced ⇒ binds both
Perfect forward
std::forward<T>(x)
conditional cast
Unconditional cast
std::move(x)
always an rvalue
On a T&& (deduced)
std::forward<T>
preserve category
On a string&&
std::move
named ⇒ is an lvalue
Return a local
return local;
(N)RVO elides it
Fast container growth
T(T&&) noexcept
enables move-on-realloc
Safe conditional move
std::move_if_noexcept(x)
move only if non-throwing
Frequently Asked Questions
Mini-Challenge: your own forwarding factory
No blanks this time — just a brief and an outline. Write the template yourself, call it three ways, and check your output against the comments. This is the exact pattern behind make_unique , emplace_back , and every modern factory.
Pro Tips
- 💡 One memorised rule: std::move on string&& , std::forward<T> on deduced T&& . Never the other way round.
- 💡 Forward exactly once: a forwarded argument may have been moved from, so use it a single time.
- 💡 Trust the compiler on returns: in C++17 a returned prvalue is guaranteed to be elided — write return local; , not return std::move(local); .
- 💡 Always noexcept your moves when they can't throw, or std::vector quietly copies on every reallocation.
🎉 Lesson Complete
- ✅ T&& with deduced T is a forwarding reference ; string&& is a plain rvalue reference
- ✅ std::forward<T> preserves the caller's category; std::move casts unconditionally
- ✅ Reference collapsing: any & wins — only && && stays &&
- ✅ A named rvalue reference is an lvalue — re-cast with std::move to actually move
- ✅ Return locals plainly; (N)RVO and guaranteed elision beat return std::move(...)
- ✅ noexcept moves + std::move_if_noexcept keep containers fast and safe
- ✅ Next lesson: RAII Architecture — applying resource ownership patterns across complex systems
Practice quiz
When is T&& a forwarding (universal) reference rather than a plain rvalue reference?
- Always — T&& is always a forwarding reference
- When T is a concrete type like std::string
- When T is a template parameter being deduced (or auto&&)
- Only inside a class member function
Answer: When T is a template parameter being deduced (or auto&&). T&& is a forwarding reference only when T is a deduced template parameter (or auto&&); string&& is a plain rvalue reference.
What is the key difference between std::move and std::forward?
- std::move always casts to an rvalue; std::forward casts to rvalue only if the original was an rvalue
- std::move conditionally casts; std::forward always casts to rvalue
- They are identical
- std::forward physically moves the data; std::move only marks it
Answer: std::move always casts to an rvalue; std::forward casts to rvalue only if the original was an rvalue. std::move is an unconditional rvalue cast; std::forward<T> preserves the caller's value category.
Inside void f(std::string&& s), the named parameter s is what?
- An rvalue, so push_back(s) moves automatically
- A forwarding reference
- A const reference
- An lvalue, so you must std::move(s) to actually move
Answer: An lvalue, so you must std::move(s) to actually move. A named rvalue reference is itself an lvalue; you must re-cast with std::move(s) or it copies.
In a perfect-forwarding factory makeWidget(Args&&... args), passing a named lvalue selects which constructor?
- The move constructor
- The copy constructor
- Neither — it fails to compile
- The default constructor
Answer: The copy constructor. std::forward preserves the lvalue category, so the copy constructor runs; an rvalue argument would select the move constructor.
Reference collapsing rule: which combination stays &&?
- && &&
- & &
- & &&
- && &
Answer: && &&. Any combination containing an lvalue reference (&) collapses to &; only && && stays &&.
When you pass an lvalue to a forwarding reference T&&, what does T deduce to?
- U (giving U&&)
- const U
- U& (which collapses to a plain lvalue reference U&)
- void
Answer: U& (which collapses to a plain lvalue reference U&). An lvalue makes T deduce to U&, yielding U& && that collapses to U&; that asymmetry is how forwarding 'remembers' the category.
For returning a local variable, why is return std::move(local); an anti-pattern?
- It always fails to compile
- It blocks (N)RVO / guaranteed copy elision, so it is at best equal, often slower
- It leaks memory
- It makes the move throw
Answer: It blocks (N)RVO / guaranteed copy elision, so it is at best equal, often slower. A plain return local; lets the compiler elide the copy/move entirely; wrapping in std::move prevents that elision.
Why does marking a move constructor noexcept matter for std::vector?
- It makes the type smaller
- It is required for the class to compile
- It disables exceptions program-wide
- It lets std::vector move (not copy) elements during reallocation
Answer: It lets std::vector move (not copy) elements during reallocation. On reallocation, vector only moves elements if the move ctor is noexcept; otherwise it copies to keep the strong guarantee.
What does std::move_if_noexcept(x) return?
- Always an rvalue reference
- An rvalue only when the move can't throw, otherwise an lvalue (so a copy is made)
- Always an lvalue
- A null pointer if x is empty
Answer: An rvalue only when the move can't throw, otherwise an lvalue (so a copy is made). It hands you an rvalue only when moving is noexcept; otherwise an lvalue so a safe copy is chosen.
On a forwarding reference inside a template, you should pass the argument onward with:
- std::move(x)
- a plain copy x
- std::forward<T>(x)
- &x
Answer: std::forward<T>(x). Use std::forward<T> on a deduced T&&; using std::move there would steal from an lvalue the caller still needs.
Continue this course
- Previous: Advanced Containers
- Next: RAII Architecture