Posts C++ notes on move semantics
Post
Cancel

C++ notes on move semantics

Imagine we have a vector of integers and an empty vector:

1
2
std::vector<int> v1{1, 2, 3, 4, 5};
std::vector<int>{};

Now, assign v1 to v2:

1
v2 = v1;

So, initially (before the assignment) v1 can be represented as:

v1

and v2:

v2

So, what we want in this case is a deep copy. We want an exact replica of v1. This means that after the assignment we don’t have to deal with any shared ownership:

v2

Suppose we now have a function that returns a vector:

1
2
3
4
5
std::vector<int> create_vector() {
    return std::vector<int>{1, 2, 3, 4, 5};
}

std::vector<int> v2{};

Now, let’s assume we directly assign create_vector to v2:

1
v2 = create_vector();

The first thing that happens, is the value from the function is returned and stored in some sort of tmp variable:

Now, this is assigned to v2. But do we really need a deep copy at this point? What we really want to happen, is to transfer the content of tmp to v2. We want to copy the pointers and to remove the pointers of tmp:

Note: this is only possible if nothing else holds a reference to tmp. So, let’s apply this to the first example:

1
2
3
4
std::vector<int> v1{1, 2, 3, 4, 5};
std::vector<int>{};

v2 = std::move(v1);

At this point, we transfer v1 contents to v2. v1 however in this case is still alive, it will be an empty vector until the end of some scope.

Let’s take a look at:

1
2
3
4
5
6
7
8
template< typename T
          , typename A = ... >
class vector {
public:
...
// copy assignment operator - takes an lvalue
vector& operator=(const vector& rhs);
}

So, when we do something like:

1
v2 = v1;

This will bind to the copy assignment operator.

When we make an assignment like:

1
v2 = create_vector()

Prior to C++11 this would actually create a copy due to there only being one assignment operator, despite this being an rvalue. However, now we have rvalue references and can introduce a move assignment operator:

1
vector& operator=(vector&& rhs);

So, now, our assignment:

1
v2 = create_vector()

actually binds to the rvalue reference, this function can now use move. Taking a look at the third one:

1
v2 = std::move(v1);

Now, v1 is an lvalue, however move declares it as an ravlue. The official term for this is actually an xvalue (expiring value). Due to the fact that this is now dealt with as an rvalue, it will be bound to the rvalue reference and use the move assignment operator. v1 is actually still alive, v1 still has a name, it can be used, it is a moved from object. The general advice is to just leave it be and don’t do anything with it anymore as long as you do not re-assign it.

std::move unconditionally casts its input into an rvalue reference, it does not actually move anything.

1
2
3
4
5
template< typename T >
std::remove_reference_t<T>&&
    move( T&& t ) noexcept {
        return static_cast<std::remove_reference_t<T>&&>( t );
    }

This is part of the reason why using a move from object does not yield in a compiler warning, it is essentially a static cast. Static analysis tools may warn you about the use of move from objects. So essentially, move is just a semantic transfer of ownership.

Some implementation details include mainly two functions that make all of this happen. Let’s use a widget class as an example.

1
2
3
4
5
6
7
8
9
10
11
12
13
class Widget {
private:
    int i{ 0 };
    std::string s{};
    unique_ptr<int> pi{};
public:
...
    // move constructor
    Widget( Widget&& w ) = default;

    // move assignment operator
    Widget& operator=( Widget&& w ) = default;
}

The move constructor and the move assignment operator are the functions that are responsible for making the move semantics work. For this class we can default these member functions. All of the data members are movable. All three types have defined move semantics. This is something we have seen before in the RAII notes - the rule of zero.

  • Core Guideline C.20: If you can avoid defining default operations, do

Let’s see an example however where we do have to add some extras, let’ change widget to use a raw pointer:

1
2
3
4
5
6
7
8
9
10
11
12
class Widget {
private:
    int i{ 0 };
    std::string s{};
    int* pi{ nullptr };
public:
...
    // move constructor
    Widget( Widget&& w ) = default;

    // move assignment operator
    Widget& operator=( Widget&& w ) = default;

Now we actually have to deal with these two functions, we can not default them. Let’s start with the move constructor. We would like to:

  • Transfer the content of w into this
  • Leave w in a valid but undefined state
1
2
3
4
Widget( Widget&& w )
    : i (std::move(w.i))
    , s (std::move(w.s))
    , pi(std::exchange(w.pi, nullptr)) {}

We have now satisfied our first goal:

✅ Transfer the content of w into this

There is also another guideline we can incorporate:

  • Core Guideline C.66: Make move operations noexcept
1
2
3
4
Widget( Widget&& w ) noexcept
    : i (std::move(w.i))
    , s (std::move(w.s))
    , pi(std::exchange(w.pi, nullptr)) {}

Why is this necessary? This has to do with performance. The reason has to do with the fact that if we promise to not throw anything, methods like push_back will actually move, however without this, it will fall back to copying. This is due to the fact that methods like push_back are no exception guaranteed.

There is yet another core guideline that we need to adhere to:

  • Core Guideline C.64: A move operation should move and leave its source in a valid state

Ideally, that moved-from should be the default value of the type. Ensure that unless there is an exceptionally good reason not to.

1
2
3
4
Widget( Widget&& w ) noexcept
    : i (std::exchange(w.i, 0))
    , s (std::move(w.s))
    , pi(std::exchange(w.pi, nullptr)) {}

With this we are also actually completing our second goal:

✅ Leave w in a valid but undefined state.

Now lets take a look at the move assignment operator. Our goals for this are:

  • Clean up all visible resources
  • Transfer the content of w into this
  • Leave w in a valid but undefined state
1
2
3
4
5
6
7
8
Widget& operator=( Widget&& w ) {
    delete pi;
    i = std::move(w.i);
    s = std::move(w.s);
    pi = std::move(w.pi);

    return *this;
}

With this we have achieved the first goal:

✅ Clean up all visible resources.

1
2
3
4
5
6
7
Widget& operator=( Widget&& w ) {
    delete pi;
    i = std::move(w.i);
    s = std::move(w.s);
    pi = std::exchange(w.pi, nullptr);
    return *this;
}

With this, the second goal is done:

✅ Transfer the content of w into this

What if we use a swap instead though?

1
2
3
4
5
6
Widget& operator=( Widget&& w ) {
    delete pi;
    i = std::move(w.i);
    s = std::move(w.s);
    std::swap(pi, wi.pi);
}

The arguments against this are that in this case we are transferring our old resource into w. This leaves the destruction of the resource up to the future, which will be eventually done, but we don’t know when. It is less deterministic. Also, if we count pointer operations, swap is a little less efficient.

1
2
3
4
5
6
7
8
Widget& operator=( Widget&& w ) {
    delete pi;
    i = std::move(w.i);
    s = std::move(w.s);
    pi = std::exchange(w.pi, nullptr);
    w.i = 0;
    return *this;
}

We have now achieved the third and final goal:

✅ Leave w in a valid but undefined state.

The default move operations are generated if no copy operation or destructor is user-defined. The default copy operations are generated if no move operation is user-defined. =default and =delete count as user-defined.

This comes back to the rule of 5:

  • Core Guideline C.21L If you define or =delete any default operation, define or =delete them all
This post is licensed under CC BY 4.0 by the author.

Recent Update

    Contents