Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 3

The Rule of Three

Recommended resources

In our last lesson, we explored the object lifecycle and established the crucial difference between shallow and deep copies. We concluded with the "Rule of Three" as a guiding principle for any class that manages resources. Today, we transition from theory to practice. Your goal is to move beyond just understanding the rule and be able to correctly implement the three special member functions it dictates: the destructor, the copy constructor, and the copy assignment operator.

For a senior engineer, this is a fundamental skill. Interviewers often use questions about resource-managing classes to test depth of C++ knowledge. Mastering this implementation demonstrates you can write code that is not only correct but also robust against subtle memory-related bugs.

The Problem Revisited: Shallow vs. Deep Copy

As a quick refresher, remember that the compiler-generated copy functions perform a shallow copy. For a class managing a resource via a raw pointer, this is a recipe for disaster. Both the original and the copied object's pointers will point to the same memory block.

This diagram contrasts a shallow copy, where two variables point to the same underlying object, with a deep copy, where a new, independent object is created for the copied variable. The Rule of Three is our mechanism for enforcing deep copy semantics.

When one object is destroyed, it frees the memory. The other object is now left with a "dangling pointer" to invalid memory. Accessing it leads to undefined behavior, and when the second object is destroyed, it attempts to free the same memory again—a "double free" error.

To prevent this, we must implement the Rule of Three. Let's build a simple String class that manages a C-style character array on the heap. This is the canonical example for this concept.




#include <cstring> // For strlen, strcpy
#include <iostream>

class String {
private:
    char* m_data;
    size_t m_size;

public:
    // Constructor
    String(const char* str = "") {
        m_size = std::strlen(str);
        m_data = new char[m_size + 1]; // +1 for null terminator
        std::strcpy(m_data, str);
        std::cout << "Constructed: " << m_data << std::endl;
    }

    // How do we implement these?
    // ~String();                               // 1. Destructor
    // String(const String& other);             // 2. Copy Constructor
    // String& operator=(const String& other);  // 3. Copy Assignment Operator

    void print() const {
        std::cout << m_data << std::endl;
    }
};

Our String constructor allocates memory on the heap. This immediately makes us responsible for managing that memory's lifecycle.

The Stack Overflow article you'll read below gives an excellent "plain English" explanation of why managing a resource like this necessitates implementing all three special members.

c++ - What is The Rule of Three? - Stack Overflow

This Stack Overflow answer is one of the most popular and clear explanations of the Rule of Three. It details precisely why default memberwise copy fails for resource-managing classes.

Focus on three key parts of the accepted answer by user 'fredoverflow': Read the section titled "Managing resources", which sets up the problem using a char* member. Then, read the section from another user, 'Stefan', which gives a very clear, plain-English summary of the problems that arise. Finally, review the section "The Rule of Three states" for the formal definition.

Now, let's implement them one by one.


1. The Destructor

Purpose: To release resources acquired by the object during its lifetime. In our case, it must deallocate the heap memory pointed to by m_data.

Implementation:
The implementation is straightforward: it calls delete[] on the pointer. The [] is critical; it tells C++ to deallocate an array, not a single object. Forgetting it when you allocated with new[] is a common source of bugs.

~String() {
    std::cout << "Destroying: " << (m_data ? m_data : "nullptr") << std::endl;
    delete[] m_data;
}

2. The Copy Constructor

Purpose: To construct a new object as a copy of an existing one. This is called for declarations like String s2 = s1; or String s2(s1);. It must perform a deep copy.

Implementation:
A deep copy involves two steps:

  1. Allocate a new, separate block of memory for the new object.
  2. Copy the content from the original object's memory block into the new one.
String(const String& other) {
    m_size = other.m_size;
    m_data = new char[m_size + 1]; // 1. Allocate new memory
    std::strcpy(m_data, other.m_data); // 2. Copy the content
    std::cout << "Copy Constructed: " << m_data << std::endl;
}

Notice the parameter const String& other. It's a const reference for two reasons:

  • Efficiency: Passing by reference avoids making an unnecessary copy of the other object.
  • Correctness: If we passed by value (String other), the compiler would need to call the copy constructor to create the other parameter, leading to infinite recursion.

3. The Copy Assignment Operator

Purpose: To overwrite an existing object with the state of another one. This is called for s2 = s1;, where s2 already exists.

Implementation:
This is the most complex of the three because the target object (*this) already holds a resource that must be dealt with correctly. There are two main pitfalls:

  1. Self-Assignment: A user might write s1 = s1;. If you naively deallocate m_data first, you will lose the data you need to copy. The standard guard is if (this != &other).
  2. Resource Management: You must deallocate the old memory held by *this to prevent a memory leak before allocating new memory.

Here is the classic implementation:

String& operator=(const String& other) {
    std::cout << "Copy Assigned" << std::endl;
    // 1. Protect against self-assignment
    if (this != &other) {
        // 2. Deallocate existing memory
        delete[] m_data;

        // 3. Allocate new memory and copy data
        m_size = other.m_size;
        m_data = new char[m_size + 1];
        std::strcpy(m_data, other.m_data);
    }
    // 4. Return a reference to the current object
    return *this;
}

The return type String& allows for chaining assignments (e.g., s3 = s2 = s1;).

The following video provides an outstanding step-by-step walkthrough of implementing these exact three functions for a class with pointers. The visual examples make the memory operations exceptionally clear.

Difference between copy constructor and assignment operator in C++ (OOP tutorial for beginners)

This CodeBeauty tutorial is perfect for visualizing the implementation details. It explains not just how to write the code, but why each line is necessary.

Please watch these key segments: First, watch the section covering the need for a custom destructor and how to implement it when you add a pointer member from this timestamp. Next, watch the excellent visual explanation and implementation of the copy constructor in this part. Finally, watch the walkthrough for the copy assignment operator, which clearly explains the need for deallocation and the self-assignment check here.


The Complete Picture

Now let's put it all together. The cppreference page provides a complete, runnable example that solidifies these concepts.

The rule of three/five/zero - cppreference.com

This is the definitive reference. It concisely states the rule and provides a complete, canonical implementation.

Read the section titled Rule of three. Study the rule_of_three class. Compare its implementation of the destructor, copy constructor, and copy assignment operator to what we have just discussed. Note that their assignment operator uses an idiom called "copy-and-swap," which is a more advanced and robust technique that we will cover in a future lesson on exception safety.

Here is our final String class with all three special functions implemented.




#include <cstring>
#include <iostream>
#include <utility>

class String {
private:
    char* m_data;
    size_t m_size;

public:
    // Constructor
    String(const char* str = "") {
        m_size = std::strlen(str);
        m_data = new char[m_size + 1];
        std::strcpy(m_data, str);
        std::cout << "Constructed: " << m_data << std::endl;
    }

    // 1. Destructor
    ~String() {
        std::cout << "Destroying: " << (m_data ? m_data : "nullptr") << std::endl;
        delete[] m_data;
    }

    // 2. Copy Constructor
    String(const String& other) {
        m_size = other.m_size;
        m_data = new char[m_size + 1];
        std::strcpy(m_data, other.m_data);
        std::cout << "Copy Constructed: " << m_data << std::endl;
    }

    // 3. Copy Assignment Operator
    String& operator=(const String& other) {
        std::cout << "Copy Assigning to: " << m_data << " from: " << other.m_data << std::endl;
        if (this != &other) {
            delete[] m_data;
            m_size = other.m_size;
            m_data = new char[m_size + 1];
            std::strcpy(m_data, other.m_data);
        }
        return *this;
    }

    void print() const {
        if (m_data) std::cout << m_data << std::endl;
    }
};

int main() {
    String s1("Hello");       // Constructor
    String s2 = s1;           // Copy Constructor
    String s3("World");       // Constructor
    
    std::cout << "\n--- Assignment ---\n";
    s3 = s1;                  // Copy Assignment Operator
    std::cout << "--- Assignment Done ---\n\n";

    std::cout << "s1: "; s1.print();
    std::cout << "s2: "; s2.print();
    std::cout << "s3: "; s3.print();

    std::cout << "\n--- Exiting main ---\n";
    return 0; // s3, s2, s1 are destroyed in reverse order
}

I encourage you to compile and run this code. Observe the output to trace the constructor, copy, assignment, and destructor calls. You will see that each allocation is paired with exactly one deallocation, confirming our implementation is correct.


Conclusion

In this lesson, you have moved from the "what" and "why" of the Rule of Three to the "how." You can now implement the three special member functions required for any class that manually manages a resource.

Here are the key takeaways for your interview preparation:

  • Destructor: Releases resources. For heap arrays, use delete[].
  • Copy Constructor: Initializes a new object from an existing one. It must perform a deep copy by allocating new memory and copying the data.
  • Copy Assignment Operator: Modifies an existing object. It must handle self-assignment (if (this != &other)), deallocate old resources to prevent leaks, and then perform the deep copy.

While this pattern is fundamental to C++, it's also worth noting that modern C++ provides tools to avoid manual memory management altogether. Concepts like RAII (which we will cover soon) and smart pointers often allow you to follow the Rule of Zero—writing none of these special functions because resource-owning members handle it themselves. We will delve into these modern techniques in the next module.

Our next lesson will focus on the practical consequences of getting this wrong. We will learn to identify and fix common memory bugs like memory leaks, dangling pointers, and double frees—the very problems that a correct Rule of Three implementation is designed to prevent.