Skip to content
academia.sh

Lesson 03 / 11

The Rule of Zero and the Rule of Five

How writing a destructor suppresses the implicit move constructor, how the five special member functions depend on one another, and how the rule of zero reaches the same result without ever forming that dependency.

Published Updated

In this lesson

  • builds an example showing how writing a destructor suppresses the implicit move constructor
  • explains when explicitly requesting the five special member functions with = default is needed
  • correctly handles a class following the rule of zero whose member falls into the unspecified kind after a move
  • justifies that the choice between the rule of zero and the rule of five is the class author's decision
Contents

The previous lesson showed that std::move alone does not guarantee a move — the actual decision is made by overload resolution: a const source could not bind to the move constructor, so it fell back to the copy constructor. The same fall can happen for a non-const source too — this time not because of a type qualifier, but because of another special member function the class itself writes. This lesson measures how writing a destructor silently removes the implicit move constructor.

Writing a Destructor Suppresses the Move Constructor

// record.cpp — how writing a destructor suppresses the implicit move constructor
#include <cstdio>
#include <string>
#include <utility>
struct Record {
    std::string name;
    ~Record() {}
};
static void destructor_class() {
    Record a{"the long name of a class with a written destructor, past the small string optimization"};
    std::string before = a.name;
    Record b = std::move(a);
    std::printf("b.name = %s\n", b.name.c_str());
    std::printf("a.name unchanged: %s\n", (a.name == before) ? "yes" : "no");
}
struct Package {
    std::string content;
    explicit Package(std::string c) : content(std::move(c)) {}
    ~Package() = default;
    Package(const Package&) = default;
    Package& operator=(const Package&) = default;
    Package(Package&&) = default;
    Package& operator=(Package&&) = default;
};
static void five_default() {
    Package a("the content of a package moved with all five functions explicitly requested, kept long");
    Package b = std::move(a);
    (void)b;
    std::printf("five functions explicitly requested, move ran\n");
}
struct PlainRecord {
    std::string name;
};
static void zero_rule() {
    PlainRecord a{"the long name of a plain record following the rule of zero, for a move"};
    PlainRecord b = std::move(a);
    std::printf("b.name = %s\n", b.name.c_str());
    a.name = "new value";
    std::printf("a.name (after a new value is given) = %s\n", a.name.c_str());
}
int main() {
    destructor_class();
    five_default();
    zero_rule();
}
b.name = the long name of a class with a written destructor, past the small string optimization
a.name unchanged: yes
five functions explicitly requested, move ran
b.name = the long name of a plain record following the rule of zero, for a move
a.name (after a new value is given) = new value

OM5. Record’s only written special member function is the destructor — even with an empty body, the moment the user writes a destructor the compiler no longer generates an implicit move constructor or an implicit move assignment. The line Record b = std::move(a); therefore finds no move constructor; overload resolution falls back to the copy constructor, which still implicitly exists. The result is that a.name stays unchanged even after the move — the a.name unchanged: yes line in the output confirms this. std::move(a) was written here too, but what got called was not a move but a copy.

This looks like it reaches a different conclusion from the question the previous course measured (M11/K04: which object does an expression operate on), but it actually repeats the same distinction: in K04, the copy kind named a destination opening a new object; here, unchanged names the source itself staying the same. The line Record b = std::move(a); is a copy operation in the sense K04 measures (b is a new object), but the source’s (a’s) state in the sense this course measures is unchanged — the two measurements evaluate the same line on two different axes.

The Five Special Member Functions and the Rule of Five

A class has five special member functions: the destructor, the copy constructor, the copy assignment operator, the move constructor, the move assignment operator. When one of these is written by hand, the implicit versions the compiler generates for the others can change or disappear entirely — the Record example above showed exactly that. The rule of five turns this dependency into a discipline: if one of these five is written by hand, the remaining four should also be considered, and written or requested explicitly if needed.

Each of the five runs at a different moment, for a different purpose. The destructor (~T()) is responsible for releasing every resource the object holds when its lifetime ends; if a class holds a raw resource (a file handle, a pointer, a lock), the destructor is the only guaranteed place to give that resource back. The copy constructor (T(const T&)) builds a new object as a copy of an existing one; it does not share the source, it opens its own independent copy. The copy assignment operator (T& operator=(const T&)) does the same copying work, but over a target object that already exists — so it must first release the target’s old resource, then set up the new one. The move constructor (T(T&&)) and the move assignment operator (T& operator=(T&&)), on the other hand, take over the resource instead of copying it: without opening a new resource, they move what is in the source to the target and leave the source in a state the destructor can safely run against. This is exactly where the dependency among the five comes from — one is built on the assumption of another: the move constructor cannot safely empty the resource without knowing how the destructor releases it; the copy assignment operator cannot release the old resource without knowing which resource the destructor releases. When a class writes only one of these five by its own hand, the compiler can no longer confidently answer “were the rest also written under the same assumptions,” and it stops generating some of them.

The Package class makes this request explicit with = default: the destructor, the copy constructor, the copy assignment operator, the move constructor, the move assignment operator — all five are requested from the compiler with = default. There is no gap here for the compiler to fill: Package’s own written constructor (explicit Package(std::string c)) only blocks the implicit default constructor (the parameterless Package()) from being generated; the other five special member functions would have been generated the same way even without writing = default. = default does not change the outcome, it makes the intent visible in the code: a reader sees that the five functions exist and run in their compiler-generated form without having to scan the file. The five functions explicitly requested, move ran line in the output states that the move constructor really is called here (and that it moves the content member in its compiler-generated form).

This rule is an extended version of K04’s rule of three (rule of three — a copy constructor, a copy assignment operator, and a destructor depend on one another) with the move functions added; before C++11 only three functions depended on each other, and adding the move functions extended the dependency to five. K04’s rationale was: if a class writes its own destructor (usually to release a raw resource), the compiler’s generated implicit copy constructor and copy assignment operator copy that resource member by member — two objects end up sharing the same resource, and both try to release it in their own destructor, resulting in a double free. The same rationale applies to the move functions once added: if an implicit move constructor copies the resource from source to target and leaves it in the source too (without the zeroing specific to a move constructor), it carries the same double-free risk. The rule of five is therefore a continuation of the rule of three, not a separate rule: it is the same principle — “a hand-written special member function breaks the implicit assumption of the others” — extended to also cover moving.

Suppression Does Not Work the Same Way in Both Directions

The Record example showed one direction: writing a destructor prevents the move functions from being generated — there is no move constructor or move assignment, as if they had never been declared. Overload resolution sees this absence and falls back to the still-implicitly-generated copy constructor; that is why Record b = std::move(a); compiles and runs, it just performs a copy.

The reverse direction works differently. If a class writes its move constructor or move assignment by hand, the compiler does not implicitly generate the copy constructor and copy assignment — moreover, it treats them as explicitly = deleted. Code that tries to copy such a class does not compile; overload resolution does not find a fallback and quietly fall to it, it produces an error. This is exactly why Package explicitly requests all five with = default: had it written only the move functions and left copying to itself, any code trying to copy Package would be rejected at compile time — this may or may not be the outcome the class intends, but it is not meant to happen silently.

This asymmetry between the two directions is not arbitrary, it is a safety choice. When the compiler sees a move function written, it assumes the class author thought something special about how the resource is managed — perhaps the class considers it meaningless or dangerous for more than one object to share the same resource. Generating an implicit copy constructor for such a class (by copying the resource member by member) could silently violate the class’s intent, leading to an error such as two objects holding the same raw resource and both trying to release it. The compiler avoids this risk by closing off copying entirely and waiting for the class author to decide explicitly. When a destructor is written, the same assumption does not hold — writing a destructor alone does not mean “this class cannot be moved,” so the compiler does not close off copying, it only stops generating the move functions implicitly and falls back to the copying it finds safer.

The Rule of Zero

The rule of zero reaches the same goal by the opposite road from the rule of five: the class writes no special member function by hand at all, leaving the resource to members (such as std::string, std::unique_ptr, std::vector) that already manage their own five correctly. PlainRecord shows this: its only member is a std::string, and it has no special member function of its own. The compiler implicitly generates a move constructor that calls its member’s move constructor — and this generated move constructor performs a real move.

This is why the rule of zero is preferred in practice: Package’s five = default lines add no new behavior, they only explicitly request what the compiler would generate anyway — without writing these lines at all, leaving the content member to another resource-managing member instead of a direct std::string (say, a std::unique_ptr<std::string>) would have been a way to get the same five functions implicitly from the compiler, without writing a single line. Package had to request the five by hand here because it wrote its own constructor (explicit Package(std::string c)); a class following the rule of zero is a class that also writes none of its own constructors by hand. A class delegating its resource management to its members is considered safer than the rule of five both because it takes less code and because it entirely removes the risk of manually setting up the dependency among the five functions correctly.

OM6. But the result of a real move falls into a different kind than Record‘s. b.name takes on the source’s old value (the destination’s value is always defined), but a.name is now subject to std::string’s own post-move rule: a valid but unspecified state, that is, unspecified. This lesson assigns "new value" to a.name before reading it — the last line in the output shows the value after that assignment, not the raw post-move content.

The Bounding Measurement

Placing these three examples side by side shows this: a line that looks the same (X b = std::move(a);) can reach three different outcomes depending on how the class is written. For Record the result is unchanged (because it falls to the copy constructor), for PlainRecord the result is unspecified (a real move, but subject to the member’s own valid-but-unspecified-state rule). Removing the destructor — taking the destructor out of Record — moves the class into PlainRecord‘s situation, that is, from unchanged to unspecified. Which one is wanted is not a matter of right or wrong; it is a decision the class author makes in response to questions like whether the class should be movable at all and how cheap a move should be. This lesson does not show which way that decision should fall, only how the decision’s outcome can be observed.

The concrete mechanics of this shift are as follows: Record’s body is no different from PlainRecord‘s except for an empty destructor — both hold a single std::string member. The only difference is that Record writes the destructor by hand and PlainRecord never writes one. This one-line difference completely changes which constructor the call X b = std::move(a); falls into: in PlainRecord the compiler generates the move constructor, the source (a.name) is really moved and falls into the unspecified kind; in Record the move constructor is never generated, the call falls to the copy constructor, and the source stays unchanged. Adding or removing a destructor on a class, with no other line changed, can flip the same call’s result from unchanged to unspecified or from unspecified to unchanged — among the four classes followed through this lesson, this is the one example where a class change is triggered this indirectly (by adding/removing a function that looks like it has nothing to do with moving at all).

Summary

  • Writing a destructor by hand prevents the compiler from generating an implicit move constructor and move assignment; in that case move calls fall to the copying version and the source stays unchanged.
  • The five special member functions (destructor, copy constructor, copy assignment, move constructor, move assignment) depend on one another; the rule of five recommends reviewing all of them when one is written, requesting them explicitly with = default if needed.
  • The rule of zero writes no special member function at all, leaving the resource to members that already manage it correctly (such as std::string); in that case the compiler-generated move constructor performs a real move.
  • Once a class following the rule of zero has its member moved, the source falls into the unspecified kind — a new value must be assigned before it is read.
  • The choice between unchanged and unspecified is the class author’s decision; this lesson’s bounding measurement shows that the same line (X b = std::move(a);) can fall into two different kinds depending on how the class is written.

Next Step

This lesson implicitly showed that a source staying empty after a move can be either a promise the standard gives (as in the unique_ptr example) or a behavior the class writes by hand itself. The next lesson takes up this second path explicitly: the resource of a class that writes its own move constructor empties by a promise the class itself gives, not the standard.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close