Copy and Move Semantics

The Rule of Three

When a class has only fundamental types as data members, you usually don't need to write your own destructor, copy constructor, or copy-assignment operator because the ones the compiler generates do exactly the right thing. When your class requires a user-defined version of one of these three, it almost certainly requires them all. This is known as the Rule of Three.

These three special member functions are the destructor, the copy constructor, and the copy-assignment operator. Their commonality lies in how they each address the object's lifespan in terms of its data. What is special in one case often must be matched by special behavior in the other cases.

Consider a class that solely logs its creation and destruction. Only a user-defined destructor is necessary in this case, as the compiler-generated copy operations are still suitable.

#include <iostream>

class Tracer
{
private:
    int id{};

public:
    Tracer(int i)
        : id{ i }
    {
        std::cout << "constructed " << id << '\n';
    }

    // Run this destructor when the object lifetime ends
    ~Tracer()
    {
        std::cout << "destroyed " << id << '\n';
    }
};

int main()
{
    Tracer a{ 1 };
    Tracer b{ a };
    return 0;
}

The constructor prints when it starts, the destructor prints when it's killed. The compiler-generated copy constructor copies the integer, and when the program finishes, the two objects' destructors will be called in reverse order, printing two messages. The program above is fine and doesn't call the Rule of Three because only the destructor was overridden, and no external resources were in play.

This picture changes as soon as non-trivial copies become necessary. For instance, if you have a class and you later need it to perform a specific action when an object is copied, updating a counter of live objects, for instance, or performing a deep copy of some data, a user-defined copy constructor and copy-assignment operator will soon follow. You must now ensure your destructor matches.

#include <iostream>

class Tracer
{
private:
    int id{};
    static int live;

public:
    Tracer(int i)
        : id{ i }
    {
        ++live;
        std::cout << "constructed " << id << " (live " << live << ")\n";
    }

    // Bind by reference without allowing modification through it
    Tracer(const Tracer& other)
        : id{ other.id }
    {
        ++live;
        std::cout << "copied " << id << " (live " << live << ")\n";
    }

    Tracer& operator=(const Tracer& other)
    {
        id = other.id;
        std::cout << "assigned " << id << '\n';
        return *this;
    }

    ~Tracer()
    {
        --live;
        std::cout << "destroyed " << id << " (live " << live << ")\n";
    }
};

int Tracer::live{};

int main()
{
    Tracer a{ 1 };
    Tracer b{ a };
    Tracer c{ 3 };
    c = b;
    return 0;
}

In this program, all three special members are now user-defined; the constructor and the copy constructor increment the shared counter, the destructor decrements it, and the assignment operator, which does not create a new object, just adjusts the ID but not the count. When running, you can see the counter increasing when it's created/copied, and falling when it's destroyed. Any attempt to let the compiler perform any of these actions would produce a bad count.

Hence, the Rule of Three can be viewed more as a design guideline or a checklist rather than a strict language rule. The C++ compiler doesn't complain when you only define a destructor, or a copy-assignment operator, or any other combination. The program can run and even appear to function correctly for a time. The rule exists because we've found that these three special members tend to be tightly coupled; when one requires special behavior, the other two will almost certainly need corresponding adjustments to ensure consistency.

For many classes which hold only fundamental types, these three functions should remain implicit: the compiler-generated member-wise destructor, member-wise copy constructor, and member-wise copy-assignment operator all act in a way that does exactly what is needed. It is when a class has to perform additional work besides this, such as dealing with an external resource that needs to be acquired (in a constructor) and then released (in a destructor), that you must write your own versions of the copy operations as well. 

Exercise: The Rule of ThreeStarter code, hints and solutionOpenClose
Guided practiceBeginner · 10–15 min

Practice: The Rule of Three

Work through the mission, reveal hints only when needed, and compare your result with the model solution.

Your challenge

Add logging copy-constructor and copy-assignment operations to Tracer alongside its destructor. Create c with id 2 and assign a to c, then classify each construction and assignment.

Mission checklist

  1. Run the starter as a baseline, then predict the change requested in Your challenge.
  2. Make that change in your editor; use the lesson-specific hints if you need help.
  3. Compile and run your answer, then compare with the worked solution and explain the result.

Starter program

This is the working baseline, not the completed answer. Compile as C++20, then make the change described above.

#include <iostream>

class Tracer
{
private:
    int id{};

public:
    Tracer(int i)
        : id{ i }
    {
        std::cout << "constructed " << id << '\n';
    }

    // Run this destructor when the object lifetime ends
    ~Tracer()
    {
        std::cout << "destroyed " << id << '\n';
    }
};

int main()
{
    Tracer a{ 1 };
    Tracer b{ a };
    return 0;
}

Hints

Hint 1

Tracer has a user-written destructor but only an int member. Its implicit copy constructor copies id without printing the ordinary constructor's message.

Hint 2

A copy constructor initializes id from the source; copy assignment copies id into an existing object and returns *this. Keep logging in all three special members.

Worked solution

The three relevant special members are explicit and logged: copy constructor, copy assignment, and destructor. b is copy-constructed from a; c is ordinarily constructed with 2 and then copy-assigned from a. This value-only tracer is safe; owning raw resources would additionally require an ownership-aware copy policy.

#include <iostream>

class Tracer
{
private:
    int id{};

public:
    Tracer(int i)
        : id{ i }
    {
        std::cout << "constructed " << id << '\n';
    }

    // Run this destructor when the object lifetime ends
    Tracer(const Tracer& other) : id{other.id}
    { std::cout << "copy constructed " << id << '\n'; }
    Tracer& operator=(const Tracer& other)
    { id = other.id; std::cout << "copy assigned " << id << '\n'; return *this; }

    ~Tracer()
    {
        std::cout << "destroyed " << id << '\n';
    }
};

int main()
{
    Tracer a{ 1 };
    Tracer b{ a };
    Tracer c{2};
    c = a;
    return 0;
}

Example output

constructed 1
copy constructed 1
constructed 2
copy assigned 1
destroyed 1
destroyed 1
destroyed 1

0 comments

No comments yet. Be the first.

Add a comment

About CppLearn

CppLearn is an interactive, free reference for modern C++. In each lesson, you'll find out how the language works, rather than just lines of code that you can copy and paste. We cover concepts starting from beginner lessons on fundamentals and proceed to templates, move semantics, smart pointers, and areas that usually trap or scare C++ learners.