Rule of Three / Five / Zero
C++ classes that manage resources need more than a constructor. The compiler generates copy, move, and destructor operations automatically — but only under specific conditions, and those conditions form a set of rules you must internalize to avoid silent bugs and silent performance regressions.
The five special member functions
C++ defines five special member functions whose bodies the compiler can generate automatically when you don't declare them yourself. Together with the default constructor (a sixth one, with different rules), these are the operations that define a type's value semantics — how it is created, duplicated, moved, and destroyed.
| Function | Syntax | What the compiler generates |
|---|---|---|
| Destructor | ~T() | Calls member and base destructors in reverse construction order |
| Copy constructor | T(const T&) | Memberwise copy-constructs each data member |
| Copy assignment | T& operator=(const T&) | Memberwise copy-assigns each data member |
| Move constructor | T(T&&) | Memberwise move-constructs each data member |
| Move assignment | T& operator=(T&&) | Memberwise move-assigns each data member |
“Memberwise” means the operation is applied to each non-static data member in declaration order (and to base class subobjects first). This works perfectly when your members are themselves well-behaved value types — integers, strings, vectors. It breaks down when a member is a raw pointer, a file descriptor, or any other resource that requires ownership semantics the compiler cannot infer.
The generation rules — what suppresses what
The compiler does not generate these functions unconditionally. The generation of each one depends on what you have (or haven't) declared yourself. The rules are not symmetric, and the asymmetry is the source of most surprises.
Destructor
Always generated unless you declare it yourself. Generated destructors are implicitly noexcept. The generated destructor is virtual only if the base class destructor is virtual — a critical detail for polymorphic hierarchies (see the base class pattern at the bottom of this page).
Copy constructor & copy assignment — independent of each other
Each copy operation is generated independently. Declaring the copy constructor does not suppress generation of the copy assignment operator, and vice versa. However, both are suppressed — and generated as deleted— if the class declares a move operation (move constructor or move assignment). And both are deprecated (still generated, but you should not rely on it) if the class declares a destructor, because declaring a destructor is a strong signal that the class manages a resource that the compiler's memberwise copy may handle incorrectly.
class Widget {
public:
Widget(const Widget&); // user-declared copy ctor
// copy assignment: still generated by compiler (independent)
// move ctor: NOT generated — user declared copy ctor
// move assign: NOT generated — user declared copy ctor
};Move constructor & move assignment — not independent
Move operations are generated only when the class declares none of the following: a copy constructor, a copy assignment operator, a move constructor, a move assignment operator, or a destructor. That is a strict conjunction — any one of the five is enough to suppress both generated move operations. And unlike the copy operations, the two move operations are not independent: declaring the move constructor suppresses generation of the move assignment operator, and vice versa.
class Widget {
public:
Widget(Widget&&); // user-declared move ctor
// move assignment: NOT generated (one move suppresses the other)
// copy ctor: DELETED (declaring a move op causes this)
// copy assign: DELETED (declaring a move op causes this)
};| User declares… | Dtor | Copy ctor | Copy assign | Move ctor | Move assign |
|---|---|---|---|---|---|
| Nothing | ✓ | ✓ | ✓ | ✓ | ✓ |
| Destructor | — | ⚠ | ⚠ | ✗ | ✗ |
| Copy constructor | ✓ | — | ⚠ | ✗ | ✗ |
| Copy assignment | ✓ | ⚠ | — | ✗ | ✗ |
| Move constructor | ✓ | ⊘ | ⊘ | — | ✗ |
| Move assignment | ✓ | ⊘ | ⊘ | ✗ | — |
Rule of Three (C++98)
Before C++11 introduced move semantics, the rule was simpler: if your class needed a custom destructor, it almost certainly also needed a custom copy constructor and copy assignment operator. This is the Rule of Three. The reasoning is straightforward — if you have a destructor, you manage a resource. If you manage a resource, the compiler's memberwise copy is wrong: it will shallow-copy the pointer (or handle), leaving two objects that both think they own the same resource, and the destructor will try to free it twice.
// C++98 resource-managing class — needs all three
class Buffer {
public:
explicit Buffer(std::size_t size)
: data_(new char[size]), size_(size) {}
~Buffer() { delete[] data_; } // 1. destructor
// Without these two, copying creates a double-free:
Buffer(const Buffer& other) // 2. copy constructor
: data_(new char[other.size_]), size_(other.size_) {
std::copy(other.data_, other.data_ + size_, data_);
}
Buffer& operator=(const Buffer& other) { // 3. copy assignment
if (this != &other) {
char* new_data = new char[other.size_];
std::copy(other.data_, other.data_ + other.size_, new_data);
delete[] data_;
data_ = new_data;
size_ = other.size_;
}
return *this;
}
private:
char* data_;
std::size_t size_;
};The copy-then-swap idiom seen above (allocate new storage, copy, then swap or assign) also provides the strong exception safety guarantee: if the allocation throws, the original object is unchanged. This is the canonical form of copy assignment for resource-managing types.
Rule of Five (C++11)
C++11 added move semantics. The move constructor and move assignment operator let a class transfer its resource from a temporary (or an object you're done with) instead of copying it. For a large buffer, that's the difference between an O(n) copy and an O(1) pointer swap. But move operations are not generated if you already declared any of the other three. So if you have a destructor (which you do, because you manage a resource), you must now declare all five. This is the Rule of Five.
// C++11 resource-managing class — all five
class Buffer {
public:
explicit Buffer(std::size_t size)
: data_(new char[size]), size_(size) {}
~Buffer() { delete[] data_; }
// Copy operations (same as Rule of Three)
Buffer(const Buffer& other)
: data_(new char[other.size_]), size_(other.size_) {
std::copy(other.data_, other.data_ + size_, data_);
}
Buffer& operator=(const Buffer& other) {
Buffer tmp(other); // copy-then-swap (strong exception safety)
swap(*this, tmp);
return *this;
}
// Move operations — transfer ownership, leave source empty
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;
}
friend void swap(Buffer& a, Buffer& b) noexcept {
using std::swap;
swap(a.data_, b.data_);
swap(a.size_, b.size_);
}
private:
char* data_;
std::size_t size_;
};Notice both move operations are marked noexcept. This matters for containers: std::vector::push_backchecks at compile time whether your move constructor is noexcept. If it is, the vector uses moves during reallocation — O(1) per element. If it is not (or if there is no move constructor), the vector falls back to copies to preserve the strong exception guarantee. A move constructor that is not noexceptis almost as bad as no move constructor at all for vector-stored types.
The silent performance regression trap
This is the subtlest failure mode, and it is entirely silent — no compiler error, no warning, just code that runs many times slower than expected. Consider a class that worked correctly in C++98. You add a destructor — perhaps just for logging, perhaps for a tracing tool. The class still compiles. Tests still pass. But you have just told the compiler “I am managing a resource”, and as a consequence the compiler stops generating move operations. Any code that previously moved objects of this type now copies them instead.
// Before: move operations generated automatically
class StringTable {
public:
// ... constructor, lookup methods ...
private:
std::map<int, std::string> values_;
};
// After: someone adds a destructor for logging
class StringTable {
public:
StringTable() { /* log "created" */ }
~StringTable() { /* log "destroyed" */ } // ← this silently kills moves
private:
std::map<int, std::string> values_;
};
// User code — looks like a move, IS actually a copy:
StringTable make_table() { return StringTable{...}; }
auto t = make_table(); // move was optimal; now copies entire std::map
// std::map move: O(1), swap of root pointers
// std::map copy: O(n), allocates a node per entry
// For a table with 100k entries, this is the difference between
// nanoseconds and milliseconds — a 6-order-of-magnitude regressionThe fix is to declare the move operations explicitly with = default. This tells the compiler: “I know I declared a destructor, but I still want you to generate the correct memberwise move operations for me.”
class StringTable {
public:
StringTable() {}
~StringTable() {}
// Explicitly opt back in to generated moves:
StringTable(StringTable&&) noexcept = default;
StringTable& operator=(StringTable&&) noexcept = default;
// And since we declared move ops, copy ops are deleted —
// restore them too if the class should be copyable:
StringTable(const StringTable&) = default;
StringTable& operator=(const StringTable&) = default;
private:
std::map<int, std::string> values_;
};Rule of Zero — the goal
The Rule of Five is necessary when your class directly manages a resource. But directly managing resources is precisely what smart pointers and standard containers exist to avoid. The Rule of Zero states: if you can design your class so that its members are all value types or smart pointers, you need to declare none of the five special members. The compiler-generated versions will do the right thing automatically.
// Rule of Zero: let members handle their own lifetime
class Widget {
public:
explicit Widget(std::size_t n)
: data_(std::make_unique<char[]>(n)), size_(n) {}
// No destructor, no copy/move declarations needed.
// unique_ptr's destructor handles deletion.
// Widget is move-only (unique_ptr is move-only) — correct ownership semantics.
private:
std::unique_ptr<char[]> data_;
std::size_t size_ = 0;
};
// If you need copyable semantics, use shared_ptr or a vector:
class Document {
public:
// All five special members: compiler-generated, all correct
private:
std::string title_;
std::vector<std::string> lines_;
std::shared_ptr<Metadata> meta_; // shared ownership, reference-counted
};In modern C++, the Rule of Zero is the default. You write the Rule of Five only when you are building a low-level resource wrapper — the kind of class that implements a smart pointer, a file handle, or a network socket abstraction. All code above that layer should use those wrappers and rely on the Rule of Zero.
The polymorphic base class pattern
Polymorphic base classes need a virtual destructor to prevent undefined behavior when deleting through a base pointer. But declaring a virtual destructor suppresses the generation of move operations. The fix is the same as the StringTable trap: use = default to explicitly restore what the destructor declaration suppressed.
class Shape {
public:
virtual ~Shape() = default; // virtual for correct delete-through-base-ptr
Shape(Shape&&) noexcept = default; // explicitly restore move ctor
Shape& operator=(Shape&&) noexcept = default; // explicitly restore move assign
Shape(const Shape&) = default; // explicitly restore copy ctor
Shape& operator=(const Shape&) = default; // explicitly restore copy assign
protected:
Shape() = default; // prevent direct instantiation
};This is the canonical form for any class intended to be used as an abstract base. The = default declarations are not redundant boilerplate — they are documentation that the author considered each operation and intentionally wants the compiler-generated behavior.
= default and = delete
= default requests the compiler-generated implementation explicitly — useful when generation would otherwise be suppressed, or when you want to document intent in the class interface. = delete prevents the operation entirely, turning a would-be silent compilation error into an explicit one.
// Move-only type: allow moves, explicitly forbid copies
class FileHandle {
public:
explicit FileHandle(const char* path);
~FileHandle();
FileHandle(FileHandle&&) noexcept = default;
FileHandle& operator=(FileHandle&&) noexcept = default;
FileHandle(const FileHandle&) = delete; // explicit: no copies
FileHandle& operator=(const FileHandle&) = delete;
private:
int fd_ = -1;
};
// Non-movable, non-copyable: pinned to its address (e.g., mutex)
class Mutex {
public:
Mutex() = default;
~Mutex() = default;
Mutex(const Mutex&) = delete;
Mutex& operator=(const Mutex&) = delete;
Mutex(Mutex&&) = delete;
Mutex& operator=(Mutex&&) = delete;
};Rules to remember
Rule of Zero: prefer it
Design classes so members handle their own lifetime. No raw owning pointers, no manual delete. Compiler-generated operations are correct and concise.
Rule of Five: applies to resource wrappers
If you need a custom destructor, you must think carefully about all five. Declaring a destructor silently suppresses move generation — always = default the moves explicitly.
Two copy ops are independent; two move ops are not
Declaring one copy op does not suppress the other. Declaring one move op suppresses the other and deletes both copy ops.
Make move ops noexcept
std::vector::push_back uses moves during reallocation only if the move constructor is noexcept. Without noexcept, the vector copies — often orders of magnitude slower.
Virtual destructor = default on base classes
A virtual destructor suppresses move generation. Use = default to restore moves, or document their absence with = delete.
= default is documentation, not just syntax
An explicit = default tells readers: I considered this operation and want compiler-generated semantics. It is not redundant when generation might have been suppressed.