Skip to main content

Welcome to a Grasp lesson

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

Lesson 8

Mastering the Rule of Five

Recommended resources

Hello! Welcome back.

In our last lesson, we established the powerful RAII pattern: binding a resource's lifetime to an object's scope using constructors and destructors. This automates cleanup and prevents memory leaks, a crucial first step for building robust, high-performance systems. However, we ended with a critical question: what happens when you copy an object that owns a resource, like a raw pointer to heap memory?

The default behavior—a shallow copy—leads to two objects pointing to the same resource. When the first object is destroyed, it frees the memory. When the second object is destroyed, it tries to free the same memory again, causing a double-free error and crashing your program.

Today, we will master the solution to this problem. You will learn how to implement the Rule of Five (destructor, copy/move constructor, copy/move assignment). These five special member functions give you complete control over your object's lifecycle, allowing you to define precisely what it means to copy or move an object that manages resources. This is a cornerstone of intermediate C++ and is absolutely essential for writing safe and efficient systems code.

The Rule of Three: Taking Control of Copies

Before C++11, the solution was known as the "Rule of Three." It states that if a class defines any of the following three special member functions, it should probably define all three:

  1. Destructor: ~MyClass()
  2. Copy Constructor: MyClass(const MyClass& other)
  3. Copy Assignment Operator: MyClass& operator=(const MyClass& other)

You already know the destructor's role from our last lesson. Let's now focus on the two functions that manage copying.

  • The Copy Constructor is called when a new object is created from an existing object.
  • The Copy Assignment Operator is called when an existing object is assigned the value of another existing object.

The following video masterfully walks through the problems that arise from compiler-generated defaults and how to solve them by implementing the Rule of Three.

Safely copying, moving, and destroying objects in Modern C++ with the rule of "all or nothing"

The video 'Safely copying, moving, and destroying objects...' from the Code for yourself channel explains why we need to manage copying manually. It demonstrates the dangerous consequences of shallow copies and how to implement deep copies correctly.

Please watch from 2:19 to 7:35. The video builds the argument piece by piece: It shows why a custom destructor is needed (recap). It demonstrates the 'double free' error caused by the default copy constructor. It shows how to implement a correct 'deep copy' constructor. It reveals that a similar problem exists for assignment, causing memory leaks and crashes. Finally, it shows how to implement a correct copy assignment operator, including the critical self-assignment check.

As the video demonstrated, for a class that manages a resource like new T[], the solution is to perform a deep copy:

  • The copy constructor allocates its own, separate memory and copies the content from the source object into its new memory block.
  • The copy assignment operator first deallocates its existing resource, then allocates new memory and copies the content from the source. It must also handle self-assignment (e.g., myObject = myObject;) to avoid destroying its own data before copying.

The Performance Problem: When Copying is Wasteful

Deep copying solves the correctness problem, but it introduces a performance cost. Allocating memory and copying large amounts of data can be slow. For your telemetry engine, which aims for zero overhead, this is a major concern.

Consider this common scenario:

MyData create_data() {
    return MyData(1000000); // Creates an object with a large resource
}

MyData data = create_data(); // What happens here?

The function create_data returns a temporary object (an "rvalue"). We then use it to initialize data. A naive implementation would perform a full, expensive deep copy from the temporary object into data, only for the temporary object to be immediately destroyed. This is incredibly wasteful. Wouldn't it be better if data could just take the resource from the temporary object instead of copying it?

This is the central idea behind move semantics.


The Rule of Five: Efficiently Transferring Resources

C++11 extended the Rule of Three to the Rule of Five by adding two new special member functions that leverage move semantics:
4. Move Constructor: MyClass(MyClass&& other)
5. Move Assignment Operator: MyClass& operator=(MyClass&& other)

The && syntax denotes an rvalue reference. These functions are automatically chosen by the compiler when the source object is a temporary (an rvalue), or when you explicitly permit a move using std::move.

Instead of a deep copy, a move operation performs a cheap "resource theft":

  1. The destination object's pointer is set to point to the source object's resource.
  2. The source object's pointer is set to nullptr.

This transfers ownership without any new allocations or large data copies. The source object is left in a valid but empty state, ready to be safely destroyed.

Copy Constructor vs. Move Constructor
This diagram illustrates the fundamental difference between a copy and a move. A copy (left) duplicates the resource, resulting in two independent objects. A move (right) transfers ownership of the resource, which is much more efficient.

The performance implications are significant. The following video provides a practical demonstration that is highly relevant to your goal of building a high-performance profiler.

Classes part 10 - Rule of Five - Have fun reducing memory allocations! | Modern Cpp Series Ep. 46

Mike Shah's video 'Rule of Five' offers an excellent hands-on demonstration of move semantics and its performance impact. He uses a tool called valgrind to measure memory allocations, proving the efficiency gains.

Watch from 10:10 to 23:02. This is a crucial segment. It introduces the move constructor and move assignment operator. It clearly explains the concept of 'stealing' or transferring resources from an rvalue reference. Most importantly, it shows a benchmark of adding objects to a std::vector. Pay close attention to the dramatic reduction in memory allocations when std::move is used. This is exactly the kind of optimization your telemetry engine will rely on.

As you saw, a simple std::move can reduce allocations and improve performance by a large factor when dealing with collections of objects, which is a common pattern in data processing pipelines.


A Complete Example

Let's consolidate everything. Here is the canonical structure for a class that correctly implements the Rule of Five.

#include <iostream>
#include <utility> // For std::move

class MemoryBlock {
private:
    int* _data;
    size_t _length;

public:
    // 1. Constructor
    explicit MemoryBlock(size_t length) : _data(new int[length]), _length(length) {
        std::cout << "Constructor called. length = " << _length << ".\n";
    }

    // 2. Destructor
    ~MemoryBlock() {
        if (_data != nullptr) {
            std::cout << "Destructor called. Deleting resource. length = " << _length << ".\n";
            delete[] _data;
        } else {
            std::cout << "Destructor called on a moved-from object.\n";
        }
    }

    // 3. Copy Constructor (Deep Copy)
    MemoryBlock(const MemoryBlock& other) : _data(new int[other._length]), _length(other._length) {
        std::cout << "Copy Constructor called. length = " << _length << ".\n";
        std::copy(other._data, other._data + _length, _data);
    }

    // 4. Copy Assignment Operator (Deep Copy)
    MemoryBlock& operator=(const MemoryBlock& other) {
        std::cout << "Copy Assignment Operator called. length = " << other._length << ".\n";
        if (this != &other) { // Self-assignment check
            delete[] _data; // Free existing resource
            _length = other._length;
            _data = new int[_length];
            std::copy(other._data, other._data + _length, _data);
        }
        return *this;
    }

    // 5. Move Constructor (Resource Theft)
    MemoryBlock(MemoryBlock&& other) noexcept : _data(other._data), _length(other._length) {
        std::cout << "Move Constructor called. length = " << _length << ".\n";
        // Leave the source object in a valid, destructible state
        other._data = nullptr;
        other._length = 0;
    }

    // 6. Move Assignment Operator (Resource Theft)
    MemoryBlock& operator=(MemoryBlock&& other) noexcept {
        std::cout << "Move Assignment Operator called. length = " << other._length << ".\n";
        if (this != &other) { // Self-assignment check
            delete[] _data; // Free existing resource
            _data = other._data;
            _length = other._length;
            
            // Leave the source object in a valid, destructible state
            other._data = nullptr;
            other._length = 0;
        }
        return *this;
    }
};

This full example is complex, but it contains all the patterns you've just learned. The following article provides another complete implementation and breaks down each part, which will be a valuable reference.

C++ the rule of 5 explained.

The article 'C++ the rule of 5 explained' from Udacity's blog gives a complete, annotated implementation of a class that follows the Rule of Five. It's a great piece to study to see all the parts working together.

Read the section 'The “Rule of Five” in practice'. Focus on 'Listing 17', which shows the full class definition. Compare their implementation of each of the five special functions to the MemoryBlock example above. Don't worry about understanding the experiments that follow it just yet; focus on the class implementation itself.

Test your understanding!

Modify the MemoryBlock class from above. Your task is to add a main function that demonstrates each of the five rules in action.

Create a main function that does the following:

  1. Creates an initial MemoryBlock object (block1).
  2. Creates a second object (block2) as a copy of the first (triggers copy constructor).
  3. Creates a third object (block3) and then assigns the first object to it (triggers copy assignment).
  4. Creates a fourth object (block4) by moving from the first object (block1) using std::move (triggers move constructor).
  5. Creates a fifth object (block5) and then move-assigns the second object (block2) to it (triggers move assignment).

The std::cout statements will help you verify that the correct function is being called in each case. Note what happens to the objects that have been moved from when they are destroyed at the end of main.

Show answer

Here's one possible main function to test the MemoryBlock class:

#include <iostream>
#include <vector>
#include <utility> // For std::move

// ... (Paste the MemoryBlock class definition here) ...

int main() {
    std::cout << "--- Step 1: Construction ---\n";
    MemoryBlock block1(100);

    std::cout << "\n--- Step 2: Copy Construction ---\n";
    MemoryBlock block2(block1); // Calls Copy Constructor

    std::cout << "\n--- Step 3: Copy Assignment ---\n";
    MemoryBlock block3(50);
    block3 = block1; // Calls Copy Assignment Operator

    std::cout << "\n--- Step 4: Move Construction ---\n";
    // block1 is now cast to an rvalue, allowing its resource to be moved.
    MemoryBlock block4(std::move(block1)); // Calls Move Constructor

    std::cout << "\n--- Step 5: Move Assignment ---\n";
    MemoryBlock block5(25);
    block5 = std::move(block2); // Calls Move Assignment Operator

    std::cout << "\n--- Exiting main, destructors will be called in reverse order of creation ---\n";
    
    return 0; // block5, block4, block3, block2, block1 are destroyed
}

When you run this code, you should see output that confirms which function is called at each step. You'll also notice that block1 and block2, after being moved from, have their destructors called on a "moved-from object," indicating their pointers were correctly set to nullptr.


Conclusion

You've now learned the low-level mechanics of C++ object ownership. This is a significant milestone. Manually managing resources by implementing the Rule of Five gives you ultimate control over performance and correctness, which is invaluable in systems programming.

Key Takeaways:

  • The Rule of Three (Pre-C++11): If your class manages a resource, you need to implement a destructor, copy constructor, and copy assignment operator to ensure correct deep copying.
  • The Rule of Five (C++11 and beyond): To add performance, you should also implement a move constructor and move assignment operator. These enable efficient resource transfer instead of expensive copying.
  • Move Semantics: Moving is about "stealing" resources from temporary or explicitly "moved-from" objects. This is achieved via rvalue references (&&).
  • std::move: This is not an operation but a cast. It tells the compiler, "Treat this object as a temporary so that its resources can be moved."

Manually implementing the Rule of Five is powerful, but it's also verbose and error-prone. What if you could use a class that already has all five rules implemented perfectly for you?

In our next lesson, we will do exactly that. We'll explore smart pointers (std::unique_ptr and std::shared_ptr), which are RAII wrappers around raw pointers. They encapsulate the very logic you learned today, making modern C++ resource management dramatically safer and simpler. You've learned the hard way; now you get to learn the smart way.