5.2.11 · D4C++ Programming

Exercises — Rule of Zero — prefer compiler-generated specials

2,251 words10 min readBack to topic

Before we count anything, let us fix the vocabulary so no symbol appears unearned.

The scoreboard we use everywhere below is a single count, drawn as a dial.

Figure — Rule of Zero — prefer compiler-generated specials

One more fact used constantly, so we state it once and picture it.

Figure — Rule of Zero — prefer compiler-generated specials

Level 1 — Recognition

L1.1

State whether this class obeys Rule of Zero (writes zero specials correctly). How many specials will the compiler generate for it?

struct Point { int x; int y; };
Recall Solution

Both members are int — plain value types, no resource. Memberwise copy/move/destroy is correct. So Rule of Zero holds. The compiler generates all 5 specials (plus the default constructor). Hand-written specials needed: 0.

L1.2

For each member type, answer "does the class directly own a non-RAII resource?" (a) std::string name; (b) int* buf; (built with new) (c) std::unique_ptr<Node> next; (d) FILE* f;

Recall Solution

(a) Nostd::string is RAII, self-managing → Rule of Zero. (b) Yes — raw owning pointer, non-RAII → forces Rule of Five. (c) Nounique_ptr is RAII → Rule of Zero. (d) YesFILE* has no standard RAII wrapper → must wrap it. Count of members that break Rule of Zero: 2 (b and d).

L1.3

Rule of Zero means I must literally never type any special-function keyword. True or false?

Recall Solution

False. Rule of Zero means zero hand-written resource logic (no bodies babysitting a raw resource). Writing T() = default; to keep a default constructor after adding another constructor is perfectly within the spirit — you wrote no ownership code.


Level 2 — Application

L2.1

This class compiles. Exactly how many of the five specials are user-declared, and how many does the compiler generate?

class Log {
    std::vector<std::string> lines;
public:
    Log() = default;
    void add(std::string s) { lines.push_back(std::move(s)); }
};
Recall Solution

Log() is a default constructor, which is not one of the five specials — it does not trip the suppression rule. So:

  • User-declared specials (of the five): 0.
  • Compiler-generated specials: 5 (dtor, copy-ctor, copy-assign, move-ctor, move-assign), all correct via vector<string>. Adding a member function like add never affects special generation.

L2.2

Refactor to Rule of Zero and count the lines you deleted.

class Buffer {
    int* data; size_t n;
public:
    Buffer(size_t n) : data(new int[n]), n(n) {}
    ~Buffer()                       { delete[] data; }
    Buffer(const Buffer& o)         : data(new int[o.n]), n(o.n) { std::copy(o.data,o.data+n,data); }
    Buffer& operator=(const Buffer&);   // copy-and-swap body elsewhere
    Buffer(Buffer&& o) noexcept     : data(o.data), n(o.n) { o.data=nullptr; }
    Buffer& operator=(Buffer&&) noexcept;
};
Recall Solution

Replace the raw int* + size_t with a self-managing std::vector<int>:

class Buffer {
    std::vector<int> data;
public:
    explicit Buffer(size_t n) : data(n) {}
};

We removed the 5 declared specials. Counting the special-function lines shown (dtor, copy-ctor, copy-assign, move-ctor, move-assign = 5 declarations), all are gone. Hand-written specials: from 5 → 0.


Level 3 — Analysis

L3.1

A teammate adds a "harmless" logging destructor:

class Session {
    std::vector<char> buf;
public:
    ~Session() { std::cout << "Session closed\n"; }
};

Which specials does the compiler now generate, and what performance bug appears?

Recall Solution

Declaring any destructor trips the suppression gate: the compiler no longer generates the move constructor and move assignment. It still generates copy-ctor and copy-assign.

  • Generated: dtor (user's), copy-ctor, copy-assign = the class becomes copy-only.
  • Bug: every "move" of a Session now silently deep-copies the whole vector<char> — potentially huge and slow. See the gate figure. Fix: delete the logging dtor, or explicitly = default all five to restore moves (but prefer to log elsewhere).

L3.2

Is Widget copyable? movable? Explain from the members, no compiler needed.

class Widget {
    std::unique_ptr<Shape> shape;
    std::string label;
};
Recall Solution

unique_ptr is movable but not copyable; std::string is both.

  • Copy: the implicit copy-ctor would need to copy every member. Since unique_ptr's copy is deleted, the compiler implicitly deletes Widget's copy-ctor and copy-assign. So Widget is not copyable.
  • Move: both members are movable, so the compiler generates a correct move-ctor/move-assign. Widget is movable. Result: Widget is move-only, exactly as intended, with 0 hand-written specials.

L3.3

Count the correctly-working specials the compiler provides for

class Cache { std::shared_ptr<Table> t; int hits; };
Recall Solution

shared_ptr is copyable (bumps refcount) and movable; int is trivially both. No member disables anything. So the compiler generates all 5 specials, all correct: copy shares ownership, move transfers it, destroy decrements the count. Hand-written: 0.


Level 4 — Synthesis

L4.1

You must wrap a raw FILE* (no standard RAII exists). Design the minimum so that a Logger holding it can itself follow Rule of Zero. How many classes end up writing specials, and how many specials in total?

Recall Solution

Isolate the danger in one RAII type (Single Responsibility Principle):

class FileHandle {                     // the ONLY class with 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&&) noexcept;
    FileHandle(const FileHandle&)            = delete;
    FileHandle& operator=(const FileHandle&) = delete;
};
 
class Logger {
    FileHandle h;                      // Rule of Zero here!
    std::string name;
};  // Logger writes ZERO specials; move-only, copy disabled — inherited from FileHandle.
  • Classes writing specials: 1 (FileHandle).
  • Specials FileHandle declares: dtor, move-ctor, move-assign, deleted copy-ctor, deleted copy-assign = 5.
  • Specials Logger writes: 0. Logger is move-only because FileHandle is.

L4.2

FileHandle's move ops are marked noexcept. Why does that keyword matter for a std::vector<Logger>?

Recall Solution

When a vector grows it reallocates. It will move existing elements into new storage only if the move is noexcept — otherwise it copies them to preserve the strong exception guarantee. But Logger/FileHandle are non-copyable! So without noexcept, vector<Logger> growth would fail to compile (no copy available) or be forced down the copy path. Marking moves noexcept lets the vector move safely. See noexcept and move operations.


Level 5 — Mastery

L5.1

A tree node wants to deep-copy its whole subtree, yet you refuse to hand-write a copy constructor. Which member type gives copyable value semantics while keeping Rule of Zero, and which gives shared semantics instead? Contrast the two.

Recall Solution
  • Value (deep) copy, Rule of Zero: use a value-semantic wrapper that clones on copy (e.g. a "polymorphic value" type, or store std::vector<Node> directly so children copy deeply). Copy produces an independent subtree; still 0 hand-written specials because the member's own copy is deep.
  • Shared semantics: use std::shared_ptr<Node>. Copy is cheap and shares the same children (refcount++), not an independent subtree. The choice of member dictates copy behaviour — you never write the copy constructor; you pick it. Compare with Rule of Three and Rule of Five, where you'd hand-craft the deep copy instead.

L5.2 (capstone count)

Given this design, total the hand-written specials across all classes, and state whether the whole system still counts as "Rule of Zero throughout."

struct Vec3   { double x,y,z; };
class Mesh    { std::vector<Vec3> verts; std::string name; };
class GpuBuf  { unsigned id; ~GpuBuf(){ glDelete(id);} GpuBuf(GpuBuf&&) noexcept; /* +2 deleted copies, +1 move-assign */ };
class Model   { Mesh mesh; std::unique_ptr<GpuBuf> gpu; };
Recall Solution
  • Vec3: three doubles → 0 hand-written.
  • Mesh: vector + string, both RAII → 0.
  • GpuBuf: raw unsigned OS-like handle → must write specials: dtor + move-ctor + move-assign + 2 deleted copies = 5 declared.
  • Model: members are Mesh (Rule of Zero) and unique_ptr<GpuBuf> (RAII) → 0. Because unique_ptr is move-only, Model is move-only automatically. Total hand-written specials across the system: 5, all confined to the single resource-owning GpuBuf. Every other class writes 0. Verdict: the system is "Rule of Zero throughout" in the intended sense — resource logic is isolated to exactly one audited type, and no business class writes a special. This is Rule of Zero + Single Responsibility working together.

Recall One-line self-test

The reflex ::: Ask "does this class directly own a non-RAII resource?" — if no, write zero specials. The gate ::: Declaring any of {dtor, copy-ctor, copy-assign} silently deletes the free move-ctor and move-assign.