When C++ Doesn't Move
This article was first published in July 2019 and was updated in July 2024.
Move semantics were introduced in C++11 with the hope of adding more performance to an already performant language. In most cases, move semantics were successful in achieving efficiency, but there are some equally confusing and important situations where a move won’t be performed, and as a C++ developer, it’s beneficial to understand when these situations could occur.
Today, move semantics are relatively unchanged in modern revisions of C++, with the only minor modifications coming in C++20 relating to r-value references, and return values.
But before you start reading about the pitfalls and performance gains surrounding move semantics, I’d suggest first reading up on r-value references and move semantics and coming back to this post with a better base of knowledge to build upon.
When a Move Is Not Performed
Sometimes the compiler cannot call the move constructor and it defaults back to the copy constructor or another operation — even when std::move
is called. From the compiler output, you have no way of knowing when this happens, and in most instances, you’ll be losing performance without even realizing it (or at least throwing away that slight performance increase you could benefit from).
For here on out, I’ll be using a class that will print the information from each constructor or assignment. It’ll highlight the constructors and assignments that are called, in addition to sharing at which point during the execution of the following examples they’re called:
class Lifetime { public: Lifetime() { std::cout << "Constructor" << std::endl; } Lifetime(const Lifetime &other) { std::cout << "Copy Constructor" << std::endl; } Lifetime(Lifetime &&other) noexcept { std::cout << "Move Constructor" << std::endl; } Lifetime &operator=(const Lifetime &other) { std::cout << "Copy Assignment" << std::endl; return *this = Lifetime(other); } Lifetime &operator=(Lifetime &&other) noexcept { std::cout << "Move Assignment" << std::endl; return *this; } };
The Move Is Implicitly Deleted
The compiler will refuse to implicitly declare the move constructor/assignment in some cases. We can understand this better when reading N3225 section 12.8/10:
If the definition of a class X does not explicitly declare a move constructor, one will be implicitly declared as defaulted if and only if
- X does not have a user-declared copy constructor,
- X does not have a user-declared copy assignment operator,
- X does not have a user-declared move assignment operator,
- X does not have a user-declared destructor, and
- the move constructor would not be implicitly defined as deleted.
The rule of zero advises us not to declare any default operations, and this advice holds true if we want to use move semantics. When any of the copy semantics or the destructor are explicitly defined and move semantics are not explicitly defined, the compiler will not implicitly define any move semantics. What this means is the class isn’t movable and it’ll revert to a copy:
class Lifetime { public: Lifetime() { std::cout << "Constructor" << std::endl; } Lifetime(const Lifetime &other) { std::cout << "Copy Constructor" << std::endl; } }; int main() { Lifetime temp; Lifetime x = std::move(temp); return 0; }
Output:
Constructor Copy Constructor
We can see this when we only define the copy constructor in the Lifetime
class and attempt a move: The output will show a copy is actually made. The explanation of this lies within the C++ standard passage seen above, with the relevant line repeated below, where X is Lifetime
:
X does not have a user-declared copy constructor
If we use the original Lifetime
class declared at the top of the post, we’ll see a move operation:
Constructor Move Constructor
Moving a const Value
Const
ness will often disable move operations, and you can see this from the definition of the move constructor: It takes in a non-const
r-value reference. When a const
value is passed to std::move
, the compiler will revert to a copy:
int main() { const Lifetime temp; Lifetime x = std::move(temp); return 0; }
Constructor Copy Constructor
However, if you have some mutable data in your const
object, it can make sense to define a const
move constructor:
class Lifetime { public: ... Lifetime(const Lifetime &&other) noexcept : value(other.value) { std::cout << "Const Move Constructor" << std::endl; other.value = 0; } mutable int value; };
Now the const
move constructor will be called.
Strictly speaking, to fulfill the requirements of a move, no mutable members should be defined. However, because we’ve been explicit in defining value
as mutable, it theoretically makes sense to allow a const
move constructor. That said, I haven’t found any reason for this as of yet, so it’s good to know that const
data won’t be moved:
Constructor Const Move Constructor
Move Operations and the STL (Standard Template Library)
If your code is making use of the STL (which it should), it’s a good idea to make sure your classes will play nicely.
Because the STL can still be used with C++ exceptions disabled, it’s important to notify the compiler when a move constructor or move assignment won’t throw, i.e. when it’s noexcept
. When we forget to do this, some operations of the STL will fail to use move semantics — for example, the resize
method of vector
.
If we remove noexcept
from our Lifetime
move constructor:
Lifetime(Lifetime &&other) {
std::cout << "Move Constructor" << std::endl;
}
And then attempt a resize of a vector
of Lifetime
s, then we’ll see the copy constructor is called:
int main() { std::vector<Lifetime> temp; temp.emplace_back(); temp.emplace_back(); temp.resize(1); return 0; }
Output:
Constructor Constructor Copy Constructor
To avoid this, we always want to ensure we declare noexcept
when we’re sure the move constructor or assignment won’t throw.
RVO Has Been Used
Now, this is a good one. RVO, or return value optimization, is an optimization the compiler is allowed to use to merge the copy or move. It’ll construct the object in the place of the assigned value. RVO is the one time where we don’t want to see a move operation, as it’s more efficient than a move operation:
Lifetime getLifetime() { Lifetime lifetime; return lifetime; } int main() { const auto localLifetime = getLifetime(); return 0; }
Output:
Constructor
Now, if we ignored RVO, in the example above we’d expect to see both a constructor being called and a move or copy constructor placing the value into localLifetime
. But RVO can perform its optimizations and construct a Lifetime
object into localLifetime
directly.
It’s important to note that this isn’t always the case, as RVO is up to the discretion of the compiler. However, most modern compilers will ensure RVO is used.
FAQ
Here are a few frequently asked questions about move semantics.
What Are Move Semantics in C++?
Move semantics in C++ allow the efficient transfer of resources from one object to another, avoiding the overhead of making a copy. This is particularly useful for objects that manage resources, which are expensive to duplicate.
When Are Move Semantics Used?
Move semantics may be utilized in C++ when returning objects from functions, passing objects by value, manipulating dynamic memory (such as resizing an std::vector
or std::string
), and when handing over unique ownership (as with unique_ptr
).
How Do I Implement a Move Constructor in C++?
A move constructor enables transfer of resource ownership and is typically implemented using an rvalue reference (designated with &&
). However, C++ may implicitly generate a move constructor if it isn’t explicitly defined in certain situations.
What Is the Purpose of std::move?
std::move()
casts an lvalue to an rvalue, enabling efficient resource transfer via move constructors or move-assignment operators, without triggering a deep copy. But be careful with it, as moving from an object leaves it in a valid but unspecified state.
How Do C++ Move Semantics Improve Performance?
Move semantics may improve performance by eliminating unnecessary deep copies of objects, which can be costly in terms of time and memory. By transferring resources directly, move semantics reduce the overhead associated with managing dynamic resources, leading to more efficient code execution.
Conclusion
In this post, we learned that C++ move semantics may be deleted, const
ness will prohibit move operations, noexpect
is required for move semantics when using the STL, and RVO may be used instead of moving. It’s important to keep these points in mind when assuming that a move will be performed, as std::move
doesn’t always mean a move will be performed.
Here at PSPDFKit, we’ve implemented clang tidy as one of our many CI jobs, and it has many checks to ensure you’re using move semantics correctly or optimizing when possible. It’s a great tool to pick up the slack when you forget rules at odd times.
When Nick started tinkering with guitar effects pedals, he didn’t realize it’d take him all the way to a career in software. He has worked on products that communicate with space, blast Metallica to packed stadiums, and enable millions to use documents through Nutrient, but in his personal life, he enjoys the simplicity of running in the mountains.