Exercises — Move semantics — rvalue references (&&), std - move, std - forward
Before we start, one shared mental model that every question leans on:

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.
x→ lvalue (named variable,&xis legal).x + 1→ rvalue (the sum is a nameless temporary).s→ lvalue (named).make()→ rvalue (return value is a temporary).std::move(s)→ rvalue (it is a cast tostd::string&&; the result is an rvalue reference, so it behaves as a stealable temporary). Notesitself 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.
f(a);—ais an lvalue → onlyconst T&is viable → copy overload.f(T{});—T{}is a temporary (rvalue) → both viable →T&&preferred → move overload.f(std::move(a));— cast to rvalue → move overload. (ais 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:
ais an lvalue → copy → allocates a new buffer → 1. - Line 3:
std::move(a)→ rvalue → move → stealsa's pointer, 0 allocations. - Line 4:
Vec(1000)is a temporary; building it allocates → 1. Thed = ...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 copy → 0 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:
sis an lvalue →T = std::string&→T&&collapsesstd::string& && → std::string&. - Call 2:
std::string{}is an rvalue →T = std::string→T&&=std::string&&.
Now:
- Call P (
callee(arg)):argis 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::string→static_cast<std::string&&>→ rvalue ✅ (matches caller).
- Call 1:
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 localxis 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 nowVec&&, not the named localx, 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...: eachargsis a named parameter → lvalue →T'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::moveis 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.
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.std::cout << a;— safe to execute but prints garbage/unspecified content. Don't expect"hello".a = "new";— safe & recommended — assigning a fresh value is always allowed on a moved-from object.a.clear();— safe (a valid operation with no precondition on current value).a[0]— NOT guaranteed —operator[]has preconditionindex < size(), and sincesize()is unspecified it might be 0, makinga[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 && && → &&.
T = int&→int& &&→ collapse& with && → &→int&.T = int&&→int&& &&→&& with && → &&→int&&.T = int→int &&(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