Today I'd like to propose a narrative for building an intuition for move semantics in C++ by describing a world in which they don't exist and how they naturally appear as a solution for pre-C++11 problems. My goal is to provide a clear understanding of what move semantics are, without any hand waviness. We'll start with the state of affairs in C++98, what the limitations were, what C++11 fundamentally brought to the table, how it can be used, how the language helps us use new "move semantics" idioms and finally what potential alternatives are. I am not aiming for historical accuracy, but you should be left with a good sense of what is available nowadays and why/how we got there.
There are plenty of resources talking about that, many of which are terrible.
They are either nebulous, based on unfitting metaphors or just plain wrong. Some resources are good at
providing rules of thumb and high-level explanations, but they fail to provide a
fundamental understanding of the mechanisms at work. Moreover as we'll see, I
strongly believe that the use of std::move
should be dramatically
de-emphasized in teaching material. I also believe that move semantics are best
explained bottom-up rather than top-down.
So let's get started, from scratch.
The key to understanding move semantics is understanding "rvalue references",
those &&
you might have seen in type declarations. Those rvalue
references are exactly the same thing as "regular" &
references
(called lvalue references), except that they are incompatible. You can think of
them as "colored" references, as if lvalue references were blue and rvalue
references were green. Besides that, they just work the same way:
int x = 0; int& lvalueRef = (int&)x; int&& rvalueRef = (int&&)x; print(lvalueRef); // 0 print(rvalueRef); // 0 lvalueRef++; print(lvalueRef); // 1 print(rvalueRef); // 1 rvalueRef++; print(lvalueRef); // 2 print(rvalueRef); // 2As you can see, you can read and set a variable via both kinds of references, there is no difference and no reason to make a fuss about them. However, as noted earlier, those references are incompatible, even if they behave the same. This means that you can't pass a reference to a function expecting a reference of the other color (e.g. you can't pass an
int&
in a function
expecting an int&&
). However, because they behave identically, we
can cast from one to the other:
int x = 2; int& lvalueRef = x; // wouldn't compile, those aren't compatible types: // int&& rvalueRef = lvalueRef; // this is fine: int&& rvalueRef = (int&&) lvalueRef;I strongly suggest that you play a little with those. Try to write a small program that uses rvalue references where you would usually use lvalue references and notice that while the compiler might require you to constantly cast your references to
&&
again, even though that's how they were
already declared, your code will work just like if you were using regular old
lvalue references. Here is a small example:
struct RefHolder { RefHolder(int&& x) : m_x((int&&)x) {} RefHolder(const RefHolder&& other) : m_x((int&&)other.m_x) {} const int&& m_x; }; int addOne(const int&& ref) { return ref + 1; } int main() { int x = 2; const RefHolder refHolder((int&&)x); const RefHolder otherRefHolder((RefHolder&&)refHolder); return addOne((int&&)refHolder.m_x); }Everything works as expected, including copy constructors, and the program returns 3.
Hopefully I've convinced you that rvalue references are not scary nor that special. The key insight at this point is to realize that we can use the fact that our two reference colors are incompatible to discriminate between them and get different behavior from both. More concretly, you can have an overloaded function that takes either an rvalue reference or an lvalue reference and execute different logic in either case.
Hopefully that sounded like a terrible design idea, because it generally is. You
wouldn't want your handleUserRequest
function to behave differently
when taking a request pointer or a request reference, the same thing should be
true for different kinds/colors of references! On the other hand, why introduce
a new kind of reference if it's not to do something different with it?
Let's forget about references for a second and think about something seemingly
unrelated: algorithms over containers. In this context, we'll loosely define
containers as "data structures which contain a bunch of heap allocated data".
Examples would include std::vector
, std::map
and
friends. You can reduce those containers as "a small class which holds a pointer
to lots of data".
One problem that people can face with such containers is that they are expensive
to copy: not only do you need to copy the few members of the class itself, you
also need to copy all of the heap allocated data that it points to. In the case
of a std::vector
holding millions of elements, that makes
auto v2 = v1;
quite costly.
There exists a simple, much more efficient algorithm for transferring data from one container to another: copy the metadata (size and capacity) and point to the same heap allocated data. This turns an O(n) operation into O(1).
At this point, you're faced with a big issue: since two containers point to the same memory, your program just got a lot more complicated. Now, every time one of the instances gets updated, the shared data might be modified, but the other instance's metadata doesn't! This is quite error-prone.
Something that you could do is set the data of the original vector to
nullptr
and set the size and capacity. Now the original vector
doesn't have access to the data anymore, but you've successfully transferred it
to the new vector, as if it had been stolen. This trade-off is the best you can
get without into more complicated things like copy-on-write, which is out of
scope. Note also that this concept could similarly apply to a linked list, tree,
hash map, and many other kinds of containers.
So in conclusion, we haven't found a way to magically copy data in O(1) from one container to another, but we've managed to transfer instead, as if the data had been... moved over? We're getting somewhere.
We've outlined a category of very simple algorithms: those that can steal/move
data over from one container to another. We could probably just implement it as
a method on all the containers that could support it. In fact, we could
implement it on all containers at all and fallback to just copying if no
implementation really makes sense for some containers: we'd still have the data
at the destination in the end, it might just not particularly efficient. Note
that this isn't a deal-breaker: we already have things like
std::find
which can perform drastically differently depending on
what container is searched.
A simple method would feel like a second-class citizen compared to the existing
constructors and operator=
though, and we'd rather make it as easy
as possible to use our stealing algorithm since it has the potential to be much
faster than plain old copying. We'd also prefer not to introduce brand new
syntax in the language if possible.
I'll let you think of the various ways to expose our algorithm, but let's orient
our thoughts a little: how about treating it as a special case of copying? This
way we could probably reuse our constructor and assignment syntax. So how do we
express whether we'd like to steal/move the values or just copy them? We could
have two overloads for constructors and operator=
, one that takes a
reference and the other that takes a reference to const:
class Container { public: Container() = default; Container(Container const&) { // we can't do anything to the other container since it's const // let's just copy its data } Container(Container&) { // we *can* do anything to the other container since it's *not* const // let's steal its data! } private: int* m_data; // ... }This is quite neat, now we can do something like the following:
const Container c1 = makeContainer(); Container copy{c1}; // c1 is const, we can only copy it Container c2 = makeContainer(); Container steal{c1}; // c2 is not const, we can steal its data!In practice though, this becomes complicated quite quickly. First off, you might need your container to not be const to put some things in it, before wanting to copy it, so you'll need boilerplate to make sure that you pass it as const. More importantly, if you fail to do so, the data will be moved, which is likely going to lead to bugs later on (this is called use-after-move). Finally, lots of the code that you're already using/that you've already written will not conform to that convention, making your life a lot harder.
What we'd like instead is something that behaves like a reference, but that we can easily discriminate to make sure that we know if we're going to move or copy, which will not interfere with our existing code and which will not compile if we use it in the wrong place. Wait, that sounds familiar...
Indeed, this is precisely where rvalue references come in. We use them in the same places where we would otherwise use regular lvalue references, except that by convention, we try to steal data where we'd otherwise copy it:
class Container { public: Container() = default; Container(Container const&) { // this is a regular lvalue reference // let's just copy its data } Container(Container&&) { // this is one of those rvalue references // let's steal its data! } private: int* m_data; // ... }Again, I'd like to emphasize that there is nothing different between those kinds of references: they just have a different color, they are simply incompatible, you just cannot use one instead of the other without casting. As such, it would be perfectly reasonable to swap their semantics: you could copy from rvalue references and move out of lvalue references. We simply don't do that because we've been copying out of lvalue references for decades and if we want to establish a solid convention, it better be compatible with all of the code that already exists out there.
Back to our shared_ptr
: what can we do if we're done using our
handle and want to pass it to another function? Passing it by lvalue reference
will cause a reference count bump, which can be costly if our implementation is
thread-safe and later a reference drop when we reach the end of our
shared_ptr
's scope (again, possibly costly).
class MySharedPtr; void f(MySharedPtr); MySharedPtr ptr = makeSharedPtr(); // f needs a MySharedPtr, it will be created with the const& overload f((const MySharedPtr&)ptr); // f needs a MySharedPtr, it will be created with the && overload f((MySharedPtr&&)ptr);This way, we can control how we want to pass our shared pointer to
f
. This idiom can apply to many resources where copying them is
sometimes desirable but also expensive and avoidable by stealing the data
instead.
We can also push this a little further, as does std::unique_ptr
.
This class is a small wrapper around a pointer with interesting semantics: you
can never copy it, but you can forward/move/steal it to another instance, so
that only one instance ever holds that pointer. By convention, we delete it's
const&
overloads to signal that it is not copyable and instead
provide &&
overloads which do what we expect: set the new pointer
and null the original so that only one of the instances holds it. I might be
beating a dead horse at this point, but once again: nothing would prevent you
from using a regular lvalue (&
) reference to "move" the pointer,
we're only following conventions.
std::move
So we have a nice little useful idiom on our hands that is enabled by the two colors of references that C++ provides, but two things make it a little annoying to use in practice, which you will have quickly noticed by playing around with those concepts:
static_cast
over a C-style cast to be stricter. While we're
at it, we might as well wrap that cast in a helper function to more clearly
express our intention, to save us some typing and to facilitate refactoring
by not needing to explictly state the type:
template <typename T> T&& move(T& a) { return static_cast<T&&>(a); }This is exactly what
std::move
is and does, except that it usually
looks a bit worse, like all standard library code does. Using
std::move
is exactly and only equivalent to casting to an
rvalue reference, which in turn only calls a specific overload of a
function or constructor. There is no magic and all the behavior is dictated by
how those functions are implemented, which conventionally is a copy for the
lvalue overload and "some kind of stealy algorithm" for the rvalue overload,
when applicable, usually with a fallback to the first "copy" behavior
otherwise.
Now is the time to stop for a minute and think about all this:
std::move
helper function to clearly express intent and save
yourself some typing;std::move
, copy/move
constructors/assignments nor moving in general.Now that we've built move semantics from scratch, let's dive a bit deeper into why the code that we write and use looks and behaves the way it does, starting with something that you might have noticed:
operator=
always have const parametersoperator=
never have const parameters&
or
const&&
constructors as well. If we do care about move semantics
though, making all lvalue parameters const makes sense: we should never need
mutation to copy a structure. However, making rvalue parameters non-const is
more debatable: it enables us to clear out the original object to avoid later
using the data through it afterwards, but:
In the case of regular functions, here is what you might have noticed:
If it takes an rvalue reference, it's probably to call the rvalue reference constructor of that object down the line. Since we've established that it makes sense for constructors to take non-const rvalue references, then it makes sense for our functions to also take them non-const: if they were const we couldn't do much with them down the line. But let's pause for a second, think about the intent and think about whether that's the right way to go about it: we want to "move" some object into a function, so that it can have it as efficiently as possible, under the assumption that we won't use it anymore.
Could we do it by taking an rvalue reference? Sure, this would be cheap (just passing a reference), the caller would expect the move and we wouldn't need to do much. Note however that if we do not move the object in the body of that function, the caller will still have the original object, which might be unexpected (imagine moving a huge vector into a function, wanting to reuse it expecting it to now be empty but still having all the data because that function did nothing with the rvalue reference).
Could we do it by taking a value? Sure, then we can decide at the call site if we want to copy or move it, but note that we'll need to construct that new value either way, which might be more expensive than passing a reference. At least, if you've moved into it, you're sure to now have a moved-out-of (usually empty) object since you did move it into the function (otherwise the new value couldn't have been constructed), unlike in the previous case. We'll later see how things can be slightly more complicated, but this is a decent mental model to get started.
Could we do it by taking an lvalue reference? Unfortunately yes: because lvalue
and rvalue references can easily be converted from one to another via casting
(aka std::move
), someone taking an lvalue reference could just move
the data out it. On one hand, this is very unexpected because if you wanted move
behvavior, you'd probably want to pass an rvalue reference instead, by
convention. On the other hand, if you passed a non-const lvalue reference, you
agreed that anything could happpen to it, so one could agree that this is to be
expected. I'll let you be the judge of that.
As you can see, because there are many ways to pass values in C++ and different use cases, the combinations get quite high. Unfortunately, there are no right answers, silver bullets or rule of thumbs that can tell you exactly what you should do, depending on the size of a class, whether it is copy-only, move-only or both, whether we want a function or its caller to decide how to deal with that class, whether we follow typical move conventions or want more speed and more, we might want to do things differently. The fundamentals remain very simple: two colors of references and function overloading, but the combinatorial explosion of cases prevent us from designing simple guidelines. When in doubt, try to desugar the copy and move abstractions, go back to the types and functions you are dealing with and think about what code is actually being called and executed.
So far I've asserted that lvalue and rvalues references were identical but incompatible, but you might have noticed some odd differences anyways. For example, you never need to cast to an lvalue reference, but you always need to cast to an rvalue reference. Did I lie to you? Not really: they behave similarly, but the language picks which one to use when both are valid. This should not sound unreasonable if you've worked with overloads before, consider:
void f(int); void f(int&);Those two overloads are valid, but if you call
f
with some
int
variable, the compiler will complain that the call is
ambiguous, because both overloads are valid but none is obviously a better pick
than the other.
In the case of references, the language defines which one should be picked in certain contexts. It basically goes like this: pick the rvalue overload if the value is temporary and the lvalue overload otherwise. A temporary value simply means that this value is about to die, to be destructed. Examples include values returned from a function and literals: those aren't referenced anywhere else so sure, why not just move them? It will be less expensive. Examples of values that are not temporary include anything that is bound to a variable. Let's have a look at some examples:
// the return value of g isn't referenced anywhere, it is temporary and // therefore passed as an rvalue reference to f f(g()); // same here f(Foo{}) // foo is not temporary, it will still be alive after we've called f and // could be used long after: it is passed by lvalue reference Foo foo; f(foo); // even though scopedFoo would be destroyed right after the call to f, it is not // considered a temporary, so it will also be passed by lvalue reference { Foo scopedFoo; f(scopedFoo); } // because localFoo is being returned, we know that it is about to expire, // therefore it becomes a temporary: it is passed as an rvalue reference Foo makeFoo() { Foo localFoo; return localFoo; }I won't go into details about how temporaries work here, you can read about value categories on your own if you care. You might discover that lvalue and rvalue references can be divided into finer grained categories, but that should not change the mental model that I present here, which is coarser but still correct and sufficient for programming, including complicated library work.
If you think hard enough about how C++ picks which reference is being used, you might make a key observation: while there is nothing inherant to copying or moving to lvalue and rvalue references, the language itself strongly orients which overload is going to be used based on usage. Values that won't be reused are passed by rvalue references. Values that might are passed by lvalue reference. Knowing this, it makes more sense to reserve destructive behavior for rvalue overloads, since it won't affect anyone else. This is what really makes move semantics possible: the language itself encourages certain overloads to be picked in certain situations. You can still use lvalue/rvalue overloads to do other things, or even swap their copy/move behaviors, but you'll be fighting against the language, constantly casting values to "correct" what C++ does by default.
Finally, you should be aware of a subtlety of passing objects by value in C++:
when passing types with non-trivial constructors or destructors to a function,
C++ actually passes a reference instead. This is hidden, but makes intuitive
sense at the ABI level: the caller is the one constructing the value, so it must
also be the one destructing it. For it to destruct it, the object must be in the
caller's stack frame, hence the passing by reference even though the function
takes a value. In any case, this language "feature" might complexify your mental
model of when to pass things by reference vs value. This is particularly
relevant for move-only types like std::unique_ptr
, which almost
always have non-trivial destructors. You might elect to pass them by value when
they are cheap to copy, but if they are going to be passed by reference anyways,
you might as well avoid the extra construction and destruction and just pass
them by rvalue reference (which again isn't a silver bullet as the callee might
unpredictably not actually consume that value).
We're almost done here, I have no more concepts to introduce, no more complexity to add to your mental model. I just want to discuss some of the implications of C++'s move semantics and what the alternatives are. If you've understood everything so far, you should be well-armed to deal with move semantics already.
First off, I consider it a tremendous failure that people need to be "well-armed" to deal with moves in C++. Ironically, I believe that this article shows that rvalue references can easily be explained and understood but that understanding move semantics is much more involved, even though rvalue references are merely a tool for move semantics. In other words, it's easy to build move semantics from scratch for yourself with just function overloads and two colors of references, but the real world is much more complicated. It might be acceptable if this complexity was inherant to the problem of "copying or moving data around", but I claim that it isn't.
When dealing with the complexities of C++, I like to think about how the same
problem would be dealt with in C. In this case, you wouldn't need overloads,
different references or even constructors at all. You'd simply have two simple
functions: void copy_foo(Foo*, Foo*)
and void move_foo(Foo*,
Foo*)
[3]. Sure, this has all of the downsides that come with C in terms of
unsafety, code duplication and more, but it would also be incredibly more
simple. You would also only have to think about moves for types which can
benefit from them and not have to deal with any extra complexity for all other
types[4].
If you'd rather want to look at how a modern, safe language does it, have a look at Rust. Its model is also very simple: only moves exist. This makes sense as a default so that you never copy by accident, but also makes life much easier. Should you want to copy something, you could always do it explicitly with a dedicated method[5].
Another difference in Rust is that values cannot be used after a move, while they simply "should not be used, mostly" in C++. The Rust compiler actually enforces that, which has the side-effect of enabling moves from const values: since the value can't be used afterwards, there's no reason to clear it out, so it can be const. This is technically possible in C++ too as we've discussed before, but in the real world, rvalue references are passed around non-const. This, together with the lack of guarantees regarding the state of a moved-from object, enable a unique category of bugs in C++, called use-after-move, on top of the performance loss of having to clear out objects to mitigate the same use-after-move bugs.
I'd like to address a special thank you to Jean-François Marquis for proofreading this article, making sure that it was accurate, readable and helpful: merci JF !
[1] It's a bit more complicated in practice, especially with
std::shared_ptr
which supports weak references as well, but this
will do in our context. Jump back
[2] We also need to null our own pointer so that our destructor does not release it down the line. There are of course more subtleties, but again, we're not here to design a correct reference counted pointer. Jump back
[3] In fact, if you decide to go for a non-clearing move and your type only has
one layer of pointers, C's assignment operator will already functionally be a
move, in which case you only need a single deep_copy_foo
function!
Jump back
[4] You can mostly ignore move semantics for POD types, but they sure need to be dealt with more often than for the cases where they make sense, like containers, smart pointers and ownership tokens. Jump back
[5] Yes, the Copy
trait exists. Jump back