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

💡 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 asWhat it isBinds to
string&& rPlain rvalue reference (concrete type)rvalues only
T&& x (T deduced)Forwarding referencelvalues and rvalues
auto&& xForwarding referencelvalues and rvalues
vector<T>&& xRvalue 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:  Temp

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.

#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-copy

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 writeDeduction givesCollapses 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>: false

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.

#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  safe

Common Errors (and the fix)

📋 Quick Reference

GoalCodeNotes
Forwarding referencetemplate<class T> f(T&& x)T deduced ⇒ binds both
Perfect forwardstd::forward<T>(x)conditional cast
Unconditional caststd::move(x)always an rvalue
On a T&& (deduced)std::forward<T>preserve category
On a string&&std::movenamed ⇒ is an lvalue
Return a localreturn local;(N)RVO elides it
Fast container growthT(T&&) noexceptenables move-on-realloc
Safe conditional movestd::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

🎉 Lesson Complete

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

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&&.

Related lessons