Lesson 20 • Advanced
Move Semantics, Perfect Forwarding & Rvalue References
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.
What You'll Learn
- Tell a forwarding reference (T&& with deduced T) apart from a plain rvalue reference
- Use std::forward to preserve a caller's lvalue/rvalue category through a wrapper
- Apply the reference-collapsing rules that make perfect forwarding work
- Choose std::move vs std::forward correctly — and never confuse them
- Rely on RVO / guaranteed copy elision instead of forcing moves on return
- Mark moves noexcept and use std::move_if_noexcept so containers stay fast
std::move does. Here we go deeper — into forwarding, reference collapsing, and elision.💡 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 | Forwarding reference | lvalues and rvalues |
vector<T>&& x | Rvalue reference (T not at top level) | rvalues only |
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.
Worked example: a perfect-forwarding factory
One template, two outcomes — copy for lvalues, move for rvalues. Read every comment, then run it.
#include <iostream>
#include <string>
#include <utility> // std::forward, std::move
using namespace std;
struct Widget {
string name;
// Two constructors so we can SEE which one runs:
Widget(const string& n) : name(n) { // copies from an lvalue
cout << " copy-ctor ('" << name << "')\n";
}
Widget(string&& n) : name(std::move(n)) { // steals from an rvalue
cout << " move-ctor ('" << name << "')\n";
}
};
// A FORWARDING reference: T
...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.
🎯 Your turn: preserve the value category
Fill in the ___ so lvalues copy and rvalues move. Check against the expected output.
#include <iostream>
#include <string>
#include <utility>
using namespace std;
struct Logger {
Logger(const string& s) { cout << "copied: " << s << "\n"; }
Logger(string&& s) { cout << "moved: " << s << "\n"; }
};
// 🎯 YOUR TURN — finish this perfect-forwarding wrapper.
template <typename T>
Logger build(T&& value) { // T&& is a FORWARDING reference here
// We must preserve value's category. Inside this function 'value' is a
// NAMED variable, so it is an lva
...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.
🎯 Your turn: move, don't copy
Re-cast the named rvalue reference so push_back moves it. Check the expected output.
#include <iostream>
#include <string>
#include <vector>
#include <utility>
using namespace std;
// 🎯 YOUR TURN — sink the string into the vector WITHOUT copying it.
void store(vector<string>& out, string&& s) {
// 's' is an rvalue REFERENCE, but because it now has a name it is an
// lvalue inside this function. push_back will COPY unless you re-cast it.
out.push_back(___); // 👉 cast s back to an rvalue so it MOVES
// hint: std::mov
...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.
Worked example: reference collapsing in action
Watch T deduce to an lvalue ref or an rvalue ref depending on the argument.
#include <iostream>
#include <string>
#include <type_traits>
#include <utility>
using namespace std;
// A forwarding reference. What T deduces to drives reference collapsing:
// pass an lvalue -> T = U& -> U& && collapses to U& (lvalue ref)
// pass an rvalue -> T = U -> U && stays U&& (rvalue ref)
template <typename T>
void inspect(T&& x) {
cout << " is_lvalue_reference<T>: " << boolalpha
<< is_lvalue_reference<T>::value << "\n";
}
int main() {
cout << "
...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.
Worked example: elision, noexcept move & move_if_noexcept
See the elided return, the move on vector growth, and move_if_noexcept choosing a move.
#include <iostream>
#include <vector>
#include <string>
#include <utility> // std::move_if_noexcept
using namespace std;
struct Heavy {
string label;
Heavy(string l) : label(std::move(l)) { cout << " ctor " << label << "\n"; }
Heavy(const Heavy& o) : label(o.label) { cout << " COPY " << label << "\n"; }
// A noexcept move ctor lets std::vector move (not copy) on reallocation:
Heavy(Heavy&& o) noexcept : label(std::move(o.label)) { cout << " MOVE " << label << "\n"; }
...Common Errors (and the fix)
- Using
std::moveinstead ofstd::forwardin a template: writingreturn 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 usestd::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: insidevoid f(string&& s),sis an lvalue, sovec.push_back(s)copies. Writevec.push_back(std::move(s))to actually move. return std::move(local);— this disables (N)RVO and is usually slower, not faster. Justreturn local;and let the compiler elide.- Forgetting
noexcepton the move constructor: without it,std::vectorreallocation falls back to copying every element. Mark the move ctornoexceptwhen it genuinely can't throw.
📋 Quick Reference
| Goal | Code | Notes |
|---|---|---|
| Forwarding reference | 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
Q: What is the difference between std::move and std::forward?
std::move always casts to an rvalue — use it when you definitely want to steal from something, like an rvalue reference parameter. std::forward conditionally casts: it produces an rvalue only if the original argument was an rvalue, otherwise it leaves it as an lvalue. Use std::forward only on a forwarding reference (T&& with deduced T) to preserve the caller's value category.
Q: Is T&& always an rvalue reference?
No. T&& is an rvalue reference only when T is a concrete type, like std::string&&. When T is a template parameter being deduced (template <typename T> void f(T&& x)) or auto&&, T&& is a forwarding reference (also called a universal reference) and can bind to both lvalues and rvalues.
Q: Why is a named rvalue reference an lvalue inside the function?
Once an rvalue reference parameter has a name, you can take its address and refer to it repeatedly, so it behaves as an lvalue. That is intentional and safe — it stops you from accidentally moving from it on every use. To actually move out of it you must explicitly write std::move(param).
Q: Should I write return std::move(x) to make returns faster?
Usually no. For a local variable, return x; lets the compiler apply (N)RVO and construct the result directly in the caller, eliding the move entirely. Wrapping it in std::move blocks that elision and is at best the same, often slower. Only use std::move on return when returning a member or a function parameter, which are not eligible for NRVO.
Q: Why does noexcept matter for a move constructor?
When a std::vector reallocates, it must move or copy its existing elements. If your move constructor is noexcept, the vector moves them (fast). If it can throw, the vector copies instead (slow) to keep the strong exception guarantee. std::move_if_noexcept encodes this rule: it returns an rvalue only when moving cannot throw, otherwise an lvalue so a copy is made.
Q: What is reference collapsing?
When references-to-references appear during template deduction, C++ collapses them with one rule: any combination containing an lvalue reference collapses to an lvalue reference (& wins), and only && && collapses to &&. This is exactly what makes a forwarding reference work — deducing T as U& yields U& && which collapses to U&, while deducing T as U yields U&&.
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.
🎯 Mini-Challenge: build a forwarding factory
Write make() with std::forward and prove it copies lvalues but moves rvalues.
#include <iostream>
#include <string>
#include <utility>
using namespace std;
struct Resource {
Resource(const string& s) { cout << "copy '" << s << "'\n"; }
Resource(string&& s) { cout << "move '" << s << "'\n"; }
};
int main() {
// 🎯 MINI-CHALLENGE: a perfect-forwarding factory of your own
// 1. Write: template <typename T> Resource make(T&& v)
// that forwards v into a Resource using std::forward<T>(v).
// 2. In main, call make() three times:
// -
...Pro Tips
- 💡 One memorised rule:
std::moveonstring&&,std::forward<T>on deducedT&&. 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;, notreturn std::move(local);. - 💡 Always
noexceptyour moves when they can't throw, orstd::vectorquietly copies on every reallocation.
🎉 Lesson Complete
- ✅
T&&with deducedTis a forwarding reference;string&&is a plain rvalue reference - ✅
std::forward<T>preserves the caller's category;std::movecasts unconditionally - ✅ Reference collapsing: any
&wins — only&& &&stays&& - ✅ A named rvalue reference is an lvalue — re-cast with
std::moveto actually move - ✅ Return locals plainly; (N)RVO and guaranteed elision beat
return std::move(...) - ✅
noexceptmoves +std::move_if_noexceptkeep containers fast and safe - ✅ Next lesson: RAII Architecture — applying resource ownership patterns across complex systems
Sign up for free to track which lessons you've completed and get learning reminders.