C++ • C++-Specific Topics

Rule of Three/Five — Understand the Rule of Three/Five conceptually

Master the essential idiom that prevents resource leaks and undefined behavior in C++ classes.

Programming braces with a command terminal and binary digits.
Inside this lesson 10 sections 5 questions 11 figures

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++

Bjarne Stroustrup develops C++ with constructors and destructors, establishing the RAII (Resource Acquisition Is Initialization) idiom. The compiler begins generating default copy constructors and copy-assignment operators for every class.

1998 • C++98 Standardized

The first ISO standard formalizes the implicit generation rules for the destructor, copy constructor, and copy-assignment operator. Community experts begin articulating the Rule of Three as an essential guideline in books and mailing lists.

2000–2005 • Rule of Three Codified

Authors such as Scott Meyers (Effective C++) and Herb Sutter (Exceptional C++) popularize the rule. It becomes a standard interview question and a core element of C++ pedagogy.

2011 • C++11 and Move Semantics

C++11 introduces rvalue references, the move constructor, and the move-assignment operator. The Rule of Three expands to the Rule of Five to account for these two new special member functions.

2011–Present • Rule of Zero Emerges

Modern best practice advocates the Rule of Zero: design classes so that compiler-generated defaults are correct by composing RAII wrappers like 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.

1

Destructor

Invoked when an object's lifetime ends. Responsible for releasing resources. The compiler-generated destructor calls destructors of all members but does not call delete on raw pointers.
2

Copy Constructor

Creates a new object as a copy of an existing one. The compiler-generated version performs a member-wise (shallow) copy, which copies pointer values rather than the memory they point to.
3

Copy-Assignment Operator

Replaces the contents of an existing object with a copy of another. Must handle self-assignment safely and release any previously held resources before acquiring new ones.
4

Move Constructor (C++11)

Transfers ownership of resources from a temporary (rvalue) into a new object, leaving the source in a valid but unspecified state. Avoids expensive deep copies.
5

Move-Assignment Operator (C++11)

Transfers resources from a temporary into an existing object. Combines the concerns of copy-assignment (release old resources) and move construction (steal new resources).

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

Think of a class that manages a resource as a person holding a lease on an apartment. The destructor is what happens when the lease ends — you vacate and return the keys. The copy constructor is like signing a second lease for a friend on a separate apartment with identical furniture, not handing them a duplicate key to your apartment. If you correctly handle vacating (destructor) but forget to handle the lease-duplication scenario (copy constructor), two people end up sharing a single apartment unknowingly — and when one moves out, the other's belongings are thrown away. The Rule of Three/Five says: if you need custom logic for any of these lease operations, you almost certainly need it for all of them.

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.

Shallow Copy (Default)Deep Copy (User-Defined)objAdata_ = 0xABCDobjB (copy)data_ = 0xABCDHeap Block[42, 17, 99]⚠ Both point to same memory!After objA destructor runs:objA frees 0xABCD → memory deallocatedobjB.data_ is now a dangling pointerobjAdata_ = 0xABCDobjB (copy)data_ = 0xEF01Heap Block A[42, 17, 99]Heap Block B[42, 17, 99]✓ Independent heap allocationsAfter objA destructor runs:objA frees 0xABCD → only its own blockobjB.data_ (0xEF01) remains valid ✓Rule of Three/Five: If your destructor does something special, your copy/move operations must too.Otherwise the compiler's shallow defaults will produce double-free or use-after-free bugs.
Left: the compiler-generated shallow copy leaves both 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…DestructorCopy CtorCopy AssignMove CtorMove Assign
Nothing✓ default✓ default✓ default✓ default✓ default
Destructoruser✓ deprecated†✓ deprecated†✗ deleted✗ deleted
Copy Constructor✓ defaultuser✓ default✗ deleted✗ deleted
Copy-Assignment Op.✓ default✓ defaultuser✗ deleted✗ deleted
Move Constructor✓ default✗ deleted✓ defaultuser✗ deleted
Move-Assignment Op.✓ default✓ default✗ deleted✗ deleteduser

† 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

 ClassName();~ClassName();

No parameters, no return type. Called automatically when scope ends or delete is invoked.

COPY CONSTRUCTOR

ClassName(const ClassName& other);

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

ClassName& operator=(const ClassName& other);

Returns *this by reference to enable chaining (e.g., a = b = c). Must handle self-assignment.

MOVE CONSTRUCTOR

ClassName(ClassName&& other) noexcept;

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

ClassName& operator=(ClassName&& other) noexcept;

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.

Decision Flowchart: Which Rule Applies?Does your class manage a resource?NoYesRule of ZeroDon't define any special member functions.Use RAII wrappers (unique_ptr, shared_ptr, string).Can you wrap it in a smart pointer?YesNo→ Still Rule of ZeroEncapsulate the resource, let defaults work.Are you targeting C++11 or later?No (C++98/03)YesRule of ThreeDefine: destructor, copy ctor,copy-assignment operator.Rule of FiveDefine: destructor, copy ctor,copy assign, move ctor, move assign.Always prefer Rule of Zero. Fall back to Rule of Five only when direct resource management is unavoidable.
A decision flowchart guiding class designers from the preferred Rule of Zero through the Rule of Three (pre-C++11) to the Rule of Five (C++11 and later). The key branching point is whether the class directly manages a resource that cannot be wrapped in an RAII smart pointer.
GuidelineFunctions to DefineWhen to Use
Rule of ZeroNone — rely on compiler defaultsClass members are all RAII types (e.g., std::string, std::vector, std::unique_ptr)
Rule of ThreeDestructor, copy constructor, copy-assignment operatorPre-C++11 code or when move semantics are not needed
Rule of FiveDestructor, copy ctor, copy assign, move ctor, move assignC++11+ class that directly manages a resource and cannot delegate to RAII members

Comparison of the three related guidelines

💡

Tip: = default and = delete

In C++11 and later you can write 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. 1

    Step 1 — Define the Class and Constructor

    We declare private members int* data_ and std::size_t size_. The parameterized constructor allocates the array: IntArray(std::size_t n) : data_(new int[n]()), size_(n) {} The () in new int[n]() value-initializes all elements to zero.
    Class owns a heap-allocated array of n ints.
  2. 2

    Step 2 — Destructor

    ~IntArray() { delete[] data_; } The destructor frees the heap memory. Because we used new[], we must use delete[]. 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. 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 using std::copy. This guarantees the copy has its own independent storage.
    Two independent objects with identical contents.
  4. 4

    Step 4 — Copy-Assignment Operator (Copy-and-Swap Idiom)

    IntArray& operator=(IntArray other) { swap(*this, other); return *this; } By accepting other by value, the copy (or move) constructor is invoked to create other. We then swap data_ and size_ between *this and other. When other goes 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. 5

    Step 5 — Move Constructor

    IntArray(IntArray&& other) noexcept : data_(other.data_), size_(other.size_) { other.data_ = nullptr; other.size_ = 0; } We steal other's pointer and size, then null out other.data_ so that other's destructor does not free our newly acquired memory. Marking noexcept is critical — std::vector will only use move during reallocation if the move constructor is noexcept.
    O(1) resource transfer; source left in a valid empty state.
  6. 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, the other parameter is move-constructed rather than copy-constructed, giving us move-assignment semantics for free. Alternatively, one could write a separate operator=(IntArray&& other) noexcept that performs delete[] 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

For the copy-and-swap idiom to work, provide a friend 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.

StrengthsPitfalls / 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_]; ... destroys data_ 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 with delete (instead of delete[]) is undefined behavior.
⚙

DESIGN PREFERENCE

The Rule of Five is a necessary safeguard, but it is also a code smell: it signals that your class is directly managing a raw resource. In modern C++, the preferred approach is the Rule of Zero — encapsulate each resource in a dedicated RAII wrapper (such as 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 LessonAdvanced Extension
Copy constructor / copy-assignment operatorCopy-and-Swap idiom — a canonical pattern providing strong exception safety and DRY implementation of both copy and move assignment.
Move constructor / move-assignment operatorRvalue references and perfect forwarding — std::forward and universal references enable generic code to preserve value categories.
Rule of ZeroRAII 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 classVirtual 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

A class 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
The compiler generates the copy constructor and copy-assignment operator (as deprecated behavior) and suppresses the move constructor and move-assignment operator. The hazard is that the compiler-generated copy operations perform a shallow (member-wise) copy of the file handle, so two 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

Consider a class 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

You have the following class. Identify all Rule-of-Five violations and explain what could go wrong: 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
The class defines a destructor but none of the other four special member functions. Under C++11 and later, the compiler will generate (deprecated) copy constructor and copy-assignment operator that perform shallow copies of 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

You are writing a 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
You define the destructor (~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

A colleague argues: "The Rule of Five is obsolete because we have smart pointers now. You should never need to write a destructor." Provide a nuanced response that identifies at least two scenarios where the Rule of Five remains necessary even in modern C++, and explain why the Rule of Zero does not fully replace it.
Show answer
While the Rule of Zero is the preferred default, the Rule of Five remains essential in at least two situations. First, when implementing the RAII wrappers themselves — 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.

Varsity Tutors • C++ • Rule of Three/Five — Understand the Rule of Three/Five conceptually