SECTION 1
Historical Context & Motivation
C++ has always given programmers direct control over memory and system resources, but that power comes with a corresponding responsibility to manage object lifetimes correctly. In the earliest days of the language, developers routinely encountered bugs caused by double deletions, dangling pointers, and resource leaks — all stemming from the compiler's willingness to generate copy operations automatically, even when those default implementations were semantically wrong. The Rule of Three crystallized as a community guideline to address precisely this class of defects, and its later extension to the Rule of Five reflected the new move semantics introduced in C++11.
1979–1985 • C with Classes → C++
1998 • C++98 Standardized
2000–2005 • Rule of Three Codified
2011 • C++11 and Move Semantics
2011–Present • Rule of Zero Emerges
std::unique_ptr and std::shared_ptr. When that is not possible, the Rule of Five still applies.The fundamental question that the Rule of Three/Five addresses is deceptively simple: if your class manages a resource whose default bitwise copy is incorrect, have you told the compiler how to copy, move, and destroy objects of that type? Failing to answer this question completely leads to some of the most insidious bugs in C++ programs — bugs that may compile cleanly, pass simple tests, and only manifest under specific runtime conditions such as exception unwinding or container reallocation.
SECTION 2
Core Principles & Definitions
The Rule of Three/Five is not a language rule enforced by the compiler; it is a design guideline that describes a logical dependency among the special member functions the compiler can generate implicitly. If any one of these functions requires a user-defined implementation — typically because the class directly manages a resource such as heap memory, a file handle, or a network socket — then the default implementations of the others are almost certainly wrong as well. Understanding why requires grasping what the compiler does by default and how resources differ from plain data.
Destructor
delete on raw pointers.Copy Constructor
Copy-Assignment Operator
Move Constructor (C++11)
Move-Assignment Operator (C++11)
The Rule in One Sentence
If you define (or = delete) any one of the destructor, copy constructor, copy-assignment operator, move constructor, or move-assignment operator, you should explicitly define (or = delete / = default) all five. In the pre-C++11 era, the relevant set was only the first three — hence the original Rule of Three. Since C++11 the expanded set gives us the Rule of Five.
KEY TAKEAWAY
SECTION 3
Visual Explanation — Shallow vs. Deep Copy
The most common scenario where the Rule of Three/Five matters is a class containing a raw pointer to dynamically allocated memory. The diagram below contrasts what happens under a compiler-generated shallow copy versus a user-defined deep copy. In the shallow-copy case, both objects' internal pointers refer to the same heap block; when one object's destructor frees that block, the other is left with a dangling pointer.
objA and objB pointing to the same heap block, causing a double-free when both destructors run. Right: a user-defined deep copy gives each object its own independent heap allocation.In the shallow-copy scenario on the left, observe how the compiler simply copies the raw pointer value 0xABCD into objB without allocating new memory. When objA's destructor fires and calls delete[] data_, the block at 0xABCD is freed, yet objB still holds a pointer to it. Any subsequent access through objB invokes undefined behavior, and objB's own destructor will attempt to free the same block a second time. The deep-copy version on the right eliminates both problems by allocating a fresh heap block for objB and copying the element values into it.
SECTION 4
How It Works — Compiler Generation Rules
Understanding when the compiler generates special member functions — and when it declines to do so — is essential for applying the Rule of Five correctly. The C++ standard specifies a set of interacting conditions that govern implicit generation. Declaring certain functions suppresses the generation of others, sometimes silently, which can produce classes that are copyable but not movable (or vice versa) without the developer realizing it.
Implicit Generation Matrix
| If you declare… | Destructor | Copy Ctor | Copy Assign | Move Ctor | Move Assign |
|---|---|---|---|---|---|
| Nothing | ✓ default | ✓ default | ✓ default | ✓ default | ✓ default |
| Destructor | user | ✓ deprecated† | ✓ deprecated† | ✗ deleted | ✗ deleted |
| Copy Constructor | ✓ default | user | ✓ default | ✗ deleted | ✗ deleted |
| Copy-Assignment Op. | ✓ default | ✓ default | user | ✗ deleted | ✗ deleted |
| Move Constructor | ✓ default | ✗ deleted | ✓ default | user | ✗ deleted |
| Move-Assignment Op. | ✓ default | ✓ default | ✗ deleted | ✗ deleted | user |
† Generating copy operations when a destructor is user-declared is deprecated in C++11 and later.
The critical observation from this table is that declaring any one of the Big Five has cascading effects on the others. For instance, if you define only a destructor (perhaps to release a resource), the compiler will suppress move operations entirely and continue to generate copy operations only as a deprecated legacy behavior. This means your class will silently fall back to copies where moves could have been used, degrading performance, and the shallow copies remain semantically incorrect. The only safe path is to define all five — or to explicitly = default / = delete them.
Signatures of the Big Five
DESTRUCTOR
No parameters, no return type. Called automatically when scope ends or delete is invoked.
COPY CONSTRUCTOR
Takes a const lvalue reference. Invoked during initialization from an existing object, pass-by-value, or return-by-value (when copy elision does not apply).
COPY-ASSIGNMENT OPERATOR
Returns *this by reference to enable chaining (e.g., a = b = c). Must handle self-assignment.
MOVE CONSTRUCTOR
Takes an rvalue reference. Steals resources from other and leaves it in a destructible state. Marking noexcept enables optimized container operations (e.g., std::vector reallocation).
MOVE-ASSIGNMENT OPERATOR
Releases current resources, steals from other, and returns *this. Must handle self-assignment (move from self).
SECTION 5
Rule of Three vs. Five vs. Zero
Modern C++ recognizes three related guidelines that form a hierarchy of best practice. Choosing the correct rule depends on whether your class directly manages a resource. The diagram below illustrates a decision flowchart that guides you to the appropriate rule for any given class design.
| Guideline | Functions to Define | When to Use |
|---|---|---|
| Rule of Zero | None — rely on compiler defaults | Class members are all RAII types (e.g., std::string, std::vector, std::unique_ptr) |
| Rule of Three | Destructor, copy constructor, copy-assignment operator | Pre-C++11 code or when move semantics are not needed |
| Rule of Five | Destructor, copy ctor, copy assign, move ctor, move assign | C++11+ class that directly manages a resource and cannot delegate to RAII members |
Comparison of the three related guidelines
Tip: = default and = delete
ClassName(const ClassName&) = default; to explicitly request the compiler-generated version, or ClassName(const ClassName&) = delete; to forbid copying entirely. Both of these count as 'declaring' the function for the purposes of the generation rules. Using = default or = delete for all five makes your intent explicit and documents the class's resource semantics at a glance.SECTION 6
Worked Example — IntArray Class
Let us build a minimal IntArray class that owns a dynamically allocated array of integers. We will implement all five special member functions following the Rule of Five, walking through each one's responsibilities and explaining the design choices at every step.
IntArray: Full Rule-of-Five Implementation
- 1
Step 1 — Define the Class and Constructor
We declare private membersint* data_andstd::size_t size_. The parameterized constructor allocates the array:IntArray(std::size_t n) : data_(new int[n]()), size_(n) {}The()innew int[n]()value-initializes all elements to zero.Class owns a heap-allocated array ofnints. - 2
Step 2 — Destructor
~IntArray() { delete[] data_; }The destructor frees the heap memory. Because we usednew[], we must usedelete[]. Defining a destructor triggers the Rule of Five — we must now address all remaining special member functions.Heap memory is correctly released when the object goes out of scope. - 3
Step 3 — Copy Constructor (Deep Copy)
IntArray(const IntArray& other) : data_(new int[other.size_]), size_(other.size_) { std::copy(other.data_, other.data_ + size_, data_); }We allocate a new block of the same size and copy element values usingstd::copy. This guarantees the copy has its own independent storage.Two independent objects with identical contents. - 4
Step 4 — Copy-Assignment Operator (Copy-and-Swap Idiom)
IntArray& operator=(IntArray other) { swap(*this, other); return *this; }By acceptingotherby value, the copy (or move) constructor is invoked to createother. We then swapdata_andsize_between*thisandother. Whenothergoes out of scope, its destructor frees our old memory. This idiom is self-assignment safe and exception safe.Strong exception guarantee; old resources freed via destructor of temporary. - 5
Step 5 — Move Constructor
IntArray(IntArray&& other) noexcept : data_(other.data_), size_(other.size_) { other.data_ = nullptr; other.size_ = 0; }We stealother's pointer and size, then null outother.data_so thatother's destructor does not free our newly acquired memory. Markingnoexceptis critical —std::vectorwill only use move during reallocation if the move constructor isnoexcept.O(1) resource transfer; source left in a valid empty state. - 6
Step 6 — Move-Assignment Operator
Because we used the copy-and-swap idiom in Step 4, the copy-assignment operator already handles move assignment! When the right-hand side is an rvalue, theotherparameter is move-constructed rather than copy-constructed, giving us move-assignment semantics for free. Alternatively, one could write a separateoperator=(IntArray&& other) noexceptthat performsdelete[] data_; data_ = other.data_; size_ = other.size_; other.data_ = nullptr; other.size_ = 0; return *this;for maximum efficiency.Copy-and-swap unifies both assignment operators; a dedicated overload avoids one extra move.
The swap Function
swap function: friend void swap(IntArray& a, IntArray& b) noexcept { using std::swap; swap(a.data_, b.data_); swap(a.size_, b.size_); }. This enables argument-dependent lookup (ADL) and ensures swapping is noexcept.SECTION 7
Strengths, Pitfalls, and Common Mistakes
Applying the Rule of Five correctly prevents an entire category of bugs, but it also introduces complexity. Understanding the trade-offs helps you decide when to implement the rule directly versus when to refactor toward the Rule of Zero.
| Strengths | Pitfalls / Limitations |
|---|---|
| Eliminates double-free and use-after-free bugs by ensuring every copy is deep and every move leaves the source safe. | Five functions to write, test, and maintain per resource-owning class — a significant maintenance burden. |
| Move semantics (Rule of Five) enable O(1) resource transfer, dramatically improving performance for objects stored in STL containers. | Forgetting noexcept on move operations silently degrades container performance — std::vector falls back to copying. |
| The copy-and-swap idiom provides a single, exception-safe implementation pattern that handles both copy and move assignment. | Copy-and-swap always allocates a temporary, which can be suboptimal in tight loops compared to a hand-written assignment operator. |
| Makes the class's resource semantics explicit and documented in the interface. | If a derived class also manages resources, the base class's Big Five alone are insufficient — the derived class needs its own set. |
Strengths vs. pitfalls of applying the Rule of Five
Common Mistakes
- Defining only the destructor: The compiler still generates (deprecated) shallow copy operations and suppresses moves. The class appears to work until objects are copied.
- Not nulling the source pointer in the move constructor: The moved-from object's destructor will free the memory now owned by the move target, causing a double-free.
- Ignoring self-assignment in the assignment operator: Writing
delete[] data_; data_ = new int[other.size_]; ...destroysdata_before reading from it when&other == this. The copy-and-swap idiom avoids this pitfall entirely. - Using the wrong form of delete: Allocating with
new[]but freeing withdelete(instead ofdelete[]) is undefined behavior.
DESIGN PREFERENCE
std::unique_ptr) and compose your class from those wrappers. Then the compiler-generated defaults are correct, and you write zero special member functions. Reserve the Rule of Five for low-level resource handles where smart pointers are insufficient.SECTION 8
Connection to Advanced Topics
The Rule of Five does not exist in isolation; it connects deeply to several advanced C++ mechanisms. Understanding these connections prepares you for more sophisticated resource management patterns and template-heavy library code.
| This Lesson | Advanced Extension |
|---|---|
| Copy constructor / copy-assignment operator | Copy-and-Swap idiom — a canonical pattern providing strong exception safety and DRY implementation of both copy and move assignment. |
| Move constructor / move-assignment operator | Rvalue references and perfect forwarding — std::forward and universal references enable generic code to preserve value categories. |
| Rule of Zero | RAII and smart pointers — std::unique_ptr (exclusive ownership), std::shared_ptr (shared ownership), custom deleters. |
| Suppression of implicit generation | = default / = delete — explicit control over special members, non-copyable/non-movable types, constrained APIs. |
| Resource-owning base class | Virtual destructors and polymorphic ownership — if you delete through a base pointer, the destructor must be virtual; this interacts with Rule of Five generation rules. |
How the Rule of Five connects to advanced C++ topics
As you move into template metaprogramming and library design, you will encounter type traits such as std::is_copy_constructible, std::is_nothrow_move_constructible, and std::is_trivially_destructible. These compile-time queries inspect the special member functions you have (or have not) defined, and generic algorithms and containers use them to select optimal code paths. A class that incorrectly omits noexcept on its move constructor, for example, will cause std::is_nothrow_move_constructible<T>::value to be false, forcing std::vector<T> to copy rather than move during reallocation — a subtle performance cliff that traces directly back to how you apply the Rule of Five.
SECTION 9
Practice Problems
Question 1: CONCEPTUAL
FileLogger has a user-defined destructor that closes a file handle, but no other special member functions are declared. Under C++17, which of the Big Five does the compiler generate implicitly, and what is the potential hazard?Show answer
FileLogger objects will hold the same handle. When one is destroyed, the file is closed, and the other's handle becomes invalid — a classic double-close / use-after-close bug. The fix is to define all five special member functions, or more practically, to wrap the file handle in a RAII class (or std::unique_ptr with a custom deleter) so that the Rule of Zero applies.Question 2: BASIC CALCULATION
Buffer with member char* data_ allocated via new char[capacity_]. Write the signature and body of its copy constructor so that it performs a deep copy.Show answer
Buffer(const Buffer& other) : data_(new char[other.capacity_]), capacity_(other.capacity_) { std::memcpy(data_, other.data_, capacity_); }
This allocates a new block of the same size and uses std::memcpy to copy the bytes. After this constructor runs, the new Buffer owns an independent heap allocation with identical contents. Remember that defining this copy constructor suppresses implicit move operations, so you must also define the move constructor and move-assignment operator (as well as the destructor and copy-assignment operator) to satisfy the Rule of Five.Question 3: INTERMEDIATE
class Matrix { int* data_; size_t rows_, cols_; public: Matrix(size_t r, size_t c) : data_(new int[r*c]()), rows_(r), cols_(c) {} ~Matrix() { delete[] data_; } };Show answer
data_. Move operations are suppressed entirely. If a Matrix is copied — for example, when passed by value or stored in a std::vector — two objects will share the same data_ pointer. The first destructor to run frees the block; the second causes a double-free (undefined behavior). Additionally, because move construction is deleted, std::vector<Matrix> will use the shallow copy during reallocation, compounding the problem. To fix this, either implement all five (destructor, copy ctor, copy assign, move ctor, move assign) or replace int* data_ with std::vector<int> and apply the Rule of Zero.Question 4: APPLIED
UniqueSocket class that wraps a Unix file descriptor (an int returned by socket()). The socket should be closed with close(fd) on destruction, ownership should be transferable (movable) but not copyable. Which of the Big Five do you define, which do you = delete, and what does the move constructor body look like?Show answer
~UniqueSocket() { if (fd_ >= 0) close(fd_); }), the move constructor, and the move-assignment operator. You = delete the copy constructor and copy-assignment operator because a file descriptor cannot be meaningfully duplicated (or you choose not to expose dup() semantics). The move constructor looks like: UniqueSocket(UniqueSocket&& other) noexcept : fd_(other.fd_) { other.fd_ = -1; }. Setting other.fd_ to -1 (an invalid descriptor) prevents the moved-from object's destructor from closing the socket we just acquired. This pattern mirrors std::unique_ptr's exclusive-ownership semantics and satisfies the Rule of Five (all five are accounted for, even though two are deleted).Question 5: CRITICAL THINKING
Show answer
std::unique_ptr, std::shared_ptr, std::vector, etc. — someone must write the Big Five for those classes. If you are building a library that manages a resource type not covered by the standard library (e.g., a GPU buffer handle, an OpenGL texture ID, a custom memory pool), you need the Rule of Five. Second, performance-critical code sometimes requires custom allocators or memory layouts (e.g., a flat array-of-structs stored contiguously for cache friendliness) where the overhead of smart-pointer indirection or reference counting is unacceptable. In these cases, writing custom move/copy operations allows fine-grained control. The Rule of Zero is a design strategy that rests on the Rule of Five being correctly implemented by the underlying wrapper types — it does not eliminate the need for the Five, it merely pushes that responsibility to a lower layer of abstraction.SUMMARY
Summary — The Rule of Three/Five at a Glance
Summary
The Rule of Three states that if a class requires a user-defined destructor, copy constructor, or copy-assignment operator, it almost certainly requires all three. In C++11 and later, the Rule of Five extends this to include the move constructor and move-assignment operator. The root cause is that compiler-generated defaults perform shallow (member-wise) operations that are incorrect when the class manages a resource through a raw handle. Violating the rule leads to double-free, use-after-free, dangling pointers, and resource leaks.
Modern C++ best practice favors the Rule of Zero: compose classes from RAII members like std::unique_ptr and std::vector so that the compiler-generated special member functions are correct by default. When direct resource management is unavoidable, apply the Rule of Five and consider the copy-and-swap idiom for exception-safe assignment. Always mark move operations noexcept to unlock optimal STL container behavior, and use = default or = delete to make your intent explicit for every special member function.