The Big Three in C++ OOP

Destructor, Copy constructor, and Copy Assignment Operator

The Big Three in C++ OOP

Image: Clicked from Rameswaram Pambanbridge, Tamilnadau, India.

Hey there, C++ enthusiasts!

Today we're about to unravel the magic of the "Big Three" – no, not a secret society, but something just as cool!

In C++ Object-Oriented Programming (OOP), the "big three" (or the "Rule of Three") refers to three special member functions that are often required when dealing with dynamically allocated resources: the copy constructor, the copy assignment operator, and the destructor. These functions play a crucial role in managing the lifetime and ownership of dynamically allocated memory and resources in C++ classes.

Copy Constructor: This constructor creates a new object as a copy of an existing object. It is invoked when an object is initialized as a copy of another object, such as when passing an object by value to a function, returning an object by value from a function, or explicitly creating a copy of an object using the assignment operator. The signature of the copy constructor typically looks like this:

ClassName(const ClassName& other);

Copy Assignment Operator: This operator assigns the contents of one object to another object of the same class. It is invoked when an object is assigned the value of another object using the assignment operator (=). The signature of the copy assignment operator usually looks like this:

ClassName& operator=(const ClassName& other);

Destructor: The destructor is a special member function that is automatically called when an object goes out of scope or is explicitly deleted. It is responsible for releasing any resources acquired by the object during its lifetime, such as dynamically allocated memory or handles to system resources. The signature of the destructor is as follows:

~ClassName();

In C++, Compiler provides default constructor, destructor, copy constructor, and copy assignment operator. If a class consists of data members that are exclusively primitive types and objects for which the defaults make sense, the class defaults will usually make sense. Thus a class whose data members are int, double, vector<int>, string, and even vector<string> can accept the defaults.

The main problem occurs in class that contains a data member that is a pointer. Suppose the class contains a single data member that is a pointer. This pointer points at a dynamically allocated object. The default destructor for pointers does nothing. Furthermore, the copy constructor and operator== both copy not the objects being pointed at, but simply the value of the pointer. Thus we will simply have two class instances that contain pointers that point to the same object. This is a so-called Shallow copy. Typically, we would expect a Deep copy, in which a clone of the entire object is made. Thus, when a class contains pointers as data members, and deep semantics are important, we typically must implement the destructor, operator==, and copy constructor ourselves.

Let’s say there is a class named IntCell. The signatures of these operations are:

~IntCell();
IntCell(const IntCell& rhs);
const IntCell& operator=(const IntCell& rhs);

In the routines that we write, if the defaults make sense, we will always accept them. However, if the defaults do not make sense, we will need to implement the destructor, operator==, and the copy constructor. When the defaults does not work, the copy constructor can generally be implemented by mimicking normal construction and then calling operator==. Another often-used option is to give a reasonable working implementation of the copy constructor, but then place it in the private section to disallow call by value.

When the Defaults Do Not Work

The most common situation in which the defaults do not work occurs when a data member is a pointer type, and the pointee is allocated by some object member function (such as the constructor). Consider the following example in which we implement the IntCell by dynamically allocating as int

class IntCell {
public:
    explicit IntCell(int initialValue = 0) {
        storedValue = new int(initialValue);
    }
    int read() const {
        return *storedValue;
    }
    void write(int x) {
        *storedValue = x;
    }
    private:
        int *storedValue;
}

Now, let’s assume a function that uses the above class:

int f() {
    IntCell a(2);
    IntCell b = a;
    IntCell c;
    c = b;
    a.write(4);
    cout << a.read() << b.read() << c.read();

    return 0;
}

There are now numerous problems that are exposed in above function.

First, the output is three 4s, even though logically only a should be 4. The problem is that the default operator== and copy constructor copy the pointer storedValue. Thus, a.storedValue, b.storedValue, and then the pointees are copied. A second, less obvious problem is a memory leak. The int initially allocated by a's constructor remains allocated and needs to be reclaimed. The int allocated by c's constructor is no longer referenced by any pointer variable. It also needs to be reclaimed, but we no longer have a pointer to it.

To fix these problems, we implement the big three. And here is how it’s done:

#include <iostream>
class IntCell {
    public:
        explicit IntCell(int initialValue = 0);

        IntCell(const IntCell& rhs);
        ~IntCell();
        const IntCell& operator=(const IntCell& rhs);

        int read() const;
        void write(int x);

    private:
        int *storedValue;
};
IntCell::IntCell(int initialValue) {
    storedValue = new int(initialValue);
}
IntCell::IntCell(const IntCell& rhs) {
    storedValue = new int(*rhs.storedValue);
}
IntCell::~IntCell() {
    delete storedValue;
}
const IntCell& IntCell::operator=(const IntCell& rhs) {
    if(this != &rhs)
        *storedValue = *rhs.storedValue;
    return *this;
}
int IntCell::read() const {
    return *storedValue;
}
void IntCell::write(int x) {
    *storedValue = x;
}

int main()
{
    IntCell obj1(2);        // Obj1 = 2
    IntCell obj2 = obj1;    // Obj2 = 2
    IntCell obj3;
    obj3 = obj2;            // Obj3 = 2
    obj1.write(4);          // Obj1 = 4
    std::cout << obj1.read() << " " << obj2.read() << " " << obj3.read() << '\n';

    return 0;
}

Here are two points worth remembering:

  1. In general, if a destructor is necessary to reclaim memory, then the defaults for copy assignment and copy construction are not acceptable.

  2. If the class contains data members that do not have the ability to copy themselves, then the default operator== will not work.

In summary, the "Big Three" in C++ Object-Oriented Programming – the copy constructor, copy assignment operator, and destructor – form the cornerstone of effective memory management and resource handling. Together, they ensure proper initialization, assignment, and cleanup of dynamically allocated resources, empowering developers to write robust, efficient, and maintainable code.

Mastering these fundamental concepts not only enhances your understanding of C++, but also equips you with the skills to dive into codebases that are as intimidating as an unorganized sock drawer! Armed with the knowledge of the Big Three, you'll be the hero who saves the day from memory leaks and resource mismanagement.

So, sharpen your swords (or rather, your keyboards) and embark on your coding odyssey! Farewell for now, brave coders – may your programs compile swiftly and your bugs be few and far between!

Until we meet again in the realms of C++! 🚀👋

References:

Data Structures and Algorithm Analysis in C++ by Mark Allen Weiss

The C++ Programming language by Bjarne Stroustrup