C++ Part I · Module 13 · Copy and Move Semantics
The Rule of Zero and Why It Is the Goal
Ten chapters have arrived at a discipline that works and is expensive. A class directly owning a raw resource needs five functions written carefully and consistently, and this module has measured a distinct failure for each way of getting them wrong: one buffer with two owners and a process killed by a signal, a buffer still outstanding after both objects were destroyed, a live-resource counter driven negative by a double release, a self-assignment producing a silently wrong value that AddressSanitizer does not report, a different self-assignment that is a genuine use-after-free, a destructor silently removing the move operations so that a container copies at twice the allocation cost, and a missing keyword causing that container to copy even when the move exists. Every one of those was measured on a class holding a raw pointer, and every one of them is simply absent from a class that holds a member which already handles its own resource — which also, because it declares none of the five, gets all five generated correctly. This chapter measures the two designs side by side. They have identical capability. One of them contains about twelve lines of resource management and the other contains none, and the argument of the chapter is that the second is the normal case rather than the lucky one.
Advanced18 min readC++Part IFundamentalsDesign
Part I · Module 13 · Chapter 13.11 · The Rule of Zero and Why It Is the Goal
1. The Rule
Write none of the five. Hold members that already manage themselves, and let the compiler generate all five correctly.
That is the Rule of Zero. It is not an exception to the Rules of Three and Five — it is the case those rules are for avoiding, and it is the normal case.
2. Two Designs, Same Capability
// Rule of Five, by hand
class ByHand {
public:
explicit ByHand(int n) : data_(new int[n]), size_(n) {}
~ByHand() { delete[] data_; }
ByHand(const ByHand& o); // deep copy
ByHand& operator=(const ByHand& o); // guard, allocate, copy, release
ByHand(ByHand&& o) noexcept; // take, clear
ByHand& operator=(ByHand&& o) noexcept; // guard, release, take, clear
private:
int* data_; int size_;
};
// Rule of Zero
class ByComposition {
public:
explicit ByComposition(int n) : data_(static_cast<std::size_t>(n), 0) {}
// nothing else
private:
std::vector<int> data_;
};Measured:
ByHand (5 fns) copy-ctor=1 copy-assign=1 move-ctor=1 move-assign=1 dtor=1
ByComposition (0) copy-ctor=1 copy-assign=1 move-ctor=1 move-assign=1 dtor=1
lines of resource-management code: ByHand ~12, ByComposition 0Identical capability across all five operations. One class spent about twelve lines achieving it and the other spent none.
3. Why It Works
Two mechanisms, both already measured.
The generated functions are memberwise and recursive. Chapter 13.1 §2 established the rule and Chapter 13.1 §3 measured it on a std::string member:
x.s data at 0x102e2d9a0
y.s data at 0x102e2d9e0
same buffer? noThe generated copy constructor did not do anything deep. It called std::string's own copy constructor, which allocated and duplicated the characters. So the depth came from the member, and somebody else wrote it.
And declaring nothing suppresses nothing. Chapter 13.10 §2's table has a corollary that is easy to miss: every row begins "if you declare…". Declare none of the five and none of the suppression rules fire, so all five are generated. Chapter 13.10 §4 measured the opposite case — a class with a destructor silently losing its move operations.
The Rule of Zero is not the compiler being generous. It is the suppression rules not firing, plus members whose own five are correct.
4. What Counts as Self-Managing
A member qualifies if its own five functions are correct for it. In practice:
| Need | Member | Chapter |
|---|---|---|
| a run of objects | std::vector<T> | 19.2 |
| text | std::string | 9.5 |
| one owned object | std::unique_ptr<T> | 14.2 |
| a shared object | std::shared_ptr<T> | 14.5 |
| a fixed-size array | std::array<T, N> | 19.2 |
| scalars, enums | int, double, enum class | — |
And one case worth highlighting because it behaves differently from the rest: a std::unique_ptr member makes the enclosing class's copy deleted rather than wrong. Chapter 13.1 §8 measured it:
struct D { std::unique_ptr<int> p; } copy-constructible = 0So composition with a unique_ptr produces a move-only type automatically, with no declarations at all — which is Chapter 13.10 §8's second coherent shape, obtained for free.
5. What It Buys
Collecting the module's measurements as the argument. Every failure below was measured on a raw-pointer class, and every one is structurally impossible in a composed class.
| Failure | Measured | Why composition avoids it |
|---|---|---|
| double free | exit 133, 0 diagnostics (13.1 §4) | the member's copy duplicates |
| leak on assignment | 1 buffer live after both destroyed (13.3 §8) | the member's assignment releases |
| double release | live count −1 (13.3 §8) | ordering is the member's problem |
| self-assignment, silent | 1 → 0, exit 0, ASan clean (13.3 §4) | the member guards |
| self-assignment, UAF | heap-use-after-free (13.3 §5) | as above |
| partial repair | 3 live where 2 expected (13.5 §4) | there is nothing to half-repair |
| suppressed moves | copy=1 move=0 (13.10 §4) | nothing is declared, so nothing is suppressed |
missing noexcept | containers copy (13.8 §6) | the member's moves are already noexcept |
Eight measured failures, none of them reachable. That is the argument, and it is stronger than "less code to write": the code you did not write cannot be wrong, and the code you would have written has eight documented ways to be wrong.
And the performance is not a trade-off. Chapter 13.8 §7's measurement — 0 copies, 7 moves, 4 allocations — was for a type with correct hand-written moves. A std::vector member gives the same behaviour, because std::vector's move is the same two-assignments-and-clear.
6. When You Still Have to Write Them
The rule is a default, not an absolute. Three genuine cases:
Wrapping a resource no standard type models. A vendor handle, a C API's opaque pointer, a registration that must be undone. Chapter 12.9 §10's table covers almost everything and not everything.
Implementing one of the self-managing types. Somebody wrote std::vector's five, and that work is the reason this chapter exists.
And a type whose copy means something unusual — copy-on-write, a deliberately shallow handle, reference counting with custom behaviour.
Even in the first case the answer is often still zero functions, because std::unique_ptr accepts a custom deleter — so a vendor handle can frequently be held as a unique_ptr with a release function rather than wrapped in a new class. Chapter 14.4 is that facility, and it removes most of the remaining excuses.
7. The Honest Costs
Three, and none of them changes the conclusion.
An extra indirection. A std::vector<int> member stores a pointer to its elements, so reaching element i is one dereference further than a raw array member would be. Chapter 9.3 measured a 60× penalty for a traversal the memory hierarchy disliked, which is the same concern — but that measurement was about layout within a traversal, and a vector's elements are contiguous (Chapter 9.1), so the indirection is paid once per traversal rather than per element.
Slightly larger objects. std::vector carries three pointers' worth of state where a raw array carries none, and std::string is 24 bytes (Chapter 9.5) regardless of content.
And less control over allocation. You get the allocator the container uses unless you say otherwise.
For the overwhelming majority of code these are irrelevant, and Chapter 25.8's rule applies: measure before optimising. Chapter 25.9 is Hidden Copies and Allocations, which is where you would investigate if a profile pointed at a container — and Chapter 13.2's counting instrument is how.
8. Why This Is the Normal Case
It is worth stating why the Rule of Zero is a default rather than an aspiration, because the module's structure can suggest the opposite — ten chapters of five-function discipline and one chapter saying do not bother.
Most classes are not resource wrappers. A RegisterField (Chapter 11.9), a Signal (Chapter 9.11), a transaction record, a parsed configuration — these hold strings, numbers, enums and containers. Chapter 11.9 §8 noted that its finished design needed no destructor at all.
And the resource wrappers that remain are few and already written. In a large codebase the number of types that genuinely wrap a raw OS or vendor resource is small — a handful — and each of them exists once. Everything else composes.
So the ratio in practice is heavily one-sided, and the discipline in Chapters 13.2 to 13.10 is what you need in order to read the standard library, implement the rare wrapper, and recognise a type that has got it wrong.
9. The One Thing Left
There is a loose end that this chapter's advice depends on and has not addressed.
The Rule of Zero means objects are passed and returned by value freely — a std::string returned from a function, a std::vector handed back from a factory, a composed type returned from a builder. Chapter 6.3 recommended this and Chapter 10.2's callout insisted on it. But every one of those returns looks like a copy of something potentially large, and this module has spent ten chapters establishing that copies are expensive.
Chapter 12.10 §7 already measured the resolution partially: a returned temporary cost 0 copy constructor calls, unchanged even under -fno-elide-constructors. The next and final chapter of the module is what the standard actually guarantees there, which cases are guaranteed and which merely usual, and the one shape that produces a real copy at every optimisation level.
10. What to Actually Do
Hold members that manage themselves — std::vector, std::string, std::unique_ptr, std::array.
Declare none of the five for such a type. Not even = default ones, because declaring triggers suppression.
Use std::unique_ptr for a single owned object — it makes the enclosing type move-only automatically.
Reach for a custom deleter before writing a wrapper class (Chapter 14.4).
Write all five only for a genuine resource wrapper, and then all five, with = delete counting.
Do not pre-emptively avoid containers for performance. Measure first (Chapter 25.8).
Keep the question, the picture, the two defaults and the counting instrument.
And return composed types by value — Chapter 13.12 is why that is free.
11. Mistakes Worth Making Once
Writing the five when a std::vector member would do. Eight measured failure modes for no benefit.
Declaring ~T() = default; on a composed type. You have just suppressed the generated moves (Chapter 13.10 §3).
Holding a raw pointer "for performance" without measuring. Chapter 25.8's rule.
Assuming the generated functions are shallow and therefore wrong. They delegate to the members, which is the whole point.
Learning the Rule of Zero without the reasoning. You cannot then evaluate a member or read a wrapper.
Writing a wrapper class for a vendor handle before trying a unique_ptr with a custom deleter.
Declaring a subset of the five on a wrapper. Both subsets were measured failing.
Treating this chapter as permission not to understand the other ten.
12. Interview Relevance
"What is the Rule of Zero?" Design types that need none of the five special members, by holding members that manage their own resources. The answer that lands adds why it works — the generated functions are memberwise and delegate to the members' own correct versions, and declaring nothing triggers no suppression.
"Why is it better than writing them correctly?" Because the code you do not write cannot be wrong, and there are documented failure modes for each of the five. Being able to name two or three — double free, leak on assignment, silently suppressed move — is the strong version.
"What makes a member self-managing?" Its own five are correct for it: vector, string, unique_ptr, array, and scalars trivially.
"What does a unique_ptr member do to the enclosing class?" Makes it move-only automatically — the copy is deleted, not wrong.
"When must you still write them?" Wrapping a resource no standard type models, implementing one of those types, or a genuinely unusual copy semantic — and a custom deleter often removes the first case.
"What are the costs?" An extra indirection, slightly larger objects, less allocation control — and you should measure before caring.
13. What You Should Be Able to Say Now
- The Rule of Zero: write none of the five, holding members that manage themselves.
- Measured:
ByHandandByCompositionhave identical capability across all five — ~12 lines against 0. - It works for two reasons: the generated functions are memberwise and delegate, and declaring nothing triggers no suppression.
- Measured delegation: the generated copy produced different string buffers because
std::string's own copy constructor ran. - Self-managing members:
vector,string,unique_ptr,shared_ptr,array, scalars. - A
unique_ptrmember makes the class move-only automatically — copy-constructible 0, deleted rather than wrong. - Eight measured failures are structurally unreachable: double free (exit 133), leak (1 live), double release (−1), two self-assignment bugs (one ASan-clean), partial repair (3 live), suppressed moves (copy=1 move=0), missing
noexcept. - No performance trade-off for moves — a
vectormember's move is the same two assignments. - Still write them for: an unmodelled resource, implementing a self-managing type, or unusual copy semantics.
- A custom deleter often removes the first case (Chapter 14.4).
- Honest costs: one indirection, larger objects, less allocator control — measure first.
- Most classes are not resource wrappers, and the wrappers that exist are few and already written.
- Keep four things: the question, the picture, the two defaults, the counting instrument.
- Never a subset of the five — both subsets were measured failing.
14. Exercises
Exercise 1 — convert it. Rewrite a class holding int* data_; int size_; plus five hand-written functions, as a Rule-of-Zero type. Say how many of the five you declare.
Exercise 2 — which member? Choose a self-managing member for each: a run of samples of unknown length; a name; one owned polymorphic object; a 4×4 matrix of doubles; a file handle from a C API.
Exercise 3 — the accidental suppression. A colleague adds ~T() = default; to a composed class "for clarity". Say exactly what that costs and cite the measurement.
Exercise 4 — move-only for free. Explain why a class holding a std::unique_ptr is move-only without declaring anything.
Exercise 5 — the failure table. Pick three rows from §5 and explain, for each, why composition makes it unreachable.
Exercise 6 — when not to. Give two types that genuinely need all five, and for each say whether a custom deleter would avoid it.
Exercise 7 — the costs. State the three honest costs and describe the measurement you would make before accepting any of them as a reason to avoid a container.
Exercise 8 — read a class. Given an unfamiliar class with a destructor, a copy constructor and no move operations, say what you know about it immediately and what you would check.
15. Summary
- Write none of the five — hold members that manage themselves.
- Measured: identical capability, ~12 lines against 0.
- It works because the generated functions delegate to the members, and declaring nothing suppresses nothing.
- A
unique_ptrmember yields a move-only type for free — copy deleted, not wrong. - Eight measured failures become unreachable, including exit 133, a −1 release count, and an ASan-clean silent corruption.
- No move performance is given up.
- Still write them for an unmodelled resource, for implementing a self-managing type, or for unusual copy semantics — and a custom deleter often removes the first.
- Honest costs: an indirection, object size, allocator control — measure first.
- Keep the question, the picture, the two defaults and the counting instrument.
One assumption has been load-bearing throughout this chapter and has not been justified. The Rule of Zero works because objects can be handed around by value — returned from factories, produced by builders, passed out of functions — and every one of those returns appears to be a copy of something that may be large. Chapter 6.3 recommended returning by value, Chapter 10.2's callout insisted on it over every alternative, and ten chapters here have established that copies are exactly what you should be worried about. Chapter 12.10 measured a returned temporary costing zero copy constructor calls and found the result unchanged by the compiler flag whose only purpose is to disable such elisions, which settles one case and raises the general question. The final chapter of the module is what the standard guarantees rather than what folklore claims, measured across five different return shapes — one of which turns out to produce a genuine copy at every optimisation level.
Standards & specifications
- Governing standard
- ISO/IEC 14882 (the C++ language standard)(opens ISO/IEC (JTC 1/SC 22/WG 21) in a new tab)
Defines the C++ language and its standard library — the type system and templates, the object model and object lifetime, initialisation and overload resolution, the memory model and the containers, algorithms and utilities the library provides. Like C's standard it is a FAMILY of editions (C++98/03, C++11, C++14, C++17, C++20, C++23) and the editions are NOT interchangeable: a feature this curriculum relies on may be unavailable, differently specified or merely proposed in another edition, so each lesson names the edition its claim depends on. It does not define any foreign-function interface to a hardware description language, nor what a compiler actually emits, nor platform behaviour on real silicon — those are the implementation's and the device's contracts. Working drafts published by WG21 are freely available and are what most engineers read day to day; a draft is not the published standard.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the C++ Programming curriculum.
