CodeRigor

FREE LESSON

Classes: invariants and the rule of zero/five

Design classes that establish and keep an invariant, let the compiler write the special members (rule of zero), and know exactly when it can't.

A class is a promise. Its data members can hold many combinations of values, but only some of them are meaningful — a fraction whose denominator is zero, an interval whose low end is above its high end, a buffer whose size exceeds its capacity. The set of meaningful states is the class's invariant. Making the data private, establishing the invariant in constructors and preserving it in every member function is what turns a bag of fields into a type you can trust.

The second half of this lesson is about the other member functions — copying, moving and destroying — and the best way to write them: usually, not at all.

1. Invariants: establish, then preserve

Here's a fraction that is always in lowest terms with a positive denominator:

C++
#include <numeric>
#include <print>
#include <stdexcept>

class Fraction {
public:
    Fraction() = default;                      // 0/1
    explicit Fraction(long long n, long long d = 1) {
        if (d == 0) throw std::invalid_argument("zero denominator");
        if (d < 0) { n = -n; d = -d; }         // the sign lives in the numerator
        long long g = std::gcd(n, d);          // gcd(0, d) == d, so 0/d -> 0/1
        num_ = n / g;
        den_ = d / g;
    }
    long long num() const { return num_; }
    long long den() const { return den_; }

private:
    long long num_ = 0;                        // default member initialisers
    long long den_ = 1;
};

int main() {
    long long cases[][2] = {{2, 4}, {1, -2}, {0, -7}, {-6, -9}};
    for (auto [n, d] : cases) {
        Fraction f(n, d);
        std::println("{}/{} -> {}/{}", n, d, f.num(), f.den());
    }
}
Every Fraction goes through one gate
Fraction(-6, -9): the sign moves to the numerator, then both divide by their gcd — 2/3. Members that change the value build a new one through the same constructor, so normalisation lives in one place.
Every Fraction goes through one gate
Fraction(-6, -9): the sign moves to the numerator, then both divide by their gcd — 2/3. Members that change the value build a new one through the same constructor, so normalisation lives in one place.

Three things make the invariant hold:

  • Private data. Nobody outside the class can set den_ = 0.
  • Constructors establish it. Every way to create a Fraction passes through code that checks and normalises. Invalid input is an exception, not a broken object.
  • Members preserve it. Any member that changes state must leave the object valid. The easiest way: compute the new value and pass it through the constructor, so normalisation lives in one place.

The payoff is that every other piece of code can assume the invariant. Equal fractions have identical members, so comparing them is just comparing members.

2. Construction details that bite

Initialisation order is declaration order, not the order you write the member-initialiser list. GCC warns with -Wreorder; take the warning seriously:

C++
#include <print>

struct Tracer {
    explicit Tracer(const char* name) { std::print("{} ", name); }
};

struct Widget {
    Tracer first;
    Tracer second;
    Widget() : second("second"), first("first") {}   // still constructs first, then second
};

int main() {
    Widget w;
    std::println();
}
The list's order is ignored
Members are constructed in the order they're declared. Writing second(...) first in the list changes nothing — and if first's initialiser read second, it would read an object that doesn't exist yet.
The list's order is ignored
Members are constructed in the order they're declared. Writing second(...) first in the list changes nothing — and if first's initialiser read second, it would read an object that doesn't exist yet.

Bases are initialised before members, and destruction runs in exactly the reverse order. If one member's initialiser reads another member, the one being read must be declared earlier.

Default member initialisers (long long den_ = 1;) give every constructor a safe starting point and let the default constructor be = default.

explicit stops a constructor from being used as an implicit conversion. Without it, void show(Fraction) would accept show(3) and silently build 3/1. Make single-argument (and defaulted-multi-argument) constructors explicit unless the conversion is genuinely lossless and expected, like std::string from a string literal:

C++
class Fraction {
public:
    explicit Fraction(long long n, long long d = 1) : n_(n), d_(d) {}
private:
    long long n_, d_;
};

void show(Fraction) {}

int main() {
    show(3);   // error: no implicit conversion from int
}

3. The special member functions

The compiler can write six members for you: default constructor, destructor, copy constructor, copy assignment, move constructor, move assignment. Whether it does depends on what you declared:

You declare… copy ctor copy assign move ctor move assign
nothing generated generated generated generated
destructor generated (deprecated) generated (deprecated) not declared not declared
copy constructor yours generated (deprecated) not declared not declared
copy assignment generated (deprecated) yours not declared not declared
move constructor deleted deleted yours not declared
move assignment deleted deleted not declared yours

The destructor is generated unless you declare one. The default constructor is generated only if you declare no constructor at all — and copy and move constructors count.

Two rows deserve emphasis:

  • Declaring a destructor — even an empty one — removes the moves. They aren't deleted, just not declared, so T b = std::move(a); quietly picks the copy constructor. Your code still compiles; it just copies where it used to move, and those copies can throw.
  • Declaring a move removes the copies (they become deleted).

"Generated" also means "generated if possible": a generated copy constructor is deleted when some member can't be copied (a std::unique_ptr, say).

4. The rule of zero

If a class doesn't directly manage a resource, declare none of the five (destructor, copy/move constructor, copy/move assignment).

Compose your class from members that already manage themselves — std::string, std::vector, std::unique_ptr, std::optional — and the generated members do the right thing, member by member:

C++
#include <memory>
#include <print>
#include <string>
#include <type_traits>
#include <vector>

struct Document {                         // copyable: copies every line
    std::string title;
    std::vector<std::string> lines;
};

struct Editor {                           // move-only, because unique_ptr is
    std::string user;
    std::unique_ptr<Document> open;
};

int main() {
    std::println("Document: copy {}, nothrow move {}",
                 std::is_copy_constructible_v<Document>,
                 std::is_nothrow_move_constructible_v<Document>);
    std::println("Editor:   copy {}, nothrow move {}",
                 std::is_copy_constructible_v<Editor>,
                 std::is_nothrow_move_constructible_v<Editor>);
}
The rule of zero: members vote
The generated copy and move are member-by-member. Document copies because all its members copy; Editor is move-only because one member is.
The rule of zero: members vote
The generated copy and move are member-by-member. Document copies because all its members copy; Editor is move-only because one member is.

Each member decides its own copy/move semantics, and the class inherits them. Editor didn't have to say "I'm move-only" — holding a unique_ptr is saying it. When you need a deliberate deep copy of such a class, give it an explicit named function (clone(), fork()) rather than an expensive copy constructor.

5. The rule of five, when you must

A class that directly owns a resource — a raw new, a file descriptor, a FILE*, a mutex handle from a C API — needs all five, because the generated versions would copy the handle and release it twice. That class should do nothing else: wrap the resource once (or use std::unique_ptr with a custom deleter, as in RAII, scope and lifetime), then build everything else from that wrapper by the rule of zero. The Move semantics and Dynamic array skills cover writing the five in detail.

6. = default and = delete

= default asks for the compiler's version explicitly — useful to bring back a member you suppressed (e.g. moves after declaring a destructor) or to document intent. = delete removes an overload. C++26 lets you say why, and the message appears in the compiler error:

C++
#include <string>

class Ticket {
public:
    explicit Ticket(std::string id) : id_(std::move(id)) {}
    Ticket(const Ticket&) = delete("a ticket is unique; move it instead");
    Ticket& operator=(const Ticket&) = delete("a ticket is unique; move it instead");
    Ticket(Ticket&&) noexcept = default;     // declaring the copy removed these; bring them back
    Ticket& operator=(Ticket&&) noexcept = default;
private:
    std::string id_;
};

int main() {
    Ticket a("T-1");
    Ticket b = a;   // error: use of deleted function ... a ticket is unique; move it instead
}

7. Comparisons: defaulted == and <=>

C++20 can write comparisons for you. A defaulted == compares members in declaration order; a defaulted <=> compares them lexicographically and gives you <, <=, >, >= (a defaulted <=> also implies a defaulted ==):

C++
#include <compare>
#include <print>

struct Version {
    int major = 0;
    int minor = 0;
    auto operator<=>(const Version&) const = default;
};

int main() {
    std::println("{}", Version{1, 10} > Version{1, 9});   // true: 10 > 9, not "10" < "9"
    std::println("{}", Version{2, 0} == Version{2, 0});
}

Lexicographic order is right for versions, dates and tuples — and wrong for fractions, where 1/3 < 1/2 even though the numerators tie and 3 > 2. When the member order isn't the value order, write <=> yourself, and remember that a custom <=> doesn't give you ==: default == separately if memberwise equality is still right (it is for a normalised fraction).

Hidden friends. Define operators as friends inside the class:

C++
friend bool operator==(const Fraction&, const Fraction&) = default;
friend std::strong_ordering operator<=>(const Fraction& a, const Fraction& b) {
    return a.num_ * b.den_ <=> b.num_ * a.den_;
}

A friend defined in the class body isn't visible to ordinary lookup; it's found only by argument-dependent lookup when one of the arguments is a Fraction. That keeps it out of every other overload set in the program (faster compiles, shorter error messages), and it treats both operands symmetrically.

8. Const-correct accessors

Accessors that return references come in pairs: the const overload hands out read-only access to a const object, the non-const one allows modification:

C++
const std::string& line(std::size_t i) const { return lines_.at(i); }
std::string&       line(std::size_t i)       { return lines_.at(i); }

Returning a reference to a member is fine — as long as the caller doesn't outlive the object. const std::string& t = make_doc().title(); dangles as soon as the temporary document is destroyed at the end of the statement.

Worked example: completing Fraction

Arithmetic builds raw results and lets the constructor normalise them, so the invariant can't break:

C++
#include <compare>
#include <numeric>
#include <print>
#include <stdexcept>

class Fraction {
public:
    Fraction() = default;
    explicit Fraction(long long n, long long d = 1) {
        if (d == 0) throw std::invalid_argument("zero denominator");
        if (d < 0) { n = -n; d = -d; }
        long long g = std::gcd(n, d);
        num_ = n / g;
        den_ = d / g;
    }
    long long num() const { return num_; }
    long long den() const { return den_; }

    friend bool operator==(const Fraction&, const Fraction&) = default;
    friend std::strong_ordering operator<=>(const Fraction& a, const Fraction& b) {
        return a.num_ * b.den_ <=> b.num_ * a.den_;
    }
    friend Fraction operator+(const Fraction& a, const Fraction& b) {
        return Fraction(a.num_ * b.den_ + b.num_ * a.den_, a.den_ * b.den_);
    }

private:
    long long num_ = 0;
    long long den_ = 1;
};

int main() {
    Fraction s = Fraction(1, 6) + Fraction(1, 3);
    std::println("1/6 + 1/3 = {}/{}", s.num(), s.den());
    std::println("1/3 < 1/2: {}", Fraction(1, 3) < Fraction(1, 2));
    std::println("2/4 == 1/2: {}", Fraction(2, 4) == Fraction(1, 2));
}

Faded example — your turn

An Interval holds [lo, hi] with the invariant lo <= hi. Fill in the constructor so that an interval given "backwards" is stored the right way round, and decide: can operator== and operator<=> both be defaulted here?

C++
class Interval {
public:
    Interval(int a, int b) : lo_(/* ??? */), hi_(/* ??? */) {}
    int lo() const { return lo_; }
    int hi() const { return hi_; }
    auto operator<=>(const Interval&) const = /* ??? */;
private:
    int lo_, hi_;
};
Answer

lo_(std::min(a, b)), hi_(std::max(a, b)). Equality can be defaulted (same endpoints means same interval). Defaulting <=> gives "order by lo, then by hi" — a perfectly good total order for sorting intervals, so = default is fine here. Note lo_ is declared before hi_, so initialisation order matches.

Summary

  • An invariant is the set of valid states. Private data + constructors that establish it + members that preserve it.
  • Members initialise in declaration order. Use default member initialisers and explicit constructors.
  • Rule of zero: build classes from self-managing members and declare none of the five special members.
  • Declaring a destructor or copy operation silently removes the moves; declaring a move deletes the copies. = default brings them back.
  • Rule of five only in the one small class that owns a raw resource.
  • = delete("reason") (C++26) documents why an operation is gone.
  • Defaulted ==/<=> compare memberwise; write <=> yourself when member order isn't value order. Prefer hidden friends.

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

When the compiler generates each special member, and when declaring one suppresses others — the table you need for the rule of zero/five.

Focused walk through the rule of five with worked examples.

Designing classes around invariants and good interfaces (part 1).

Part 2: implementation guidelines — member initialisation, data member order, special members.

=delete beyond copy suppression: deleting overloads and special members to make misuse a compile error.

Default member initializers: how they keep constructors short and make rule-of-zero classes safe by default.