Rule of Zero, Three and Five Cheatsheet¶
The five special member functions are where C++ classes acquire lifetime bugs. This sheet is the decision table, the evidence for why it matters, and how to make the whole question disappear.
Pairs with Module 10 — Copying and Resource Management
What the compiler generates, and when¶
| Special member | Generated when | Suppressed by |
|---|---|---|
| Default constructor | No constructor is declared at all | Declaring any constructor |
| Destructor | Always | Nothing |
| Copy constructor | No copy ctor, move ctor, copy assign or move assign is declared | Declaring a move operation (then it is deleted, not defaulted) |
| Copy assignment | Same as above | Same |
| Move constructor | No copy ctor, copy assign, move assign or destructor is declared | Declaring a destructor — this is the one that surprises people |
| Move assignment | Same as above | Same |
The row that costs marks: declaring a destructor suppresses the implicit move operations, so
containers silently copy your objects instead of moving them. Write = default explicitly, or
better, own nothing directly.
The three rules¶
| Rule | Statement | When it applies |
|---|---|---|
| Rule of Zero | Declare none of the five; own resources through members that already manage them (std::string, std::vector, std::unique_ptr) |
The goal, and the answer for almost every class you will write |
| Rule of Three | If you write a destructor, copy constructor or copy assignment, write all three | A class that owns a resource by hand and must be copyable |
| Rule of Five | The Rule of Three plus the move constructor and move assignment | The same class, made efficient |
// Rule of Three: correct, but only because it was written deliberately
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_)
{
std::copy(other.data_, other.data_ + size_, data_);
}
Buffer& operator=(const Buffer& other)
{
if (this != &other) { Buffer copy(other); swap(copy); } // copy-and-swap
return *this;
}
void swap(Buffer& other) noexcept
{
std::swap(data_, other.data_);
std::swap(size_, other.size_);
}
private:
int* data_;
std::size_t size_;
};
// Rule of Zero: the same class, with the whole problem removed
class Buffer
{
public:
explicit Buffer(std::size_t n) : data_(n) {}
std::size_t size() const { return data_.size(); }
private:
std::vector<int> data_;
};
No destructor, no copy, no move, no delete, no double free, and the compiler's versions are
correct because std::vector already is.
Why this matters: two captured demonstrations¶
A defaulted copy that double-frees¶
class Buffer // owns a raw pointer, declares no copy operations
{
public:
explicit Buffer(int size) : data_(new int[size]), size_(size) {}
~Buffer() { delete[] data_; }
private:
int* data_;
int size_;
};
int main()
{
Buffer a(4);
Buffer b = a; // compiler-generated copy: both now own one allocation
std::cout << "two Buffers constructed\n"; // never printed
return 0; // both destructors run
}
The program aborts inside the heap on the second delete[]. It compiled with
-Wall -Wextra -Werror, with no warning at all — this is the class of bug the compiler cannot
see, and the reason the Rule of Three exists.
A missing noexcept that costs copies¶
class Quick { public: Quick() = default; Quick(const Quick&) { ++copies; }
Quick(Quick&&) noexcept { ++moves; } /* counts */ };
class Slow { public: Slow() = default; Slow(const Slow&) { ++copies; }
Slow(Slow&&) { ++moves; } /* no noexcept */ };
Four push_backs of an lvalue into a growing std::vector:
std::vector will only move during reallocation if the move cannot throw — otherwise it
copies, to keep its strong guarantee if a move were to fail. One missing noexcept turned three
moves into three copies here, and it turns into a real cost on a large vector.
Deciding what to write¶
| Situation | Write |
|---|---|
Members are values, std::string, std::vector, smart pointers |
Nothing — Rule of Zero |
| Owns a raw resource and should never be copied | ~T(), T(const T&) = delete, T& operator=(const T&) = delete |
| Owns a raw resource, copyable and movable | All five (Rule of Five), with noexcept on the moves |
| Polymorphic base | virtual ~T() = default; and nothing else |
| Move-only handle (a file, a socket, a connection) | = delete the copies, = default or write the moves |
The details that decide marks¶
std::movedoes not move. It is a cast toT&&. If the target has no move operation, it copies, silently.- Moved-from objects are valid but unspecified. Destroying and reassigning are allowed; assuming it is empty is not.
- A move constructor should leave the source in a state its destructor can handle. That is the whole contract.
- Self-assignment must be safe.
a = ais legal C++ and a copy-and-swap implementation handles it for free; a naivedelete-then-copy does not. - Mark moves
noexceptunless they genuinely allocate. Check the container behaviour above. - Copying a
constobject cannot move.std::move(const x)yields aconst T&&, which binds toconst T&— a copy.
Checklist¶
- [ ] Does this class own anything directly? If not, did I declare none of the five?
- [ ] If it owns something, is it a raw pointer? Could a member type own it instead?
- [ ] Are the move operations
noexcept? - [ ] Is self-assignment safe?
- [ ] Does copying a
constobject still work correctly (it will not move)? - [ ] Would a deliberate bug in the copy path be caught by my tests?
Related: Smart Pointers · Constructors and Destructors · Common OOP Bugs · Module 10 — Copying and Resource Management