C++ move rationale
Why is C++ move not destructive and what does that mean? We see that the way move is implemented in C++ is only one of the ways of doing move.
This article is part of a series on the history of regular data type in C++.
Motivation
The motivation of move is fundamentally the transfer of data where ownership of
the data can be transferred more efficiently than taking a copy (or where copy
is not possible). It can improve fundamental operations like swap
:
1
2
3
4
5
// written like this swap makes unnecessary copies
foo tmp = a; // copy constructor
a = b; // copy assignment
b = tmp; // copy assignment
// end result: three deep copies
Historically, improving on the situation above required specializations of
swap
for various types.
C++ move syntax
Syntax-wise C++ move relies on r-value references the mechanics of which I’ve described previously in some details.
You have to implement the move constructor and assignment. Like in the case of copy, constructor and assignment require distinct implementations for maximum efficiency. For our class managing data via a pointer they can look like:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class foo {
bar * ptr;
public:
// ...
foo(foo && other) : ptr{ other.ptr } noexcept {
other.ptr = nullptr;
}
foo & operator=(foo && other) noexcept {
if (this != &other) {
delete ptr; // asumes bar destructor does not throw
ptr = other.ptr;
other.ptr = nullptr;
}
return *this;
}
};
This leads to a much better swap:
1
2
3
4
T tmp = std::move(a); // move constructor
a = std::move(b); // move assignment
b = std::move(tmp); // move assignment
// end result: three ownership transfers
“Moved from” state
When we moved from the other
, the source of the move, we diligently assigned
nullptr
to other.ptr
. That’s because the compiler will call the destructor
on the source of the move.
What can you do with a “moved from” object?
You have to be able to destruct it. This is because of the design of move in C++ that will call the destructor on the source of move.
You have to be able to assign to it. This is because otherwise swap
won’t
work (it uses move assignment).
What else can you do with it?
Academically the answer is “any operation that does not have pre-conditions”.
This is the path that components from the C++ standard library took (it does
not use the term “moved from” or resource-less either). Some operations make
sense, such as a function assign
or other named functions that do assignment.
But the path that the C++ standard took leads to absurd situations where you
can call .empty()
on a moved from container, though you might get either
true
or false
. Similar .size()
can return any size, operator+=
will
happily append, but you can end with anything. That is silly: being able to
call functions with no promise of outcome is useless. It falls into the
category: just because you can do something does not mean it’s a good idea to
actually do it.
What I recommend is that you follow the rule: a “moved from” object can be destroyed, assigned and only operations that are specifically allowed can be called. Basically, if you wish, introduce an implicit pre-condition on most of the functions to not be called on a “moved from” object.
noexcept and move
It’s a historical accident that move constructors and assignment are not
implicitly noexcept
like the destructor. They should be. Failing that we have
to diligently ensure that they are noexcept
. As far as I can figure out it is
due to Microsoft insistence on ensuring that end iterators don’t get
invalidated for containers like set
, map
and list
even if those
containers get moved. The move constructor for such containers can throw. The
requirement is really niche, the downside of handling moves that can throw
outweighs the gain (this bad choice has far implications like the exception
guarantees of vector operations).
I recommend that you design your move constructor and assignment so that they don’t throw and don’t forget to explicitly mark the move constructor and assignment as noexcept.
Other options
Alex Stepanov said “[std] vector is just one kind of a vector”. Similarly move can be implemented in many ways.
In Elements of Programming, move is described in terms of copy of underlying
type. For example, a typical vector has three pointers which it uses to manage
the resizeable buffer which is allocated on the heap. The underlying type for
such a vector would be a struct
containing three pointers.
This approach works well in EOP, in particular for re-arrangements which range
from the simple swap
we’ve seen above, to algorithms like rotate
which uses
clever maths to achieve O(N)
complexity: the greatest common denominator
(gcd) to determine the number of move cycles.
A similar approach is taken by Rust (another programming language). There move
is effectively a memcpy
which leaves the source unchanged.
In both cases they don’t deal with containers that have pointers from the
remote parts back to the local parts (such as a double linked list with the
dummy node stored in the list itself), though theoretically a language like
Rust could add customization points to adjust such pointers after the
memcpy
Destructive move, where the destructor is not called for the source of the move
by the compiler has been considered during the design of the current move
syntax in C++ (for what became C++11), see references below for more info. It
is difficult for the compiler to always track if the object has been moved
from. In particular for objects with static or thread storage duration. I also
struggle with the case where the object is moved from a container even as
simple as an std::optional
or std::unique_ptr
: in the latter case the
memory still needs to be freed, but the destructor not called. Such move has to
be noexcept
, else situations like moving a type that has a base class become
really complicated. So as explained in the references below, this approach has
been de-prioritised for the time being as “too much pain for not enough gain”.
References
Why does C++ move semantics leave the source constructed? question and answer by Howard Hinnant on stackoverflow
Note on alternative move designs in N1377