The "Rule of zero"

Tuesday, April 1, 2025

So back in the day there used to be this thing called the "Rule of three", which you can read about in more detail on stackoverflow among other place like this blog post. The statement of the rule is:

If a class requires a user-defined destructor, a user-defined copy constructor, or a user-defined copy assignment operator, it almost certainly requires all three.

The reason for this rule is that classes which acquire resources in their constructor are expected to manage their resources. This principle is commonly called Resource Acquisition Is Initialization (RAII). The implicitly-defined copy constructor, copy assignment operator, and destructor make some assumptions about how these classes manage their resources, and if those assumptions are incorrect for one method, then they are likely to be incorrect for all methods.

As an example, the implicitly-defined copy constructor will perform a shallow copy by default:

... the constructor performs full member-wise copy of the object's direct base subobjects and member subobjects, in their initialization order, using direct initialization. For each non-static data member of a reference type, the copy constructor binds the reference to the same object or function to which the source reference is bound.

Below are a few examples demonstrating how the default behavior of these methods might lead to bugs and how explicitly defining all of these methods can avoid these bugs. If the bugs are not obvious after reading the description below, then consider reading catching bugs with static analysis.

Example 1

When j is copy constructed from i using the implicitly-defined copy constructor, j gets a shallow copy of i.value. Memory is leaked when i goes out of scope because the implicitly-defined destructor has an empty body, and therefore never frees the memory allocated when i is constructed.

class Int {
    int* value;

public:
    explicit Int(const int& value): value(new int(value)) {}
};

auto main(int argc, char** argv) -> int {
    Int i(1);
    Int j(i);
}

Example 2

As before j gets a shallow copy of i.value when it is constructed, but now that we have an explicitly defined destructor that does not leak memory, we instead have a double free of the memory allocated when i is constructed when i goes out of scope.

class Int {
    int* value;

public:
    explicit Int(const int& value): value(new int(value)) {}

    ~Int() {
        delete value;
    }
};

auto main(int argc, char** argv) -> int {
    Int i(1);
    Int j(i);
}

Example 3

Because we have an explicitly defined copy constructor, j gets a deep copy of i.value when j is constructed. When our explicitly defined destructors run they each free their own copy of value. The copy assignment operator is included for completeness, but it is not explicitly used.

class Int {
    int* value;

public:
    explicit Int(const int& value): value(new int(value)) {}

    Int(const Int& other): Int(*other.value) {}

    Int& operator=(const Int& other) {
        Int temp(other);
        this->value = temp.value;
        return *this;
    }

    ~Int() {
        delete value;
    }
};

auto main(int argc, char** argv) -> int {
    Int i(1);
    Int j(i);
}

Hopefully you can understand the motivation for the "Rule of three". The "Rule of five" variant is similar as it includes the move constructor and move assignment operator which were added in C++ 11.

The "Rule of zero" however is more interesting because it states that classes should not have to define custom destructors, copy or move constructors, or copy or move assignment operators unless they need to manually manage a resource. Classes which need to manually manage a resource, for example a file descriptor, should abstract away the management of that resource and do nothing else. An example of this rule can be seen below with std::shared_ptr abstracting away the management of the raw pointer in the previous examples.

#include <memory>

class Int {
    std::shared_ptr<int> value;

public:
    explicit Int(const int& value): value(std::make_shared<int>(value)) {}
};

auto main(int argc, char** argv) -> int {
    Int i(1);
    Int j(i);
}

This implementation is so clean it should speak for itself, but if it does not here the guideline is even included in the C++ Core Guidelines.