4. Value semantics: copy, move, and the rule of five¶
Time: ~3 hours Prerequisites: 02, 03
1. In plain words¶
In C, a = b for structs copies the bytes. If the struct holds a pointer, both copies now point at
the same memory and you have to decide by convention who frees it. C++ lets a type define what
copying means: std::string a = b; makes an independent deep copy. That is value semantics:
objects behave like integers, independent after copy.
Deep copying is expensive when the source is about to die anyway, like a function's return value or a
temporary. Move semantics (C++11) adds a second operation: instead of copying the contents,
steal them, leaving the source empty but valid. Moving a std::vector of a million elements
copies three pointers.
Analogy: copying a house means building an identical second house. Moving a house means handing over the keys: the first owner is left with nothing, but nothing was rebuilt.
2. The real thing¶
2.1 Value categories: lvalue and rvalue¶
An lvalue is an expression that names an object that persists: a variable, *p, arr[i],
a function returning T&. An rvalue is a temporary or something about to expire: f() returning
by value, T{}, a + b, or std::move(x).
The compiler uses the category to pick between copy and move:
std::string a = b; // b is an lvalue -> copy constructor
std::string c = make_name(); // rvalue -> move constructor (or elided entirely)
std::string d = std::move(a); // std::move casts a to an rvalue -> move constructor; a is now moved-from
std::move does not move anything. It is a cast: static_cast<T&&>(x). It says "you may treat
this as a temporary". The move happens in whatever constructor or assignment receives it.
2.2 Rvalue references¶
T&& binds to rvalues only. It is how you write an overload that receives temporaries:
void push(const std::string& s); // called for lvalues: must copy
void push(std::string&& s); // called for rvalues: may steal from s
Inside the second function, s itself is an lvalue (it has a name), so to actually move its contents
you write std::move(s) again. Forgetting that is the most common move-semantics bug: a T&&
parameter that is copied.
2.3 The rule of five, written correctly¶
class Buffer {
char* data_ = nullptr;
std::size_t size_ = 0;
public:
Buffer() noexcept = default;
explicit Buffer(std::size_t n);
~Buffer() { std::free(data_); } // 1: release
Buffer(const Buffer& o); // 2: deep copy (allocate + memcpy)
Buffer(Buffer&& o) noexcept // 3: steal
: data_(std::exchange(o.data_, nullptr)), size_(std::exchange(o.size_, 0)) {}
Buffer& operator=(Buffer o) noexcept { swap(*this, o); return *this; } // 4 and 5 together: copy-and-swap
friend void swap(Buffer& a, Buffer& b) noexcept { std::swap(a.data_, b.data_); std::swap(a.size_, b.size_); }
};
Key points:
- Move operations must be
noexcept.std::vectoronly moves elements during reallocation if the move constructor isnoexcept; otherwise it copies, to preserve the strong exception guarantee. A non-noexcept move constructor silently makes your type slow in containers. - Moved-from state must be valid. The destructor will run on the source.
free(nullptr)is fine;fclose(nullptr)is not. Reset handles to their empty value. - Copy-and-swap gives one assignment operator for both lvalues and rvalues (the parameter is
copy-constructed or move-constructed depending on the argument), handles self-assignment, and is
strongly exception-safe: if the copy throws,
*thisis untouched. - Declaring any of the five suppresses the implicit move operations. Declaring a move suppresses implicit copies. Write all five or none (rule of zero).
2.4 Copy elision and NRVO¶
C++17 guarantees that T x = T(...) and return T(...) (a prvalue) construct the object directly
in its final location: zero copies, zero moves, even if the type is not movable. For a named local
(T t; ...; return t;) the compiler is allowed to elide (NRVO) and GCC does it at every
optimisation level when there is a single return object; otherwise it falls back to a move. Writing
return std::move(t); is wrong: it disables NRVO and forces a move. GCC warns with -Wpessimizing-move.
2.5 Choosing parameter types¶
| You want to | Take | Why |
|---|---|---|
| read, not copy | const T& |
no copy, binds to everything |
| modify the caller's object | T& |
|
| keep a copy ("sink": store into a member, push into a container) | T by value, then std::move into place |
1 copy for lvalues, 0 for rvalues, one overload |
| steal specifically from temporaries | T&& plus a const T& overload |
only when the by-value version's extra move matters |
small trivially copyable (int, double, string_view, pointers) |
T by value |
cheaper than a reference |
| forward anything to another function | T&& in a template + std::forward<T>(x) |
perfect forwarding |
Return by value. Returning const T& to a local is a dangling reference; returning T&& almost never
makes sense.
3. Code¶
code/m04_move_only_handle.cpp contains UniqueFd (move-only) and Buffer (full rule of five, as
above). The part that shows the noexcept rule and moved-from state:
std::vector<UniqueFd> fds;
fds.push_back(UniqueFd(3)); // rvalue: moved in
UniqueFd f(4);
fds.push_back(std::move(f)); // f is now moved-from: valid, holds -1
// fds.push_back(f); // error: copy constructor is deleted
fds.emplace_back(5); // constructed in place
// vector grows by MOVING elements only because UniqueFd(UniqueFd&&) is noexcept
std::vector<UniqueFd> moved = std::move(fds); // O(1): three pointers change hands; fds is empty
code/m04_copy_elision_nrvo.cpp uses a Loud type whose special members print, so you can see
exactly what happens. Build it twice, once with -fno-elide-constructors, and compare:
Loud make_prvalue() { return Loud("prvalue"); } // C++17: guaranteed elision, prints only "ctor"
Loud make_nrvo() { Loud l("nrvo"); l.tag += "!"; return l; } // NRVO: "ctor" only on GCC; MOVE if disabled
Loud make_no_nrvo(bool flag) { Loud a("a"), b("b"); if (flag) return a; return b; } // two candidates: MOVE
void by_value(Loud l); // COPY for lvalue arguments, MOVE for rvalues
void by_cref(const Loud& l); // nothing
void by_rref(Loud&& l); // nothing at the call; the move happens inside when you std::move(l)
struct Person {
std::string name;
explicit Person(std::string n) : name(std::move(n)) {} // sink parameter
};
Person p1(n); // 1 copy into the parameter, 1 cheap move into the member
Person p2(std::move(n)); // 0 copies
Expected highlights of the output:
-- prvalue (guaranteed elision)
ctor prvalue
-- nrvo
ctor nrvo
-- no nrvo possible
ctor a
ctor b
MOVE a
-- by_value(lvalue): COPY
COPY prvalue
4. Under the hood (senior-level)¶
A move is a shallow copy plus a reset. For std::vector, the move constructor copies three
pointers (begin, end, capacity end) and nulls the source's. For std::string with libstdc++'s
small-string optimisation, a short string (15 chars or fewer) lives inside the object, so moving it
is copying 32 bytes; only long strings move by pointer steal. Moving is not free, it is just cheap.
Why noexcept matters to std::vector. On reallocation the vector must relocate N elements into
the new buffer. If relocation can throw halfway, some elements have been moved out of the old buffer
and the vector cannot restore the old state: the strong exception guarantee is lost. So
std::vector uses std::move_if_noexcept: it moves when the move constructor is noexcept, and
copies otherwise. Check with static_assert(std::is_nothrow_move_constructible_v<T>).
Moved-from state in the standard library. The standard says "valid but unspecified" for most
types. Practically, libstdc++ leaves a moved-from std::string empty and a moved-from
std::vector empty, but code relying on that is non-portable. std::unique_ptr is guaranteed null
after a move. The safe operations on any moved-from object: assign to it, destroy it, or call
functions with no preconditions (size(), empty(), clear()).
Copy elision mechanics. Without elision, Loud x = make_prvalue(); would construct a temporary
in the callee's frame and then move it to x. With elision, the caller passes the address of x
as a hidden first argument (the return slot) and the callee constructs directly there. This is why
returning large objects by value is free in modern C++ and why "return by output parameter" is a
C-era habit to drop.
Implicit moves on return. return local; treats local as an rvalue first (so a move is tried),
then as an lvalue. Writing std::move there is redundant at best (GCC: -Wredundant-move) and
harmful when it blocks NRVO (-Wpessimizing-move, in -Wall).
Self-move. x = std::move(x) is legal and must leave x valid. Copy-and-swap handles it
(result: x ends up empty). Hand-written move assignment needs if (this != &o) or must be written
so the order of operations is safe. GCC 13's -Wself-move flags the obvious cases.
Perfect forwarding. In template <class T> void f(T&& x), T&& is a forwarding reference:
T deduces to U& for lvalues and U for rvalues, and reference collapsing (U& && -> U&)
makes x an lvalue or rvalue reference accordingly. std::forward<T>(x) restores the original
category when passing x on. This is how emplace_back and make_unique avoid copies.
5. In production¶
- Move-only handle types make ownership transfer explicit in function signatures. In tcpkv, a
Connectionis move-only: passing it to a worker thread by value is the hand-off, and the compiler stops two threads from both owning it. - Hidden copies are the most common performance bug in C++ written by C programmers.
for (auto conn : connections)copies every element.std::string s = view;allocates. Passingstd::vectorby value to a function that only reads it copies the whole thing. Build with-Wall -Wextraand watch for-Wrange-loop-construct(in-Wallsince GCC 11). std::moveon aconstobject silently copies.const T&&cannot bind to a move constructor takingT&&, so the copy constructor is chosen. No warning. Grep forstd::move(onconstlocals and on members accessed throughconstmethods.- Returning
std::move(member)from a getter destroys the object's state for the next caller. A move-out should be a named, documented operation (take_result()), never a getter. - Benchmark before adding
T&&overloads. The by-value sink costs one extra move over theconst&/&&pair. For astd::stringthat is 32 bytes of copying. Add the overloads only when profiling shows it matters. - 3 AM debugging. A use-after-move: an object is moved into a container inside a loop and then
used after the loop. Symptoms: empty strings, null pointers, "impossible" states. clang-tidy's
bugprone-use-after-movecatches most cases statically; enable it in CI (module 14).
6. Try it¶
- (easy) Build
m04_copy_elision_nrvo.cppwith and without-fno-elide-constructors. Count the MOVE and COPY lines in each. Done when you can explain every difference. - (easy) Change
UniqueFd's move constructor to dropnoexcept, then add astatic_assert(std::is_nothrow_move_constructible_v<UniqueFd>). Done when you see it fail and can explain whatstd::vectorwould do differently. - (medium) Write
class Matrix(owningdouble*, rows, cols) with the full rule of five using copy-and-swap. Verify with aLoud-style print thatMatrix m = a * b;performs zero copies. Done when the output shows only constructions and moves. - (medium) Write
void set_name(std::string)(sink) and the pairset_name(const std::string&)/set_name(std::string&&). Call each with an lvalue, an rvalue, and a string literal, and count copies and moves. Done when you have a table of 6 cells and can say when the two-overload version is worth it. - (hard) Write a
template <class... Args> T& emplace(Args&&... args)for a tiny fixed-capacity container using perfect forwarding and placement new. Verify withLoudthatemplace("x")constructs exactly once. Done whenemplace(std::move(loud))prints one MOVE andemplace(loud)prints one COPY. - (hard) Find the bug: a function takes
std::string&& sand doesnames.push_back(s);. Fix it, then explain why the original compiles without warning. Done when you can state the rule "a named rvalue reference is an lvalue".
7. Interview questions¶
Q: What does std::move do?
Nothing at run time. It is a cast to an rvalue reference (static_cast<T&&>). The move actually
happens in the move constructor or move assignment that receives the result. Applied to a const
object it silently results in a copy.
Q: What state is an object in after being moved from?
Valid but unspecified for most standard types: it can be destroyed or assigned to, and functions
without preconditions (size, clear) are safe. std::unique_ptr is guaranteed null. Your own
types should reset handles so the destructor is a no-op.
Q: Why must move constructors be noexcept?
Containers like std::vector relocate elements during reallocation. If the move could throw midway,
the old buffer would be partially destroyed and the strong exception guarantee lost, so the vector
falls back to copying unless the move is noexcept. A non-noexcept move makes your type slower
without any warning.
Q: Explain copy-and-swap.
Write assignment as T& operator=(T other) { swap(*this, other); return *this; }. The parameter is
constructed by copy or move depending on the argument, so one function serves both. The swap is
noexcept, so if the copy throws nothing has changed (strong guarantee), and self-assignment is
handled because the copy is made before anything is modified.
Q: What is guaranteed copy elision in C++17?
Initialising an object from a prvalue (T x = T(...), return T(...)) constructs it directly in
place with no copy or move, and it works even for non-movable types. NRVO (returning a named local)
remains optional but is done by GCC when there is a single return object.
Q: Should you write return std::move(local);?
No. return local; already tries a move, and std::move prevents NRVO, so it is strictly worse.
GCC flags it with -Wpessimizing-move / -Wredundant-move.
Q: How do you pass a parameter you intend to store?
Take it by value and std::move it into place. Lvalue callers pay one copy (unavoidable), rvalue
callers pay one cheap move, and there is a single overload to maintain. Add const& / && overloads
only if profiling shows the extra move matters.
Q: What is a forwarding reference?
T&& where T is a deduced template parameter. It binds to both lvalues and rvalues, deducing T
as U& or U, and std::forward<T>(x) passes the argument on with its original value category.
This is how emplace_back constructs elements in place with zero copies.
8. Recap¶
- Lvalues name persistent objects; rvalues are temporaries or
std::moved objects. std::moveis a cast; the move happens in the receiving constructor/assignment.- Move = steal the guts and reset the source; the source must remain destructible.
- Move operations must be
noexcept, orstd::vectorwill copy instead. - Rule of five: destructor, copy ctor, copy assign, move ctor, move assign; copy-and-swap merges the last two safely.
- C++17 guarantees elision for prvalues; NRVO is optional but GCC does it; never
return std::move(x). - Parameter rules:
const T&to read,Tby value to sink,T&&+std::forwardto forward. - A named
T&¶meter is an lvalue inside the function;std::moveit to actually move. std::moveonconstcopies silently; use-after-move is caught by clang-tidy, not the compiler.