The C++ Textbook

Part 3 · Types you make

The rule of zero, three, and five

How many special member functions you actually need to write.

By the end of this chapter you can

  • Apply the rule of zero to a class that owns nothing
  • List the five special member functions and when each is generated
  • Explain why declaring a destructor suppresses move operations

You now have all the pieces: constructors, destructors, copying, moving. This chapter is the short set of rules that tells you which of them to write, and the answer is usually none.

The five special member functions

The compiler can generate five functions for you:

Signature
Destructor ~T()
Copy constructor T(const T&)
Copy assignment T& operator=(const T&)
Move constructor T(T&&)
Move assignment T& operator=(T&&)

Whether each is generated depends on what else you have declared, and the rules are not intuitive. They exist for backward compatibility: code written before C++11 had no move operations, and adding them silently to every old class would have changed behaviour.

The rule of zero

If your class does not directly own a resource, declare none of the five.

A class with no special members at all
#include <iostream>
#include <memory>
#include <string>
#include <vector>

struct Widget {
    int id = 0;
};

class Workspace {
public:
    Workspace(std::string name) : name_(std::move(name)) {}

    void add(int id) { widgets_.push_back(Widget{id}); }
    void set_active(int id) { active_ = std::make_unique<Widget>(Widget{id}); }

    std::size_t count() const { return widgets_.size(); }
    const std::string& name() const { return name_; }
    bool has_active() const { return active_ != nullptr; }

private:
    std::string name_;
    std::vector<Widget> widgets_;
    std::unique_ptr<Widget> active_;
};

int main() {
    Workspace a{"main"};
    a.add(1);
    a.add(2);
    a.set_active(1);

    Workspace moved = std::move(a);      // move: generated, and correct
    std::cout << moved.name() << " has " << moved.count()
              << " widgets, active: " << moved.has_active() << '\n';

    // Workspace copy = moved;           // would not compile: unique_ptr
                                         // is not copyable, so neither is this
}

Workspace manages a string, a vector, and a heap object — and declares none of the five. Every member already knows how to destroy, copy, and move itself, so the generated versions do exactly the right thing. The class is also automatically non-copyable, because unique_ptr is, which is very likely what you want for a type holding a unique resource.

This is the target. A class written this way cannot have a double-free bug, a leak, a broken self-assignment, or a missing noexcept, because it has no code in which to have one.

The rule of three, and then five

Sometimes you are the building block. Then:

If you write any one of the destructor, copy constructor, or copy assignment, you almost certainly need all three. That is the rule of three, and the reason is that all three exist for the same purpose — managing an owned resource — so needing one implies the others.

C++11 added moves, extending it to the rule of five: if you write any of the five, consider all five.

All five, written out once
#include <cstddef>
#include <iostream>
#include <utility>

class Buffer {
public:
    explicit Buffer(std::size_t n) : data_(new int[n]{}), size_(n) {}

    ~Buffer() { delete[] data_; }

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

    Buffer& operator=(const Buffer& other) {
        if (this != &other) {
            int* fresh = new int[other.size_];
            for (std::size_t i = 0; i < other.size_; ++i) fresh[i] = other.data_[i];
            delete[] data_;
            data_ = fresh;
            size_ = other.size_;
        }
        return *this;
    }

    Buffer(Buffer&& other) noexcept : data_(other.data_), size_(other.size_) {
        other.data_ = nullptr;
        other.size_ = 0;
    }

    Buffer& operator=(Buffer&& other) noexcept {
        if (this != &other) {
            delete[] data_;
            data_ = other.data_;
            size_ = other.size_;
            other.data_ = nullptr;
            other.size_ = 0;
        }
        return *this;
    }

    std::size_t size() const { return size_; }

private:
    int* data_;
    std::size_t size_;
};

int main() {
    Buffer a{4};
    Buffer b = a;               // copy ctor
    Buffer c = std::move(a);    // move ctor
    b = c;                      // copy assign
    b = std::move(c);           // move assign

    std::cout << "b.size() = " << b.size() << ", c.size() = " << c.size() << '\n';
}

Sixty lines to manage one pointer. Now compare:

The same thing, following the rule of zero
#include <iostream>
#include <utility>
#include <vector>

class Buffer {
public:
    explicit Buffer(std::size_t n) : data_(n) {}

    std::size_t size() const { return data_.size(); }

private:
    std::vector<int> data_;
};

int main() {
    Buffer a{4};
    Buffer b = a;
    Buffer c = std::move(a);
    b = c;
    b = std::move(c);

    std::cout << "b.size() = " << b.size() << ", c.size() = " << c.size() << '\n';
}

Identical behaviour, all five operations correct, and nothing to review. Unless you are implementing std::vector, the second version is the one to write.

The trap: a destructor suppresses moves

This is the rule that costs real programs real performance, silently.

Declaring a destructor prevents the compiler from generating the move constructor and move assignment operator. Copy operations are still generated (deprecated, but generated), so the class still compiles and still works — it just copies everywhere you expected a move.

One empty destructor, and the moves are gone
#include <iostream>
#include <string>
#include <utility>
#include <vector>

struct Fine {
    std::vector<int> data;
    explicit Fine(std::size_t n) : data(n) {}
};

struct Spoiled {
    std::vector<int> data;
    explicit Spoiled(std::size_t n) : data(n) {}
    ~Spoiled() {}                       // does nothing at all
};

int main() {
    Fine a{5};
    Fine b = std::move(a);
    std::cout << "Fine:    source left with " << a.data.size()
              << " elements (moved)\n";

    Spoiled c{5};
    Spoiled d = std::move(c);
    std::cout << "Spoiled: source left with " << c.data.size()
              << " elements (copied!)\n";
}

Spoiled’s destructor does nothing. Its presence alone means std::move on a Spoiled performs a copy — the source still has its five elements, because nothing was taken from it. For a vector of five ints that is invisible; for a class holding a large buffer, in a std::vector that reallocates, it is a silent order-of-magnitude cost.

Saying what you mean with = default and = delete

You do not have to write a function body to declare one:

Declaring all five without implementing any
#include <iostream>
#include <string>
#include <vector>

class Resource {
public:
    Resource() = default;
    virtual ~Resource() = default;             // needed for polymorphic deletion

    // Because the destructor is declared, these would not be generated.
    // Ask for them back explicitly:
    Resource(Resource&&) noexcept = default;
    Resource& operator=(Resource&&) noexcept = default;

    // And be explicit that copying is not wanted:
    Resource(const Resource&) = delete;
    Resource& operator=(const Resource&) = delete;

    std::vector<int> data;
};

int main() {
    Resource a;
    a.data = {1, 2, 3};

    Resource b = std::move(a);      // moves, because we asked for it back
    std::cout << "b has " << b.data.size() << ", a has " << a.data.size() << '\n';

    // Resource c = b;              // compile error, and deliberately so
}

= default asks for the compiler’s version, which is both shorter and less error-prone than writing it out. = delete states that an operation must not exist, turning a run-time disaster into a compile error.

Declaring all five explicitly, even as = default or = delete, is worth doing for any class that declares one of them. It makes the intent visible and removes the need for the reader to remember the suppression rules.

The generation rules, for reference

You do not need to memorise this table, but you should know it exists:

If you declare… Copy ctor Copy assign Move ctor Move assign Destructor
nothing generated generated generated generated generated
a destructor generated¹ generated¹ suppressed suppressed yours
a copy operation generated¹ generated¹ suppressed suppressed generated
a move operation deleted deleted generated

¹ Generated, but deprecated: relying on it is a smell.

The row that matters most is the last: declaring any move operation deletes both copy operations. That one is usually what you want — a type with a hand-written move is usually an owner that should not be silently copied — but it can surprise you.

Check yourself

Practice