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.

Press + to interact
#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 obj1
return obj1; // A copy constructor will be called here again.
}
int main()
{
MyClass obj1(2,3); // any parameters needed
MyClass obj2 = obj1; // Obj2 is passed as this and obj1 as R in copy constructor
MyClass obj3; // default constructor will be called
obj3 = obj1; // The operator=() function will be called with obj3 as this and obj1 as R
obj2 = 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 copy
MyClass obj3;
obj3 = obj1; // shallow copy
cout << *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 object
return 0;
}
/*
This program will terminate perfected, The only issue is that
changing in one object is changing the other object.
*/
Logical error

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.

Press + to interact
Shallow copy
Shallow copy

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.

Question

In each of the above examples, can you determine if memory leaks are happening? If so, where and how?

Show Answer

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.

Press + to interact
#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 ptr
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
return *this; // enabling cascading
}
~MyClass()
{
delete ptr;
}
};
int main()
{
MyClass obj1;
MyClass obj2 = obj1; // deep copy
MyClass obj3;
obj3 = obj1; // deep copy
cout << *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 object
return 0;
}
/*
This program will terminate perfectly. This will make a separate copy
of 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.

Press + to interact
Deep copy
Deep copy

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 execute return *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.