C++ Interview Prep · Deep Dive
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 💬.
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.
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)?
Every expression (not object — expression) has a value category. Three leaf categories:
| Category | Meaning | Examples | Steal from it? |
|---|---|---|---|
| lvalue | has identity, not expiring — you can take its address, it may be used again | x, obj.member, *p, arr[i], string literal | No |
| prvalue (pure rvalue) | a fresh temporary with no identity yet | 42, x + y, f() returning by value, Widget{} | Yes |
| xvalue (expiring value) | has identity, but declared expiring | std::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.
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.
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:
T& binds to lvalues only. const T& binds to everything (and extends a temporary's lifetime). T&& binds to rvalues only.const T& and T&& overloads exist, an rvalue argument prefers T&& — that preference is the entire dispatch mechanism of move semantics. Copy ctor vs move ctor is just this overload resolution.template <class T>
constexpr std::remove_reference_t<T>&& move(T&& t) noexcept {
return static_cast<std::remove_reference_t<T>&&>(t);
}
&& path. Nothing is moved by std::move itself; a better name would have been rvalue_cast.clear(), size()), but you must not assume its contents. Your own types should honor the same contract (nulled pointers do it naturally).bugprone-use-after-move) still flag it because it's almost always a logic bug.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.
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;
}
};
o is an lvalue (§2: it has a name). So data_(o.data_) would copy the pointer but leave the source owning it too — double delete later. For class-type members you must write member_(std::move(o.member_)); for raw handles, std::exchange(o.h, null_value) steals and resets in one step. Forgetting std::move on class-type members silently copies them — the compiler won't warn.x = std::move(x)): rare but reachable through aliased references; the this != &o guard (or a swap-based implementation) keeps it from freeing the resource it's about to steal.swap(*this, o) — the source's destructor cleans up your old resource. Simple and self-move-safe, at the cost of the resource living slightly longer.vector/string/unique_ptr, write none of this — the compiler-generated moves member-wise-move correctly. Hand-written moves are for classes owning raw resources.| You declare… | Copy ops | Move ops |
|---|---|---|
| nothing | generated | generated |
a destructor (even = default) | generated (deprecated!) | NOT generated ⚠️ |
| copy ctor or copy assignment | the declared one; other still generated (deprecated) | NOT generated |
| move ctor or move assignment | deleted | the declared one only |
~Widget() = default; (or a logging destructor) and every "move" of Widget quietly becomes a copy — code still compiles, still runs, just slower. Fix: also write Widget(Widget&&) = default; and Widget& operator=(Widget&&) = default; (and the copies, for clarity). This is the Rule of Five: declare one of the five, declare (or = default/= delete) all five.-Wdeprecated-copy-dtor.static_assert(std::is_nothrow_move_constructible_v<Widget>); — cheap insurance in real codebases, and a great line to mention.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.
std::move_if_noexcept: elements are moved only if the move constructor is noexcept (or if the type isn't copyable at all); otherwise they're copied.noexcept on your move ctor and every vector growth deep-copies all N elements — a silent, order-of-magnitude regression that profiles as "time in copy constructor during push_back." Diagnosing exactly this is a plausible interview exercise for an optimization role.noexcept is both true and load-bearing. Defaulted moves of noexcept-movable members are automatically noexcept.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
}
return std::move(local);: the expression is no longer a plain name, so NRVO is disqualified — you've forced the fallback (a move) in exchange for nothing. Best case you broke elision; worst case (non-movable type) you broke compilation. Compilers warn (-Wpessimizing-move). The exception where an explicit move in a return IS needed: returning a member or a parameter (return std::move(pair.first);), since those never qualify for NRVO.auto s = make_b(); constructs in place. Elision is why "just return by value" is the modern default — output parameters for performance are mostly a pre-2011 habit.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
}
T&& where T is deduced (template parameter, or auto&&). Deduction encodes the argument's category into T: lvalue Widget → T = Widget&; rvalue → T = Widget.& + && → &, && + && → && (an lvalue reference anywhere in the stack wins). So T&& with T = Widget& collapses to Widget&.std::forward<T>(arg) = a conditional cast: casts arg (an lvalue — it has a name!) back to rvalue only if T says the caller passed an rvalue. move is unconditional; forward is conditional on T. Pairing: forward with forwarding references, move everywhere else — mixing them up either forces copies (forward misuse) or steals from callers' lvalues (move misuse ⚠️).Widget&& (concrete type — rvalue reference, binds rvalues only) vs T&&/auto&& (deduced — forwarding reference, binds anything). Favorite trick question. Also: vector<T>::push_back(T&&) is an rvalue reference, not forwarding — T was fixed by the class, not deduced by the call.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.
unique_ptr, thread, fstream — copying is deleted; ownership transfer must be explicit via std::move. Your own single-owner types should follow suit: delete copies, define moves.v.emplace_back(a, b) perfect-forwards constructor args and builds in place — no temporary at all, one better than push_back(T{a,b}) (build + move).[buf = std::move(buffer)]() { use(buf); } — the way to hand a resource to a callback/thread without copying.vector steals three pointers regardless of size. ⚠️ Exception: std::string under SSO (small string optimization — short strings live inside the object, ≤15 chars in libstdc++/libc++) copies its bytes on move; still cheap, but "moves are always free" is falsifiable and interviewers know it. Second caveat: moving doesn't shrink or free the source's capacity guarantees — moved-from is unspecified, don't reason about it.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"); }
};
| Snippet | Prints | Why |
|---|---|---|
Noisy b = a; | copy | a is an lvalue |
Noisy b = std::move(a); | move | xvalue → move ctor wins |
const Noisy c; Noisy d = std::move(c); | copy | const&& 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, move | std::move disqualified NRVO (§8) |
auto f = make(); where make returns Noisy{} | default | guaranteed elision of prvalues, C++17 |
void g(Noisy n); g(std::move(a)); | move | moved 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 noexcept | copy ×(N+1) | move_if_noexcept copies old elements (§7) |
| same, move IS noexcept | copy, move ×N | arg 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.