The Rule of Five and Rule of Zero in C++: Special Members
Declaring a destructor or copy in a C++ class silently stops the moves being generated. The five special members, the rule of five and the rule of zero.
- Course: C++ study plan
- Module: Copies, moves and the rule of five
- Kind: Lesson
- Reading time: 14 min
- Runtime: Clang 18 · C++20
What is the rule of five in C++?
The rule of five says that if a C++ class declares any of the five special member functions — destructor, copy constructor, copy assignment, move constructor or move assignment — it should consider all five. Declaring a destructor or a copy operation stops the moves being generated, so std::move silently copies. The rule of zero is the better default: own resources through members like std::vector and std::unique_ptr, and declare none.
Lesson
With moves in the language, a class has five special member functions beyond the default constructor: the destructor, the copy constructor, the copy assignment, the move constructor and the move assignment. The compiler generates each of them under rules that depend on which of the others you declared, and those rules have a consequence that surprises everyone once: writing a destructor switches the moves off. This lesson lays out the generation table, states the rule of five and the rule of zero, and gives the one-line decision for which of them a class should follow.
The five
| Special member | Signature | Generated version does |
|---|---|---|
| Destructor | ~T() | destroys members in reverse order |
| Copy constructor | T(const T&) | copy-constructs each member |
| Copy assignment | T& operator=(const T&) | copy-assigns each member |
| Move constructor | T(T&&) noexcept | move-constructs each member |
| Move assignment | T& operator=(T&&) noexcept | move-assigns each member |
Plus the default constructor T(), which the compiler generates only when the class declares no constructor at all — the rule Module 8 lesson 2 gave. The generated move operations are memberwise moves: each member is moved with std::move, so a member that is a std::vector is transferred and a member that is an int is copied. A generated move is noexcept when every member's move is.
What the compiler generates, and when
The full rule fits in four lines:
- If you declare nothing, all five are generated.
- If you declare a move constructor or move assignment, the copy constructor and copy assignment are deleted. A type that knows how to move but says nothing about copying is presumed non-copyable —
std::unique_ptris exactly this shape. - If you declare a copy constructor, copy assignment or destructor, the move constructor and move assignment are not declared at all. They are not deleted:
std::move(x)still compiles, overload resolution finds onlyconst T&, and the object is copied. = defaultand= deleteboth count as declaring.
Rule 3 is the trap. The reasoning behind it is sound — a class that wrote a destructor or copy is managing something, and a generated memberwise move might transfer that something incorrectly — but the failure mode is silent. The class still compiles, still runs, and is slower than it looks:
struct Logged {
std::vector<int> data;
~Logged() { std::cout << "gone\n"; } // user-declared destructor: moves are not generated
};
Logged a;
Logged b = std::move(a); // copies data — a million ints if there are a million
Nothing warns. The compiler was told the class has special needs and did the conservative thing. The fix is one line, Logged(Logged&&) = default; together with its assignment — or, better, not writing the destructor.
The rules are why "does this class have a move constructor?" is a real interview question with a three-part answer: yes if it declares nothing or declares the moves; no if it declares any of the copy operations or the destructor and does not also default the moves.
The rule of five
The rule of three, extended: if a class declares any one of the five, it should consider all five. A rule-of-three class from before C++11 is correct after C++11 — copies work, moves fall back to copies — but it leaves performance behind, because everything that could have been a move is now a copy. Adding the two move operations completes it:
class IntBuffer {
public:
explicit IntBuffer(std::size_t n);
IntBuffer(const IntBuffer& other); // deep copy
IntBuffer& operator=(const IntBuffer& other); // self-safe deep copy
IntBuffer(IntBuffer&& other) noexcept; // steal, null the source
IntBuffer& operator=(IntBuffer&& other) noexcept; // release, steal, null
~IntBuffer(); // delete[]
// ...
};
With copy-and-swap the count drops: a by-value operator=(IntBuffer other) serves both assignments, chosen by whether the argument is an lvalue (copy-constructed parameter) or an rvalue (move-constructed parameter). That version is four functions plus a swap.
"Consider" is deliberate. Some classes should declare the moves as = delete — a type that must never leave its address, such as a mutex — and some should declare the copies deleted and only the moves, like a file handle. The rule is that the decision must be made for all five, not that all five must be written.
The rule of zero
The better rule is to never be in that position. A class that declares none of the five gets correct copies, correct moves and a correct destructor from its members — provided every member owns itself:
class Document {
public:
Document(std::string title, std::vector<std::string> lines)
: title_(std::move(title)), lines_(std::move(lines)) {}
// no destructor, no copy, no move: std::string and std::vector do it all
private:
std::string title_;
std::vector<std::string> lines_;
};
Document is copyable (both members are), movable (both members are, noexcept), and destroys itself. It cannot double-delete, cannot leak, cannot be shallow-copied, because there is no code in it that could get those things wrong. The same holds when a member is std::unique_ptr:
class Grid {
public:
Grid(std::size_t rows, std::size_t cols)
: rows_(rows), cols_(cols), cells_(std::make_unique<int[]>(rows * cols)) {}
bool empty() const { return !cells_; }
private:
std::size_t rows_, cols_;
std::unique_ptr<int[]> cells_;
};
Grid g(2, 3);
Grid h = std::move(g); // moves cells_, copies rows_ and cols_; g.empty() is now true
Grid k = h; // error: unique_ptr's copy constructor is deleted, so Grid's is too
The class is movable and non-copyable by construction, and the compiler says so at the point of use. Note what the moved-from g looks like: rows_ and cols_ still hold 2 and 3, because ints copy, and cells_ is null. A rule-of-zero class's moved-from state is the sum of its members' moved-from states, which is why empty() tests the pointer and not the sizes.
The name means what it says: the ideal number of special member functions in a class is zero. Ownership of raw resources belongs in small, single-purpose classes — std::unique_ptr, std::vector, a FileHandle you write once — and everything else composes them.
Which rule, when
| The class... | Rule | Declare |
|---|---|---|
| owns nothing directly; members are strings, vectors, smart pointers | zero | nothing |
owns one raw resource (a new[] block, a file descriptor, a C handle) | five | all five, noexcept on the moves |
| must not be copied but may be moved | five | copies = delete, moves written or defaulted |
| must not be copied or moved (a mutex, a singleton) | five | copies = delete; the moves are then not generated, so std::move fails to compile too |
| is a polymorphic base | five | virtual ~T() = default; plus the four defaulted, usually protected (Module 10) |
Two habits follow. First, one raw resource per class: a class that owns two raw pointers has a copy assignment that must handle one allocation succeeding and the other failing, which is exactly the code nobody gets right. Second, = default is documentation: Widget(Widget&&) = default; in a class that needed a destructor says "I know the rule, and the memberwise move is correct here".
Pitfalls
| Mistake | What happens |
|---|---|
| A destructor added for logging or debugging | The moves vanish; every std::move becomes a copy |
| Copy constructor written, move forgotten | Correct but slow: std::move and returns copy |
| Move constructor written, copies not mentioned | The copies are deleted; T b = a; fails to compile |
| Two raw owning pointers in one class | An assignment operator with a failure case between two allocations |
| Defaulting the moves in a class with a raw owning pointer | The memberwise move copies the pointer and leaves the source owning it too |
Key takeaways
- Five special members: destructor, copy constructor, copy assignment, move constructor, move assignment — all generated memberwise when you declare none.
- Declaring a copy operation or a destructor stops the moves being generated;
std::movethen silently copies. Declaring a move deletes the copies. - Rule of five: declare one, decide about all five. Rule of zero: own resources through members and declare none.
- A rule-of-zero class's moved-from state is its members' moved-from states — a
std::unique_ptrbecomes null, anintstays. - Keep raw ownership in small single-resource classes and compose them;
= defaulton a move documents that the memberwise version is right.
Common questions
What is the rule of zero in C++?
The rule of zero says a class should declare none of the five special members. If every member manages itself — std::string, std::vector, std::unique_ptr — the generated copy, move and destructor are all correct, and the class cannot leak, double-delete or be shallow-copied. Raw ownership belongs in small single-resource classes.
When does the compiler generate a move constructor?
Only when the class declares no copy constructor, copy assignment, destructor or move operation of its own. Declaring any copy operation or a destructor, even as = default, means the moves are not declared, so rvalues bind to the copy constructor. Write T(T&&) = default; to get them back.
Why does declaring a destructor make my class slower?
A user-declared destructor, even ~T() = default;, stops the compiler generating the move constructor and move assignment. std::move still compiles, but overload resolution finds only the copy constructor, so every intended move becomes a full copy, with no warning.
What happens when a class declares a move constructor but no copy constructor?
Its copy constructor and copy assignment are deleted, so the type is move-only, like std::unique_ptr. T b = std::move(a); compiles, while T b = a; is an error naming the deleted copy constructor.
Exercises
A rule-of-zero grid
Write Grid, a rows × columns table of int that owns its cells through std::unique_ptr<int[]> and declares none of the five special member functions — the rule of zero. The compiler makes it movable and non-copyable because std::unique_ptr is.
Grid(std::size_t rows, std::size_t cols)allocatesrows * colszeroed cells withstd::make_unique<int[]>.int& at(std::size_t r, std::size_t c)and aconstoverload, row-major:cells_[r * cols_ + c].bool empty() constreturns whether the pointer is null — the moved-from state of a rule-of-zero class is its members' moved-from states, and a moved-fromstd::unique_ptris null while the two sizes are simply copied.Grid transpose() constreturns a new columns × rows grid.void print() constprints each row on its own line, values separated by single spaces.
Read R C and then R rows of C integers into g. Compute Grid t = g.transpose();, then Grid moved = std::move(g);. Print grid RxC: followed by moved's rows, transposed CxR: followed by t's rows, then source empty=true and moved empty=false from the two empty() calls (print true/false). If you are curious, add Grid copy = moved; and read the compiler's error before removing it.
Input: R C, then R lines of C integers. Output: as below.
2 3
1 2 3
4 5 6
prints
grid 2x3:
1 2 3
4 5 6
transposed 3x2:
1 4
2 5
3 6
source empty=true
moved empty=falseWhat std::move actually does
Test the generation table. Tracker is a member type that prints copy, move, copy-assign or move-assign (two leading spaces) whenever one of its special members runs. Complete four wrapper structs, each holding one Tracker t;:
Zerodeclares nothing;DtorOnlydeclares only~DtorOnly() = default;;CopyOnlydeclaresCopyOnly() = default;, a defaulted copy constructor and a defaulted copy assignment;Fivedeclares a defaulted default constructor and all five special members, defaulted.
Read words until the input ends. For each word print it on its own line, then run X a; X b = std::move(a); a = std::move(b); for the matching type, so Tracker prints what the compiler generated: a move when the move operations exist, a copy when they were suppressed. An unknown word prints unknown. Predict the output before you run it: which of the four declared something that stops the moves being generated?
Input: words separated by whitespace. Output: three lines per word.
zero dtor
prints
zero
move
move-assign
dtor
copy
copy-assignIn this module: Copies, moves and the rule of five
- Copy semantics — what a copy is, and when the default one is wrong
- Destructors and the rule of three
- Move semantics — rvalue references, std::move and noexcept
- The rule of five and the rule of zero (this lesson)
- Value categories, temporaries and copy elision
- Checkpoint — Copies, moves and the rule of five
← Move semantics — rvalue references, std::move and noexcept · Value categories, temporaries and copy elision →