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:
#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());
}
}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
Fractionpasses 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:
#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();
}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:
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:
#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>);
}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:
#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 ==):
#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:
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:
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:
#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?
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
explicitconstructors. - 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.
= defaultbrings 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.