Rule Of Three In C++ - C++ Programming Tutorial
C++ Course / Miscellaneous / Rule Of Three In C++

Rule Of Three In C++

BLUF: Mastering Rule Of Three In C++ is a critical step in becoming a proficient C++ developer. This lesson provides a deep dive into the syntax, performance considerations, and real-world applications of this concept.
Key Performance Insight: Rule Of Three In C++

C++ is renowned for its efficiency. Learn how Rule Of Three In C++ enables low-level control and high-performance computing in the tutorial below.

In C++ development, proficient resource management is crucial for creating resilient and sustainable applications. C++ projects often deal with classes responsible for handling dynamic resources like memory, file descriptors, network sockets, or various system-level references. Mishandling these resources can result in issues such as memory leaks, dangling pointers, and unpredictable program behavior. This is where the Rule of Three acts as a design principle to guarantee proper resource management within a class.

The Rule of Three suggests that when a class requires a custom implementation of any one of the following three special member functions, it is advisable to implement all three: the destructor, the copy constructor, and the copy assignment operator. These functions are crucial for properly handling an object's lifecycle, especially when dealing with resources. Failure to implement all three when necessary can often lead to complex and time-consuming bug tracking in C++ projects.

Key Concepts

In C++ programming, the Rule of Three is a fundamental principle that explains how resources are managed within classes. It becomes relevant when a class is responsible for handling resources such as dynamically allocated memory, file descriptors, or similar system handles. If a class implements any of the three specific member functions - the destructor, copy constructor, and copy assignment operator - it is imperative to implement all three. These lifecycle functions oversee the management of resources, guarantee proper cleanup of resources, and prevent issues like shallow copying or memory leaks.

The Rule of Three is created to steer clear of typical issues related to mishandling resources and avoiding problems like double deletion, unintended sharing of resources, and unpredictable behavior. This principle guarantees that a class provides clear and defined behaviors for copying, assigning, and deallocating its instances.

Motivation

The primary purpose for utilizing the Rule of Three is to ensure the safety and reliability of resources. Initially, default behaviors of special member functions offered by the C++ compiler may not be robust enough for C++ classes responsible for handling dynamic resources. For instance:

A shallow duplication happens when the default copy constructor or assignment operator of the compiler merely replicates pointer references rather than creating a new instance of the original resource. This situation can result in problems like multiple deallocations if two instances are in charge of the same resource concurrently.

Memory leaks occur due to the inadequacy of the compiler-generated destructor in managing the release of dynamically allocated memory and additional resources.

By implementing all three functions, developers ensure that:

  • They handle their own resources independently.
  • When not needed, the proper resources are cleaned up.
  • With side effects, copies of objects behave as expected.
  • That is the practical Rule of Three guideline of the balance of resource ownership and the consistency of class behavior.
  • Use Cases

If a class is responsible for handling an external resource, the Rule of Three should be considered. Typical scenarios where this applies are:

1. Dynamic Memory Management

To prevent memory leaks and system crashes, classes that reserve memory with new or comparable methods need to guarantee appropriate release of the reserved memory. These classes are equipped with clearly defined destructor, copy constructor, and copy assignment operator.

2. File Handling

To effectively handle file operations, a class must guarantee the proper closure of file handles upon object destruction. Additionally, it is essential for instances of such classes to support self-copying and assignment to ensure accurate duplication of file access semantics.

3. Networking and Sockets

When network socket or connection classes are no longer required, they should release resources like open sockets or streams. It is crucial for these classes to prevent inadvertent sharing of connection handles when objects are duplicated.

4. Custom Containers

Elements are commonly allocated in dynamic memory for custom containers, such as those created using dynamic arrays or linked lists. The Rule of Three is correctly executed when it ensures the secure handling of copying, assigning, and destroying these containers.

5. Low-Level System Resources

Efficient management of resources and precise duplication semantics are crucial in various scenarios, including resource classes responsible for handling fundamental resources like mutexes, thread handles, or GPU memory.

Approach 1: Manual Implementation

The manual method requires you to specifically declare the three essential special member functions: destructor, copy constructor, and copy assignment operator to effectively handle resources. This approach is particularly beneficial when a class interacts with active, dynamic resources such as raw pointers, file descriptors, or other system handles that cannot be managed automatically by the default compiler-generated functions.

Program:

Let's consider a scenario to demonstrate the Rule of three concept in C++.

Example

#include <iostream>
#include <algorithm> // For std::copy
class DynamicArray {
private:
    int* data;      // Pointer to dynamically allocated array
    size_t size;    // Number of elements in the array
public:
    // Default constructor
    DynamicArray(size_t n = 0) : size(n), data(nullptr) {
        if (size > 0) {
            data = new int[size]; // Allocate memory for the array
            for (size_t i = 0; i < size; ++i) {
                data[i] = 0; // Initialize array elements to 0
            }
        }
        std::cout << "Default constructor: Array of size " << size << " created.\n";
    }
    // Destructor
    ~DynamicArray() {
        delete[] data; // Release the dynamically allocated memory
        std::cout << "Destructor: Array of size " << size << " destroyed.\n";
    }
    // Copy constructor
    DynamicArray(const DynamicArray& other) : size(other.size), data(nullptr) {
        if (size > 0) {
            data = new int[size]; // Allocate new memory
            std::copy(other.data, other.data + size, data); // Copy elements
        }
        std::cout << "Copy constructor: Array of size " << size << " copied.\n";
    }
    // Copy assignment operator
    DynamicArray& operator=(const DynamicArray& other) {
        if (this == &other) {
            std::cout << "Self-assignment detected. Skipping operation.\n";
            return *this; // Handle self-assignment
        }
        // Release existing resources
        delete[] data;
        // Copy size and allocate new memory
        size = other.size;
        data = nullptr;
        if (size > 0) {
            data = new int[size];
            std::copy(other.data, other.data + size, data); // Copy elements
        }
        std::cout << "Copy assignment: Array of size " << size << " assigned.\n";
        return *this;
    }
    // Function to set the value of an element
    void set(size_t index, int value) {
        if (index < size) {
            data[index] = value;
        } else {
            std::cerr << "Index out of bounds.\n";
        }
    }
    // Function to get the value of an element
    int get(size_t index) const {
        if (index < size) {
            return data[index];
        } else {
            std::cerr << "Index out of bounds.\n";
            return -1; // Return an invalid value
        }
    }
    // Function to print all elements in the array
    void print() const {
        for (size_t i = 0; i < size; ++i) {
            std::cout << data[i] << " ";
        }
        std::cout << "\n";
    }
    // Function to return the size of the array
    size_t getSize() const {
        return size;
    }
};
int main() {
    std::cout << "Creating array1 with size 5.\n";
    DynamicArray array1(5); // Create an array with 5 elements
    std::cout << "Setting values in array1.\n";
    for (size_t i = 0; i < array1.getSize(); ++i) {
        array1.set(i, static_cast<int>(i * 10)); // Set values in array1
    }
    std::cout << "Printing array1:\n";
    array1.print();
    std::cout << "\nUsing copy constructor to create array2 from array1.\n";
    DynamicArray array2 = array1; // Invoke copy constructor
    std::cout << "Printing array2:\n";
    array2.print();
    std::cout << "\nModifying array2's elements.\n";
    for (size_t i = 0; i < array2.getSize(); ++i) {
        array2.set(i, static_cast<int>(i * 20)); // Modify array2
    }
    std::cout << "Printing array1 (should remain unchanged):\n";
    array1.print();
    std::cout << "Printing array2 (modified values):\n";
    array2.print();
    std::cout << "\nUsing copy assignment operator to assign array1 to array3.\n";
    DynamicArray array3; // Create an empty array
    array3 = array1;     // Invoke copy assignment operator
    std::cout << "Printing array3:\n";
    array3.print();
    std::cout << "\nDemonstrating self-assignment.\n";
    array3 = array3; // Self-assignment
    std::cout << "Printing array3 after self-assignment:\n";
    array3.print();
    std::cout << "\nEnd of main function. All destructors will now be called.\n";
    return 0;
}

Output:

Output

Creating array1 with size 5.
Default constructor: Array of size 5 created.
Setting values in array1.
Printing array1:
0 10 20 30 40 
Using copy constructor to create array2 from array1.
Copy constructor: Array of size 5 copied.
Printing array2:
0 10 20 30 40 
Modifying array2's elements.
Printing array1 (should remain unchanged):
0 10 20 30 40 
Printing array2 (modified values):
0 20 40 60 80 
Using copy assignment operator to assign array1 to array3.
Default constructor: Array of size 0 created.
Copy assignment: Array of size 5 assigned.
Printing array3:
0 10 20 30 40 
Demonstrating self-assignment.
Self-assignment detected. Skipping operation.
Printing array3 after self-assignment:
0 10 20 30 40 
End of main function. All destructors will now be called.
Destructor: Array of size 5 destroyed.
Destructor: Array of size 5 destroyed.
Destructor: Array of size 5 destroyed.

Explanation:

Class Design

Member properties:

  • Data: Refers to the dynamic array, which is responsible for holding an array with memory allocated dynamically.
  • Size: Tracks the quantity of elements present in the array.

Constructors:

  • Parameterized Constructor: When a non-zero size is specified, it reserves memory for the array and initializes all elements to zero. For example, creating a DynamicArray object array1(5) results in an array of size 5 with elements [0, 0, 0, 0, 0].

It frees up the memory reserved for the array upon the object's destruction, preventing memory leaks. For example, the destructor of array1 deallocates memory when array1 is no longer in scope.

Copy Constructor:

  • The copy constructor manages object copying. For instance, when DynamicArray array2 = array1 is executed, it ensures that all resources are correctly duplicated for array2
  • It allocates new memory to array2.
  • It sets the contents of array1 into the new memory.
  • That ensures all the data in array2 was copied over to array2, separate from array1.

Copy Assignment Operator:

It handles between objects assignment. For example, array3 = array1:

  • It also releases existing memory in array3 to prevent memory leaks.
  • Copy data from array1 and allocates new memory.
  • Self assignment (array3 = array3) checks to avoid unnecessary operations.
  • Utility Functions

Set and get:

It enables the alteration and fetching of array elements while ensuring bounds are checked.

Print:

It displays contents of the array in debug mode.

Main Function

  • It shows how to make arrays, copy using copy constructor, and assign using copy assignment operator.
  • It works some more highlighting deep copy behavior by changing array2 and proving array1 is untouched.
  • It shows handling of self assignment to ensure safety.
  • Complexity Analysis:

    Time Complexity:

Default Constructor:

The default constructor reserves memory for the array and initializes it with zero values. Nonetheless, it requires looping through each element to explicitly assign zero to them. This loop iterates n times, where n represents the array size. Therefore, the time complexity of the standard constructor is O(n).

Destructor:

The purpose of the destructor is to free up the memory allocated dynamically using delete. Its time complexity is constant (O(1)) as it directly releases the allocated memory block without any need for element iteration.

Copy Constructor:

One copy constructor creates a fresh array and duplicates every item from the original array. Copying involves iterating through all array elements. The time complexity for this operation is O(n).

Copy Assignment Operator:

This explains why the copy assignment operator verifies self-assignment (in constant time), releases the current array (in constant time), creates a new array, and duplicates elements. The operation of copying elements requires n operations, resulting in a time complexity of O(n).

It signifies that actions related to building, duplicating, and assigning all share a time complexity of O(n).

Space Complexity:

Default Constructor:

An array of length n is allocated by the constructor to store the integers, necessitating O(n) space.

Destructor:

Keep in mind that destructors do not require additional space other than freeing up the previous array, resulting in an O(1) space complexity.

Copy Constructor:

For the duplication constructor, the spatial complexity increases to O(n) as it reserves additional memory for replicating the array.

Copy Assignment Operator:

The copy assignment operator allocates new memory for the copied array before storing it in the destination object. This leads to a space complexity of O(n).

With the information provided, the space complexity for class operations is O(n).

Approach 2: Using std::vector or Other STL Containers

When using standard library containers like std:vector, the Rule of Three is often not necessary in cases such as these (, , or ) because these containers usually take care of resource management internals.

  • Destructor: Containers automatically release all their memory when the container goes out of scope.
  • Copy Constructor: Due to containers, the copying of their elements is deeply, you do not have to do it manually.
  • Copy Assignment Operator: Copying, assigning elements and memory is handled by the container automatically.

If your class primarily holds data and does not require managing memory allocation, copying, or cleanup, STL containers effectively address memory management concerns.

Program:

Let's consider an illustration to demonstrate the rule of three in C++ utilizing the std::vector container.

Example

#include <iostream>
#include <vector>
class ResourceHandler {
private:
    std::vector<int> data;  // Vector automatically manages memory for integers
public:
    // Constructor to initialize the vector with n elements
    ResourceHandler(int size, int initial_value) {
        data.resize(size, initial_value);  // Resize vector and fill with initial_value
        std::cout << "ResourceHandler constructor called: Vector size = " << size << "\n";
}
// Destructor: Automatically handled by std::vector
    ~ResourceHandler() {
        std::cout << "ResourceHandler destructor called\n";
    }
    // Copy Constructor: Automatically handled by std::vector
    ResourceHandler(const ResourceHandler& other) {
        data = other.data;  // Vector will handle deep copy automatically
        std::cout << "ResourceHandler copy constructor called\n";
    }
    // Copy Assignment Operator: Automatically handled by std::vector
    ResourceHandler& operator=(const ResourceHandler& other) {
        if (this != &other) {  // Prevent self-assignment
            data = other.data;  // Vector will handle the assignment
            std::cout << "ResourceHandler copy assignment operator called\n";
        }
        return *this;
    }
    // Method to print the contents of the vector
    void printData() const {
        std::cout << "Vector contents: ";
        for (const int& element : data) {
            std::cout << element << " ";
        }
        std::cout << "\n";
    }
    // Method to modify an element in the vector
    void setData(int index, int value) {
        if (index >= 0 && index < data.size()) {
            data[index] = value;
            std::cout << "Element at index " << index << " set to " << value << "\n";
        } else {
            std::cout << "Index out of bounds!\n";
        }
    }
    // Method to get an element from the vector
    int getData(int index) const {
        if (index >= 0 && index < data.size()) {
            return data[index];
        } else {
            std::cout << "Index out of bounds!\n";
            return -1; // Return an invalid value in case of out-of-bounds access
        }
    }
    // Method to resize the vector
    void resize(int new_size, int fill_value) {
        data.resize(new_size, fill_value);
        std::cout << "Resized vector to new size: " << new_size << "\n";
    }
};
int main() {
    // Creating a ResourceHandler object with 5 elements initialized to 10
    ResourceHandler handler1(5, 10);
    handler1.printData();
    // Modifying an element in handler1
    handler1.setData(2, 20);
    handler1.printData();
    // Copying handler1 to handler2 using the copy constructor
    ResourceHandler handler2 = handler1;
    handler2.printData();
    // Modifying handler2 and showing that handler1 remains unchanged
    handler2.setData(4, 30);
    handler2.printData();
    handler1.printData();  // handler1 should remain unchanged
    // Creating another ResourceHandler object
    ResourceHandler handler3(3, 50);
    handler3.printData();
    // Assigning handler3 to handler1 using the copy assignment operator
    handler1 = handler3;
    handler1.printData();
    // Resizing handler1 and printing the new vector contents
    handler1.resize(6, 100);
    handler1.printData();
    return 0;
}

Output:

Output

ResourceHandler constructor called: Vector size = 5
Vector contents: 10 10 10 10 10 
Element at index 2 set to 20
Vector contents: 10 10 20 10 10 
ResourceHandler copy constructor called
Vector contents: 10 10 20 10 10 
Element at index 4 set to 30
Vector contents: 10 10 20 10 30 
Vector contents: 10 10 20 10 10 
ResourceHandler constructor called: Vector size = 3
Vector contents: 50 50 50 
ResourceHandler copy assignment operator called
Vector contents: 50 50 50 
Resized vector to new size: 6
Vector contents: 50 50 50 100 100 100 
ResourceHandler destructor called
ResourceHandler destructor called
ResourceHandler destructor called

Explanation:

Constructor:

The ResourceHandler class's constructor sets up a std::vector<int> with a specified size and initial value. To adjust the size of the vector and populate it with a specific value, the std::resize function is employed. Here, the size for memory allocation and element initialization is determined at runtime.

Destructor:

The std::vector automatically handles the destruction process. When a ResourceHandler instance reaches the end of its scope, the std::vector automatically deallocates the memory it had reserved (such as the internal array) by using the delete operation internally. This eliminates the need for manual memory management within the class.

Copy Constructor:

The std::vector automatically manages the copy constructor. When a copy is made, the vector performs a deep copy of the elements within the ResourceHandler object. This ensures that each new vector created through copying has its own separate memory allocation, avoiding shallow copies where one entity controls all the data.

Copy Assignment Operator:

Likewise, the assignment operator for copying is managed by std::vector. In the scenario of assigning a different object, the vector performs a deep duplication of the information while taking charge of managing resources effectively. A check for self-assignment (if (this != &other)) guarantees that an object does not try to assign itself, avoiding potential undefined behavior.

Other Methods:

  • In setData, we can modify the elements in the vector at a specific index.
  • At getData, we are just retrieving values from the vector.
  • The resize function will resize the vector thereby changing its size and terminating values.
  • Complexity Analysis:

    Time Complexity:

Constructor (ResourceHandler(int, int)):

The time complexity of the resize function in std::vector is O(n), where n represents the length of the std::vector. This function involves allocating memory and initializing each element with the provided value to prepare the data for use.

Destructor:

The std::vector's destructor is called automatically upon the object's scope exit, guaranteeing correct resource cleanup. The time complexity for this operation is O(n) due to the deallocation of memory for the vector's dynamically allocated elements.

Copy Constructor:

Moreover, the duplicate constructor performs a deep replication by copying each item within the array. As a result, its time complexity is O(n).

Copy Assignment Operator:

First, it verifies whether it is self-assignment (constant time), then proceeds to generate a deep replica (O(n)) of the vector.

Space Complexity:

Vector Storage:

It requires n elements, resulting in a space complexity of O(n). The std::vector<int> contains n elements and is stored in O(n) space.

Approach 3: Using Smart Pointers (e.g., std::unique_ptr and std::shared_ptr)

In this approach, we use the smarcpp tutorialers from the Standard Library to handle these resources automatically so that we will not get into things like memory leaks, dangling pointers, and double deletes. RAII (Resource Acquisition Is Initialization) is a technique that ensures resources are acquired during object construction and released automatically when the object goes out of scope, often using smarcpp tutorialers for managed memory.

  • Destructor: std::uniqueptr and std::sharedptr handle memory deallocation automatically, ensuring the destructor is called when the object goes out of scope
  • Copy Constructor: Because the std::uniqueptr function offers exclusive ownership, deep copying is not allowed for std::uniqueptr. The copy constructor for the std::shared_ptr function increments the reference count, so under shared ownership of the resource.
  • Copy Assignment Operator: Just like the std::uniqueptr function, assignment is not allowed for std::uniqueptr because the owner cant get transferred but std::shared_ptr allows assignment and reference count is updated accordingly.
  • Program:

Let's consider an example to demonstrate the rule of three in C++ using smarcpp tutorials.

Example

#include <iostream>
#include <memory>
class ResourceHandler {
private:
    // Using unique_ptr for exclusive ownership
    std::unique_ptr<int> data;
public:
    // Constructor: Allocate memory and set value
    ResourceHandler(int value) {
        data = std::make_unique<int>(value);
        std::cout << "Constructor called. Value: " << *data << std::endl;
    }
    // Destructor: Memory is automatically freed when unique_ptr goes out of scope
    ~ResourceHandler() {
        std::cout << "Destructor called. Memory automatically freed.\n";
    }
    // Copy Constructor: Not allowed with unique_ptr, deep copy not supported
    ResourceHandler(const ResourceHandler& other) = delete;
    // Copy Assignment Operator: Not allowed with unique_ptr, deep copy not supported
    ResourceHandler& operator=(const ResourceHandler& other) = delete;
    // Move Constructor: Transfer ownership
    ResourceHandler(ResourceHandler&& other) noexcept {
        data = std::move(other.data);  // Transfer ownership
        std::cout << "Move constructor called. Value moved: " << *data << std::endl;
    }
    // Move Assignment Operator: Transfer ownership
    ResourceHandler& operator=(ResourceHandler&& other) noexcept {
        if (this != &other) {
            data = std::move(other.data);  // Transfer ownership
            std::cout << "Move assignment called. Value moved: " << *data << std::endl;
        }
        return *this;
    }
    // Print data value
    void printData() const {
        std::cout << "Value: " << *data << std::endl;
    }
};
int main() {
    // Create ResourceHandler with unique_ptr
    ResourceHandler handler1(10);
    handler1.printData();
    // Use move constructor to transfer ownership
    ResourceHandler handler2 = std::move(handler1);
    handler2.printData();
    // Create another ResourceHandler
    ResourceHandler handler3(20);
    handler3.printData();
    // Use move assignment operator to transfer ownership
    handler3 = std::move(handler2);
    handler3.printData();

    return 0;
}

Output:

Output

Constructor called. Value: 10
Value: 10
Move constructor called. Value moved: 10
Value: 10
Constructor called. Value: 20
Value: 20
Move assignment called. Value moved: 10
Value: 10
Destructor called. Memory automatically freed.
Destructor called. Memory automatically freed.
Destructor called. Memory automatically freed.

Explanation:

Copy Constructor and Copy Assignment Operator:

It guarantees that duplication of resources or assigning them is avoided. This mechanism also safeguards against duplicate deletions or the scenario where multiple individuals end up owning an entity that should ideally have a single owner.

Move Constructor:

The move constructor involves employing the std::move function to transfer ownership of std::unique_ptr from one object to another, enabling the transfer of a resource without duplicating the actual data it holds.

Move Assignment Operator:

Likewise, the move assignment operator shifts ownership by employing std::move, ensuring that the resource stays synchronized without causing memory leaks upon assignment to a different object.

Complexity Analysis:

Time Complexity:

  • Constructor: Constructor using the std::make_unique funciton has time complexity O(1) because it allocates memory for only a single integer and initializes it.
  • Move Constructor and Move Assignment: Both operations uses std::move that transfers ownership in O(1) time.
  • Destructor: Since only one integer is deallocated, the destructor takes O(1) time to free the dynamically allocated memory.
  • Space Complexity:

When it comes to Space Complexity, the std::unique_ptr function only requires a single integer, resulting in a fixed size space complexity of O(n). This method boasts a time and space complexity of O(1).

Input Required

This code uses input(). Please provide values below:

Logic Practice
Install Logic Practice
Add to home screen for a faster app-like experience