Destructor, Copy Constructor, and Assignment Operator Overloading
Understand the importance of copy constructors and destructors along with their implementation.
Copy constructor and assignment operator overloading (operator=()
)
Creating a copy of an existing object of the same class is a common requirement in programming. This is where the copy constructor and operator=()
come into play, as they are two special member functions designed specifically for this purpose.
The copy constructor is invoked when an object is copied during its creation, which can occur in the following three scenarios:
- When an object is passed by value to a function
- When an object is returned by value from a function
- When an object is created and assigned to another object.
Similarly, the operator=()
function is called automatically when we have two already declared objects and we assign one object to another.
By default, C++ provides a copy constructor and operator=()
implementations that perform a shallow copy. This means that an exactly same, byte-by-byte copy of the same object is made, which can lead to unexpected behavior in some cases.
To better understand shallow and deep copying, let’s look at a code example and its illustration.
Shallow copy
A shallow copy creates a new object that has, byte by byte, the same value as the assigned object. By default, C++ adds the following two functions automatically to every class we make: the copy constructor and the operator=()
function. We can see in the following code how each attribute is copied. Also, understand how these two functions are automatically called in the main()
function.
#include <iostream>using namespace std;class MyClass{int attribute1;int attribute2;/*Type attribute3;...*/public:MyClass(int a1=0, int a2=0){this->attribute1 = a1;this->attribute2 = a2;}// Copy constructor: is automatically added in every class.MyClass(const MyClass& R){cout << "Copy constructor is called..."<<endl;this->attribute1 = R.attribute1;this->attribute2 = R.attribute2;/* ...*/}MyClass& operator=(const MyClass& R){cout << "operator= is called..."<<endl;this->attribute1 = R.attribute1;this->attribute2 = R.attribute2;/* ...*/return *this; // for enabling cascading affect obj1 = obj2 = obj3;}};MyClass function(MyClass obj1, MyClass &obj2){// The copy constructor will be called only for obj1return obj1; // A copy constructor will be called here again.}int main(){MyClass obj1(2,3); // any parameters neededMyClass obj2 = obj1; // Obj2 is passed as this and obj1 as R in copy constructorMyClass obj3; // default constructor will be calledobj3 = obj1; // The operator=() function will be called with obj3 as this and obj1 as Robj2 = function(obj1, obj2);// Two copy constructors and the operator =() function will be called/* ...*/return 0;}
In the main()
function, MyClass obj2 = obj1
automatically calls the copy constructor while obj3 = obj1
automatically calls the operator=()
function. Both of these functions will make the exact byte-by-byte copy of the assigned object.
Problems with shallow copy implementation
The default implementation of these two functions will work perfectly as far as the attributes inside the class are primitive and custom variables. However, if there is an attribute that is a pointer (pointing toward a heap memory that is dynamically allocated and that memory’s creation and deletion are the responsibility of the same object), the shallow copy will lead to serious problems.
Let’s look at a few examples to understand this better.
Tip: Run each code and identify its logical errors before reading the explanation and details provided below.
#include <iostream>using namespace std;class MyClass{public:int* ptr;MyClass(){ptr = new int{}; // Create an integer on heap and its address will be stored in "ptr"}~MyClass(){// delete ptr;}};int main(){MyClass obj1;MyClass obj2 = obj1; // shallow copyMyClass obj3;obj3 = obj1; // shallow copycout << *obj1.ptr << " "<< *obj2.ptr << " "<< *obj3.ptr << " "<< endl; // outputs 0*obj1.ptr = 5;cout << *obj1.ptr << " "<< *obj2.ptr << " "<< *obj3.ptr << " "<< endl; // outputs 5*obj2.ptr = 10;cout << *obj1.ptr << " "<< *obj2.ptr << " "<< *obj3.ptr << " "<< endl; // outputs 10*obj3.ptr = 20;cout << *obj1.ptr << " "<< *obj2.ptr << " "<< *obj3.ptr << " "<< endl; // outputs 20// Therefore, any change that happens through ptr in obj1, obj2 or obj3 will change every objectreturn 0;}/*This program will terminate perfected, The only issue is thatchanging in one object is changing the other object.*/
In the Example0.cpp
file, we can see that obj1
is created, and the ptr
pointer inside the object is pointing to the memory location inside the heap having a value {0}
. Because obj2
and obj3
are shallow copies of obj1
, any change through any of these objects will make the change at the same memory location. That is not the intended behavior for copies here because all three objects should have been different.
In the Example1.cpp
file, we have added the destructor function ~MyClass()
, which deletes the dynamically allocated memory assigned to its ptr
attribute. Now, when we execute this program, the program crashes after the same output as the previous example. This is because the destructor is automatically called for all the local variables whenever the scope of the function ends (and always in reverse order i.e., the object which was created last will be destroyed first). Therefore, the memory pointed by obj3.ptr
will be destroyed first. Because the obj2.ptr
and obj1.ptr
pointers have now become dangling pointers, their destructor call will lead to deallocating an illegal memory, causing the program to crash.
In the Example2.cpp
file, the obj2
is the exact copy of the obj1
due to the call to operator=()
on line 25. So, when the inner scope ends (on line 29), the destructor of obj2
is automatically called, and the heap memory is destroyed. But, since obj1.ptr
is still holding the deallocated memory’s address (because it has become a dangling pointer), the next scope ending (on line 30) will cause the program to crash (double free or corruption).
In the Example3.cpp
file, similar to the previous example, the same crash will occur due to the shallow copying by the default copy constructor call.
Quiz
Let's take a quick quiz.
In each of the above examples, can you determine if memory leaks are happening? If so, where and how?
The remedy to the problems associated with shallow copying is deep copying.
Deep copy
The idea behind a deep copy is to override the already default functions (copy constructor and operator=()
). Here, overriding actually means that whenever we add the two functions (copy constructor and operator=()
) explicitly, the compiler-generated shallow copy implementation gets disabled, and our implementation takes over the control.
#include <iostream>using namespace std;class MyClass{public:int* ptr;MyClass(){ptr = new int{}; // Create an integer on heap and its address will be stored in "ptr"}MyClass(const MyClass& R){ptr = new int{}; // we allocate a separate heap memory and point ptr there*ptr = *R.ptr; // Here we are not copying address but the value present at that address}const MyClass& operator=(const MyClass& R){if(this==&R) return *this;delete ptr; // delete the previously allocated memory pointed by ptrptr = new int{}; // we allocate a separate heap memory and point ptr there*ptr = *R.ptr; // Here we are not copying address but the value present at that addressreturn *this; // enabling cascading}~MyClass(){delete ptr;}};int main(){MyClass obj1;MyClass obj2 = obj1; // deep copyMyClass obj3;obj3 = obj1; // deep copycout << *obj1.ptr << " "<< *obj2.ptr << " "<< *obj3.ptr << " "<< endl; // outputs 0*obj1.ptr = 5;cout << *obj1.ptr << " "<< *obj2.ptr << " "<< *obj3.ptr << " "<< endl; // outputs 5*obj2.ptr = 10;cout << *obj1.ptr << " "<< *obj2.ptr << " "<< *obj3.ptr << " "<< endl; // outputs 10*obj3.ptr = 20;cout << *obj1.ptr << " "<< *obj2.ptr << " "<< *obj3.ptr << " "<< endl; // outputs 20// Therefore, any change that happens through ptr in obj1, obj2 or obj3 will change every objectreturn 0;}/*This program will terminate perfectly. This will make a separate copyof all three objects, due to the copy constructor and the operator=() function.So, any change at one memory will not affect the data at other memory locations.*/
In both the copy constructor and the operator=()
function, we have passed a constant (const
) object of MyClass&
to prevent any updation to the original object. Lines 14–15 and lines 23–24 are the same; these two lines make sure that instead of pointer assignment, we allocate a separate memory and assign the value present at that memory (instead of the assignment of addresses, as was the case of shallow copy of default C++ implementation). The same can be seen in the following illustration.
Note: In the
operator=()
function, we have some extra lines of code. Line 19 ensures that if there is a self-assignment, then there is no need to do anything, just executereturn *this
to enable cascading. Line 21 makes sure to erase the already allocated memory on the heap (this is necessary to avoid any memory leakage).
A deep copy is generally safer than a shallow copy because it ensures that changes made to the copied object do not affect the original object. However, a deep copy can be more expensive in terms of memory and processing time, especially for large or complex objects. Therefore, the choice between deep copy and shallow copy depends on the program’s specific use case and requirements.