5.2.9 · D3C++ Programming

Worked examples — Move semantics — rvalue references (&&), std - move, std - forward

2,806 words13 min readBack to topic

Parent: Move Semantics topic note

This page is a drill through every case move semantics can throw at you. First we lay out a matrix of every scenario class — every value-category, every degenerate object, every "gotcha" — then we work each cell fully. If a case exists, you will see it here.

Before we start, one anchor you must never lose:

If any symbol below feels unfamiliar, it is defined the moment it enters. Let's build the map first.


The scenario matrix

Think of every move-semantics situation as landing in one of these cells. The columns are "what kind of thing are we handing over?" and the rows are "what machinery is involved?".

Every worked example below is tagged with the cell(s) it hits. Together they cover the whole table.

Here is the mental picture that unifies all of them — the difference between copy (build a whole new buffer) and move (re-point the pointer, empty the source):

Figure — Move semantics — rvalue references (&&), std - move, std - forward

Look at the two panels. On the left, copy allocates a fresh box and duplicates every byte. On the right, move just redraws the arrow from destination to the existing box and blanks the source's arrow. That single picture is what all ten cells are really about.


Example 1 — Cell A: named lvalue copies by default

Forecast: guess before reading — copy or move? How many new[] calls?

  1. Identify the value category of a. a is a variable — it has a name and an address. Why this step? Selection between copy and move is decided purely by whether the argument is an lvalue or rvalue. A name ⇒ lvalue.
  2. Match against the binding rules. An lvalue cannot bind to Str&& (rvalue ref), but binds happily to const Str&. Why this step? The compiler picks the only viable overload — the copy constructor Str(const Str&).
  3. Count the work. The copy constructor runs new char[len+1] once, then memcpy. So 1 allocation for b. Why this step? We're auditing cost; copy = allocate + byte-copy.

Verify: a is untouched (still "hello", length 5), b is an independent "hello". Two separate buffers, one allocation for b. ✅


Example 2 — Cell B & Cell D: std::move on a named lvalue, then reading it

Forecast: does std::move(a) copy or move? What happens to a?

  1. Apply the cast. std::move(a) is static_cast<Str&&>(a) — it relabels a as an rvalue. Why this step? Now the argument is an rvalue, so it binds to Str(Str&&): the move constructor is chosen.
  2. Execute the move body. c.data = a.data; c.len = a.len; then a.data = nullptr; a.len = 0;. Why this step? Two objects must never own the same buffer, so the source is emptied to a valid but unspecified state.
  3. Answer (ii). After the move, a.len == 0 and a.data == nullptr. Reading a's value as if it still said "hello" is a Cell D mistake — the standard only guarantees it's valid (safe to destroy or reassign), not meaningful. Why this step? The whole point: a moved-from object is a hollow shell.

Verify: c holds "hello" (len 5), a.len == 0. Zero new allocations for c — the buffer was transferred, not duplicated. ✅


Example 3 — Cell C: pure temporary auto-moves, no std::move

Forecast: copy, move, or neither?

  1. Classify makeStr(). A function returning Str by value produces a prvalue (a pure temporary with no name). Why this step? No name ⇒ rvalue ⇒ binds to Str&& naturally.
  2. Realise std::move is redundant here. Writing Str d = std::move(makeStr()); would cast an already-rvalue to an rvalue — no benefit, and it can block copy elision (see Example 7). Why this step? std::move is for turning lvalues into rvalues; a temporary is already one.
  3. What actually happens. With guaranteed copy elision (C++17), the temporary is constructed directly into dzero constructor calls, not even a move. Why this step? The compiler skips the middleman entirely.

Verify: d == "hello", and in C++17 exactly 0 move/copy constructor calls (elided). Adding std::move would force 1 move call. ✅


Example 4 — Cell E: an Str&& parameter is an lvalue inside

Forecast: the type says && — surely it moves, right?

  1. Look at s inside the body. s is a named parameter. A name ⇒ lvalue, regardless of its declared type. Why this step? Value category is about the expression, not the type. Str&& describes what s may bind to, not what s is once named.
  2. So Str kept = s; copies. The lvalue s matches const Str&. Why this step? This is the single most surprising rule — the && in the type is a decoy inside the function.
  3. Fix. Write Str kept = std::move(s);. Now the cast turns the lvalue s back into an rvalue and the move ctor fires. Why this step? You know s was handed to you as stealable, so you re-assert that with std::move.

Verify: With std::move(s): 0 allocations, s emptied. Without it: 1 allocation (a wasteful copy). ✅


Example 5 — Cell F: perfect forwarding preserves category

Forecast: for each call, does callee copy or move?

  1. Deduce T for each call. T&& here is a forwarding reference (a T&& where T is deduced). For wrapper(x) (lvalue), T = Str&; for wrapper(makeStr()) (rvalue), T = Str. Why this step? The deduced T encodes how the caller passed the argument.
  2. Apply reference collapsing to std::forward<T>(arg) = static_cast<T&&>(arg). Using the rules and :
    • T = Str&Str& && collapses to Str& ⇒ forwarded as lvaluecallee copies.
    • T = StrStr&& ⇒ forwarded as rvaluecallee moves. Why this step? Reference collapsing is the machinery that turns the remembered T back into the right category.
  3. Contrast with the buggy callee(arg). Plain arg is a name ⇒ always lvalue ⇒ always copies (Cell E again). std::forward is what rescues the rvalue case. Why this step? Shows why forward exists.

The collapsing table, drawn:

Figure — Move semantics — rvalue references (&&), std - move, std - forward

Verify: wrapper(x) → 1 copy (allocation), x intact. wrapper(makeStr()) → 1 move (0 allocations). ✅


Example 6 — Cell H: std::move on a no-move type silently copies

Forecast: we wrote std::move — does it move?

  1. Check what overloads exist. Declaring a copy constructor suppresses the implicitly-generated move constructor. So Plain has only a copy ctor. Why this step? std::move only offers an rvalue; something must exist to accept it as a move.
  2. Overload resolution. The rvalue std::move(a) has no Plain&& overload to bind to, but const Plain& accepts rvalues too. Why this step? const T& is the universal fallback (it binds to both lvalues and rvalues).
  3. Result: the copy constructor runs. std::move did nothing observable except waste your intent. Why this step? This is the "std::move makes it fast by itself — false" lesson, made concrete.

Verify: a.v is unchanged after the line (copy leaves source intact); b.v is an independent duplicate. If a move had happened, a.v would be empty. ✅


Example 7 — Cell G: return std::move(x) is a pessimization

Forecast: intuition says std::move on return avoids a copy — is that right?

  1. Analyse (A) return x;. For a local variable returned by value, the compiler applies NRVO (named return value optimization) — it constructs x directly in the caller's slot: 0 copies, 0 moves. Why this step? Elision beats even a move.
  2. Analyse (B) return std::move(x);. std::move(x) yields an expression that is not the name of a local variable — it's a cast result. This makes x ineligible for NRVO. The compiler must now perform a move into the return slot: 1 move. Why this step? By writing std::move, you traded a free elision for a cheap-but-nonzero move.
  3. Verdict. (A) is faster (or equal). Rule: return x; — let elision win. Why this step? Cements the mistake pattern.

Verify: (A) → 0 move ctor calls; (B) → 1 move ctor call. So calls_A < calls_B, i.e. (A) is at least as cheap. ✅


Example 8 — Cell I: why noexcept gates vector growth

Forecast: guess the allocation count and the move count.

  1. Allocate the new buffer. Growing needs 1 big allocation for the capacity-8 array. Why this step? vector never resizes in place; it relocates.
  2. Relocate the 4 existing elements. Because the move ctor is noexcept, vector uses it: 4 element moves (each move = 0 per-element heap allocations, just pointer steals). Why this step? If the move ctor could throw, a half-moved buffer couldn't be rolled back, so vector would copy instead (4 element copies = 4 extra allocations). noexcept is what unlocks the cheap path.
  3. Tally. 1 buffer allocation + 4 pointer-stealing moves + 0 per-element allocations. Had it been throwing-move: 1 buffer allocation + 4 element copies = 5 allocations total. Why this step? Makes the noexcept payoff a number.

Verify: noexcept path total allocations = 1; throwing path total allocations = 5. Element moves in noexcept path = 4. ✅

See also std::vector internals and noexcept specifier for the reallocation machinery.


Example 9 — Cell J: self-move safety in copy-and-swap assignment

Forecast: self-assignment often breaks naive operator= — does this one survive?

  1. Build the by-value parameter o. std::move(x) is an rvalue, so o is move-constructed from x: o.p = x.p, then x.p = nullptr. Why this step? The by-value parameter is a separate object; x's guts are now inside o, and x is a valid-but-empty shell.
  2. Swap. std::swap(p, o.p) exchanges x.p (now nullptr) with o.p (the real buffer). After the swap x.p holds the real buffer again, o.p holds nullptr. Why this step? The swap is symmetric and never aliases the same pointer twice.
  3. Destroy o. At function end o's destructor runs delete[] o.p where o.p == nullptr — a no-op. x retains the live buffer. Why this step? No pointer is freed twice; self-move is safe for free.

Verify: After x = std::move(x), x still owns its original buffer (non-null), and delete[] was called on nullptr only. Number of double-frees = 0. ✅


Example 10 — Word problem: a document editor's undo buffer

Forecast: guess the MB copied for each strategy.

  1. Copy strategy cost. push_back(current) copies current — 1 MB of memcpy per edit. Over 100 edits: MB copied. Why this step? Each copy duplicates the whole buffer bytewise.
  2. Move strategy cost. push_back(std::move(current)) steals the pointer — 0 bytes copied per push. Over 100 edits: MB copied. Why this step? A move is pointer arithmetic, independent of buffer size.
  3. Interpret. Move saves the entire 100 MB of copying. This is exactly the RAII and Ownership transfer idea in action. Why this step? Connects the number to the real-world win.

Verify: copy strategy = 100 MB copied; move strategy = 0 MB copied; savings = 100 MB. ✅


Recall Self-test: name the cell

Str b = a; where a is a named variable — copy or move? ::: Copy (Cell A: named lvalue). Str c = std::move(a); — what state is a left in? ::: Valid but unspecified; a.len == 0, a.data == nullptr (Cell B/D). Inside void f(Str&& s), is s an lvalue or rvalue? ::: An lvalue — it has a name (Cell E). Use std::move(s) to move. Why is return std::move(x); slower than return x;? ::: It disables NRVO/copy elision, forcing an actual move (Cell G). std::move on a type with only a copy ctor does what? ::: Silently copies — no move overload to bind to (Cell H).

See also: Rule of Five, Reference collapsing, Templates and type deduction, Copy elision and RVO.