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.
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 }
For our operator+
function, we would like to do the following:
Consider this implementation of operator+
. It appears to meet all three requirements:
lhs
by value, the compiler creates a function-scoped copy that we can manipulaterhs
to our copy of lhs
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 }
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
.
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.
std::move
outside of move constructors. Explicit moves can circumvent some of C++'s type system protections and may cause use-after-move bugs.