C++ Rule of Three and Five
When a C++ class manages a resource directly, such as memory allocated with new, a file handle, or a network socket, the compiler’s automatically generated copy and move behavior is often wrong. The Rule of Three and Rule of Five are guidelines that tell you which special member functions to define together so your objects copy, move, and destroy themselves safely. Getting this wrong is one of the most common sources of crashes, memory corruption, and double-free bugs in C++ programs.
Overview / How It Works
Every C++ class has up to five “special member functions” that the compiler can generate for you automatically if you don’t write them yourself: the destructor, the copy constructor, the copy assignment operator, the move constructor, and the move assignment operator. By default, the compiler generates versions that do a member-by-member copy or move. For simple classes made only of value types (int, double, std::string, std::vector, and so on), this default behavior is exactly right, because those member types already manage their own memory correctly.
The trouble starts when a class holds a raw pointer or handle to a resource it owns, for example int* data pointing at memory obtained with new int[n]. The default copy constructor just copies the pointer value, not the data it points to. Now two objects believe they own the same block of memory. When the first object is destroyed, its destructor calls delete[] on that memory. When the second object is destroyed, it calls delete[] again on memory that has already been freed, this is a double free, and it is undefined behavior that often crashes or silently corrupts the heap.
The Rule of Three says: if your class needs a custom destructor, a custom copy constructor, or a custom copy assignment operator, it almost certainly needs all three. The reasoning is symmetric, if you need to write custom cleanup logic in the destructor because ownership is involved, then copying must also be handled with the same care, and vice versa.
The Rule of Five extends this for modern C++ (C++11 onward), which added move semantics. A move constructor and move assignment operator let an object “steal” the internals of a temporary or an object you explicitly mark as movable with std::move, avoiding an expensive deep copy. If you define any of the three classic members, you should also consider defining the move constructor and move assignment operator, otherwise the compiler either won’t generate them at all (falling back to copies, which is slow but safe) or, in older situations, may behave unexpectedly. Together, all five functions form the Rule of Five.
There is also an important corollary called the Rule of Zero: the best number of special member functions to write yourself is usually zero. If you delegate resource ownership to existing types that already follow the Rule of Five correctly, such as std::vector, std::string, or std::unique_ptr, your class inherits correct copy/move/destroy behavior for free, and you never have to write these five functions by hand. The Rule of Three/Five matters most when you are the one writing a low-level resource-owning class, which is less common in application code and more common when building libraries or containers.
Syntax
The five special member functions for a class named ClassName look like this:
class ClassName {
public:
~ClassName(); // destructor
ClassName(const ClassName& other); // copy constructor
ClassName& operator=(const ClassName& other); // copy assignment
ClassName(ClassName&& other) noexcept; // move constructor
ClassName& operator=(ClassName&& other) noexcept; // move assignment
};
- Destructor
~ClassName()— releases the resource the object owns (e.g.delete[] data_;). - Copy constructor — takes a
const&to an existing object and creates a new, independent copy of its resource. - Copy assignment — replaces the current object’s resource with a copy of another’s; must release its own old resource first and should guard against self-assignment.
- Move constructor
(ClassName&& other)— takes an rvalue reference (a “movable” object) and steals its internals, leaving the source in a valid but empty state. Should be markednoexcept. - Move assignment — same idea as move construction, but for an already-existing target object; must release its own resource first.
Examples
Example 1: A class that follows the Rule of Three. IntBuffer owns a dynamically allocated array. It defines a destructor, a deep-copy copy constructor, and a copy assignment operator so that every copy is fully independent.
#include <iostream>
#include <algorithm>
class IntBuffer {
public:
explicit IntBuffer(size_t size) : size_(size), data_(new int[size]) {
for (size_t i = 0; i < size_; ++i) data_[i] = 0;
}
~IntBuffer() {
delete[] data_;
}
IntBuffer(const IntBuffer& other) : size_(other.size_), data_(new int[other.size_]) {
std::copy(other.data_, other.data_ + size_, data_);
std::cout << "Copy constructor called\n";
}
IntBuffer& operator=(const IntBuffer& other) {
std::cout << "Copy assignment called\n";
if (this == &other) return *this;
int* newData = new int[other.size_];
std::copy(other.data_, other.data_ + other.size_, newData);
delete[] data_;
data_ = newData;
size_ = other.size_;
return *this;
}
void set(size_t idx, int val) { data_[idx] = val; }
int get(size_t idx) const { return data_[idx]; }
size_t size() const { return size_; }
private:
size_t size_;
int* data_;
};
int main() {
IntBuffer a(3);
a.set(0, 10); a.set(1, 20); a.set(2, 30);
IntBuffer b = a; // copy constructor
b.set(0, 999);
std::cout << "a[0]=" << a.get(0) << " b[0]=" << b.get(0) << "\n";
IntBuffer c(2);
c = a; // copy assignment
std::cout << "c size=" << c.size() << " c[1]=" << c.get(1) << "\n";
return 0;
}
Output:
Copy constructor called
a[0]=10 b[0]=999
Copy assignment called
c size=3 c[1]=20
Because the copy constructor and copy assignment operator both allocate their own new array and copy the values in, modifying b after copying it from a does not affect a. Without these custom functions, the compiler-generated shallow copy would have made a and b share one array, and setting b‘s element would have changed a‘s too, plus both destructors would eventually free the same memory.
Example 2: Extending to the Rule of Five with move semantics. Now add a move constructor and move assignment operator so that transferring ownership from a temporary or via std::move is cheap, no allocation or copying required.
#include <iostream>
#include <algorithm>
#include <utility>
class IntBuffer {
public:
explicit IntBuffer(size_t size) : size_(size), data_(new int[size]) {
for (size_t i = 0; i < size_; ++i) data_[i] = 0;
}
~IntBuffer() {
delete[] data_;
}
IntBuffer(const IntBuffer& other) : size_(other.size_), data_(new int[other.size_]) {
std::copy(other.data_, other.data_ + size_, data_);
}
IntBuffer& operator=(const IntBuffer& other) {
if (this == &other) return *this;
int* newData = new int[other.size_];
std::copy(other.data_, other.data_ + other.size_, newData);
delete[] data_;
data_ = newData;
size_ = other.size_;
return *this;
}
IntBuffer(IntBuffer&& other) noexcept : size_(other.size_), data_(other.data_) {
other.data_ = nullptr;
other.size_ = 0;
std::cout << "Move constructor called\n";
}
IntBuffer& operator=(IntBuffer&& other) noexcept {
std::cout << "Move assignment called\n";
if (this == &other) return *this;
delete[] data_;
data_ = other.data_;
size_ = other.size_;
other.data_ = nullptr;
other.size_ = 0;
return *this;
}
void set(size_t idx, int val) { data_[idx] = val; }
int get(size_t idx) const { return data_[idx]; }
size_t size() const { return size_; }
private:
size_t size_;
int* data_;
};
int main() {
IntBuffer a(3);
a.set(0, 1); a.set(1, 2); a.set(2, 3);
IntBuffer b = std::move(a); // move constructor
std::cout << "b[0]=" << b.get(0) << " b size=" << b.size() << "\n";
IntBuffer c(1);
c = std::move(b); // move assignment
std::cout << "c[0]=" << c.get(0) << " c size=" << c.size() << "\n";
return 0;
}
Output:
Move constructor called
b[0]=1 b size=3
Move assignment called
c[0]=1 c size=3
After std::move(a), object a is left in a valid but empty state (its pointer is nullptr and size is 0), while b now owns the array that used to belong to a. No new memory was allocated and no elements were copied, only pointers were swapped, which is far cheaper than a deep copy for large buffers.
Example 3: The Rule of Zero in practice. Instead of writing all five functions yourself, delegate to a standard container that already does it correctly.
vector<int> data = {10, 20, 30};
vector<int> copy = data; // deep copy, handled entirely by std::vector
copy[0] = 999;
cout << "data[0]=" << data[0] << " copy[0]=" << copy[0] << endl;
Output:
data[0]=10 copy[0]=999
Because std::vector already implements a correct Rule of Five internally, a class that stores a std::vector<int> member instead of a raw int* gets correct copy, move, and destruction behavior automatically, with no hand-written special member functions at all.
Under the Hood: What Happens During Copy vs Move
When you write IntBuffer b = a;, the compiler selects the copy constructor because a is an lvalue (a named, addressable object). Inside it, new int[other.size_] asks the operating system’s memory allocator for a fresh block of heap memory, and std::copy walks element by element, copying each int value into the new block. Two completely separate arrays now exist.
When you write IntBuffer b = std::move(a);, std::move does not actually move anything, it is purely a cast that converts the lvalue a into an rvalue reference, which makes the compiler prefer the move constructor overload. Inside the move constructor, no new memory is requested at all, the pointer value stored in data_ is simply copied (just the pointer, a few bytes), and the source object’s pointer is set to nullptr so that when its destructor eventually runs, delete[] nullptr is a safe no-op. This is why moves are typically O(1) regardless of how large the resource is, while copies are O(n).
This is also why the move constructor and move assignment operator should be marked noexcept: containers like std::vector check at compile time whether your move operations can throw. If they might throw, std::vector falls back to copying elements during reallocation instead of moving them, to preserve its strong exception-safety guarantee, silently giving up the performance benefit you wrote the move constructor for.
Common Mistakes
Mistake 1: Defining a destructor but relying on the default (shallow) copy. This is the classic double-free trap. The class below compiles, but is broken:
class Broken {
public:
Broken(int n) : data_(new int[n]) {}
~Broken() { delete[] data_; }
// No copy constructor or copy assignment defined!
// The compiler generates ones that copy the pointer, not the data.
private:
int* data_;
};
// Broken a(5);
// Broken b = a; // shallow copy: a.data_ == b.data_
// when a and b are both destroyed, delete[] runs TWICE on the same pointer
The fix is exactly Example 1 above: define a copy constructor and copy assignment operator that allocate a new array and copy the values, so each object owns independent memory.
Mistake 2: Forgetting the self-assignment check in copy assignment. If you write obj = obj; (directly, or indirectly through aliasing), a naive assignment operator that deletes its own data before checking whether the source is itself will destroy the very data it’s about to read from:
Broken& operator=(const Broken& other) {
delete[] data_; // deletes data_ ...
data_ = new int[other.size_]; // but other.data_ might BE data_
std::copy(other.data_, other.data_ + other.size_, data_); // reads freed memory
size_ = other.size_;
return *this;
}
The fix is the if (this == &other) return *this; guard shown in Example 1’s copy assignment operator, or building the new buffer before releasing the old one (the “copy-and-swap” idiom), which is naturally self-assignment-safe.
Best Practices
- Prefer the Rule of Zero: store resources in
std::vector,std::string,std::unique_ptr, orstd::shared_ptrinstead of raw pointers, so you never have to write the Rule of Five by hand. - If you must manage a raw resource, implement all five special member functions together, not just one or two, an incomplete set is a bug waiting to happen.
- Always mark move constructors and move assignment operators
noexceptwhen they truly cannot throw, so standard containers actually use them. - Guard copy assignment (and move assignment) against self-assignment.
- Leave moved-from objects in a valid, destructible state (typically “empty”, with null pointers and zero sizes).
- Consider the copy-and-swap idiom for assignment operators, it gives you exception safety and self-assignment safety for free.
- If none of the five functions need custom logic, explicitly default or delete them (
Foo(const Foo&) = default;or= delete;) to document your intent clearly rather than relying on implicit generation.
Practice Exercises
Exercise 1: Write a class StringBox that owns a char* allocated with new char[] to store a C-style string. Implement the destructor, copy constructor, and copy assignment operator (Rule of Three) so copies are fully independent. Test it by copying a StringBox, modifying the copy, and printing both to confirm the original is unchanged.
Exercise 2: Extend your StringBox from Exercise 1 with a move constructor and move assignment operator (completing the Rule of Five). Add std::cout messages inside each of the five functions, then write a main() that triggers all five (construct, copy-construct, copy-assign, move-construct via std::move, move-assign via std::move) and verify the right messages print in the right order.
Exercise 3: Rewrite StringBox to store its data in a std::string member instead of a raw char*, and delete all five special member functions you wrote (or simply remove them). Confirm the class still copies and moves correctly, this demonstrates the Rule of Zero in action. Expected output: identical copy/move behavior with far less code.
Summary
- The Rule of Three: if a class needs a custom destructor, copy constructor, or copy assignment operator, it needs all three.
- The Rule of Five extends this to include the move constructor and move assignment operator, added in C++11 for efficient ownership transfer.
- Raw pointers to owned resources are the usual reason a class needs these functions, the default compiler-generated versions only do shallow, member-by-member copies.
- A missing or shallow copy constructor/assignment operator on a resource-owning class typically causes a double free or shared-state bug.
- Move operations should be marked
noexceptso standard containers actually use them instead of falling back to copies. - The Rule of Zero, delegating ownership to types like
std::vectororstd::unique_ptr, is usually the best design: it avoids writing the Rule of Five entirely.
