cpp

Introduction

The move semantics applies only to the data of class types, so I’ll talk about objects, and not data as I do elsewhere. An object is an instance of a class type (i.e., a piece of memory interpreted according to how a class or a structure was defined). Usually the state of the object is called the value.

The definition of the value of an object depends on the implementation of the class. Usually the value of an object is the state of its base and member objects. However, there might be some supporting data in an object (e.g., some cache data that are part of the object state) that do not have to be part of the object value.

The value of an object can be copied when the object is:

all of which involve:

We’re interested in the case where the source expression <expr> is of a class type, i.e., it has an object, which we call the source object, or just a source. Object t is the target object, or just a target.

Important facts about copying:

By anywhere I mean different memory locations, i.e., copying is not limited to objects on the stack or the heap only. For instance, the source can be on the stack, and the target in the fixed-size memory for static and global data. Objects should have no clue where they live.

Copying might be a problem depending on whether it’s necessary or not. It’s not a problem, when it’s necessary, e.g., when we need to modify a copy, and leave the original intact.

Copying is a problem when it’s unnecessary. Copying is unnecessary, when the source is not needed after copying. Unnecessary copying is a performance problem: the code will work alright, but we wish it was faster.

The move semantics

The move semantics allows for moving the value from a source expression to a target, when copying is unnecessary. It was introduced in C++11, but its need was already recognized in the 1990’s. Moving is like salvaging goods (the value) from a sinking ship (the object that soon will not be needed).

The move semantics takes effect when:

The move semantics is implemented by:

How it works

The copy and move constructors

A class can have either the copy constructor or the move constructor, both or none.

The move constructor of class T has a single parameter of type T &&.

A simple example

In the example below the class has both constructors defined:

#include <iostream>

using namespace std;

struct A
{
  A()
  {
    cout << "default ctor\n";
  }

  // The copy constructor has a single parameter of type const A &.
  A(const A &)
  {
    // Copy the data from object a to *this.
    cout << "copy-ctor\n";
  }

  // The move constructor has a single parameter of type A &&.
  A(A &&)
  {
    // Move the data from object a to *this.
    cout << "move-ctor\n";
  }
};

int
main()
{
  A a;
  // Calls the copy constructor.
  A b{a};
  // Only the default constructor will be called, because the move
  // constructor will be elided.  Compile with -fno-elide-constructors
  // to see the move constructor called.
  A c{A()};
  // Calls the move constructor.
  A d{move(a)};
}

Implementation of the constructor overloads

In the implementation of the move constructor, in the initialization list of the base and member objects, the initializing arguments should be rvalues, so that the compiler can choose the move constructors for the base and member objects. To this end we can use the std::move function, as shown in the example below, where the copy constructor is also implemented for comparison.

#include <string>
#include <utility>

struct A {};

struct B: A
{
  std::string m_s;

  B() {}

  // The copy constructor ------------------------------------------

  // The implementation of the copy constructor has to copy the base
  // and member objects of the source object.
  B(const B &source): A(source), m_s(source.m_s)
  {
  }

  // Above is the default implementation which we can get with:
  // B(const B &) = default;

  // The move constructor ------------------------------------------

  // The implementation of the move constructor has to use the
  // std::move function to move the base and member objects of the
  // source object, otherwise they would be copied.
  B(B &&source): A(std::move(source)), m_s(std::move(source.m_s))
  {
  }

  // Above is the default implementation which we can get with:
  // B(B &&) = default;
};

int
main()
{
  B b1;
  B b2(b1);
  B b3(std::move(b1));
  B b4{B()};
}

The copy and move assignment operators

A class can have either the copy assignment operator or the move assignment operator, both or none.

The move assignment operator of class T has a single parameter of type T &&.

A simple example

In the example below the class has both operators defined:

#include <iostream>

using namespace std;

struct A
{
  A()
  {
    cout << "default ctor\n";
  }

  // The copy assignment operator:
  // * has a single parameter of type const A &,
  // * returns A &.
  A &
  operator=(const A &)
  {
    cout << "copy assign\n";
    return *this;
  }

  // The move assignment operator:
  // * has a single parameter of type A &&,
  // * returns A &.
  A &
  operator=(A &&)
  {
    cout << "move assign\n";
    return *this;
  }
};

int
main()
{
  A a, b;
  // Calls the copy assignment operator.
  a = b;
  // Calls the move assignment operator.
  a = A();
}

The return type of the move assignment operator

If a and b are of type T, then expression a = b = T() should move the value of the temporary object T() to b, and then should copy the value from b to a. That expression is evaluated right-to-left, because the assignment operator has the right-to-left associativity.

Therefore, the move assignment operator should return an lvalue reference, and not an rvalue reference. If the move assignment operator returned an rvalue reference, then that expression would move the value from the temporary object T() to b, and then move (not copy) the value of b to a.

Interestingly, because the move assignment operator returns an lvalue reference (when it is declared as T &operator=(T &&);), we can initialize an lvalue reference with the return value of the operator: T &l = T() = T(); even though T &l = T(); would fail to compile.

Implementation of the assignment operator overloads

In the implementation of the move assignment operator, the argument expressions for the assignment operators of the base and member objects should be rvalues, so that the compiler can choose the move assignment operators for the base and member objects. To this end we can use the std::move function, as shown in the example below, where the copy assignment operator is also implemented for comparison.

#include <string>
#include <utility>

struct A {};

struct B: A
{
  std::string m_s;

  B() {}

  // The copy assignment operator ------------------------------------

  // The copy assignment operator has to copy the base and the member
  // objects of the source object.
  B & operator=(const B &source)
  {
    A::operator=(source);
    // We can assign (as above) to the base object this way too:
    // static_cast<A &>(*this) = source;
    m_s = source.m_s;
    return *this;
  }

  // Above is the default implementation which we can get with:
  // B &operator=(const B &) = default;

  // The move assignment operator ------------------------------------

  // The implementation of the move assignment operator has to use the
  // std::move function to move the base and the member objects of the
  // source object, otherwise they would be copied.
  B & operator=(B &&source)
  {
    A::operator=(std::move(source));
    // We can assign (as above) to the base object this way too:
    // static_cast<A &>(*this) = std::move(source);
    m_s = std::move(source.m_s);
    return *this;
  }

  // Above is the default implementation which we can get with:
  // B &operator=(B &&) = default;
};

int
main()
{
  B b1, b2;
  b1 = b2;
  b1 = std::move(b2);
  b1 = B();
}

Overload resolution

The overload resolution of a constructor or an assignment operator (i.e., whether the copy or the move version is chosen) depends on the category of the source expression, and the availability of the copy and move overloads. The same rules apply as in the overload resolution for a function overloaded with reference types.

Special member functions

The special member functions are:

A special member function can be either undeclared or declared. A function can be declared:

When a function is declared as deleted (regardless of whether implicitly or explicitly), the function is considered in overload resolution, but when the function is chosen, an error message reports the function is deleted.

Explicitly defaulted

A programmer can explicitly request the default implementation of a special member function with = default, like this:

struct A
{
  // We need to include the default constructor, because the
  // definition of the argument constructor below would inhibit the
  // generation of the default constructor.
  A() = default;

  A(int x)
  {
  }
};

int
main()
{
  A a, b(1);
}

Default implementation

All base and member objects in a defaulted (regardless of whether implicitly or explicitly):

Deleted

A programmer can explicitly request a special member function be deleted with = delete, like this:

// This example is wierd (I haven't yet seen a destructor deleted),
// but it shows how to explicitely delete a special member function.
struct A
{
  ~A() = delete;
};

int
main()
{
  // This compiles, because we're not deleting the object.
  A *p = new A();
  // A a; // Error: a local variable has to have a destructor called.
}

Rules for special member functions

All special member functions are implicitly defaulted (if they are needed), but:

These rules ensure the seamless integration of the move semantics into the legacy and modern code. For instance, the legacy code (such as std::pair) that doesn’t do any special resource management (in the copy constructor, the copy assignment operator, and the destructor), will have the move semantics implemented by default.

Move-only types

A move-only type can only be moved: it cannot be copied. This is an example of a move-only type:

#include <utility>

// A move-only type.  We do not have to explicitely delete the copy
// constructor, and the copy assignment operator, because they will be
// implicitely deleted, since the move constructor and the move
// assignment operator are explicitely defaulted.
struct A
{
  A() = default;
  A(A &&) = default;
  A & operator=(A &&) = default;
};

int
main()
{
  A a;
  // A b(a); // Error: we cannot copy initialize.
  A b(std::move(a));
  // b = a; // Error: we cannot copy assign.
  b = std::move(a);
}

Implications of the move semantics

Initialization of function parameters

A function parameter is initialized with the argument expression. For a parameter of a non-reference (i.e., we pass the argument by value) class type, the constructor overload resolution will depend on the category of the argument expression and the overload availability, as usual for a function overloaded with reference types.

Implicit move of returned values

If constructor elision (or the return value optimization) cannot be used, then the value of the returned object will be implicitly moved, if the returned object is destroyed when returning from the function. The return instruction return t; is implicitly converted to return std::move(t);.

Only the return expression consisting of a variable name is implicitly moved (converted from an lvalue to an rvalue), and other expressions are not.

We shouldn’t explicitly use the std::move function (e.g., return std::move(t);) in the return statement whenever we can, because it disables the constructor elision (or the RVO).

There are two cases described below in which the RVO cannot be used, but the returned value will be implicitly moved.

Case 1

When we return a function parameter. A function parameter is allocated and initialized in a location on the stack that is different from the location for the return value. A function parameter is destroyed when returning from the function, so we can move the value from it to the location for the return value. The return expression is the name of the parameter only, so the implicit move can take place.

#include <iostream>

using namespace std;

struct A
{
  A() = default;

  A(A &&t)
  {
    cout << "move-ctor\n";
  }

};
  
A foo(A a)
{
  return a;
}

int
main()
{
  foo(A());
}

Case 2

When we return a base object of a local object. The local object is too big to be initialized in the location for the return value, which is of the size of the base object. The local object is destroyed when returning from the function, so we can move the value from its base object to the location for the return value. Only the value of the base object will be moved, which is called object slicing, because we slice off the value of the base object. The return expression is the name of the local object only, so the implicit move can take place.

#include <iostream>

struct A
{
  A() = default;

  A(const A &)
  {
    std::cout << "copy-ctor\n";
  }

  A(A &&)
  {
    std::cout << "move-ctor\n";
  }
};

struct B: A {};

A foo()
{
  B b;
  return b;
}

int
main()
{
  foo();
}

If the local object was static, the value would have to be copied, not moved.

The std::swap function

Let’s end with how it all began. Function std::swap is the reason for the work on the move semantics that started in the 1990’s. This function showed that it’s more efficient to move than to copy.

Function std::swap takes by reference two arguments, and swaps their values. This function is implemented in the standard library, but in the example below we also have an example implementation to show what’s going on:

#include <utility>

struct A
{
};
  
void swap(A &a, A &b)
{
  A tmp = std::move(a);
  a = std::move(b);
  b = std::move(tmp);
}

int
main()
{
  A x, y;
  swap(x, y);
}

Conclusion

Quiz

Acknowledgement

The project financed under the program of the Minister of Science and Higher Education under the name “Regional Initiative of Excellence” in the years 2019 - 2022 project number 020/RID/2018/19 the amount of financing 12,000,000 PLN.