Move Annoyance
[Written for Lakos, J., Romeo, V., Khlebnikov, R., & Meredith, A. (2021). Embracing Modern C++ Safely. Addison-Wesley Professional. Reproduced with permission.]
Annoyances
Required Postconditions of a Moved-From Object Are Overly Strict
Given an object, rv
, which has been moved from, the C++201 Standard specifies the required postconditions of a moved-from object:
rv
’s state is unspecified
[Note:rv
must still meet the requirements of the library component that is using it. The operations listed in those requirements must work as specified whetherrv
has been moved from or not. — end note] — Table 28, p. 488 C++20 Standard.
The requirement applies to both move construction and move assignment for types used with the standard containers and algorithms. The note is not normative but does clarify that the requirements on a moved from object are not relaxed.
To understand how this requirement causes an issue in practice, consider the following simple class definition. The intent of my_type
is to create a class that always holds a valid value, is copyable and equality comparable, and happens to contain a remote part. The remote part in this example is held as a unique_ptr
to an implementation
object. A remote part might be employed to improve compile times by separating the implementation from the interface, to allow a polymorphic implementation using inheritance, or to trade off a slower copy for a faster move:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class my_type {
class implementation;
std::unique_ptr<implementation> _remote;
public:
explicit my_type(int a) : _remote{std::make_unique<implementation>(a)} {}
my_type(const my_type& a) : _remote{std::make_unique<implementation>(*a._remote)} {}
my_type& operator=(const my_type& a) {
*_remote = *a._remote;
return *this;
}
friend bool operator==(const my_type& a, const my_type& b) {
return *a._remote == *b._remote;
}
};
We can add the ability to move the object by using a default move-constructor and move-assignment operator:
1
2
3
4
5
6
class my_type {
//...
public:
//...
my_type(my_type&&) noexcept = default;
my_type& operator=(my_type&&) noexcept = default;
If we ignore the library requirements and consider only the language requirements, this implementation is sufficient. The only language requirement is that a moved-from object is destructible because, without a cast, the only operation the compiler will perform on a moved-from object is to destruct it. By definition, an rvalue is a temporary object, and no other operations will be performed. The assignment a = f()
where a
is of type my_type
and f()
returns a value of type my_type
, will work correctly with the default member-wise implementations.
However, using my_type
in a standard container or algorithm will likely fail. Consider inserting an element into a vector at a position, p
:
1
2
3
4
my_type a{42};
std::vector<my_type> v;
//...
v.insert(p, a); // undefined behavior
If p
is not at the end of the vector, this code may move the range [p, end(v))
and then copy a
over a moved-from object. Implementations of the Standard Library may use a different approach to insert that would not encounter this issue.2 The copy of a
results in a statement with the effect of *p = a
where *p
is a moved-from instance of my_type
. The copy is likely to crash because of the implementation of copy assignment:
1
2
3
4
my_type& operator=(const my_type& a) {
*_remote = *a._remote;
return *this;
}
After the move _remote
is equal to nullptr
and dereferencing _remote
is undefined behavior. There are multiple ways to fix copy-assignment, but for illustration we’ll add a conditional to test _remote
and if it is equal to nullptr
, use an alternative implementation:
1
2
3
4
5
6
7
8
my_type& operator=(const my_type& a) {
if (_remote == nullptr) {
*this = my_type(a); // copy construct and move assign
} else {
*_remote = *a._remote;
}
return *this;
}
The additional check is sufficient to make all of the standard containers and algorithms work correctly. Unfortunately, this check is not sufficient to satisfy a strict reading of the standard requirements because:
- Copy construction from a moved-from object will fail
- Copy assignment from a moved-from object will fail
- Equality will fail if either operand has been moved from
All of these operations would cause a nullptr
to be dereferenced. The Standard Library states that these operations must be valid for all values of a given type.
The implementations of functions associated with the containers and algorithms in the Standard Library will never perform any operation on a moved-from object other than to destruct or assign a new value to it unless called with an object that has already been moved from (i.e., by the caller directly). The operations in the list above will never be invoked (one additional requirement imposed by std::swap()
is discussed below).3
Adding the additional checks to satisfy the Standard’s wording has an otherwise unnecessary performance impact and proves to be error-prone to implement. Beyond that, the additional code introduces a new empty state for my_type
, which must be considered if we introduce an ordering with operator<()
or any other operation the Standard may invoke. The gratuitously induced empty state defeats the purpose of value semantics because coding with an object that may or may not be empty is equivalent to coding with a pointer that may or may not be null.
The root cause of this issue is broader than just the postconditions of move operations. There is a standard proposal to address these issues.4 Until the proposal is adopted — and it may not be — a type must include these additional checks to adhere to the standard requirements.
-
Similar wording with the same intent appears in every version of the C++ Standard since C++11. ↩
-
The 11.0.1 version of the
libc++
Standard Library does use the described approach and will result in a crash. ↩ -
The
std::swap()
algorithm imposes one additional requirement. Consider swapping a value with itself;std::swap(a, a)
will generate:1 2 3
my_type tmp = std::move(a); a = std::move(a); // self-move-assignment of a moved-from object a = std::move(tmp);
The statement
a = std::move(a)
is doing a self-move-assignment of a moved-from object. The default move-assignment in the above implementation ofmy_type
will work correctly for self-move assignment of a moved-from object. The default implementation satisfies the postconditions for both the right-hand and left-hand arguments and does not affect the value ofa
. The left-hand argument of move assignment must be equal to the prior value of the right-hand argument. The containers and algorithms in the Standard Library do not self-swap objects, butstd::swap()
(annoyingly) provides the guarantee that self-swap will work if the arguments satisfy the requirements for the move-constructible and move-assignable concepts. The requirement for self-swap is both a legacy requirement from whenstd::swap()
was implemented in terms of copy and follows from a general requirement in the Standard that, unless otherwise specified, operations should work even if reference arguments alias each other in whole or in part. There is no known value in supporting self swap, and a self swap usually indicates a defect. ↩ -
Relaxing Requirements of Moved-From Objects, Sean Parent, P2345R0 ↩