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.
#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.
#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:
#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.
#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:
#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.