The Rule of Three
The Rule of Three in C++ is a principle that states: if a class requires a custom implementation for one of the following special member functions, it likely needs custom implementations for the other two as well:
- Destructor
- Copy Constructor
- Copy Assignment Operator
The Rule of Three in C++ is a fundamental guideline for writing resource-managing classes. It states that if your class manages a resource that requires explicit cleanup (e.g., dynamic memory, file handles, network sockets), you must explicitly define three special member functions to ensure proper behavior.
This rule arises because these three functions are responsible for resource management, particularly when dealing with dynamic memory or other resources such as file handles or sockets.
These functions are usually required only when a class is manually managing a dynamically allocated resource, and so all of them must be implemented to manage the resource safely.
Why the Rule of Three?
If you don’t explicitly implement these functions, the compiler will generate default versions for you. These default implementations are typically shallow copies, which can lead to problems like:
- Memory/resource leaks
- Double deletion
- Undefined behavior
Special Member Functions in the Rule of Three
1. Destructor
The destructor ensures that any allocated resource (e.g., dynamic memory) is properly released when an object is destroyed.
2. Copy Constructor
The copy constructor creates a new object as a deep copy of another object. It ensures that the new object has its own copy of the resource rather than sharing the same pointer.
3. Copy Assignment Operator
The copy assignment operator is used when an existing object is assigned the contents of another object. It ensures old resources are cleaned up before copying new ones.
Problem without Rule of Three
To understand the problem that arise without the Rule of Three
, let's explore a scenario where a class does not correctly implement the destructor, copy constructor, and copy assignment operator when managing resources such as dynamic memory.
Scenario: A class managing dynamic memory with no destructor
Suppose we have simple MyClass
that manages a dynamically allocated array of integers.
Class Without Rule of Three:
#include <iostream>
class MyClass {
private:
int* data;
size_t size;
public:
// Constructor: Allocates memory
MyClass(size_t s) : size(s), data(new int[s]) {}
// Destructor: Missing (Problem #1)
// Copy Constructor: Missing (Problem #2)
// Copy Assignment Operator: Missing (Problem #3)
};
In this class we dynamically create an array of integer.
Problem 1: Memory Leaks (Missing Destructor):
If the class lacks a destructor, the memory allocated by new
in the constructor will not be released when the object is destroyed, causing a memory leak.
void memoryLeak() {
MyClass obj(10); // Allocates memory for 10 integers
// Destructor is never called, so the memory is not freed
}
Problem 2: Dangling Pointer and Double Deletion (Shallow Copy)
What happens when an object of MyClass
is copied?
void shallowCopyProblem() {
MyClass obj1(5); // Allocates memory
MyClass obj2 = obj1; // Default copy constructor: shallow copy of `data`
// When obj1 and obj2 are destroyed, both will try to free the same memory!
}
What Happens?
When obj2
is being constructed, the default copy constructor for MyClass
will be executed (as there's no user-defined copy constructor). A default copy constructor is supplied by the C++ compiler whenever there's a missing user defined copy constructor. The default copy constructor will copy each attribute of a the class as-is (shallow copy). Which means, both obj1.data
and obj2.data
point to the same location.
- Both
obj1
andobj2
share the samedata
pointer. - When the destructor for both objects is invoked, the same memory is deleted twice (double deletion), causing undefined behavior.
Problem 2: Resource Corruption (Assignment Operator):
The compiler-generated default copy assignment operator also performs a shallow copy. If an object is reassigned after being initialized, the new object's resources overwrite the old one, potentially leading to resource corruption or memory leaks.
void assignmentProblem() {
MyClass obj1(5); // Allocates memory
MyClass obj2(10); // Allocates memory
obj2 = obj1; // Default assignment operator: shallow copy
// obj2's previously allocated memory is leaked
// Both obj1 and obj2 now share the same `data` pointer
}
What Happens?
- Memory allocated for
obj2
is leaked because the default assignment operator doesn’t release it. - Both
obj1
andobj2
now share the samedata
pointer, causing issues like double deletion or unintended modifications.
The Problem Without the Rule of Three with Destructor
Consider a class that manages a dynamically allocated array:
class MyClass {
private:
int* data;
size_t size;
public:
// Constructor
MyClass(size_t s) : size(s), data(new int[s]) {}
// Destructor
~MyClass() { delete[] data; }
// Copy Constructor and Copy Assignment Operator are missing!
};
Scenarios Leading to Problems
- Shallow Copy
- Default copy constructor and assignment operator copy the
data
pointer. - Two objects now share the same memory.
- Leads to double deletion when both objects are destroyed.
- Default copy constructor and assignment operator copy the
- Memory Leak
- Default assignment operator doesn’t free existing memory in the target object.
- Causes a memory leak when one object is assigned to another.
void exampleWithoutRuleOfThree() {
MyClass obj1(5); // Allocates memory
MyClass obj2 = obj1; // Default copy constructor: shallow copy
MyClass obj3(10);
obj3 = obj1; // Default assignment operator: shallow copy
// Existing memory in obj3 is leaked.
}
Key Issues Without Rule of Three
Problem | Cause | Example |
---|---|---|
Memory Leaks | Missing Destructor | Memory allocated by new is not released. |
Double Deletion | Shallow Copy (Default Copy Constructor) | Multiple objects share the same pointer and both try to free it. |
Unintended Modifications | Shallow Copy (Default Copy Constructor or Assignment Operator) | Modifying one object changes the shared resource. |
Lost Resources | Default Assignment Operator | Overwriting an object's pointer without freeing existing memory results in memory leaks. |
How Rule of Three Solves These Problems
By implementing the Rule of Three (destructor, copy constructor, and copy assignment operator), we ensure proper handling of resources.
- Destructor
- Ensures memory is freed when an object is destroyed, preventing memory leaks.
- Copy Constructor
- Creates a new object with its own copy of the resource, avoiding shared ownership and double deletion.
- Copy Assignment Operator
- Properly cleans up the target object’s existing resources before assigning new ones, preventing leaks.
Fixing the Problems:
class MyClass {
private:
int* data;
size_t size;
public:
// Constructor
MyClass(size_t s) : size(s), data(new int[s]) {}
// Destructor
~MyClass() {
delete[] data; // Free allocated memory
}
// Copy Constructor
MyClass(const MyClass& other) : size(other.size), data(new int[other.size]) {
std::copy(other.data, other.data + size, data); // Deep copy
}
// Copy Assignment Operator
MyClass& operator=(const MyClass& other) {
if (this == &other) return *this; // Handle self-assignment
delete[] data; // Free existing memory
size = other.size;
data = new int[size]; // Allocate new memory
std::copy(other.data, other.data + size, data); // Deep copy
return *this;
}
};
Fixed Example:
void testRuleOfThree() {
MyClass obj1(5); // Allocates memory
MyClass obj2 = obj1; // Deep copy: obj2 gets its own memory
MyClass obj3(10);
obj3 = obj1; // Deep copy: obj3's previous memory is freed
// obj3 now has a copy of obj1's data
// When obj1, obj2, and obj3 are destroyed, their destructors
// safely release their independent memory.
}
Let's create a class Employee
which would have two data member name
and id
of the employee.
#include <iostream>
using namespace std;
// Overloaded function to copy strings with a given length
// Copies `length` characters from `src` to `dest`
void stringCpy(const char* src, char* dest, int length) {
for (int i = 0; i < length; i++) {
dest[i] = src[i]; // Copy each character from source to destination
}
}
// Overloaded function to copy null-terminated strings
// Copies characters from `src` to `dest` until the null terminator (`\0`) is encountered
void stringCpy(const char* src, char* dest) {
char* destPtr = dest; // Pointer to traverse the destination buffer
while (*src != '\0') { // Loop until the end of the source string
*destPtr = *src; // Copy the current character
destPtr++; // Move to the next position in the destination
src++; // Move to the next character in the source
}
*destPtr = '\0'; // Null-terminate the destination string
}
// Class representing an Employee
class Employee {
public:
// Default constructor
Employee() {
name = nullptr; // Initialize `name` to null
id = 0; // Initialize `id` to zero
}
// Parameterized constructor
// Initializes the Employee object with a name and ID
Employee(const char* name, int id) {
int length = strlen(name); // Get the length of the name
this->name = new char[length + 1]; // Allocate memory for `name` (+1 for null terminator)
this->id = id; // Set the employee ID
stringCpy(name, this->name); // Copy the name to the allocated memory
}
// Function to print employee details
void print() {
cout << "Name = " << this->name << endl; // Print the employee name
cout << "Id = " << this->id << endl; // Print the employee ID
}
private:
char* name; // Pointer to dynamically allocated memory for the employee's name
int id; // Employee ID
};
int main() {
// Creating an employee object using the default constructor
Employee employee1;
// Creating an employee object using the parameterized constructor
Employee employee2("jack", 1);
// Printing details of employee2
employee2.print();
// The program does not clean up dynamically allocated memory for `name`
}
This code has a drawback that is it is causing the memory leak. Because the allocated memory is never freed as the C++
does not have the garbage collector so we have the free the memory manually.
This code does not have the destructor and C++
compiler provides a default destructor if we do not explicitly define one. The default destructor does nothing specific beyond destroying the object itself, meaning:
- It does not release dynamically allocated resources (e.g., memory allocated with
new
). - It destroys the member objects (if the class contains objects as members) by calling their destructors in reverse order of construction.
- For primitive types or raw pointers, no cleanup is performed—it's up to the programmer to manage them.
Let's create a destructor
which will deallocated the allocated memory. Add the below destructor in the Employee
class:
~Employee() {
delete[] this->name;
}
Now when the Employee
object goes out of scope then destructor is called which does the cleanup, like deallocate the memory.
So we fixed the memory leak problem, However there are other problems too. Like
What happens when an object of Employee
is copied?
Suppose in the main
function we copy the current object.
int main() {
// Creating an employee object using the default constructor
Employee employee1;
// Creating an employee object using the parameterized constructor
Employee employee2("jack", 1);
Employee employee3 = employee2; // Copying the employee2
}
This line of code Employee employee3 = employee2;
calls the default copy constructor for the Employee
class. If you do not provide a user-defined copy constructor, the C++ compiler automatically generates a default copy constructor for you.
A default copy constructor is supplied by the C++
compiler whenever there's a missing user-defined copy constructor.
Isn't that good?
Somewhat, the default copy constructor does the shallow copy, means both employee3 and employee2 points to the same memory location.
Default copy constructor
The default copy constructor performs a shallow copy of all members. This means:
For primitive types (like int, float), the values are directly copied.
For pointers, only the address is copied, not the content of the memory they point to.
Both employee2
and employee3
will have their name
pointers pointing to the same memory location. As a result:
- Any modification to the
name
throughemployee3
will also affectemployee2
. - When one of these objects is destroyed, it will delete the shared memory, leaving the other object with a dangling pointer. This is a classic example of a double delete error or undefined behavior.
Why the Default Copy Constructor Isn’t Always Good
While the default copy constructor may work fine for simple classes (with no pointers or dynamic resource management), it can lead to severe problems in classes with dynamically allocated memory, like this Employee
class.
- Issue 1: Shared Ownership
Both objects end up sharing the same memory, leading to unexpected behavior when one modifies it. - Issue 2: Double Deletion
When one of the objects is destroyed, the destructor will delete the memory, leaving the other object with an invalid pointer.
#include <iostream>
#include <cstring>
using namespace std;
// Custom string copy function
void stringCpy(const char* src, char* dest) {
while ((*dest++ = *src++) != '\0');
}
class Employee {
public:
// Default constructor
Employee() : name(nullptr), id(0) {}
// Parameterized constructor
Employee(const char* name, int id) {
int length = strlen(name);
this->name = new char[length + 1];
stringCpy(name, this->name); // Custom string copy
this->id = id;
}
// Destructor
~Employee() {
delete[] name; // Free allocated memory
}
// Print function
void print() const {
cout << "Name = " << (name ? name : "null") << ", Address = " << static_cast<void*>(name) << endl;
cout << "Id = " << id << endl;
}
char* name;
int id;
};
int main() {
Employee employee1("Alice", 100); // Create original object
Employee employee2 = employee1; // Copy object (shallow if no copy constructor)
cout << "Original Employee Before Modification: ";
employee1.print();
cout << "Copied Employee Before Modification: ";
employee2.print();
// Modify the name of the original object
strcpy(employee1.name, "Bob");
cout << "\nAfter Modifying Original Employee's Name:\n";
cout << "Original Employee: ";
employee1.print();
cout << "Copied Employee: ";
employee2.print();
return 0;
}
Output:
Original Employee Before Modification: Name = Alice, Address = 0x505408
Id = 100
Copied Employee Before Modification: Name = Alice, Address = 0x505408
Id = 100
After Modifying Original Employee's Name:
Original Employee: Name = Bob, Address = 0x505408
Id = 100
Copied Employee: Name = Bob, Address = 0x505408
Id = 100
Solution: Define a User-Defined Copy Constructor
To avoid these problems, you should define your own copy constructor that performs a deep copy of the dynamically allocated memory. Here's how:
Employee(const Employee& other) {
cout << "Copy Constructor Called!" << endl;
int length = strlen(other.name);
this->name = new char[length + 1];
strcpy(this->name, other.name); // Deep copy of the name
this->id = other.id;
}
Now if we create of the copy of existing object:
int main() {
Employee employee1("Alice", 100); // Create original object
Employee employee2 = employee1; // Copy object (shallow if no copy constructor)
cout << "Original Employee Before Modification: ";
employee1.print();
cout << "Copied Employee Before Modification: ";
employee2.print();
// Modify the name of the original object
strcpy(employee1.name, "Bob");
cout << "\nAfter Modifying Original Employee's Name:\n";
cout << "Original Employee: ";
employee1.print();
cout << "Copied Employee: ";
employee2.print();
return 0;
}
Copy Constructor Called!
Original Employee Before Modification: Name = Alice, Address = 0x505418
Id = 100
Copied Employee Before Modification: Name = Alice, Address = 0x505428
Id = 100
After Modifying Original Employee's Name:
Original Employee: Name = Bob, Address = 0x505418
Id = 100
Copied Employee: Name = Alice, Address = 0x505428
Id = 100
Now what if try to assign one object to another object.
employee2 = employee1;
Here, we assigned the employee1
to employee2
using the assignment operator (=
). The default assignment operator is called if you haven't defined your own custom operator. By default, the compiler provides shallow copy behavior for the assignment operator, similar to the default copy constructor.
Both objects will share the same memory for name
and modifying employee1.name
will also affect employee2.name
.
We can do deep copy by implementing by own implementation operator.
// Custom assignment operator
Employee& operator=(const Employee& other) {
cout << "Assignment Operator Called!" << endl;
if (this == &other) {
return *this; // Self-assignment, no need to proceed
}
delete[] name; // Free existing memory
int length = strlen(other.name);
this->name = new char[length + 1];
strcpy(this->name, other.name);
this->id = other.id;
return *this;
}
Complete Code:
#include <iostream>
#include <cstring>
using namespace std;
class Employee {
public:
Employee() : name(nullptr), id(0) {}
Employee(const char* name, int id) {
int length = strlen(name);
this->name = new char[length + 1];
strcpy(this->name, name);
this->id = id;
}
// Copy constructor
Employee(const Employee& other) {
cout << "Copy Constructor Called!" << endl;
int length = strlen(other.name);
this->name = new char[length + 1];
strcpy(this->name, other.name);
this->id = other.id;
}
// Custom assignment operator
Employee& operator=(const Employee& other) {
cout << "Assignment Operator Called!" << endl;
if (this == &other) {
return *this; // Self-assignment, no need to proceed
}
delete[] name; // Free existing memory
int length = strlen(other.name);
this->name = new char[length + 1];
strcpy(this->name, other.name);
this->id = other.id;
return *this;
}
~Employee() {
delete[] name; // Free allocated memory
}
void print() const {
cout << "Name = " << (name ? name : "null") << ", Address = " << static_cast<void*>(name) << endl;
cout << "Id = " << id << endl;
}
// private:
char* name;
int 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();
// Assign one object to another
employee2 = employee1;
cout << "\nAfter Assignment:\n";
cout << "Employee 1: ";
employee1.print();
cout << "Employee 2: ";
employee2.print();
// Modify the name of employee1
strcpy(employee1.name, "Charlie");
cout << "\nAfter Modifying Employee 1's Name:\n";
cout << "Employee 1: ";
employee1.print();
cout << "Employee 2: ";
employee2.print();
return 0;
}
Before Assignment:
Employee 1: Name = Alice, Address = 0x5053e8
Id = 100
Employee 2: Name = Bob, Address = 0x5053f8
Id = 200
Assignment Operator Called!
After Assignment:
Employee 1: Name = Alice, Address = 0x5053e8
Id = 100
Employee 2: Name = Alice, Address = 0x5053f8
Id = 100
After Modifying Employee 1's Name:
Employee 1: Name = Charlie, Address = 0x5053e8
Id = 100
Employee 2: Name = Alice, Address = 0x5053f8
Id = 100
References
https://www.codementor.io/@sandesh87/the-rule-of-five-in-c-1pdgpzb04f
https://github.com/sftrabbit/CppPatterns-Patterns/blob/master/common-tasks/classes/rule-of-five.cpp