Skip to content

Copy/Move Semantics

Published: at 08:00 PM

Table of contents

Open Table of contents

Preface

If you can’t explain it simply, you don’t understand it well enough. - Albert Einstein

Something became apparent to me recently while doing pull requests, that although I had done RAII topics at University, it had become quite difficult for me to explain and even more difficult to apply the more specific rules of zero/three and five. So it seems like a good way to brush up on things I have become rusty on by trying to explain them as simply as possible.

Copy and Move

As simply as can be described what does it mean to copy or to move an object?

Although it might sound obvious from the names, understanding the lifecycle of an object often isn’t. So knowing what a copy might do vs move, is important when defining none-straightforward classes.

Therefore, a copy takes the contents from one object (the data) and makes or populates another element with that data but the two objects are distinct. Updating one object would not update the second. Move, on the otherhand, takes that data and puts it in the new object (the ownership of that data is transferred). Thus if you move an object and then modify either of the references to it, both objects would reflect those changes.

Which is better?

Well move-ing is generally cheap, you take one object and put it or reference it somewhere else. But you need to remember to de-reference the original object (remove its ownership). Copy on the otherhand requires manually moving each piece of data into a completely different memory location, this is much more time consuming to do. [1]

Assignment and Construction

So the next logical step is to define what assignment and construction of an object is. Both copy and move have the relevant assignment and construction operations but what does this mean? Simply, it is either construction of a new object, when it is being constructed (copy/move construction) whereas assignment uses the = operator and replaces the contents of an already created object with another object.

We can have either copy or move variations of either of these operations. So we can either copy in an already existing object to construct a new object (leaving the old one) or move in that object (and the old one would be effectively deleted) copy construction and move construction respectively. Or we could use the = sign (assignment) to copy in the contents of an object (leaving the old one) or move in an object (removing the old one) again copy assignment and move assignment.

Rule of Zero/Three/Five

How does this all play out with classes? Generally, if you can avoid defining custom behaviour for the above copy/move operations (and destructors) then do (see here and here). This consitutes the rule of zero, which is an ominous way of saying we haven’t defined any of the copy/move functions or a custom destructor.

class zero
{
    std::string m_someString;
public:
    zero(const std::string& arg) : m_someString(arg) {}
};

*Note: Even with a custom constructor we don’t need to define any of the other methods…

Three/Five

Somewhat self-explanatory therefore, are the rules of three and five, where once we define some of those methods we must define some or all of the methods respectively. The rule of three applies to the copy operations. So if your class requires custom/user-defined behaviour in any of the destructor or copy operations then you should define all three.

class three
{
    char* m_cstring; // raw pointer used as a handle to a
                   // dynamically-allocated memory block
 
    three(const char* s, std::size_t n) // to avoid counting twice
        : m_cstring(new char[n]) // allocate
    {
        std::memcpy(m_cstring, s, n); // populate
    }
 
public:
    explicit three(const char* s = "")
        : three(s, std::strlen(s) + 1) {}
 
    ~three() // destructor
    {
        delete[] m_cstring; // deallocate
    }
 
    three(const three& other) // copy constructor
        : three(other.m_cstring) {}
 
    three& operator=(const three& other) // copy assignment
    {
        if (this == &other) // CHECK THESE OBJECTS AREN'T THE SAME
            return *this;
 
        std::size_t n{std::strlen(other.m_cstring) + 1};
        char* new_m_cstring = new char[n];            // allocate space to temporary store
        std::memcpy(new_m_cstring, other.m_cstring, n); // populate
        delete[] m_cstring;                           // deallocate OLD m_cstring
        m_cstring = new_m_cstring;
 
        return *this;
    }
 
};

Five

Finally, the rule of five means that all copy and move operations must be defined (and a custom destructor). This is a (relatively) new addition or extension to the rule of three. But it doesn’t mean that if you missed the move methods there would be a compilation error, instead your class would end up using the copy methods and possibly be less efficient.

If you have defined a destructor, copy constructor or copy assignment then the compiler will not provide the class with any move methods. Additionally if you use = default or = delete. Therefore the class will fall back to the copy methods if no move methods are provided. So if there would be a performance benefit it may be worth implementing them!

class five
{
    char* m_cstring; // raw pointer used as a handle to a
                   // dynamically-allocated memory block
public:
    explicit five(const char* s = "") : m_cstring(nullptr)
    { 
        if (s)
        {
            std::size_t n = std::strlen(s) + 1;
            m_cstring = new char[n];      // allocate
            std::memcpy(m_cstring, s, n); // populate 
        } 
    }
 
    ~five()
    {
        delete[] m_cstring; // deallocate
    }
 
    five(const five& other) // copy constructor
        : five(other.m_cstring) {}
 
    five(five&& other) noexcept // move constructor
        : m_cstring(std::exchange(other.m_cstring, nullptr)) {}
 
    five& operator=(const five& other) // copy assignment
    {
        return *this = five(other);
    }
 
    five& operator=(five&& other) noexcept // move assignment
    {
        std::swap(m_cstring, other.m_cstring);
        return *this;
    }
};

Final Notes

Generally if you stick with classes in the standard library and managed pointers you shouldn't need to worry about the lifetime of your resources. That should be handled for you. However knowing the rule of zero/three/five is much more important when you have to handle items such as raw pointers in which case it becomes your responsibility to know when and where your data is. Therefore once you start to manage your object more precisely this is when these rules become poignant and worthwhile baring in mind.