Home Articles

C++ Move Semantics Gotchas

C++ move semantics are a powerful feature for writing fast code, but they have some subtle gotchas. In this article, we will examine two potential problems: a performance issue where the move constructor is seemingly not called, and a safety issue where it is possible to use a moved value. We'll examine the cause of both of these problems and conclude with some design takeaways to avoid such problems.

The Code

In this article, we'll be examining implemenations of the operator+ function for the following test class:

 1 #include<iostream>
 2 using namespace std;
 3 
 4 struct test {
 5   int* bob;
 6 
 7   test(int b): bob(new int(b)) {}
 8 
 9   ~test() { delete bob; }
10 
11   test(const test& t) {
12     cerr << "Copy constructor\n";
13     bob = new int(*(t.bob));
14   }
15 
16   test(test&& t) {
17     cerr << "Move constructor\n";
18     bob = t.bob;
19     t.bob = nullptr;
20   }
21 
22   test& operator=(const test& t) {
23     cerr << "Copy assignment\n";
24     *bob = *(t.bob);
25     return *this;
26   }
27 
28   test& operator=(test&& t) {
29     cerr << "Move assignment\n";
30     if(this != &t) {
31       bob = t.bob;
32       t.bob = nullptr;
33     }
34     return *this;
35   }
36 
37   test& operator+=(const test& rhs) {
38     cerr << "Do +=\n";
39     *bob += *(rhs.bob);
40     return *this;
41   }
42 
43 };
44 
45 int main() {
46   test a(5), b(3);
47 
48   cerr << "Adding a and b\n";
49   test c(a + b);
50 
51   cerr << "Creating d\n";
52   test d(a);
53 
54   return 0;
55 }

Uncalled Move Constructor

For our operator+ function, we would like to do the following:

  1. Copy one of the operands to a temporary value
  2. Add the other operand to the temporary value
  3. Move the temporary value out of the function when it returns

Consider this implementation of operator+. It appears to meet all three requirements:

  1. Since we pass lhs by value, the compiler creates a function-scoped copy that we can manipulate
  2. We add rhs to our copy of lhs
  3. Lastly, we return the result of this addition, which is a function-scoped value, so the compiler should move it for us
1   friend test operator+(test lhs, const test& rhs) {
2     cerr << "Do +\n";
3     return lhs += rhs;
4   }

However, the harsh reality of C++ serves us a cold slap in the face. The above code outputs the following:

Adding a and b
Copy constructor
Do +
Do +=
Copy constructor
Creating d
Copy constructor

No calls to the move constructor at all, and three calls to the copy constructor! In particular, that second call to the copy constructor comes at the end of operator+. What is going wrong?

Before answering that question, let's consider a different implementation that appears to do the exact same thing:

1   friend test operator+(test lhs, const test& rhs) {
2     cerr << "Do +\n";
3     lhs += rhs;
4     return lhs;
5   }

The output of this code, however, is different! It works the way we expected the first function to do.

Adding a and b
Copy constructor
Do +
Do +=
Move constructor
Creating d
Copy constructor

Unlike real life, in C++, everything happens for a reason. One thing (perhaps the only thing) that differs between the two is the value of the thing being returned. In the second example, lhs has type test, but in the first, lhs += rhs has type test&. Now, consider this third example, where we're passing in lhs by reference, and thus it has type test&:

1   friend test operator+(test& lhs, const test& rhs) {
2     cerr << "Do +\n";
3     lhs += rhs;
4     return lhs;
5   }

You definitely wouldn't want the compiler to move lhs out of this function, since you might still be using the object it's referencing elsewhere! And, in fact, it doesn't. (As an aside, don't ever actually write an operator+ like this...)

So, the issue is that in our first example, we're technically returning a test&, and the compiler cannot convince itself that whatever object the return value references won't be used again somewhere else. In this case, though, we can see what the compiler doesn't: the reference returned from lhs += rhs is to the function-scoped copy of lhs. So, to ensure that our operator+ properly moves its return value out, we can either take the second approach (return lhs), or we can write a version that explicitly moves the return value:

1   friend test operator+(test lhs, const test& rhs) {
2     cerr << "Do +\n";
3     return std::move(lhs += rhs);
4   }

Use-after-move

That last example raises an interesting question: what if we tried to move the return value out of the third example? Take a look at this code, which is one character (the & on lhs) different from the last example in the previous section:

1   friend test operator+(test& lhs, const test& rhs) {
2     cerr << "Do +\n";
3     return std::move(lhs += rhs);
4   }

Everything inside this function seems fine, but when we get to test d(a) in main(), we get a glorious segfault since the copy constructor attempts to dereference a.bob, which the move constructor set to nullptr.

Conclusion

Move semantics can be tricky to get right when used implicitly, and can introduce segfaults when incorrectly used explicitly. Provided the move constructor sets the pointers in the moved object to null, the resulting segfault cannot leak uninitialized memory and is therefore probably not exploitable; however, segfaults are still frustrating to debug, especially in cases like this where the segfault is caused by a single character change from otherwise correct code.

Design Takeaways

Further Reading