Skip to content
Shafin Chowdhury
Go back

Rule of Three, Five and Zero in C++: Resource Management

Software Developer & Computer Science Student

Published:
8 min read
Edit page
Table of Contents9 topics

In our previous article on shallow copy vs deep copy, we saw that whenever a class manages a raw resource on the heap, the compiler’s default copying and destruction behavior leads to memory leaks or fatal double-free errors. To fix it, we had to write a custom destructor, copy constructor, and copy assignment operator.

This triad of member functions is not an isolated trick; it is governed by one of the most critical sets of design guidelines in the C++ programming language:

  • The Rule of Three (C++98)
  • The Rule of Five (C++11)
  • The Rule of Zero (Modern C++)

These rules form the cornerstone of safe, leak-free, high-performance systems programming in C++.


What You’ll Learn

  • The historical progression of C++ resource management
  • The Rule of Three: What it requires and precisely why it is necessary
  • The Rule of Five: How move semantics (Type&&) eliminated expensive copies
  • A clear, intuitive primer on lvalues vs rvalues and ownership transfer
  • How to implement the move constructor and move assignment operator
  • The Rule of Zero: Why modern C++ recommends writing no custom special member functions for application classes
  • A side-by-side comparison of manual memory boilerplate versus a modern Rule-of-Zero design

The Six Special Member Functions

To understand these rules, we must first catalog the special member functions that the C++ compiler can generate automatically on your behalf:

  1. Default Constructor: Type()
  2. Destructor: ~Type()
  3. Copy Constructor: Type(const Type& other)
  4. Copy Assignment Operator: Type& operator=(const Type& other)
  5. Move Constructor (C++11): Type(Type&& other) noexcept
  6. Move Assignment Operator (C++11): Type& operator=(Type&& other) noexcept

The rules dictate what you must do when a class directly manages a system resource (such as raw heap memory, a file descriptor, or a network socket).


1. The Rule of Three (C++98)

[!IMPORTANT] The Rule of Three: If a class requires a user-defined implementation of any one of the following:

  1. A destructor
  2. A copy constructor
  3. A copy assignment operator

…it almost certainly requires all three.

Why Does This Rule Exist?

If a class needs a custom destructor, it is because the class manages an external resource that the compiler does not know how to clean up automatically (for example, deallocating memory via delete[] data;).

If you write a custom destructor to free that resource, but do not write a custom copy constructor or copy assignment operator:

  1. The compiler synthesizes the default copy constructor and copy assignment operator.
  2. The default operations perform a shallow copy, copying the raw resource handle (the pointer).
  3. Two distinct objects now point to the exact same resource.
  4. When both objects are destroyed, your custom destructor runs twice on the same pointer, causing a double free crash!

Therefore, whenever a class directly manages a raw resource, you must supply all three functions to guarantee proper cleanup and deep copying.

Complete Rule-of-Three Implementation

#include <iostream>
#include <string>

using namespace std;

class Buffer {
private:
    int* data;
    size_t size;

public:
    // 1. Parameterized Constructor
    Buffer(size_t bufferSize) : size(bufferSize) {
        data = new int[size];
        for (size_t i = 0; i < size; ++i) {
            data[i] = 0;
        }
        cout << "[Buffer] Allocated " << size << " integers at: " << data << endl;
    }

    // 2. Destructor (Special Member 1 of 3)
    ~Buffer() {
        cout << "[Buffer] Freeing buffer at: " << data << endl;
        delete[] data;
    }

    // 3. Copy Constructor (Special Member 2 of 3)
    Buffer(const Buffer& other) : size(other.size) {
        data = new int[size];
        for (size_t i = 0; i < size; ++i) {
            data[i] = other.data[i];
        }
        cout << "[Buffer] Deep copy constructed at: " << data << endl;
    }

    // 4. Copy Assignment Operator (Special Member 3 of 3)
    Buffer& operator=(const Buffer& other) {
        cout << "[Buffer] Copy assignment executed" << endl;
        if (this == &other) {
            return *this; // Self-assignment check
        }

        // Free our current resource
        delete[] data;

        // Allocate and copy new resource
        size = other.size;
        data = new int[size];
        for (size_t i = 0; i < size; ++i) {
            data[i] = other.data[i];
        }

        return *this;
    }

    void set(size_t index, int value) {
        if (index < size) data[index] = value;
    }

    void printFirst() const {
        if (size > 0) cout << "First element: " << data[0] << endl;
    }
};

int main() {
    Buffer b1(100);
    b1.set(0, 42);

    Buffer b2 = b1; // Copy constructor
    b2.set(0, 99);

    b1.printFirst(); // 42 (Independent!)
    b2.printFirst(); // 99

    return 0;
}
Output:
[Buffer] Allocated 100 integers at: 0x6000037f8040
[Buffer] Deep copy constructed at: 0x6000037f8200
First element: 42
First element: 99
[Buffer] Freeing buffer at: 0x6000037f8200
[Buffer] Freeing buffer at: 0x6000037f8040

2. The Rule of Five (C++11)

In C++11, the language introduced move semantics to eliminate the performance cost of deep copies when working with temporary or dying objects.

With move semantics, two new special member functions were added: 4. Move Constructor: Type(Type&& other) noexcept 5. Move Assignment Operator: Type& operator=(Type&& other) noexcept

[!IMPORTANT] The Rule of Five: If a class directly manages a raw resource and requires custom copy operations or a destructor, it should also implement move operations:

  1. Destructor
  2. Copy Constructor
  3. Copy Assignment Operator
  4. Move Constructor
  5. Move Assignment Operator

Lvalues, Rvalues, and Ownership Transfer

To understand move operations, we need only a simple, practical mental model:

  • An lvalue is an object with an identifiable name or location in memory that persists beyond a single expression (e.g., b1, int x).
  • An rvalue is a temporary, expiring object that has no persistent name, such as the return value of a function or a temporary literal (e.g., Buffer(500)).
  • Type&& denotes an rvalue reference—a binding to a temporary object that is about to be destroyed anyway.

The Power of Moving: “Stealing” Instead of Copying

When copying an object holding a 10-megabyte heap array:

  • Copy: Allocate 10 MB of fresh heap memory; copy all 10 million elements one by one. (Slow, O(N)O(N)).
  • Move: Take the pointer address from the dying object, assign it to the new object, and set the dying object’s pointer to nullptr! (Blazing fast, O(1)O(1) pointer swap).
MOVE OPERATION (FAST & LEAK-FREE):

Temporary Object (other) ──[ Dying ]──+
  data: 0x7ffd10                      |
                                      | Pointer transfer (O(1))
New Object (this)                     v
  data: 0x7ffd10  <───────────────────+

Temporary Object (other) reset:
  data: nullptr  (Safe: delete[] nullptr is a harmless no-op!)

Complete Rule-of-Five Implementation

#include <iostream>
#include <utility>

using namespace std;

class DynamicBuffer {
private:
    int* data;
    size_t size;

public:
    // Constructor
    DynamicBuffer(size_t sz) : size(sz), data(new int[sz]) {
        cout << "[Construct] Allocated " << size << " ints at: " << data << endl;
    }

    // 1. Destructor
    ~DynamicBuffer() {
        cout << "[Destruct] Cleaning up: " << data << endl;
        delete[] data;
    }

    // 2. Copy Constructor (Deep Copy)
    DynamicBuffer(const DynamicBuffer& other) : size(other.size), data(new int[other.size]) {
        for (size_t i = 0; i < size; ++i) data[i] = other.data[i];
        cout << "[Deep Copy Const] Copied to: " << data << endl;
    }

    // 3. Copy Assignment (Deep Copy)
    DynamicBuffer& operator=(const DynamicBuffer& other) {
        if (this == &other) return *this;
        delete[] data;
        size = other.size;
        data = new int[size];
        for (size_t i = 0; i < size; ++i) data[i] = other.data[i];
        cout << "[Deep Copy Assign] Assigned to: " << data << endl;
        return *this;
    }

    // 4. Move Constructor (Resource Transfer)
    // Marks noexcept so STL containers (like vector) can use it safely
    DynamicBuffer(DynamicBuffer&& other) noexcept
        : data(other.data), size(other.size) {
        // Nullify the source object so its destructor does not delete our memory!
        other.data = nullptr;
        other.size = 0;
        cout << "[Move Const] Stole resource pointer: " << data << endl;
    }

    // 5. Move Assignment (Resource Transfer)
    DynamicBuffer& operator=(DynamicBuffer&& other) noexcept {
        if (this == &other) return *this;

        // Clean up our current resource
        delete[] data;

        // Steal the other object's resources
        data = other.data;
        size = other.size;

        // Reset the source object
        other.data = nullptr;
        other.size = 0;

        cout << "[Move Assign] Stole resource pointer: " << data << endl;
        return *this;
    }
};

int main() {
    cout << "--- Creating Buffer A ---" << endl;
    DynamicBuffer bufA(1000);

    cout << "\n--- Move Constructing Buffer B from temporary ---" << endl;
    DynamicBuffer bufB = move(bufA); // Explicitly cast bufA to rvalue to trigger move

    cout << "\n--- Exiting Program ---" << endl;
    return 0;
}
Output:
--- Creating Buffer A ---
[Construct] Allocated 1000 ints at: 0x6000010ec040

--- Move Constructing Buffer B from temporary ---
[Move Const] Stole resource pointer: 0x6000010ec040

--- Exiting Program ---
[Destruct] Cleaning up: 0x6000010ec040
[Destruct] Cleaning up: 0x0

Notice the output: when bufB was constructed from move(bufA), zero new memory was allocated. The pointer 0x6000010ec040 was simply transferred to bufB, and bufA was reset to 0x0 (nullptr). When bufA was destroyed, deleting nullptr was a safe no-op.


3. The Rule of Zero (Modern C++)

Writing all five special member functions requires dozens of lines of delicate boilerplate code. If you make a mistake in self-assignment checks, miss a member in the copy constructor, or forget noexcept on a move operator, your class develops leaks or crashes.

This brings us to the guiding philosophy of modern C++:

[!TIP] The Rule of Zero: Classes that have custom resource management should deal with exclusively that resource. All other classes should avoid declaring ANY of the special member functions.

Use existing RAII types (string, vector, unique_ptr, shared_ptr) as member variables. The compiler will automatically generate the correct, leak-free destructor, copy operations, and move operations for you.

Contrast: Rule of Five vs Rule of Zero

Imagine designing a StudentProfile class holding a student’s name, ID, and a collection of exam scores.

The Error-Prone Manual Way (Rule of Five with Raw Pointer)

// ANTI-PATTERN in Modern C++: 50+ lines of dangerous boilerplate
class StudentProfile {
private:
    string name;
    int id;
    int* scores; // Raw pointer! Requires Rule of Five!
    size_t scoreCount;

public:
    StudentProfile(string n, int i, size_t count);
    ~StudentProfile();
    StudentProfile(const StudentProfile& other);
    StudentProfile& operator=(const StudentProfile& other);
    StudentProfile(StudentProfile&& other) noexcept;
    StudentProfile& operator=(StudentProfile&& other) noexcept;
};

The Clean, Modern Way (Rule of Zero with vector)

#include <iostream>
#include <string>
#include <vector>

using namespace std;

// MODERN C++: The Rule of Zero
class StudentProfile {
private:
    string name;
    int id;
    vector<int> scores; // RAII container handles its own memory!

public:
    // We only write our business logic constructor!
    StudentProfile(string studentName, int studentId, vector<int> studentScores)
        : name(studentName), id(studentId), scores(studentScores) {}

    // Destructor? Compiler-generated! (vector cleans itself up)
    // Copy Constructor? Compiler-generated! (vector performs deep copy)
    // Copy Assignment? Compiler-generated!
    // Move Constructor? Compiler-generated! (vector moves in O(1))
    // Move Assignment? Compiler-generated!

    void addScore(int score) {
        scores.push_back(score);
    }

    void display() const {
        cout << name << " (ID: " << id << ") has "
             << scores.size() << " scores recorded." << endl;
    }
};

int main() {
    StudentProfile s1("Shafin", 101, {95, 88, 92});

    // Deep copy works out-of-the-box!
    StudentProfile s2 = s1;

    // Fast move works out-of-the-box!
    StudentProfile s3 = move(s1);

    s2.display();
    s3.display();

    return 0;
}
Output:
Shafin (ID: 101) has 3 scores recorded.
Shafin (ID: 101) has 3 scores recorded.

The Rule of Zero reduced 60 lines of error-prone pointer management down to a clean, bug-free, readable 20-line class.


When to Use Which Rule?

                          Do your class members manage
                         raw system resources directly?
                                      |
                     +----------------+----------------+
                     |                                 |
                    YES                                NO
                     |                                 |
                     v                                 v
        Can you wrap the resource              RULE OF ZERO
       in an existing RAII type?               Declare NO special
         (vector, unique_ptr)                  member functions.
                     |                         Let compiler handle it.
            +--------+--------+
            |                 |
           YES                NO
            |                 |
            v                 v
       RULE OF ZERO      RULE OF FIVE
    Use RAII wrappers.  Implement all 5 special
                        member functions safely.
  • Rule of Zero: Apply to 95%+ of your classes. Business logic, domain models, services, entities, and data structures should rely on RAII members.
  • Rule of Five: Reserve strictly for writing low-level resource wrappers (e.g., authoring your own custom container, custom smart pointer, or OS socket handler).
  • Rule of Three: The legacy C++98 predecessor to the Rule of Five before move semantics existed.

Common Beginner Mistakes

1. Implementing the Rule of Five on Every Class

Beginners sometimes believe that being a “good C++ programmer” means writing out all five functions in every class they create. In modern C++, this is an anti-pattern. If your class does not own raw resources, declaring custom copy/move operations disables compiler optimizations and introduces opportunities for omission bugs.

2. Forgetting noexcept on Move Operations

Standard-library containers like vector will refuse to use your move constructor during reallocation unless it is marked noexcept. If you omit noexcept, vector will fall back to expensive deep copies to preserve the strong exception guarantee.

3. Forgetting to Nullify the Moved-From Object

In a move constructor or move assignment operator, if you copy the raw pointer data = other.data; but forget other.data = nullptr;, the temporary object’s destructor will execute delete data; when it expires, destroying the memory you just stole!


Best Practices

  1. Strive for the Rule of Zero: Use standard library containers (vector, string, map) and smart pointers (unique_ptr, shared_ptr) so you never have to write custom destructors or copy/move operations.
  2. If you must manage a raw resource, implement all Five: Never implement just one or two; implement the full suite.
  3. Always mark move constructors and move assignments noexcept: Enable compiler optimizations and standard library move-enablement.
  4. Always reset the source object in move operations: Ensure moved-from objects are left in a valid, destructible state (usually by setting pointers to nullptr).

Summary

  • The Rule of Three states that if you need a custom destructor, copy constructor, or copy assignment operator, you need all three to prevent double-free crashes.
  • The Rule of Five updates this for C++11 by adding move constructors and move assignment operators, enabling O(1)O(1) resource transfer via rvalue references (Type&&).
  • The Rule of Zero is the modern C++ ideal: compose classes using existing RAII members (vector, string, smart pointers) so you never need to write manual special member functions.

Congratulations: You Have Mastered C++ OOP Foundations!

You have completed the entire C++ Object-Oriented Programming curriculum:

  1. Classes & Objects — Blueprints and memory instances
  2. Constructors & Destructors — Lifecycle and RAII basics
  3. Encapsulation — Invariants and data hiding
  4. Inheritance — “Is-A” hierarchies and access control
  5. Polymorphism — Virtual dispatch and runtime binding
  6. Abstraction — Abstract contracts and pure interfaces
  7. Overloading vs Overriding — Compile-time vs runtime binding
  8. Composition vs Inheritance — Pragmatic architectural trade-offs
  9. Copy Constructor — Object duplication mechanics
  10. Shallow vs Deep Copy — Memory ownership and pointer safety
  11. Rule of Three, Five, and Zero — Modern resource management

Review the full curriculum roadmap and revisit individual topics anytime on our C++ Object-Oriented Programming Topic Hub.


Continue Learning C++ OOP


Edit page
Share this post:

Related Articles

Continue exploring related topics and foundational concepts.


Next Post
C++ Shallow Copy vs Deep Copy Explained