Copy, move, and value semantics
Lesson 05 gave us RAII: a destructor that runs deterministically at scope exit, so a resource is freed exactly when its owning object dies. That solves destruction. But it silently raises a new question the moment an object is copied or returned: if two objects' destructors both run, and both think they own the same heap buffer, the buffer is freed twice — the double-free from Lesson 04, now caused by RAII itself. This lesson adds the one missing piece: how values are copied and moved. C++ defaults to value semantics — assignment and argument-passing copy by default — and the new idea is that you control what "copy" means for your type, and that you can move (transfer a resource) instead of duplicating it, for free.
b = a almost always shares — both names point at one object, and no copy happens. C++ defaults to the opposite: b = a copies, so each object is independent. That single default is why C++ data flow is visible: a copy is a real, itemizable cost (duplicate N bytes), a move is a cheaper one (steal a pointer, O(1)), and a share is something you must ask for explicitly (Lesson 07). The cost-model question "what did this assignment cost?" has a concrete, local answer.T* to a heap buffer it owns.New capability: reason about exactly what happens when an object is copied, assigned, returned, or passed by value; write a correct owning type using the rule of zero / three / five; and know when a copy (duplicate the bytes) silently becomes a move (steal the pointer, O(1)).
std::move, and the moved-from object. (5) The rule of zero / three / five — when you must write these functions and when you must not. (6) A worked Buffer class with copy cost vs move cost in concrete numbers.1 · Value semantics: the default that flips your intuition
Value semantics means a variable is its value, not a handle to a value living elsewhere. When you write b = a, b becomes an independent copy; changing b afterward does not touch a. Passing an object to a function by value hands the function its own copy. This is the C++ default for every type — built-in or user-defined.
If your background is Python or Java, this is the inverse of what you expect. There, a variable is a reference (a hidden pointer) to a heap object, and assignment copies the reference, not the object — both names alias one object. That is reference semantics.
// C++ — value semantics (objects ARE values)
std::string a = "hello";
std::string b = a; // b is an independent COPY of a's characters
b[0] = 'j'; // a is still "hello"; b is "jello"
# Python — reference semantics (variables are handles)
a = [1, 2, 3]
b = a # b and a name the SAME list
b[0] = 99 # a is now [99, 2, 3] too
The C++ default has a cost (copying may duplicate a lot of bytes) and a payoff (no aliasing surprises, and the cost is visible at the point it happens). Reference semantics has the opposite trade: cheap assignment, but mutation can leak across the program through shared aliases — the bug at the end of Lesson 00's Python example. C++ lets you opt into sharing deliberately (a reference, or a shared_ptr in Lesson 07), but you never get it by accident.
2 · The copy constructor and copy assignment operator
For a class, "what does a copy mean?" is answered by two special member functions the compiler will call for you:
- The copy constructor
T(const T& other)— builds a new object as a copy of an existing one. Called byT b = a;, by passingato a by-value parameter, and by returning aT. - The copy assignment operator
T& operator=(const T& other)— overwrites an already-constructed object with a copy of another. Called byb = a;whenbalready exists.
The parameter is const T& — a reference to the source (Lesson 03), so we read it without copying it (a by-value parameter here would need a copy to copy, which is circular). The distinction between the two functions is just does the target already exist? — construction builds from nothing; assignment must first dispose of whatever the target held.
If you write none of them, the compiler implicitly generates both: it copies each member in turn (the memberwise copy). For a type whose members are themselves value types — an int, a std::string, a std::vector — that is exactly right and you write nothing. The trouble is precisely the type that owns a raw pointer.
3 · Deep vs shallow copy — and the double-free
Suppose a class owns a heap array through a raw pointer int* data and a size. The compiler's memberwise copy copies the pointer value — the address — not the bytes it points at. That is a shallow copy: two objects now hold the same address. A deep copy would instead allocate a fresh buffer and copy the N elements into it, so each object owns a distinct buffer.
Shallow-copying an owning pointer is a disaster, and Lesson 05 is exactly why. Each object's destructor runs delete[] data; at scope exit. With two objects holding the same address, that address is freed twice — the double-free from Lesson 04 — and freeing already-freed memory is undefined behavior (corrupts the allocator; often a crash). RAII didn't cause the bug, but it guarantees the second destructor fires, turning a latent aliasing mistake into a certain crash.
struct Bad {
int* data;
int size;
Bad(int n) : data(new int[n]), size(n) {} // acquires (Lesson 05)
~Bad() { delete[] data; } // releases
// NO copy constructor written -> compiler generates a SHALLOW one
};
void boom() {
Bad a(1000); // a.data = 0x6000 (heap buffer, 4000 bytes)
Bad b = a; // shallow copy: b.data = 0x6000 (SAME address!)
} // scope exit: ~b runs delete[] 0x6000 -> freed
// ~a runs delete[] 0x6000 -> FREED AGAIN (double free, UB)
The fix is to give the type a copy constructor that does a deep copy, so the two objects own different buffers and each destructor frees its own:
Bad(const Bad& o) : data(new int[o.size]), size(o.size) {
std::copy(o.data, o.data + o.size, data); // duplicate all o.size ints
}
Now b gets its own 4000-byte buffer at a different address; both destructors free distinct memory. Correct — but it cost a full duplication of the array. That cost is the motivation for move.4 · Move semantics: transfer the resource instead of copying it
Consider returning a freshly-built buffer from a function, or inserting a temporary into a container. The source object is about to be destroyed anyway — it is a temporary. Deep-copying it into the destination and then deleting the temporary's now-useless buffer is pure waste: we duplicate N bytes only to throw the original away. A move avoids that by transferring ownership of the existing buffer — copy the pointer, then null out the source so its destructor frees nothing.
To express "this source is expendable, you may pillage it," C++ adds the rvalue reference, written T&&. The terms: an lvalue is an object with a name and an identity that persists (a); an rvalue is a temporary with no name, about to expire (the result of make(), or anything you explicitly mark). An rvalue reference binds only to rvalues, which lets you overload on "is the source disposable?"
- The move constructor
T(T&& other) noexcept— stealsother's resource and leavesotherin a safe empty state. - The move assignment operator
T& operator=(T&& other) noexcept— releases the target's own resource, then stealsother's.
std::move is the tool you use to move from a named object: it does not move anything itself: it is just a cast that says "treat this lvalue as an rvalue — I promise I'm done with it." After T y = std::move(x);, x is in a moved-from state: a valid but unspecified value. The contract is that you may destroy it or assign a new value to it, but you must not assume what it holds. A well-written move leaves the source empty (pointer set to nullptr, size 0) so its destructor is a no-op.
auto y = std::move(x); use(x); — x is valid but unspecified; reading its old contents is a bug. (2) Forgetting to null the source in your move constructor. If the move copies the pointer but doesn't null other.data, both objects hold the same address and you are back to the double-free of §3 — a move that didn't actually transfer ownership. Always leave the source empty, and mark moves noexcept (containers like vector will only use your move on reallocation if it cannot throw — otherwise they fall back to the slow copy).5 · The rule of zero / three / five
These are the rules for which of the special functions you must define. There are five, all governing the lifetime of a resource a class holds: destructor, copy constructor, copy assignment, move constructor, move assignment.
std::string, std::vector, std::unique_ptr (Lesson 07). The compiler-generated copies/moves/destructor are then all correct, because each member does the right thing. This is the target for almost every class you write.The deep lesson is that the rule of zero is reachable for nearly everything: the members do the resource management, so your class is just an aggregate of self-managing parts. You write the five functions only at the lowest layer — inside vector, inside unique_ptr, or in a deliberately hand-rolled owning type like the worked example below. Lesson 07 shows that unique_ptr is precisely "the rule of five, written once, so your class can use the rule of zero."
6 · Worked example: a Buffer class, with the numbers
Here is an owning type written to the rule of five — managing n doubles (8 bytes each) on the heap. Read each function as acquire / release / duplicate / steal.
class Buffer {
double* data_;
std::size_t n_;
public:
explicit Buffer(std::size_t n) : data_(new double[n]), n_(n) {} // acquire
~Buffer() { delete[] data_; } // release (RAII, L05)
Buffer(const Buffer& o) : data_(new double[o.n_]), n_(o.n_) { // COPY ctor: deep
std::copy(o.data_, o.data_ + o.n_, data_); // duplicate n_ doubles
}
Buffer& operator=(const Buffer& o) { // COPY assign
if (this == &o) return *this; // guard self-assign
double* fresh = new double[o.n_]; // allocate BEFORE freeing
std::copy(o.data_, o.data_ + o.n_, fresh); // (strong guarantee, L15)
delete[] data_; // free old buffer
data_ = fresh; n_ = o.n_;
return *this;
}
Buffer(Buffer&& o) noexcept : data_(o.data_), n_(o.n_) { // MOVE ctor: steal
o.data_ = nullptr; o.n_ = 0; // leave source EMPTY
}
Buffer& operator=(Buffer&& o) noexcept { // MOVE assign
if (this == &o) return *this;
delete[] data_; // free our own buffer
data_ = o.data_; n_ = o.n_; // steal pointer (O(1))
o.data_ = nullptr; o.n_ = 0; // leave source EMPTY
return *this;
}
};
Buffer of 1,000,000 doubles owns an 8,000,000-byte (8 MB) heap array.
| Operation | What runs | Heap allocations | Bytes touched | Cost |
|---|---|---|---|---|
Buffer b = a; (copy) | copy ctor | 1 new 8 MB buffer | 8,000,000 copied | O(n) — millions of stores |
Buffer b = std::move(a); (move) | move ctor | 0 | ~16 (one pointer + one size) | O(1) — a handful of stores |
Buffer from a function is cheap: the return value is a temporary (an rvalue), so the move constructor is selected automatically — no std::move needed on a return.Notice value semantics made all of this visible: every duplication of those 8 MB happens at a syntactically obvious point (an assignment, a by-value pass, a copy ctor call), and you can see — and choose — whether it is a copy or a move. In a reference-semantics language the 8 MB would be silently shared, which is cheaper but means a mutation through one handle could surprise the holder of another.
Common mistakes / failure modes
vector/unique_ptr and use the rule of zero.other.data_ = nullptr leaves both pointing at one buffer — double free again. A move must transfer, leaving the source destructible-but-empty.std::move(x), x is valid but unspecified. Only destroy or reassign it; don't read its old value.const T& — the value-semantics default charges you a full copy if you forget.b = b; that frees data_ before reading from it corrupts itself. Guard with if (this == &o), or allocate the fresh buffer before deleting the old (§6).noexceptvector won't use a throwing move on reallocation — it silently falls back to copying every element. Mark moves noexcept to actually get the O(1) path.Checkpoint exercise
Bad struct from §3 (raw int* data, custom destructor, no copy ctor). Write a main that does Bad a(1000); Bad b = a; and returns. Predict: does it leak, dangle, or double-free? Now compile with g++ -std=c++20 -fsanitize=address bad.cpp && ./a.out and read AddressSanitizer's report — you should see "attempting double-free" with two stack traces pointing at the same destructor. Then add the deep copy constructor from §3 and confirm the report disappears. Finally, add a move constructor that steals the pointer but forgets to null other.data, do Bad c = std::move(a);, and watch the double-free return — proving a move is only correct if it empties the source.Where this points next
We now know how a class can correctly own a raw resource — but writing all five functions by hand, every time, is exactly the manual discipline Lesson 04 warned is unwinnable at scale. Lesson 07 makes the obvious move: package "owns a heap resource, rule of five done right" into a reusable type. std::unique_ptr is sole ownership written once (move-only — it uses precisely the move semantics from this lesson, and deletes the copy operations so a double-free is impossible to write); std::shared_ptr is opt-in shared ownership at the cost of an atomic reference count. With those, your own classes drop to the rule of zero, and the leak / dangle / double-free trio from Lesson 04 is finally closed by the type system.
b = a and by-value passing copy, producing independent objects — the inverse of Python/Java reference semantics, and the reason every duplication is a visible, itemizable cost. The copy constructor and copy assignment define what a copy means; for a type owning a raw pointer the compiler's default shallow copy aliases one buffer into two objects, and RAII's two destructors then double-free it (Lesson 04) — so you must deep-copy. Move semantics (rvalue references T&&, std::move) transfers a resource instead of duplicating it: the move constructor steals the pointer and empties the source, turning an O(n) byte-duplication into an O(1) pointer swap — 8 MB versus ~16 bytes for a million-element buffer. The rule of zero / three / five says: prefer holding self-managing members and write none of the five; if you must hold a raw resource, write all five (and mark moves noexcept). Value semantics is the feature that makes data flow — copy, move, or deliberate share — visible at the line where it happens.Interview prompts
- Contrast value semantics with reference semantics, and give the consequence of each for
b = a. (§1 — value:bis an independent copy, mutation is local, but copying costs bytes; reference:baliases the same object, assignment is cheap, but mutation can surprise other holders.) - Why does shallow-copying a class that owns a raw pointer cause a double free? (§3 — the copy duplicates the address, not the buffer; both objects' destructors run
delete[]on the same address at scope exit — Lesson 04's double-free, now guaranteed by RAII.) - What is the difference between the copy constructor and the copy assignment operator? (§2 — the copy ctor builds a new object from a source; copy assignment overwrites an already-constructed object, so it must first release whatever the target held.)
- What does
std::moveactually do, and what state is the source left in? (§4 — nothing at runtime; it is a cast to an rvalue reference that permits the move ctor/assign to steal the resource; the source is left valid-but-unspecified — destroy or reassign only.) - Give the copy vs move cost for a 1,000,000-element buffer and explain the difference. (§6 — copy: 1 allocation + 8 MB duplicated, O(n); move: 0 allocations, ~16 bytes (pointer + size) swapped, O(1) — the move steals the existing buffer.)
- State the rule of zero / three / five and when each applies. (§5 — zero: hold self-managing members, write none; three: own a raw resource → need destructor + copy ctor + copy assign; five: also add move ctor + move assign so moves stay cheap.)
- Why must move operations be marked
noexcept? (§4 —std::vectorwill only use a move on reallocation if it can't throw; a throwing move forces a fallback to copying every element, silently losing the O(1) benefit.)