5.2.9 · D4C++ Programming

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

2,644 words12 min readBack to topic

Before we start, one shared mental model that every question leans on:

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

The picture above is the whole game: a copy allocates a second buffer and duplicates bytes (expensive, orange path); a move just re-points the pointer and empties the source (cheap, green path). Every exercise below is really asking "which of these two arrows fires, and is it safe?"


Level 1 — Recognition

Recall Solution L1.1

An lvalue is anything you could take the address of with & — it has identity. An rvalue is a temporary with no lasting name.

  1. xlvalue (named variable, &x is legal).
  2. x + 1rvalue (the sum is a nameless temporary).
  3. slvalue (named).
  4. make()rvalue (return value is a temporary).
  5. std::move(s)rvalue (it is a cast to std::string&&; the result is an rvalue reference, so it behaves as a stealable temporary). Note s itself still exists — the expression is an rvalue.
Recall Solution L1.2

Recall the binding table: const T& binds to both; T&& binds to rvalues only. When both are viable, the compiler prefers the more specialised match.

  1. f(a);a is an lvalue → only const T& is viable → copy overload.
  2. f(T{});T{} is a temporary (rvalue) → both viable → T&& preferred → move overload.
  3. f(std::move(a)); — cast to rvalue → move overload. (a is now moved-from.)

Level 2 — Application

Recall Solution L2.1

(A) grab the source's pointer and length: d(o.d), n(o.n). (B) o.d = nullptr; — so the moved-from object owns nothing. (C) o.n = 0; — keep the invariant "length matches buffer". Why (B) is the critical line: after grabbing o.d, two objects point at one buffer. Both destructors would run delete[] on it → double free (undefined behaviour). Setting o.d = nullptr makes o's destructor a harmless delete[] nullptr (a guaranteed no-op). This is the green "empty the source" arrow in the figure at the top.

Recall Solution L2.2
  • Line 1: allocates the initial buffer → 1.
  • Line 2: a is an lvalue → copy → allocates a new buffer → 1.
  • Line 3: std::move(a) → rvalue → move → steals a's pointer, 0 allocations.
  • Line 4: Vec(1000) is a temporary; building it allocates → 1. The d = ... initialisation moves (or is elided) → 0 extra.

Total = 3 allocations. The std::move on line 3 is where we saved one.


Level 3 — Analysis

Recall Solution L3.1

During reallocation the vector must transfer 4 existing elements into the new buffer. It uses std::move_if_noexcept:

  • (a) move ctor noexcept → vector moves → 4 moves, 0 copies. If a move can't throw, a half-finished reallocation can always be completed, so strong exception safety holds.
  • (b) move ctor not noexcept → vector falls back to copy0 moves, 4 copies. Reason: if a move threw halfway, some old elements would already be gutted and unrecoverable — the vector could neither finish nor roll back. To keep the strong guarantee it copies (copies leave the source intact, so on a throw it just discards the new buffer).

Lesson: forgetting noexcept silently turns your fast moves into slow copies inside every reallocating container. See noexcept specifier and std::vector internals.

Recall Solution L3.2

First deduce T (see Templates and type deduction and Reference collapsing):

  • Call 1: s is an lvalue → T = std::string&T&& collapses std::string& && → std::string&.
  • Call 2: std::string{} is an rvalue → T = std::stringT&& = std::string&&.

Now:

  • Call P (callee(arg)): arg is a named parameter → always an lvalue, regardless of the call. So call 1 → lvalue, call 2 → lvalue (the rvalue-ness was lost).
  • Call Q (callee(std::forward<T>(arg))): std::forward<T> = static_cast<T&&>(arg), and reference collapsing re-creates the original category:
    • Call 1: T = std::string&static_cast<std::string&>lvalue ✅ (matches caller).
    • Call 2: T = std::stringstatic_cast<std::string&&>rvalue ✅ (matches caller).

Summary table:

call 1 (s) call 2 (temp)
P callee(arg) lvalue lvalue (bug!)
Q callee(forward) lvalue rvalue (correct)

Only Q preserves what the caller intended — that is perfect forwarding.


Level 4 — Synthesis

Recall Solution L4.1

Two problems: (1) our current d is overwritten without freeing → memory leak; (2) if o is *this (self-move), we'd null our own pointer. A clean, standard fix — free-then-steal with a guard, or the swap idiom:

Vec& operator=(Vec&& o) noexcept {
    if (this == &o) return *this;   // self-move guard
    delete[] d;                     // free our old buffer
    d = o.d; n = o.n;               // steal
    o.d = nullptr; o.n = 0;         // empty the source
    return *this;
}

Even simpler and self-safe: copy-and-swap (parameter by value), which handles both copy and move assignment in one function — see Rule of Five:

Vec& operator=(Vec o) noexcept {    // o built by copy OR move
    std::swap(d, o.d);
    std::swap(n, o.n);
    return *this;                   // o's dtor frees our old data
}
Recall Solution L4.2
  • return x; — the compiler applies NRVO (a form of copy elision, see Copy elision and RVO): the local x is constructed directly in the caller's slot. 0 moves, 0 copies. If NRVO can't apply, the standard mandates an implicit move → 1 move.
  • return std::move(x); — the expression is now Vec&&, not the named local x, so it is not eligible for NRVO. You force 1 move (and never 0). You've disabled elision.

Better: return x;. Best case it does zero work; worst case it moves — never worse than the explicit std::move. Writing std::move on return is a classic pessimisation.


Level 5 — Mastery

Recall Solution L5.1
template<class T, class... Args>
T make(Args&&... args) {
    return T(std::forward<Args>(args)...);
}

Each Args is a forwarding reference; std::forward<Args> re-casts each to the caller's original category.

  • (a) plain args...: each args is a named parameter → lvalueT's constructor always copies, even from temporaries. Lost all moves.
  • (b) std::move(args)...: unconditionally casts every argument to rvalue → an lvalue you passed in gets moved-from behind your back! You'd gut the caller's variables. std::move is unconditional; forwarding must be conditional on the deduced type.

Rule: forwarding references ⇒ std::forward, never std::move.

Recall Solution L5.2

A moved-from standard-library object is in a valid but unspecified state: the object is intact (invariants hold) but its value is unknown.

  1. a.size()safe (well-defined operation), but the value is unspecified (could be 0, could be anything valid). Fine to call, don't rely on the number.
  2. std::cout << a;safe to execute but prints garbage/unspecified content. Don't expect "hello".
  3. a = "new";safe & recommended — assigning a fresh value is always allowed on a moved-from object.
  4. a.clear();safe (a valid operation with no precondition on current value).
  5. a[0]NOT guaranteedoperator[] has precondition index < size(), and since size() is unspecified it might be 0, making a[0] out of bounds / UB.

Safe rule of thumb: on a moved-from object, only destroy it or assign it a new value; treat every read as garbage.

Recall Solution L5.3

The mnemonic: an lvalue-ref anywhere wins (collapses to &); only && && → &&.

  1. T = int&int& && → collapse & with && → &int&.
  2. T = int&&int&& &&&& with && → &&int&&.
  3. T = intint && (nothing to collapse) → int&&.

This is exactly why a forwarding reference T&& binds to an lvalue as int& (case 1) and an rvalue as int&& (cases 2/3). See Reference collapsing.


Recall One-line self-test recap

Copy = new buffer + duplicate bytes ::: the expensive orange path Move = steal pointer + null the source ::: the cheap green path std::move(x) ::: unconditional cast to rvalue (permission to steal) std::forward<T>(x) ::: conditional cast that preserves the caller's value category Named T&& parameter inside a function ::: an lvalue (must std::move/forward to move from it) Moved-from object ::: valid but unspecified — only destroy or reassign