Copy semantics defines how the copy of an object would be made. If an object has copy semantics, we can say that object is copyable. For class types, we have used copy constructors which are the implementations of copy semantics. When we initialize a class type object with another same type, the copy semantics get invoked (through copy constructor).

Copy semantic is implemented using copy constructor and overloading assignment operator for copy.

Copy constructor takes the other object of the same class type by reference and copies (shallow or deep) data member wise.

Overloading assignment operator also takes the same parameter, but it something more:

  1. Delete the old data (deallocate dynamic allocated memory for data members if any).
  2. Allocate new memory for dynamic data member if any and do copy.
  3. Finally, return the reference to object after copy.

An example

Following example shows a DynamicArray which wraps a c-style array. It implements copy semantic using copy constructor and copy assignment operator.

// program that demonstrates a custom dynamic array with copy semantic.
 
#include <iostream>
#include <cassert>
#include <algorithm>
 
class DynamicArray
{
    int *m_arr{};
    int m_length{};
 
    void allocate(int size)
    {
        std::cout << "Allocating memory" << '\n';
        m_arr = new int[size];
    }
 
public:
    DynamicArray() = default;
 
    DynamicArray(int length)
        : m_length{length}
    {
        allocate(length);
    }
 
    ~DynamicArray()
    {
        std::cout << "Deallocating memory" << '\n';
        delete[] m_arr;
    }
    // copy constructor
    DynamicArray(const DynamicArray &other)
        : m_length{other.m_length}
    {
        allocate(m_length);
        std::copy_n(other.m_arr, m_length, m_arr);
    }
 
    // copy assignment operator
    DynamicArray &operator=(const DynamicArray &other)
    {
        if (this == &other)
            return *this;
 
        // deleting old stuff
        delete[] m_arr;
        m_arr = nullptr;
        m_length = 0;
 
        // new stuff
        allocate(other.m_length);
        m_length = other.m_length;
        std::copy_n(other.m_arr, m_length, m_arr);
 
        return *this;
    }
 
    int &operator[](int index)
    {
        assert(index < m_length);
 
        return m_arr[index];
    }
 
    int *begin() const
    {
        return m_arr;
    }
 
    int *end() const
    {
        return m_arr + m_length;
    }
 
    int length() const
    {
        return m_length;
    }
};
 
void printArray(const DynamicArray &arr)
{
    for (const int i : arr)
    {
        std::cout << i << '\n';
    }
}
 
DynamicArray createArray()
{
    DynamicArray arr(100000);
 
    for (int i{0}; i < arr.length(); ++i)
    {
        arr[i] = i;
    }
 
    return arr;
}
 
int main(int argc, char const *argv[])
{
 
    DynamicArray arr1;
 
    arr1 = createArray();
    return 0;
}

Copy is not optimal

While copy semantics are useful, they are not optimal solution when it results in an expensive copy. For example, copying a DynamicArray of 100000 items to another array. In the above example of DynamicArray, I ran the program and got following output on my machine:

❯ ./DynamicArray.out
Allocating memory
Allocating memory
Deallocating memory
Allocating memory
Deallocating memory
Deallocating memory

Three copies are being made just to create a single array.

Note

You may get different output based on how you’ve compiled and which compiler version you are using.

If you use compiler prior to C++17, compiler performs mandatory copy optimization when an object is copy constructed using a temporary object of the same type. In this case, temporary object returned by createArray().

Furthermore, in createArray() function we are returning the array arr which is used to create temporary object to be returned. Compiler can optimize this also where it elide the creation of temporary object and directly use to create the object in the caller. However, this is not mandatory and can be stopped using flag -fno-elide-constructors while compiling the program.

Explained more in copy elision.

Let’s understand the flow:

  1. In createArray, we are creating array using direct initialization constructor which dynamically allocates memory for the array. So, first Allocating memory is from here.
  2. Next, arr has to be returned by value. As arr would be destroyed once the function finishes, a temporary DynamicArray object gets copy created using arr. The next Allocating memory comes from here.
  3. Now, the function createArray is finishes and so arr would be destroyed. This gives Deallocating memory.
  4. Finally, createArray returns temporary object which then gets used to copy assign to arr1. This calls copy assignment operator to copy temporary object to arr1. It gives the final Allocating memory log.
  5. After the createArray expression finishes, the temporary object also gets destroyed which gives Deallocating memory log.
  6. In the end when the main finishes, arr1 also gets destroyed and we can Deallocating memory log.

Although some of the copy can be optimized by the compiler using copy elision but it still not an optimal choice. There is one more semantic for the type called move semantic which can help us here.

References

  1. https://www.learncpp.com/cpp-tutorial/returning-stdvector-and-an-introduction-to-move-semantics/