Contact
Evil Genius SocietyHomeLearnForumLeaderboardSocialsMessages
← Back to module

Lesson 8 of 12

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

The reasoning is short. You write a destructor because the object owns something. If it owns something, the compiler's member-by-member copy is wrong — it duplicates the handle and not the resource. So writing one implies needing the others.

The rule is famous because the failure is silent. A class with only a destructor compiles, runs, and passes tests, until the day something copies it.

Breaking it on purpose

Here is Buffer with a destructor and nothing else. Watch the accounting:

#include <iostream>

int live_arrays = 0;

class Broken {
    int* data;
    std::size_t count;

public:
    Broken(std::size_t n) : data(new int[n]), count(n) {
        ++live_arrays;
        for (std::size_t i = 0; i < count; ++i) {
            data[i] = 0;
        }
    }

    ~Broken() {
        --live_arrays;
        // delete[] data;   <-- deliberately NOT run, see below
    }

    void set(std::size_t i, int v) { data[i] = v; }
    int get(std::size_t i) const { return data[i]; }

    // Show whether two objects share one array.
    const int* address() const { return data; }
};

int main() {
    Broken a(3);
    a.set(0, 42);

    Broken b = a;              // compiler-generated copy: copies the pointer

    std::cout << "same array? " << (a.address() == b.address()) << "\n";

    b.set(0, 99);
    std::cout << "a[0] after writing through b: " << a.get(0) << "\n";

    std::cout << "constructor ran twice, arrays allocated: 2\n";
    std::cout << "but only one array is actually reachable\n";
    return 0;
}
same array? 1
a[0] after writing through b: 99
constructor ran twice, arrays allocated: 2
but only one array is actually reachable

Two things went wrong at once.

They share one array. a.address() == b.address(). Writing through b changed a — the objects are not independent, which is the entire point of a copy.

One array is orphaned. The constructor allocated two arrays; b's pointer was overwritten by a's during the copy, so the second allocation leaked immediately.

Note the destructor above does not call delete[]. If it did, this program would crash — both destructors would free the same address. That double free is the real-world symptom, and I left it out only so the example runs deterministically for you to read.

The same class, obeying the rule

#include <iostream>

class Buffer {
    int* data;
    std::size_t count;

public:
    Buffer(std::size_t n) : data(new int[n]), count(n) {
        for (std::size_t i = 0; i < count; ++i) {
            data[i] = 0;
        }
    }

    // 1. Destructor
    ~Buffer() {
        delete[] data;
    }

    // 2. Copy constructor
    Buffer(const Buffer& other) : data(new int[other.count]), count(other.count) {
        for (std::size_t i = 0; i < count; ++i) {
            data[i] = other.data[i];
        }
    }

    // 3. Copy assignment
    Buffer& operator=(const Buffer& other) {
        if (this == &other) {
            return *this;
        }
        delete[] data;
        count = other.count;
        data = new int[count];
        for (std::size_t i = 0; i < count; ++i) {
            data[i] = other.data[i];
        }
        return *this;
    }

    void set(std::size_t i, int v) { data[i] = v; }
    int get(std::size_t i) const { return data[i]; }
    const int* address() const { return data; }
};

int main() {
    Buffer a(3);
    a.set(0, 42);

    Buffer b = a;
    std::cout << "same array? " << (a.address() == b.address()) << "\n";

    b.set(0, 99);
    std::cout << "a[0]=" << a.get(0) << " b[0]=" << b.get(0) << "\n";

    Buffer c(1);
    c = a;
    c.set(0, 7);
    std::cout << "a[0]=" << a.get(0) << " c[0]=" << c.get(0) << "\n";

    a = a;                    // self-assignment must survive
    std::cout << "after self-assignment a[0]=" << a.get(0) << "\n";
    return 0;
}
same array? 0
a[0]=42 b[0]=99
a[0]=42 c[0]=7
after self-assignment a[0]=42

Separate arrays, independent objects, and a = a; does not destroy anything.

Copy-and-swap

Writing copy assignment by hand is easy to get subtly wrong: the delete[] happens before the new, so if the new throws, the object is left holding a dangling pointer. Copy-and-swap avoids that by doing all the risky work first:

#include <iostream>
#include <utility>

class Buffer {
    int* data;
    std::size_t count;

public:
    Buffer(std::size_t n) : data(new int[n]), count(n) {
        for (std::size_t i = 0; i < count; ++i) {
            data[i] = 0;
        }
    }

    ~Buffer() { delete[] data; }

    Buffer(const Buffer& other) : data(new int[other.count]), count(other.count) {
        for (std::size_t i = 0; i < count; ++i) {
            data[i] = other.data[i];
        }
    }

    // Take the parameter BY VALUE: the copy is made by the copy constructor,
    // then we simply swap with it. Self-assignment and exception safety both
    // come out for free.
    Buffer& operator=(Buffer other) {
        std::swap(data, other.data);
        std::swap(count, other.count);
        return *this;
    }   // `other` now holds our old array, and its destructor frees it

    void set(std::size_t i, int v) { data[i] = v; }
    int get(std::size_t i) const { return data[i]; }
};

int main() {
    Buffer a(3);
    a.set(0, 42);

    Buffer c(1);
    c = a;
    c.set(0, 7);

    std::cout << "a[0]=" << a.get(0) << " c[0]=" << c.get(0) << "\n";

    a = a;
    std::cout << "self-assignment survived: a[0]=" << a.get(0) << "\n";
    return 0;
}
a[0]=42 c[0]=7
self-assignment survived: a[0]=42

Note the parameter is Buffer other, by value — which is correct here and would have been infinite recursion in the copy constructor. The copy is made on the way in, the swap cannot throw, and the old data is freed by other's destructor. Self-assignment works without a check, because the copy is made before anything is released.

The rule tells you something else

Read it in reverse and it becomes a design instruction:

If you do not want to write all three, do not own a raw resource.

Almost every class can hold std::vector, std::string, or std::unique_ptr instead of a raw pointer, at which point the compiler-generated three are all correct and you write none of them.

That is the Rule of Zero, and lesson 9.11 states it properly. The Rule of Three is what you reach for on the rare occasion you genuinely are the one implementing the wrapper.

Try it

Write a class Name that owns a char* — allocating with new char[], copying with std::strcpy. Implement all three. Then rewrite it with std::string and count the lines you deleted.

Checkpoint

  • Rule of Three: needing a destructor, copy constructor, or copy assignment means needing all three.
  • The reason is ownership — if you own a resource, member-by-member copying is wrong.
  • A class with only a destructor gives shallow copies: shared resource, double free, leak.
  • Copy assignment must survive self-assignment and must free the old resource.
  • Copy-and-swap takes the parameter by value and swaps, getting both properties for free.
  • Read in reverse, the rule says: hold members that manage themselves, and write none of it.

Questions about this lesson

Log in to ask a question.