Move Semantics Advanced
Reviewed & published by Brayan K
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 worked examples and the output they print, plus practice exercises and a quick quiz.
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
💡 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.
#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&& with T deduced => binds lvalues AND rvalues.
// std::forward<Args>(args)... re-casts each arg to its ORIGINAL category,
// so an lvalue stays an lvalue (copy) and an rvalue stays an rvalue (move).
template <typename... Args>
Widget makeWidget(Args&&... args) {
return Widget(std::forward<Args>(args)...); // perfect forwarding
}
int main() {
string a = "Alice";
cout << "makeWidget(a) -> "; // a is a named lvalue
Widget w1 = makeWidget(a); // forwards as lvalue => COPY
cout << "makeWidget(\"Bob\") -> "; // string literal -> temporary
Widget w2 = makeWidget(string("Bob")); // forwards as rvalue => MOVE
cout << "makeWidget(move(a)) -> "; // explicit cast to rvalue
Widget w3 = makeWidget(std::move(a)); // forwards as rvalue => MOVE
return 0;
}
// ✅ Expected output:
// makeWidget(a) -> copy-ctor ('Alice')
// makeWidget("Bob") -> move-ctor ('Bob')
// makeWidget(move(a)) -> move-ctor ('Alice')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.
#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 lvalue — do NOT just pass it through.
return Logger(___); // 👉 wrap value so its original category is kept
// hint: std::forward<T>(value)
}
int main() {
string name = "Sam";
build(name); // lvalue -> should COPY
build(string("Temp")); // rvalue -> should MOVE
return 0;
}
// ✅ Expected output:
// copied: Sam
// moved: Temp3. 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.
#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::move(s)
// After moving, s is in a valid-but-unspecified state — don't read it.
cout << "stored; source size now: " << s.size() << "\n"; // usually 0
}
int main() {
vector<string> bucket;
string big = "a-very-long-string-we-do-not-want-to-copy";
store(bucket, std::move(big)); // hand ownership to store()
cout << "bucket[0] = " << bucket[0] << "\n";
return 0;
}
// ✅ Expected output:
// stored; source size now: 0
// bucket[0] = a-very-long-string-we-do-not-want-to-copy4. 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.
#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 << "Reference collapsing rules:\n";
cout << " & & -> & | & && -> & | && & -> & | && && -> &&\n\n";
string s = "named";
cout << "inspect(s) (lvalue):\n"; inspect(s); // T = string&
cout << "inspect(string{}) (rvalue):\n"; inspect(string{}); // T = string
cout << "inspect(move(s)) (xvalue):\n"; inspect(std::move(s)); // T = string
return 0;
}
// ✅ Expected output:
// Reference collapsing rules:
// & & -> & | & && -> & | && & -> & | && && -> &&
//
// inspect(s) (lvalue):
// is_lvalue_reference<T>: true
// inspect(string{}) (rvalue):
// is_lvalue_reference<T>: false
// inspect(move(s)) (xvalue):
// is_lvalue_reference<T>: false5. 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.
#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"; }
};
// Guaranteed copy elision (C++17): the result is built directly in the caller.
Heavy makeHeavy() {
return Heavy("elided"); // no copy, no move — constructed in place
}
int main() {
cout << "RVO / guaranteed elision:\n";
Heavy h = makeHeavy(); // prints only "ctor elided" — the move is elided
cout << "\nvector reallocation (noexcept move is used):\n";
vector<Heavy> v;
v.reserve(1);
v.push_back(Heavy("first"));
v.push_back(Heavy("second")); // capacity grows -> existing element is MOVED
cout << "\nmove_if_noexcept picks move only when it can't throw:\n";
Heavy src("safe");
Heavy dst = std::move_if_noexcept(src); // noexcept move => MOVE chosen
return 0;
}
// ✅ Expected output:
// RVO / guaranteed elision:
// ctor elided
//
// vector reallocation (noexcept move is used):
// ctor first
// MOVE first
// ctor second
// MOVE second
// MOVE first
//
// move_if_noexcept picks move only when it can't throw:
// ctor safe
// MOVE safeCommon 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 |
|---|---|---|
| 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 |
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.
#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:
// - with a named string variable -> should COPY
// - with a temporary string("temp") -> should MOVE
// - with std::move(named) -> should MOVE
//
// ✅ Expected output:
// copy 'hello'
// move 'temp'
// move 'hello'
// your code here
return 0;
}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
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: vector, list, map, unordered_map, deque Internals
- Next: RAII Architecture: Safe Resource Ownership Across Complex Systems — Apply RAII to files, locks, sockets, and other resources safely
- Quick reference: C++ cheat sheet
Frequently asked questions
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.
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.
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).
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.
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.
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&&.