This page is the "prove it on real code" companion to the parent Rule-of-Zero note . There we derived why composing self-managing members lets the compiler write your five special functions. Here we hunt down every case class a designer can run into and work each one fully.
Before we touch code, three things we must earn:
Definition Special member function (recap, in plain words)
A special member function is a function the C++ compiler can write for you when you don't. There are five that matter for resource handling:
Destructor ~T() — runs when an object dies; its job is to release whatever the object holds.
Copy constructor T(const T&) — builds a brand-new object that is an independent duplicate of another.
Copy assignment T& operator=(const T&) — makes an already-existing object become a duplicate of another.
Move constructor T(T&&) — builds a new object by stealing the guts of a temporary (fast, no copying).
Move assignment T& operator=(T&&) — makes an existing object steal the guts of a temporary.
"Rule of Zero" = arrange your members so the compiler's auto-generated versions of all five are already correct, and you write none of them.
Definition "Implicitly deleted" — a phrase you'll meet often below
When the compiler would generate a special function but discovers it can't be made to work , it does not silently skip it. Instead it generates a version that is marked deleted : the function exists on paper so overload resolution can find it, but any attempt to use it is a compile error. We say the function is implicitly deleted . Example: if a member cannot be copied, the class's copy constructor becomes implicitly deleted, so T b = a; fails to compile. This is good — it stops a broken copy at compile time instead of at runtime.
Definition "VERIFY" — what the checks at the end mean
Every worked example below ends with a Verify: line. When it says "(Checked in VERIFY)" or "(Simulated in VERIFY)" , it is pointing at a machine-checked block attached to this page: a small script that re-computes the example's numeric answer (sizes, counts, ratios, or true/false capabilities) so you can trust the number wasn't a typo. You don't have to run anything — the phrase just tells you "this claim has an automated proof stapled to it." Think of it as the answer key that runs itself.
If any of these words feel new, they are fully built in Rule of Three and Rule of Five and Move semantics and rvalue references .
The parent note gives one axis: does the class directly own a non-RAII resource? But real design has more corners than that. Here is the full grid of cases this topic can throw at you. Each cell gets at least one worked example below.
What "danger" means here — we order the cells by how much manual resource logic the case demands of you , which is exactly the thing Rule of Zero tries to drive to zero:
Zero danger (A, B): every member is a value or a self-managing type; you write nothing and it is correct.
Low danger (C, D, E): still zero written code, but you must reason about which copy/move behaviour the member hands you (and confirm the degenerate cases).
High danger (F, G, H): a raw resource, or a keystroke that poisons the generated set, appears — now real bugs (double free, silent slow copies) are possible unless contained.
Applied (I, J): put the whole ladder together in a real design and an exam trap.
So the left-to-right ordering is literally "how many ways can this bite you if you're careless" — increasing.
#
Case class
The question it forces
Danger
Example
A
All value members (int, double)
Is copy really "just bytes"?
zero
Ex 1
B
Self-managing members only (vector, string)
Does correctness compose?
zero
Ex 2
C
unique_ptr member — move-only "sign"
What happens to copy?
low
Ex 3
D
shared_ptr member — shared "sign"
Copy = duplicate or share?
low
Ex 4
E
Degenerate/zero input (empty container, nullptr handle)
Do the auto specials still behave?
low
Ex 5
F
The poison member — raw owning T*
Why the cascade fires
high
Ex 6
G
No RAII wrapper exists (FILE*, OS handle)
Isolate into one type
high
Ex 7
H
The silent trap — declaring ~T(){} for logging
Limiting behaviour: moves vanish
high
Ex 8
I
Word problem — a real game/engine class
Pick the member, not the specials
applied
Ex 9
J
Exam twist — mixed members, "what does the compiler generate?"
Reason about each of the five
applied
Ex 10
We walk this table top-to-bottom, from zero danger to applied.
Worked example Example 1 (Cell A) — a plain 2D point
struct Point {
double x;
double y;
};
Point a{ 3.0 , 4.0 };
Point b = a; // copy
Forecast: Guess before reading on: how many special functions did you write? Is b independent of a?
Count the members. Both x and y are double — a value type, no hidden resource.
Why this step? The parent's rule is "write specials ⟺ you own a non-RAII resource." No resource here, so we expect to write nothing.
Ask what copy means for double. Copying a double copies 8 bytes. The two copies never share storage — changing a.x cannot touch b.x.
Why this step? This is the "memberwise copy is correct" base case from the parent's Step 1. We must confirm it before trusting the compiler.
Conclude: compiler-generated copy = copy x, then copy y. Correct and independent. Zero specials written.
Verify: set b.x = 99. Then a.x is still 3.0 and b.x is 99.0 — independent, as promised. (Checked numerically in VERIFY.)
Worked example Example 2 (Cell B) — a record with a string and a vector
struct Record {
std ::string name;
std ::vector <int> scores;
};
Record r{ "Ana" , { 10 , 20 , 30 }};
Record s = r; // deep copy, for free
Forecast: After s = r, if we push_back(40) onto s.scores, how big is r.scores? Did you write any special function?
Classify each member. name is std::string, scores is std::vector<int>. Both are RAII types — RAII = Resource Acquisition Is Initialization , the idea that an object grabs a resource in its constructor and releases it in its destructor (see RAII — Resource Acquisition Is Initialization ). Because of that, each member already has a correct destructor, deep copy, and cheap move.
Why this step? The parent's insight is "correctness is composed, not rewritten." We must confirm every member self-manages.
See what the auto copy does. The compiler-generated copy constructor calls string's copy (duplicates the characters) then vector's copy (duplicates the elements). Both are deep .
Why this step? We need to know the copy is deep, not a shared-pointer aliasing, to trust independence.
Conclude: s.scores has its own buffer of {10,20,30}. Pushing 40 gives s.scores size 4, leaving r.scores size 3.
Verify: r.scores.size() == 3, s.scores.size() == 4 after the push. (Simulated in VERIFY with Python lists.)
Now the sign flips: a member that cannot be copied at all.
Worked example Example 3 (Cell C) — a widget owning one polymorphic shape
class Widget {
std ::unique_ptr < Shape > shape; // sole owner
};
Widget w1 = makeWidget ();
Widget w2 = std :: move (w1); // OK: move
Widget w3 = w2; // COMPILE ERROR: copy is deleted
Forecast: Which of the five specials does the compiler give Widget, and which does it delete ? (Guess before reading.)
Recall what unique_ptr allows. `std::unique_ptr` can be moved (ownership transfers) but cannot be copied (only one owner allowed).
Why this step? The generated specials for Widget mirror what its members can do. We must know the member's capabilities first.
Trace generation of all five specials.
Destructor: unique_ptr has a correct destructor (it deletes what it owns) ⇒ compiler generates Widget's destructor and it is correct — it simply destroys shape, which frees the Shape.
Move ctor / move assign: unique_ptr is movable ⇒ compiler generates working moves for Widget.
Copy ctor / copy assign: unique_ptr is not copyable ⇒ compiler marks Widget's copies as implicitly deleted (defined in the box up top — the function exists but using it is a compile error).
Why this step? Rule of Zero doesn't just "give you copy" — it gives the right answer, and we must account for all five including the destructor, not just copy/move. A single-owner widget should be uncopyable with an automatic destructor.
Conclude: Widget is automatically move-only with a correct auto destructor. You wrote zero specials and zero = delete.
Verify: the truth table "destructor=auto, movable=yes, copyable=no" is exactly what a single-owner design needs. (Encoded as booleans in VERIFY.)
Same member shape , opposite copy behaviour.
Worked example Example 4 (Cell D) — a node that shares a texture
class Sprite {
std ::shared_ptr < Texture > tex; // shared owner
};
Sprite s1 = load ();
Sprite s2 = s1; // OK: copy — now use_count() == 2
Forecast: After Sprite s2 = s1;, do s1.tex and s2.tex point to the same texture or different ones? What is the reference count?
Recall shared_ptr semantics. `std::shared_ptr` is copyable; copying bumps a shared reference count and both pointers alias the same object.
Why this step? Unlike Cell C, here copy is allowed, so Sprite's generated copy will not be deleted.
Trace generation of all five specials.
Destructor: shared_ptr has a correct destructor (it drops the reference count and frees the Texture only when the last owner dies) ⇒ compiler generates Sprite's destructor and it is correct.
Copy ctor / copy assign: shared_ptr is copyable ⇒ generated and correct; each copy bumps the count.
Move ctor / move assign: shared_ptr is movable ⇒ generated and correct; move steals the pointer and leaves the source empty (count unchanged).
Why this step? We must contrast against Cell C and still cover all five : choosing shared_ptr vs unique_ptr is how you choose your copy semantics — the member decides, not hand-written code — while the destructor is auto in both cases.
Conclude: two Sprites, one texture, use_count() == 2, destructor auto. Zero specials written.
Verify: start count 1, copy once ⇒ count 2. Destroy one ⇒ count 1. (Modelled in VERIFY.)
The parent's contract demands we cover degenerate cases: empty containers, a unique_ptr holding nullptr. Do the auto specials still behave?
Worked example Example 5 (Cell E) — empty and null members
Record empty{}; // name = "", scores = {}
Record copy = empty; // copy of nothing
Widget none; // shape holds nullptr
Widget moved = std :: move (none); // move an empty owner
Forecast: Does copying an empty Record crash or allocate? Does moving a null-owning Widget do anything dangerous?
Empty container copy. std::string("") and std::vector<int>{} copy to another empty string/vector — zero elements, no allocation, no undefined behaviour.
Why this step? The scariest bug in manual code is copying "size 0" with a raw pointer and calling new int[0] / delete[] wrong. RAII members make the empty case just work.
Null owner move. A unique_ptr holding nullptr moves fine: the destination gets nullptr, the source stays nullptr. The destructor of a nullptr unique_ptr deletes nothing.
Why this step? This is the degenerate/limiting input — the "zero vector" of ownership. It must not double-free or crash. RAII guarantees it.
Conclude: empty and null inputs are safe with zero written specials. The auto versions handle the boundary.
Verify: empty copy has size 0; null move leaves both pointers null, deletions = 0. (Checked in VERIFY.)
Here danger appears. This is why the whole rule exists.
Worked example Example 6 (Cell F) — a raw pointer forces the cascade
struct Bad {
int* p;
Bad ( int n ) : p ( new int [n]) {}
~Bad () { delete[] p; } // one hand-written destructor...
};
Bad a ( 5 );
Bad b = a; // default copy: b.p == a.p (aliased!)
Forecast: How many times is that buffer freed when a and b both die?
Spot the raw owning member. int* p came from new[] — it is a non-RAII resource; nothing but our code frees it.
Why this step? The parent's rule: write specials ⟺ direct ownership of a non-RAII resource . This cell trips exactly that condition.
See what default copy does. Compiler copy is memberwise: it copies the pointer value , so b.p == a.p. Two objects, one buffer.
Why this step? We must show the concrete failure, not just assert it.
Count the frees. When b dies, delete[] b.p frees the buffer. When a dies, delete[] a.p frees the same buffer again ⇒ double free , undefined behaviour.
Why this step? This double free is the "double-free bug" the parent warned about — it is the reason one destructor drags in copy, then move (Rule of Five).
The fix (Rule of Zero) — full corrected code. Replace int* p with std::vector<int> p. Now copy is deep, destructor is automatic, free count = 1 per object. Delete all five hand-written functions:
struct Good {
std ::vector <int> p;
explicit Good ( int n ) : p (n) {}
// no destructor, no copy, no move — all correct, all free
};
Good a ( 5 );
Good b = a; // DEEP copy: b.p is a separate buffer from a.p
Why this step? We replace the one non -RAII member with a self-managing one, so the condition in step 1 is never met — the cascade of five specials collapses back to zero, exactly the parent's escape hatch. Now a and b own independent buffers, each freed exactly once.
Figure 1 (below) draws both worlds side by side so you can see the aliasing.
Figure 1 — Cell F: raw-pointer aliasing (left) versus vector deep copy (right). On the left , the two lavender boxes a.p and b.p are the raw Bad objects' pointers; both arrows land on one shared butter-yellow buffer — freeing it twice (once when a dies, once when b dies) is the coral-labelled double free . On the right , the two lavender boxes a.p and b.p are the Good objects; each arrow lands on its own separate mint-green buffer ("buffer A", "buffer B"), so each is freed exactly once (mint CORRECT label). The picture is the whole argument: aliasing (shared butter buffer) on the left, independence (two mint buffers) on the right.
Verify: raw version ⇒ buffer freed 2 times (bug). Vector version ⇒ 2 objects, 2 independent buffers, each freed once. (Modelled in VERIFY.)
First, the keyword that shows up in the move operations below:
noexcept — "this function promises not to throw"
Writing noexcept on a function is a promise to the compiler that the function will never throw an exception . This matters enormously for move operations: containers like std::vector will only use your move constructor while resizing if it is noexcept — otherwise, to stay safe if a throw happened mid-resize, they fall back to a slower copy . So marking your move ops noexcept is what keeps moves fast. Details in noexcept and move operations .
Worked example Example 7 (Cell G) — wrap
FILE* once, then Rule of Zero everywhere
class FileHandle { // the ONE class allowed specials
FILE * f;
public:
explicit FileHandle ( const char* p ) : f ( std :: fopen (p, "r" )) {}
~FileHandle () { if (f) std :: fclose (f); }
FileHandle ( FileHandle && o ) noexcept : f (o.f) { o.f = nullptr ; }
FileHandle & operator = ( FileHandle && o ) noexcept {
if ( this != & o) { if (f) std :: fclose (f); f = o.f; o.f = nullptr ; }
return * this ;
}
FileHandle ( const FileHandle & ) = delete ;
FileHandle & operator = ( const FileHandle & ) = delete ;
};
class Logger {
FileHandle h; // Rule of Zero here!
std ::string name;
};
Forecast: How many classes in this program are allowed to touch raw ownership? What does Logger write?
Identify the un-wrapped resource. FILE* from fopen has no standard RAII wrapper, so someone must manage it. This is Cell G.
Why this step? Cell F said "wrap it"; here we do the wrapping properly, exactly once.
Mark the moves noexcept and move the nullptr carefully. Each move op is tagged noexcept (see the box above — this keeps them fast when stored in containers). After a move, the source's f is set to nullptr so its destructor's if (f) guard skips the second fclose. That is the degenerate guard preventing a double-close.
Why this step? Moves must be fast and leave a valid, destructible source — the same limiting-case discipline as Cell E.
Delete copy. A file handle can't be meaningfully copied (two owners of one OS handle = double close), so copy is = delete. This makes FileHandle move-only.
Why this step? Choosing move-only mirrors Cell C — the type's capability drives what users get.
Logger writes nothing. Its members are FileHandle (move-only) + std::string (movable+copyable). The compiler generates: destructor = yes (destroys h then name, closing the file); move ctor/assign = yes; copy = implicitly deleted (because FileHandle can't copy). Zero specials in Logger.
Why this step? This is the payoff of isolation — one audited type carries the danger, every other class stays at zero danger, and even Logger's destructor is auto-generated.
This isolation is the Single Responsibility Principle applied to ownership: one small audited type carries all the danger.
Verify: number of classes with hand-written specials = 1 (FileHandle); Logger's copy is deleted, its move is generated. (Booleans in VERIFY.)
Worked example Example 8 (Cell H) — a "harmless" destructor kills your moves
struct Cache {
std ::vector <int> data;
~Cache () { std :: puts ( "Cache destroyed" ); } // just logging!
};
Cache make ();
Cache c = make (); // was a move... now a COPY
Forecast: After adding that logging destructor, is Cache c = make() a move or a copy? Faster or slower?
Note the rule about declared destructors. Declaring any destructor — even one that only logs, even = default — makes the compiler suppress the implicit move constructor and move assignment . See noexcept and move operations for why moves matter for performance.
Why this step? This is the topic's limiting behaviour : the generated set is not "all or nothing" — one declaration silently removes two functions.
Trace the fallback. With moves gone, Cache c = make() needs to construct from a temporary, so it uses the still-generated copy constructor. vector's copy is deep — it re-allocates and copies every element.
Why this step? We must show the cost : a cheap pointer-steal became a full deep copy.
The measured effect. For a vector of n ints, a move copies ~3 pointers (constant work); a copy allocates and copies n ints (O ( n ) work). For n = 1 , 000 , 000 that's a million-element copy versus 3 pointers.
Why this step? Quantifying makes the "silent performance killer" concrete.
Fix. Remove the destructor (log elsewhere), or if you truly must keep it, explicitly = default all five to restore moves.
Why this step? You either stay in Rule-of-Zero land (no declaration) or fully take over the generated set — never leave it half-declared, where moves silently vanish.
Mnemonic One destructor, two moves gone
"Declare a destructor, delete your movers." A single ~T() suppresses both move operations.
Verify: move work ≈ 3 units (constant); copy work = n units. For n = 1 0 6 , copy/move ratio ≈ 333333. (Checked in VERIFY.)
Worked example Example 9 (Cell I) — a game
Enemy class
Statement: You are building a game. An Enemy has: a name, a list of loot item IDs, a shared pointer to the sprite sheet (many enemies share one image), and a unique pointer to its private AI-state object (never shared). Design Enemy so you write zero special functions and get: deep-copied loot, shared sprite, and move-only AI. What copy/move behaviour does Enemy end up with?
class Enemy {
std ::string name; // value-ish, deep copy
std ::vector <int> loot; // deep copy
std ::shared_ptr < SpriteSheet > art; // shared on copy
std ::unique_ptr < AIState > brain; // move-only
};
Forecast: Is Enemy copyable? Movable? Guess before the steps.
Classify all four members. name, loot: copyable + movable. art: copyable (shares) + movable. brain: not copyable , movable. All four have correct destructors.
Why this step? The generated specials of Enemy are the intersection of what its members permit.
Copy of Enemy. Copy requires every member be copyable. brain (a unique_ptr) is not ⇒ Enemy's copy is implicitly deleted . Enemy is not copyable .
Why this step? A single non-copyable member is enough to disable the whole class's copy — this is the design lever you pulled by choosing unique_ptr for brain.
Move of Enemy. Every member is movable ⇒ compiler generates a working move: name/loot steal buffers, art transfers the shared pointer, brain transfers ownership.
Why this step? Confirms we got exactly the "move-only, with shared sprite" design — no hand-written code.
Destructor of Enemy. Not declared ⇒ auto-generated; it destroys all four members (dropping the shared sprite's count and freeing the unique brain). Correct.
Why this step? We must account for all five — the destructor is the fifth and it is safe here.
Conclude: Enemy is move-only with an auto destructor. Moving carries a shared sprite (count unchanged, still one image) and a unique brain (ownership transferred). Zero specials written.
Verify: copyable = false (one unique_ptr blocks it), movable = true (all movable). (Booleans in VERIFY.)
Worked example Example 10 (Cell J) — "what does the compiler generate?"
Statement: Given this class, state for each of the five specials whether the compiler generates it, deletes it, or does not generate it (suppressed) .
struct Config {
std ::string path;
std ::unique_ptr <int [] > table;
Config ( const Config & ) = default ; // <-- copy ctor explicitly defaulted
};
Forecast: This is a trap. unique_ptr isn't copyable, yet the author wrote Config(const Config&) = default;. What happens?
Copy constructor. It is declared = default, but a defaulted copy must copy every member. unique_ptr<int[]> is not copyable ⇒ the defaulted copy is implicitly deleted (defining it as defaulted still results in a deleted function).
Why this step? Writing = default does not force copyability; if a member can't be copied, the result is a deleted copy, not a working one.
Copy assignment. Copy assignment is still implicitly generated (deprecated when a copy ctor is user-declared) — and it too is deleted because unique_ptr can't copy-assign. Net effect: copy assignment is unusable.
Why this step? Exam graders love this asymmetry between "declared" and "generated." The practical answer: no usable copy assignment.
Move constructor / move assignment. We declared a copy constructor. Declaring any copy operation suppresses the implicit move operations . So Config has no move ctor and no move assign — it falls back to the (deleted) copy for move-construction ⇒ Config becomes effectively immovable and uncopyable .
Why this step? This is the cascade from the parent, run in reverse: one hand-declared special poisoned the generated set.
Destructor. Not declared ⇒ compiler generates it; it correctly runs path's and table's destructors. This one is fine.
Why this step? We must account for all five — the destructor is the only one left unharmed.
Fix (Rule of Zero). Delete the = default copy-ctor line. Then: copy is auto-deleted (fine, unique_ptr), moves are auto-generated (fine), destructor auto (fine). Config becomes clean move-only with zero written specials.
Why this step? Removing the single hand-declared special lets the whole generated set snap back to the correct move-only shape.
Verify: with the bad line ⇒ copyable=false AND movable=false (worst outcome). Without it ⇒ copyable=false, movable=true. (Booleans in VERIFY.)
only values and containers
Does the class directly own a non-RAII resource
Rule of Zero: write nothing
Is there an RAII wrapper for it
Use the wrapper as a member then Rule of Zero
Write ONE small RAII type then Rule of Zero elsewhere
Member capabilities decide copy and move
Class is move-only copy deleted
Class is copyable copy shares
Class is copyable and movable deep
Recall Quick self-test
A class has one std::string and one std::unique_ptr<T>. Copyable? ::: No — the unique_ptr makes copy implicitly deleted, so the whole class is move-only.
You add ~T() = default; "just to be safe." What breaks? ::: The implicit move operations are suppressed, so the class silently copies (or, if a member is non-copyable, becomes immovable).
Raw FILE* with no wrapper — where do the specials go? ::: Into ONE small RAII class; every other class then uses Rule of Zero.
Copy of a class with a shared_ptr member — deep or shared? ::: Shared — both objects point at the same target and the reference count goes up.
Why mark move operations noexcept? ::: So containers actually use them; a throwing move makes vector fall back to a slow copy.
Common mistake "Defaulting a special makes it appear."
Why it feels right: = default sounds like "please give me the working version."
Why it's wrong: If a member cannot support the operation (e.g. copying a unique_ptr), the defaulted function becomes deleted . And declaring it still suppresses the implicit moves — see Ex 10.
Fix: Don't declare it. Let the compiler decide from the members. If you need copyable semantics, change the member (e.g. shared_ptr or a clone wrapper), not the specials.