Rule of Five

The Rule of Five in C++ is an extension of the Rule of Three. It acknowledges the introduction of move semantics in C++11 and states that if a class requires any of the following five special member functions, it should likely implement all five:

  1. Destructor
  2. Copy Constructor
  3. Copy Assignment Operator
  4. Move Constructor (new in C++11)
  5. Move Assignment Operator (new in C++11)

Why the Rule of Five?

In C++11 and later, move semantics allow objects to transfer ownership of resources instead of duplicating them. This is particularly useful for classes that manage resources like dynamic memory, file handles, or sockets, as it can significantly improve performance by avoiding expensive deep copies.

The Five Special Member Functions:

1. Destructor

The destructor cleans up resources when an object is destroyed.

2. Copy Constructor

The copy constructor creates a new object as a deep copy of another.

3. Copy Assignment Operator

The copy assignment operator assigns one object's resources to another, making a deep copy.

4. Move Constructor

The move constructor transfers resources from one object to another, leaving the original object in a valid but empty state. This avoids expensive deep copying.

5. Move Assignment Operator

The move assignment operator transfers resources from one object to another, cleaning up any existing resources in the target object.

The Problem Without the Rule of Five

When a class manages resources, such as dynamic memory or file handles, and does not implement the special member functions (destructor, copy/move constructors, copy/move assignment operators), the compiler automatically generates default implementations for these functions. However, these default implementations often perform a shallow copy or a naive move, which can lead to serious problems:

1 Shallow Copy Issue

By default, the compiler-generated copy constructor and copy assignment operator perform a shallow copy of all member variables. This is problematic when the class manages resources like dynamic memory.

Example:

#include <iostream>

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

public:
    // Constructor allocates dynamic memory
    MyClass(size_t s) : size(s), data(new int[s]) {}

    // Destructor cleans up the resource
    ~MyClass() { delete[] data; }
};

Scenario:

void shallowCopyProblem() {
    MyClass obj1(5);          // Allocates memory
    MyClass obj2 = obj1;      // Default copy constructor: shallow copy

    // When obj1 and obj2 are destroyed, both will attempt to delete the same pointer!
}
Result:
  • Both obj1 and obj2 point to the same dynamically allocated memory.
  • When the destructor is called for obj1 and obj2, it will try to delete the same memory twice, leading to undefined behavior.

Complete Code:

#include <iostream>

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

public:
    // Constructor allocates dynamic memory
    MyClass(size_t s) : size(s), data(new int[s]) {}

    // Destructor cleans up the resource
    ~MyClass() { delete[] data; }
};

int main()
{
    MyClass obj1(7);  // Allocates memory
    MyClass obj2 = obj1;  // Default copy constructor: shallow copy
    
    // When obj1 and obj2 are destroyed, both will attempt to delete the same pointer!

}

2 Resource Leak on Assignment

The compiler-generated copy assignment operator does not free the existing resources of the target object before assigning new resources. This can lead to memory/resource leaks.

void assignmentProblem() {
    MyClass obj1(5);          // Allocates memory
    MyClass obj2(10);         // Allocates memory

    obj2 = obj1;              // Default assignment operator: shallow copy
    // Memory allocated for obj2 (10 elements) is leaked.
}
Result:
  • The memory originally allocated for obj2 is not released before assigning obj1's resources.
  • This results in a memory leak.

3 Move Operations: Inefficiency and Undefined Behavior

Without move semantics, temporary objects (e.g., function return values) are handled using copy semantics, which can be unnecessarily expensive. Worse, if move operations are auto-generated but rely on shallow transfers, they can cause resource mismanagement.

    // 5. Move Constructor
    Employee(Employee&& other) noexcept {
        cout << "Move Constructor Called!" << endl;
        this->name = other.name; // Steal the resource
        this->id = other.id;

        other.name = nullptr; // Leave the source in a valid state
        other.id = 0;
    }

    // 6. Move Assignment Operator
    Employee& operator=(Employee&& other) noexcept {
        cout << "Move Assignment Operator Called!" << endl;
        if (this == &other) {
            return *this; // Handle self-assignment
        }

        delete[] name; // Clean up existing memory

        this->name = other.name; // Steal the resource
        this->id = other.id;

        other.name = nullptr; // Leave the source in a valid state
        other.id = 0;

        return *this;
    }

Notice the && in the parameters of the move operations. Also, the parameters are not marked as const as opposed to const parameters in copy operations.

int main() {
    Employee employee1("Alice", 100); // Create original object
    Employee employee2("Bob", 200);  // Create another object
    
    // Test Copy Assignment
    employee2 = employee1;
    
    // Test Move Constructor
    Employee employee3 = std::move(employee1);
}

Using the std::move library function, we can force move semantics.

Complete Code:

#include <iostream>
#include <cstring>
using namespace std;

class Employee {
public:
    // 1. Default Constructor
    Employee() : name(nullptr), id(0) {}

    // 2. Parameterized Constructor
    Employee(const char* name, int id) {
        int length = strlen(name);
        this->name = new char[length + 1];
        strcpy(this->name, name);
        this->id = id;
    }

    // 3. Copy Constructor (Deep Copy)
    Employee(const Employee& other) {
        cout << "Copy Constructor Called!" << endl;
        if (other.name) {
            int length = strlen(other.name);
            this->name = new char[length + 1];
            strcpy(this->name, other.name);
        } else {
            this->name = nullptr;
        }
        this->id = other.id;
    }

    // 4. Copy Assignment Operator (Deep Copy)
    Employee& operator=(const Employee& other) {
        cout << "Copy Assignment Operator Called!" << endl;
        if (this == &other) {
            return *this; // Handle self-assignment
        }

        delete[] name; // Clean up existing memory

        if (other.name) {
            int length = strlen(other.name);
            this->name = new char[length + 1];
            strcpy(this->name, other.name);
        } else {
            this->name = nullptr;
        }
        this->id = other.id;

        return *this;
    }

    // 5. Move Constructor
    Employee(Employee&& other) noexcept {
        cout << "Move Constructor Called!" << endl;
        this->name = other.name; // Steal the resource
        this->id = other.id;

        other.name = nullptr; // Leave the source in a valid state
        other.id = 0;
    }

    // 6. Move Assignment Operator
    Employee& operator=(Employee&& other) noexcept {
        cout << "Move Assignment Operator Called!" << endl;
        if (this == &other) {
            return *this; // Handle self-assignment
        }

        delete[] name; // Clean up existing memory

        this->name = other.name; // Steal the resource
        this->id = other.id;

        other.name = nullptr; // Leave the source in a valid state
        other.id = 0;

        return *this;
    }

    // 7. Destructor
    ~Employee() {
        delete[] name; // Free allocated memory
        cout << "Destructor Called!" << endl;
    }

    void print() const {
        cout << "Name = " << (name ? name : "null") << ", Address = " << static_cast<void*>(name) << endl;
        cout << "Id = " << id << endl;
    }

private:
    char* name; // Dynamically allocated memory for name
    int id;     // Employee ID
};

int main() {
    Employee employee1("Alice", 100); // Create original object
    Employee employee2("Bob", 200);  // Create another object

    cout << "Before Assignment:\n";
    cout << "Employee 1: ";
    employee1.print();
    cout << "Employee 2: ";
    employee2.print();

    // Test Copy Assignment
    employee2 = employee1;

    cout << "\nAfter Copy Assignment:\n";
    cout << "Employee 1: ";
    employee1.print();
    cout << "Employee 2: ";
    employee2.print();

    // Test Move Constructor
    Employee employee3 = std::move(employee1);

    cout << "\nAfter Move Constructor:\n";
    cout << "Employee 1 (Moved From): ";
    employee1.print();
    cout << "Employee 3: ";
    employee3.print();

    // Test Move Assignment
    employee2 = std::move(employee3);

    cout << "\nAfter Move Assignment:\n";
    cout << "Employee 3 (Moved From): ";
    employee3.print();
    cout << "Employee 2: ";
    employee2.print();

    return 0;
}
Before Assignment:
Employee 1: Name = Alice, Address = 0x505488
Id = 100
Employee 2: Name = Bob, Address = 0x505498
Id = 200
Copy Assignment Operator Called!

After Copy Assignment:
Employee 1: Name = Alice, Address = 0x505488
Id = 100
Employee 2: Name = Alice, Address = 0x505498
Id = 100
Move Constructor Called!

After Move Constructor:
Employee 1 (Moved From): Name = null, Address = 0
Id = 0
Employee 3: Name = Alice, Address = 0x505488
Id = 100
Move Assignment Operator Called!

After Move Assignment:
Employee 3 (Moved From): Name = null, Address = 0
Id = 0
Employee 2: Name = Alice, Address = 0x505488
Id = 100
Destructor Called!
Destructor Called!
Destructor Called!