Modern C++ In-Depth — Move Semantics, Part 1

Ralph Kootker
FactSet
Published in
5 min readMar 2, 2022

This week, we’re beginning a new series of posts exploring some of the more technically challenging features of C++11 and beyond. These will be deeper dives than we’ve done previously — covering more than just stability and correctness — while maintaining our focus on programmers new to modern C++. For our first topic, we’re going to take a look at how we can use move-semantics to avoid some potentially expensive copies.

Rationale

If you’ve ever tried passing a std::unique_ptr to a function, you may have noticed that you can't simply pass it along as you would most other types:

void set_widget(std::unique_ptr<Widget> bar) {
//...
}
int main() {
auto foo = std::make_unique<Widget>();
set_widget(foo); //< Whoops, that won't compile!
}

The root cause has to do with the fact that a std::unique_ptr<Widget> is meant to encapsulate exclusive ownership of a resource, and in passing our smart-pointer to set_widget(...) we're implicitly attempting to copy it – a clear violation of our desire for exclusive ownership!

So, how do we get around this?

“Moving” a std::unique_ptr<T>

Internally, a std::unique_ptr<T> does little more than (exclusively) manage a single pointer to a heap-allocated object of type T. If we could copy a std::unique_ptr<T>, we would end up with two smart-pointers that both believe that they are responsible for managing the same instance of T, which is obviously not what we want. We can solve this dilemma by ensuring that only a single instance can own the resource. This is where std::move(...) comes in:

void set_widget(std::unique_ptr<Widget> bar) {
//...
}
int main() {
auto foo = std::make_unique<Widget>();
set_widget(std::move(foo)); //< Ah, that's better!
}

While the name might suggest otherwise, std::move(...) actually doesn't do anything at runtime. In fact, std::move(...) will simply take whatever type it is handed and cast that type to an rvalue, a type of temporary object. Don't worry too much about what that actually means for now, but just think of it as a way to signal that the variable in question (i.e., foo) will no longer be used and that it can have its internal resources moved out from under it.

In practice, when we construct the formal parameter bar in the call to set_widget(std::unique_ptr<Widget> bar), we're expecting bar to "steal" the internal pointer to Widget from foo and to assume ownership of it.

As you might imagine, once bar takes the pointer from foo, foo can no longer point to the heap-allocated Widget. Instead, as per the standard, it will once again point to nullptr. This brings up an important point: once std::move(...) has been called on a variable, it should no longer be used!

Using std::move(...) With Other Types

The use of std::move(...) is not limited to smart-pointers. In fact, if you follow the Rule of Zero or the Rule of Five, as mentioned in our previous post, you may assume that most non-scalar types can be moved.

Moves are probably most powerful when you’re transferring ownership of containers, since creating copies of such types can be expensive. For instance:

class Gadget
{
public:
Gadget()
{
std::vector<Widget> items = produce_widgets();
// Some additional logic...
m_map["key"] = std::move(items);
}
//...private:
std::map<std::string, std::vector<Widget>> m_map;
};

Since the std::vector might be an enormous collection of objects, we can save a lot of effort by having the map take ownership of the vector's internal (heap-allocated) data. This relatively simple operation will be a lot more efficient than iterating over the entire vector and making a deep copy of everything it contains.

All Standard Library containers are equipped with the necessary function overloads to deal with the data types produced by a call to std::move(...). We'll look at how to define our own move-enabled operations in the next post.

Now that we know how to move data around, let’s talk about best-practices.

Don’t Get Too Move Happy

While judicious use of move semantics can certainly improve the speed and efficiency of a program, its overuse can actually be counterproductive.

Here’s a classic example:

std::vector<Widget> fetch_data()
{
std::vector<Widget> output;
// ...tons of work to populate the std::vector...
// Does it make sense to explicitly return an rvalue?
return std::move(output);
}
int main()
{
const auto data = fetch_data();
}

One might be tempted to return the output instance via a call to std::move(...), thinking that this surely has to be more efficient than performing what would otherwise appear to be a copy. Fortunately, we don't need to do anything at all in this case, since the compiler is able to help us out with a trick known as the Named Return Value Optimization (NRVO). This optimization allows the compiler to initialize data directly, without having to first populate output, return that result from fetch_data(), and finally, assign it to data. In fact, the explicit rvalue cast in the return statement is actually inhibiting NRVO.

While there are some situations where NRVO is not applicable, one should generally return a variable by value when returning from a function.

Similarly, the following is generally incorrect as well:

const auto data = std::move(fetch_data());

Attempting to Move const Objects

While you can certainly write, compile, and run code that attempts to move a const object instance, it won't do what you might think. Since moving an object requires that its innards be stolen (and thereby altered), it makes sense that a const object cannot be moved. That said, attempting to move a const object will usually not result in a compilation error, making it an easy mistake to make.

When attempting to move a const object, the compiler will try to fall back on the type's copy-constructor (since performing a copy is a safe fallback if a move cannot be made to happen).

In practice, this means that this snippet will compile and run:

void secret_sauce(std::shared_ptr<Widget> data)
{
std::cout << data.use_count() << std::endl;
// Should print: "2"
}
int main()
{
const auto data = std::make_shared<Widget>();
// This will result in two copies instead of just one.
secret_sauce(std::move(data));
}

Were we to remove the const ahead of data, the example above would print "1" instead of "2", indicating that ownership of our Widget pointer was transferred instead of copied.

If we instead were to swap our std::shared_ptr<T> for a std::unique_ptr<T> (a move-only type), we would find that compilation simply fails.

void secret_sauce(std::unique_ptr<Widget> data)
{
}
int main()
{
const auto data = std::make_unique<Widget>();
// This will attempt a copy, but fail to compile.
secret_sauce(std::move(data));
}

Since std::unique_ptr<T> doesn't have a copy-constructor, the compiler cannot make this code work. Removing the const keyword is the only way to fix the issue.

Are There Types I Cannot Move?

Yes, there are.

Recall from our previous post, that failure to follow either the Rule of Zero or the Rule of Five may result in the move-constructor and move-assignment operator not being defined. Naturally, if we don’t have these special member functions available to us, we won’t be able to move anything.

Other than that, scalar types (e.g., int, float, double, bool, etc.) cannot be moved either, only copied. Similarly, in order to guarantee thread safety, synchronization primitives (e.g., std::mutex, std::atomic<T>, etc.) typically cannot be moved (or copied) either.

What’s Next?

Next time we’ll dive a bit deeper into the nitty-gritty details of value-semantics and move-semantics.

Future topics we plan to cover in this series:

  • Perfect Forwarding
  • Variadic Templates
  • Lambda Expressions

Acknowledgments

Special thanks to all that contributed to this blog post:

Author: Tim Severeijns
Managing Editor: Ralph Kootker
Reviewers: James Abbatiello, Michael Kristofik, Jennifer Ma, Jens Maurer, Manuel Sierich

--

--

Ralph Kootker
FactSet
Writer for

I publish on behalf of others or myself. Please carefully look at the acknowledgements at the bottom of each article