C++ Interview Prep · Deep Dive

Move Semantics

The complete treatment: value categories, rvalue references, writing moves, compiler generation rules, copy elision, perfect forwarding, and a what-does-this-print trap gallery. Traps ⚠️, one-liners 💬.

⚠️ interview trap💬 one-liner worth saying verbatim

01The Problem Moves Solve

A std::string, vector, or any resource-owning object is a small handle (a pointer, a size, a capacity — say 24 bytes) plus a big resource on the heap. Copying means allocating a new resource and duplicating every byte. But when the source is about to die — a temporary, or something the caller has finished with — duplication is pure waste. A move copies just the handle and nulls the source: the resource changes owners without being touched.

Start Buffer a data_ = 0x9000 n_ = 32768 0x9000 32 KB payload Copy — Buffer b = a; Move — b = std::move(a); Buffer a data_ = 0x9000 n_ = 32768 Buffer b data_ = 0xC000 n_ = 32768 Buffer a data_ = nullptr n_ = 0 Buffer b data_ = 0x9000 n_ = 32768 0x9000 32 KB 0xC000 — new 32 KB copied owns nothing 0x9000 same 32 KB allocate + duplicate 32,768 bytes copy a 16-byte handle; payload untouched

Two design questions fall out of this, and the rest of the guide answers them: how does the language know when stealing is safe (value categories, §2–4), and how do types implement the steal (§5–7)?

💬 One-liner"A move is a copy of the handle and a promise about the source — the resource itself never moves."

↑ back to top

02Value Categories

Every expression (not object — expression) has a value category. Three leaf categories:

CategoryMeaningExamplesSteal from it?
lvaluehas identity, not expiring — you can take its address, it may be used againx, obj.member, *p, arr[i], string literalNo
prvalue (pure rvalue)a fresh temporary with no identity yet42, x + y, f() returning by value, Widget{}Yes
xvalue (expiring value)has identity, but declared expiringstd::move(x), f() returning T&&Yes

Two umbrella groupings you'll hear: glvalue (generalized lvalue) = lvalue ∪ xvalue (things with identity); rvalue = prvalue ∪ xvalue (things safe to steal from). The grid: identity? expiring? — lvalue is identity/not-expiring, xvalue is identity/expiring, prvalue is no-identity.

⚠️ Trap — categories belong to expressions, and names are lvalues

A variable declared Buffer&& r = ... has an rvalue-reference type, but the expression r is an lvalue — it has a name, it persists. Type and value category are independent axes. This single fact explains why move constructors need std::move on their members (§5) and why std::forward exists (§9). If you internalize one thing from this section, it's this.

💬 One-liner"If it has a name, it's an lvalue — no matter what its type says."

↑ back to top

03Rvalue References & Binding

void take(const std::string& s);   // #1 binds to EVERYTHING
void take(std::string&& s);         // #2 binds to rvalues only

std::string name = "yel";
take(name);              // lvalue  → #1
take(name + "!");        // prvalue → #2 (preferred over #1)
take(std::move(name));   // xvalue  → #2

Binding rules that matter:

💬 One-liner"Move semantics is just overload resolution: rvalues prefer &&, and && overloads are licensed to steal."

↑ back to top

04std::move & the Moved-From State

template <class T>
constexpr std::remove_reference_t<T>&& move(T&& t) noexcept {
    return static_cast<std::remove_reference_t<T>&&>(t);
}
⚠️ Trap — const kills moves, silently

const Widget c; Widget d = std::move(c); compiles fine and copies. std::move(c) yields const Widget&&; the move ctor takes Widget&& (non-const — it must mutate the source to null it), which can't bind. Overload resolution falls back to the copy ctor's const Widget&, which binds anything. No warning, no error, just a silent copy. Corollary: returning const values or storing to-be-moved data in const members pessimizes every move.

💬 One-liners"std::move moves nothing — it's a cast that changes which overload wins."

"Moved-from means valid but unspecified: destroy it, assign to it, assume nothing else."

↑ back to top

05Writing Move Operations

class Buffer {
    char*  data_ = nullptr;
    size_t n_    = 0;
public:
    // move constructor: steal, then null the source
    Buffer(Buffer&& o) noexcept
        : data_(std::exchange(o.data_, nullptr)),
          n_(std::exchange(o.n_, 0)) {}

    // move assignment: release own resource, steal, null the source
    Buffer& operator=(Buffer&& o) noexcept {
        if (this != &o) {
            delete[] data_;
            data_ = std::exchange(o.data_, nullptr);
            n_    = std::exchange(o.n_, 0);
        }
        return *this;
    }
};
💬 One-liner"Forget std::move on the members and you've written a copy constructor with extra steps."

↑ back to top

06What the Compiler Generates

You declare…Copy opsMove ops
nothinggeneratedgenerated
a destructor (even = default)generated (deprecated!)NOT generated ⚠️
copy ctor or copy assignmentthe declared one; other still generated (deprecated)NOT generated
move ctor or move assignmentdeletedthe declared one only

↑ back to top

07noexcept & the vector Contract

When a vector<Widget> reallocates, it must transfer N elements to the new block and still honor the strong exception guarantee: if anything throws mid-transfer, the vector must remain as it was. Copying supports that (source intact until success). Moving doesn't — a throw halfway leaves some elements gutted with no way back.

💬 One-liner"noexcept on a move constructor isn't documentation — vector reads it and changes algorithm."

↑ back to top

08Copy Elision: RVO & NRVO

Better than a move is no operation at all: the compiler constructs the result directly in the caller's storage, so nothing is copied or moved.

std::string make_a() { return std::string(80, 'x'); }  // RVO — guaranteed (C++17)

std::string make_b() {
    std::string s(80, 'x');
    process(s);
    return s;                        // NRVO — near-universal, not guaranteed;
}                                    // falls back to an implicit MOVE if not applied

std::string make_c() {
    std::string s(80, 'x');
    return std::move(s);             // ⚠️ PESSIMIZATION — see below
}
💬 One-liner"The hierarchy is elide > move > copy — and return std::move(local) voluntarily trades the best for the middle."

↑ back to top

09Perfect Forwarding

Generic wrappers (factories, emplace_back, make_unique) take arguments and hand them to something else. The goal: an lvalue argument arrives as an lvalue (gets copied), an rvalue arrives as an rvalue (gets moved) — no forced copies, no accidental steals.

template <typename T>
void wrapper(T&& arg) {                 // forwarding reference (deduced T)
    target(std::forward<T>(arg));       // restores the caller's value category
}
💬 One-liner"forward is a conditional move: it re-applies whatever value category the caller used."

↑ back to top

10Moves in API Design

Sink parameters: pass by value, then move

class Person {
    std::string name_;
public:
    Person(std::string name) : name_(std::move(name)) {}   // the modern idiom
};
Person p1("yel");           // temporary moved into param, moved into member: 0 copies
Person p2(existing_name);   // copied into param, moved into member: 1 copy (minimum possible)

For parameters the function will keep (sinks), by-value + move gives optimal behavior for both call styles with one overload. The alternative (const& + && overload pair) saves one cheap move at the cost of overload explosion; perfect-forwarding constructors are the third option with their own traps. "By value and move for sinks" is the recommended default and a strong interview answer.

The rest of the toolkit

💬 One-liner"Sinks take by value and move — one signature, optimal for lvalues and rvalues alike."

↑ back to top

11Trap Gallery: What Does This Print?

The instrumented type interviewers use (write it from memory — it's also your debugging tool):

struct Noisy {
    Noisy()                        { puts("default"); }
    Noisy(const Noisy&)            { puts("copy");    }
    Noisy(Noisy&&) noexcept        { puts("move");    }
    Noisy& operator=(const Noisy&) { puts("copy="); return *this; }
    Noisy& operator=(Noisy&&) noexcept { puts("move="); return *this; }
    ~Noisy()                       { puts("dtor");    }
};
SnippetPrintsWhy
Noisy b = a;copya is an lvalue
Noisy b = std::move(a);movexvalue → move ctor wins
const Noisy c; Noisy d = std::move(c);copyconst&& can't bind to Noisy&& → falls back to copy (§4)
Noisy make(){ Noisy n; return n; } auto e = make();default (only)NRVO — constructed directly in e; no copy, no move (§8)
same, but return std::move(n);default, movestd::move disqualified NRVO (§8)
auto f = make(); where make returns Noisy{}defaultguaranteed elision of prvalues, C++17
void g(Noisy n); g(std::move(a));movemoved into the parameter
S(Noisy n) : m_(n) {}…copy⚠️ n is an lvalue inside! needs m_(std::move(n))
v.push_back(a) triggering growth, Noisy's move NOT noexceptcopy ×(N+1)move_if_noexcept copies old elements (§7)
same, move IS noexceptcopy, move ×Narg copied in, old elements moved

Working these until they're boring is the single best prep for "what does this print" rounds — every row is a section of this guide compressed to one line.

↑ back to top

12Cheat Sheet

↑ back to top