Skip to main content

Welcome to a Grasp lesson

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

Lesson 13

The Rule of Three/Five

Recommended resources

Hello! Welcome back to your deep dive into C++.

In our last lesson, we mastered the art of bringing an object into existence correctly and efficiently using member initializer lists. We learned they are not just for performance but are mandatory for const members, reference members, and members of classes without default constructors.

Today, we address the next logical step in an object's life: what happens when an object is created from another, or when one object's state is assigned to another? This question is central to resource management in C++ and leads us directly to one of its most famous idioms.

This lesson's goal is to explain the Rule of Three/Five and implement copy/move constructors and assignment operators. We'll see how C++ gives you, the programmer, total control over what it means to copy or move your objects, a crucial skill for preventing memory leaks, double-deletions, and other resource-related bugs that are common when bridging from C-style manual memory management.

The Problem with Compiler-Generated Defaults

Let's start with a simple class that manages a dynamic resource. This should feel familiar from your C background: we acquire memory with new (like malloc) and release it with delete (like free).

#include <cstring>
#include <iostream>

class SimpleString {
private:
    char* data;
    size_t size;

public:
    // Constructor: allocates memory
    SimpleString(const char* s = "") {
        size = std::strlen(s);
        data = new char[size + 1]; // +1 for null terminator
        std::strcpy(data, s);
        std::cout << "Constructed '" << (data ? data : "") << "'\n";
    }

    // Destructor: releases memory
    ~SimpleString() {
        std::cout << "Destructing '" << (data ? data : "") << "'\n";
        delete[] data;
    }

    void print() const {
        std::cout << "String: " << (data ? data : "null") << "\n";
    }
};

This class seems fine. It correctly allocates and deallocates its own memory. But what happens when we copy it?

int main() {
    SimpleString s1("hello");
    SimpleString s2 = s1; // What happens here?

    s1.print();
    s2.print();
} // s2 is destroyed, then s1 is destroyed

When you compile and run this, you will likely get a crash. Why? Because you didn't provide a copy constructor, so the compiler generated one for you. This default copy constructor performs a member-wise copy, also known as a shallow copy. It simply copies the value of the data pointer from s1 to s2.

Now, both s1 and s2 point to the exact same block of memory. When main finishes, the s2 destructor runs, calling delete[] on the memory. Then, the s1 destructor runs and calls delete[] on that same memory again. This is a double free, which results in undefined behavior and typically crashes your program.

This is the first major pitfall of managing resources in C++. The journey to fixing it will lead us through the Rule of Three and Five.

Back to Basics: The Rule of Five in C++ - Andre Kostur - CppCon 2023

The video 'Back to Basics: The Rule of Five in C++' by Andre Kostur at CppCon 2023 provides an excellent, step-by-step walkthrough of this exact problem. Let's watch the relevant sections to see these issues demonstrated live.

Please watch from 04:50 to 08:34. The first part (up to 07:13) demonstrates the memory leak that occurs if you don't provide a destructor. The second part (from 07:13 to 08:34) shows the double-deletion problem caused by the compiler-generated shallow copy, which is exactly what we just discussed.


The Rule of Three (C++98)

To solve these problems, we need to take control from the compiler and define the copying behavior ourselves. This leads us to the classic Rule of Three.

The Rule of Three: If a class requires a user-defined destructor, copy constructor, or copy assignment operator, it almost certainly requires all three.

Our SimpleString class has a user-defined destructor to delete[] data, so it falls under this rule. We must implement a proper copy constructor and copy assignment operator.

1. Implementing the Copy Constructor

The copy constructor is called for SimpleString s2 = s1;. Its job is to create a deep copy—a new, independent object with its own resources.

// In SimpleString class
// ...

// Copy Constructor
SimpleString(const SimpleString& other) {
    size = other.size;
    data = new char[size + 1]; // Allocate our OWN memory
    std::strcpy(data, other.data); // Copy the content
    std::cout << "Copy-constructed '" << data << "'\n";
}

Now, s2 has its own allocation of memory, and the double-free problem is solved for initialization.

2. Implementing the Copy Assignment Operator

The copy assignment operator is called for s2 = s1; where s2 already exists. It has a similar goal but is more complex. A naive implementation might look like this:

// In SimpleString class
// ...

// Naive Copy Assignment Operator
SimpleString& operator=(const SimpleString& other) {
    // 1. Check for self-assignment
    if (this == &other) {
        return *this;
    }

    // 2. Free existing resource
    delete[] data;

    // 3. Allocate new resource and copy
    size = other.size;
    data = new char[size + 1];
    std::strcpy(data, other.data);
    std::cout << "Copy-assigned '" << data << "'\n";
    
    return *this;
}

This works, but it has flaws:

  • Self-assignment check: That if statement is boilerplate and slightly inefficient, as self-assignment is rare.
  • Exception-unsafe: What if new char[size + 1] throws an exception (e.g., out of memory)? Our object *this would be in a corrupted state: its data pointer has been deleted, but the new memory wasn't allocated.

There's a much more elegant and robust solution: the copy-and-swap idiom.

The Copy-and-Swap Idiom

This powerful idiom unifies copy and move assignment and provides a strong exception guarantee for free. It requires one addition to our class: a non-throwing swap function.

// Add to SimpleString class
// ...
friend void swap(SimpleString& first, SimpleString& second) noexcept {
    // By swapping the members of two objects, we effectively swap the objects.
    using std::swap;
    swap(first.size, second.size);
    swap(first.data, second.data);
}

Now, our assignment operator becomes incredibly simple:

// In SimpleString class
// ...

// Copy-and-Swap Assignment Operator
SimpleString& operator=(SimpleString other) noexcept {
    swap(*this, other);
    return *this;
}

How does this work?

  1. Pass by Value: The parameter other is passed by value. This means the copy constructor is called to create a temporary, local copy of the right-hand side object. If this allocation fails, *this is completely unaffected. This gives us the strong exception guarantee.
  2. Swap: We swap the contents of *this with the temporary other. Now *this holds the new data, and other holds the old data. This is fast because we are just swapping pointers and integers.
  3. Automatic Cleanup: When the function exits, other goes out of scope, and its destructor is called, automatically cleaning up the old data.

This single, simple function correctly handles everything, including self-assignment, without any special checks.

What is the copy-and-swap idiom?

The Stack Overflow answer 'What is the copy-and-swap idiom?' is a canonical explanation of this pattern. It walks through the problems with a naive assignment operator and shows how copy-and-swap elegantly solves them.

Please read the top-voted answer. Focus on these sections: 'A failed solution': Understand the three problems with the naive implementation: self-assignment check, weak exception guarantee, and code duplication. 'A successful solution': See how adding a swap function and passing the assignment operator's parameter by value solves all three problems. 'What about C++11?': This gives a preview of how this idiom seamlessly integrates with move semantics, which we're about to cover.


The Rule of Five (C++11) and Move Semantics

The Rule of Three gives us correctness, but deep copies can be expensive. Imagine copying a std::vector with a million elements. If the source of the copy is a temporary object that will be destroyed immediately after, making a full copy only to have the original deleted is incredibly wasteful.

C++11 introduced move semantics to solve this. The idea is simple: if the source is a temporary, we don't copy its resources; we steal them.

Visualizing C++ Move Semantics: Resource Transfer
This image illustrates the 'stealing' process. Instead of allocating new memory and copying 'Hello', the move operation simply copies the pointer and size from the temporary object (`tmp`) to the new object (`s2`), then nulls out the source pointer. This is an O(1) operation instead of O(n).

To enable this, C++11 introduced two new special member functions and the syntax to distinguish "movable" objects:

  • Move Constructor
  • Move Assignment Operator
  • Rvalue References (&&): A new reference type that binds to temporaries (rvalues). This allows us to overload functions for lvalues (objects with names) and rvalues (temporaries).

This brings us to the modern Rule of Five.

The Rule of Five: If a class requires a user-defined destructor, copy/move constructor, or copy/move assignment operator, it likely requires all five.

Let's add move operations to SimpleString.

3. Implementing the Move Constructor

The move constructor is called when initializing from a temporary, like SimpleString s3 = CreateString("temp");. It takes an rvalue reference (&&).

// In SimpleString class
// ...

// Move Constructor
SimpleString(SimpleString&& other) noexcept
    : data(nullptr), size(0) // 1. Initialize to a valid state
{
    // 2. Swap resources. `other` will now be in the valid empty state.
    swap(*this, other); 
    std::cout << "Move-constructed '" << data << "'\n";
}

Here, we construct a new, empty SimpleString, then swap its empty state with the state of other. other is left holding nullptr, which is safe to destruct. We have successfully "stolen" its resources without any deep copying. We mark it noexcept because it's crucial for performance optimizations in STL containers.

4. Implementing the Move Assignment Operator

If you use the copy-and-swap idiom, you don't need a separate move assignment operator! When you call s1 = CreateString("temp");, the temporary returned by CreateString is an rvalue.
Because our operator= takes its parameter by value (SimpleString other), the compiler will automatically call the move constructor to create other, not the copy constructor. The rest of the function (the swap) works exactly the same.

This is the beauty of the copy-and-swap idiom in modern C++: one assignment operator handles both copy and move assignment optimally.

Back to Basics: The Rule of Five in C++ - Andre Kostur - CppCon 2023

Let's return to the CppCon video, which now introduces C++11 and move semantics, building up to the Rule of Five.

Please watch from 11:12 to 16:59. This covers: The motivation for moving to avoid expensive copies. The problems that arise if you don't provide move operations. The implementation of a move constructor and move assignment operator. The formal definition of the Rule of Five.

Test your understanding!

Consider the following code snippet:

SimpleString create_string() {
    return SimpleString("factory");
}

int main() {
    SimpleString a("alpha");
    SimpleString b("beta");

    // Line 1
    SimpleString c = a;

    // Line 2
    b = a;

    // Line 3
    SimpleString d = create_string();

    // Line 4
    b = create_string();
}

For each of the four numbered lines, which special member function is called? (Assume SimpleString has all five special members correctly implemented, and the assignment operator uses copy-and-swap).

Show answer
  1. Line 1: SimpleString c = a;

    • This is initialization. a is an lvalue (it has a name). The copy constructor is called.
  2. Line 2: b = a;

    • This is assignment. a is an lvalue. The assignment operator (operator=) is called. Internally, its parameter will be initialized using the copy constructor.
  3. Line 3: SimpleString d = create_string();

    • This is initialization. The result of create_string() is an rvalue (a temporary). The move constructor is called. (Note: The compiler is also allowed to perform an optimization called "copy elision" here and construct the object directly in d, avoiding any copy or move, but the move constructor is the function it would call).
  4. Line 4: b = create_string();

    • This is assignment. The result of create_string() is an rvalue. The assignment operator (operator=) is called. Internally, its parameter will be initialized using the move constructor.

Under the Hood: The "Valid but Unspecified" State

A crucial "under the hood" detail, which distinguishes C++ moves from those in other languages like Rust, is what happens to the moved-from object. In C++, the object (other in our move constructor) is not destroyed. It still exists until it goes out of scope, and its destructor will be called.

Therefore, a moved-from object must be left in a valid but unspecified state. "Valid" means its invariants hold, and it can be safely destructed or assigned to again. In our SimpleString example, swapping with an empty object leaves the moved-from object with data = nullptr and size = 0. This is a valid state; its destructor can safely run delete[] nullptr. "Unspecified" means you shouldn't rely on its value for anything else without re-assigning to it.

Moves Are Broken

For a deeper look at the mechanics of moving and the importance of this 'valid but unspecified state', the video 'Moves Are Broken' provides an excellent analysis.

Watch the following segments to solidify your understanding: Move concept (11:13 - 15:29): This explains the problem of double-free and how C++'s non-destructive move (leaving the source object alive) solves it by nulling out the source. Move constructor implementation (20:14 - 23:38): This shows how to implement a move constructor by 'stealing' the resource and putting the source into an empty, valid state. Valid but unspecified state (23:38 - 26:24): This section explicitly discusses this concept and its implications.


Conclusion

You have now mastered one of the most important concepts for writing robust C++: controlling object copying and moving. This is how C++ bridges the gap between the low-level resource control you know from C and high-level, safe abstractions.

Key Takeaways:

  • A class that manages a resource (like memory via new/delete) must not rely on compiler-generated copy/move functions, which perform dangerous shallow copies.
  • The Rule of Three (C++98): If you write a destructor, copy constructor, or copy assignment operator, you need all three.
  • The Rule of Five (C++11): This expands the rule to include the move constructor and move assignment operator, which provide performance optimizations by "stealing" resources from temporaries.
  • Move semantics are enabled by rvalue references (&&) which allow overloading for temporaries, and std::move, which is just a cast to signal that an object can be moved from.
  • The copy-and-swap idiom is a robust pattern for implementing the assignment operator to be exception-safe and to handle both copy and move assignment with a single function.
  • A moved-from object in C++ is left in a valid but unspecified state, meaning it must be safely destructible.

In this lesson, we saw that if we manage resources manually with new and delete, we have to do a lot of work to follow the Rule of Five. What if we could get all this safety without writing any of these five functions? In our next lesson, we will explore the difference between shallow and deep copies more formally and introduce the concept of RAII (Resource Acquisition Is Initialization), which, when used with smart pointers, allows us to follow the even better Rule of Zero.