Rule of Five

When designing data types in C++, we often need to manage resource allocation manually—particularly in our constructors. In such cases, relying on the compiler-generated destructor is usually insufficient, so we define a custom destructor to ensure proper cleanup. But it doesn’t stop there. To manage copying and moving safely and correctly, we should also define a copy constructor, move constructor, copy assignment operator, and move assignment operator. This practice is known as the Rule of Five: if your class requires one of these special member functions, it likely requires all of them.

For example, consider the following class, which dynamically allocates memory in its constructor:

#include <iostream>

using namespace std;

class MemoryGobbler {
public:
    MemoryGobbler() : name_{""}, size_{}, dlist_{} {}

    MemoryGobbler(const string &name, int size) {
        name_ = name;
        size_ = size;
        dlist_ = new double[size_];   // dynamic resource alloction causes leak
        for (int i = 0; i < size_; i++) {
            dlist_[i] = i * 0.5;
        }
    }

    void print() const {
        cout << name_ << ":";
        for (int i = 0; i < size_; i++) {
            if (i != 0) cout << ", ";
            cout << dlist_[i];
        }
        cout << endl;
    }
private:
    string name_;
    int size_;
    double* dlist_;
};


int main() {
    MemoryGobbler empty{};
    MemoryGobbler a("GobblerA", 4);
    MemoryGobbler b("GobblerB", 10);
    empty.print();
    a.print();
    b.print();
    return 0;
}
:
GobblerA:0, 0.5, 1, 1.5
GobblerB:0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5

To fix the memory leak caused by the new operator in the constructor, we need to define a custom destructor to properly release the allocated memory. The name_ and size_ members automatically release their resources when the object is destroyed, since they manage their own memory. However, dlist_ is a raw pointer, so only the pointer itself is destroyed—not the memory it points to. As a result, we must explicitly deallocate the memory pointed to by dlist_ in the destructor to avoid a memory leak.

    ~MemoryGobbler() {
      delete[] dlist_;
    }

The Rule of Five reminds us that to properly manage the resources allocated by MemoryGobbler, we need to define more than just a destructor. In particular, we should implement both a copy constructor and a move constructor. The copy constructor creates a deep copy of another object and should take a constant reference as its parameter. The move constructor, on the other hand, is used when transferring ownership from a temporary (rvalue) object. Since it merely transfers pointers or ownership without allocating new resources, we can safely declare the move constructor as noexcept, which can improve performance by enabling certain compiler optimizations.

    // Copy constructor
    MemoryGobbler(const MemoryGobbler& other) :
        name_{other.name_},
        size_{other.size_},
        dlist_{new double[other.size_]} {
        std::copy(other.dlist_, other.dlist_ + other.size_, dlist_);
    }

    // Move constructor
    MemoryGobbler(MemoryGobbler&& other) noexcept:
        name_{std::move(other.name_)},
        size_{std::move(other.size_)},
        dlist_{std::move(other.dlist_)} {
        other.dlist_ = nullptr;
        other.size_ = 0;
        other.name_.clear();
    }
int main() {
    MemoryGobbler empty{};
    MemoryGobbler a("GobblerA", 4);
    MemoryGobbler b("GobblerB", 10);
    empty.print();
    a.print();
    b.print();

    MemoryGobbler c(a);
    MemoryGobbler d(std::move(b));
    a.print();
    b.print();
    c.print();
    d.print();
    return 0;
}
:
GobblerA:0, 0.5, 1, 1.5
GobblerB:0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5
GobblerA:0, 0.5, 1, 1.5
:
GobblerA:0, 0.5, 1, 1.5
GobblerB:0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5

Notice that object b appears empty the second time it is printed. This is because we used b to move-construct object d. Our move constructor is designed to leave the source object (b, in this case) in a valid but effectively empty state. Since rvalues are temporary, it’s safe—and necessary—to transfer ownership of their resources. We explicitly avoid leaving the dlist_ pointer intact in b, because if it still pointed to the dynamic array, its destructor would deallocate memory that now belongs to d, leading to a double-free error.

Assignment operators, on the other hand, are a bit more complex. They need to allocate new resources, copy the contents from the source object to ensure a true deep copy and release old resources.

The copy assignment operator, for example, has several responsibilities:

  • Protect against self-assignment, to avoid accidentally deleting data when assigning an object to itself.
  • Clean up existing resources in the left-hand object.
  • Deep copy the data from the right-hand side.
  • Finally, return a reference to the left-hand object to support chained assignments.
    // Copy assignment
    MemoryGobbler& operator=(const MemoryGobbler& other) {
        // prevent self-assignment
        if (this == &other) return *this;
        double* new_dlist = new double[other.size_];
        delete[] dlist_;
        dlist_ = new_dlist;
        size_ = other.size_;
        name_ = other.name_;
        return *this;
    }

Implementing the copy assignment operator correctly can be tricky, with many steps to remember and plenty of chances to introduce subtle bugs or leave objects in inconsistent states. Fortunately, C++ provides a helpful pattern known as the copy-and-swap idiom, which simplifies this process. The idea is to first create a temporary object by invoking the copy constructor, then swap its contents with the current object. When the temporary goes out of scope, it automatically destroys the old resources that were originally part of the left-hand object.

The only additional requirement is an efficient swap function to exchange the internal state of two MemoryGobblerobjects. Fortunately, the C++ Standard Library provides a generic std::swap() function that works well for most types, including our member variables.

    // Swap method
    void swap(MemoryGobbler& other) noexcept {
        std::swap(name_, other.name_);
        std::swap(size_, other.size_);
        std::swap(dlist_, other.dlist_);
    }

    // Copy assignment
    MemoryGobbler& operator=(const MemoryGobbler& other) {
        MemoryGobbler{other}.swap(*this);
        return *this;
    }
int main() {
    MemoryGobbler empty{};
    MemoryGobbler a("GobblerA", 4);
    MemoryGobbler b("GobblerB", 10);
    empty.print();
    a.print();
    b.print();

    MemoryGobbler c(a);
    MemoryGobbler d(std::move(b));
    a.print();
    b.print();
    c.print();
    d.print();

    b = a;
    a.print();
    b.print();
    return 0;
}
:
GobblerA:0, 0.5, 1, 1.5
GobblerB:0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5
GobblerA:0, 0.5, 1, 1.5
:
GobblerA:0, 0.5, 1, 1.5
GobblerB:0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5
GobblerA:0, 0.5, 1, 1.5
GobblerA:0, 0.5, 1, 1.5

This approach is much simpler to implement and eliminates the need for an explicit self-assignment check. While self-assignment still incurs the cost of copying, it’s a rare case, and avoiding the check improves clarity and performance in the common case. Another major advantage is that with a small change—passing the assignment parameter by value—we can write a single, unified assignment operator that handles both copy and move assignment efficiently.

    // Dual Copy and Move assignment
    MemoryGobbler& operator=(MemoryGobbler other) {
        other.swap(*this);
        return *this;
    }
int main() {
    MemoryGobbler empty{};
    MemoryGobbler a("GobblerA", 4);
    MemoryGobbler b("GobblerB", 10);
    empty.print();
    a.print();
    b.print();

    MemoryGobbler c(a);
    MemoryGobbler d(std::move(b));
    a.print();
    b.print();
    c.print();
    d.print();

    b = a;
    a.print();
    b.print();

    d = std::move(c);
    c.print();
    d.print();
    return 0;
}
:
GobblerA:0, 0.5, 1, 1.5
GobblerB:0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5
GobblerA:0, 0.5, 1, 1.5
:
GobblerA:0, 0.5, 1, 1.5
GobblerB:0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5
GobblerA:0, 0.5, 1, 1.5
GobblerA:0, 0.5, 1, 1.5
:
GobblerA:0, 0.5, 1, 1.5

In this version, the right-hand side object is passed by value, which means a copy (or move) is made before the function body executes. The copied object is then swapped with the left-hand side. When the function returns, the temporary parameter (other) goes out of scope and automatically cleans up the original resources that were swapped into it. At first glance, this may appear to be a standard copy assignment operator—but there’s more to it.

If the right-hand side is an rvalue (a temporary), the compiler will use the move constructor to initialize other, making the entire operation behave like a move assignment. This subtle optimization allows a single, elegant implementation to handle both copy and move assignment efficiently.

You may have noticed that the std::string member of our class required no special handling for resource management—that’s because it manages its own memory automatically. We can apply the same principle to our dynamically allocated array by using another powerful C++ idiom: RAII (Resource Acquisition Is Initialization).

The core idea of RAII is to tie resource management to object lifetime: resources are acquired in a constructor and released in the destructor. Because constructors and destructors are guaranteed to be called in a well-defined way, RAII ensures that resources are properly managed, even in the presence of exceptions or early returns.

To apply RAII to our array, we’ll use smart pointers from the C++ Standard Library, which handle allocation and deallocation automatically, making our code simpler, safer, and more robust.

#include <iostream>

using namespace std;

class MemoryGobbler {
public:
    MemoryGobbler() : name_{""}, size_{}, dlist_{} {}

    MemoryGobbler(const string &name, int size) {
        name_ = name;
        size_ = size;
        dlist_ = std::make_unique<double[]>(size_);    // RAII
        for (int i = 0; i < size_; i++) {
            dlist_[i] = i * 0.5;
        }
    }

    ~MemoryGobbler() {
        // No need to delete dlist_ as it gets released when destroyed
    }

    // Copy constructor
    MemoryGobbler(const MemoryGobbler& other) :
        name_{other.name_},
        size_{other.size_},
        dlist_{std::make_unique<double[]>(other.size_)} {
        std::copy(other.dlist_.get(), other.dlist_.get() + other.size_, dlist_.get());
    }

    // Move constructor
    MemoryGobbler(MemoryGobbler&& other) noexcept:
        name_{std::move(other.name_)},
        size_{std::move(other.size_)},
        dlist_{std::move(other.dlist_)} {
        other.dlist_ = nullptr;
        other.size_ = 0;
        other.name_.clear();
    }

    // Swap method
    void swap(MemoryGobbler& other) noexcept {
        std::swap(name_, other.name_);
        std::swap(size_, other.size_);
        std::swap(dlist_, other.dlist_);
    }

    // Dual Copy and Move assignment
    MemoryGobbler& operator=(MemoryGobbler other) {
        other.swap(*this);
        return *this;
    }

    void print() const {
        cout << name_ << ":";
        for (int i = 0; i < size_; i++) {
            if (i != 0) cout << ", ";
            cout << dlist_[i];
        }
        cout << endl;
    }
private:
    string name_;
    int size_;
    unique_ptr<double[]> dlist_;
};


int main() {
    MemoryGobbler empty{};
    MemoryGobbler a("GobblerA", 4);
    MemoryGobbler b("GobblerB", 10);
    empty.print();
    a.print();
    b.print();

    MemoryGobbler c(a);
    MemoryGobbler d(std::move(b));
    a.print();
    b.print();
    c.print();
    d.print();

    b = a;
    a.print();
    b.print();

    d = std::move(c);
    c.print();
    d.print();
    return 0;
}