“Trivially relocatable” versus “trivially destructible after move”
Recently I discovered that besides the various libraries using P1144-style (or occasionally P2786-style) trivial relocatability, there are also at least two that use P1029R2-style “trivial destructibility after move.”
Note: Not P1029R3-style “move = bitcopies”; that’s much closer to the union of both traits together.
This blog post demonstrates the difference between is_trivially_relocatable
and is_trivially_destructible_after_move
.
Here are the two implementations of is_trivially_destructible_after_move
of which
I’m aware:
-
Domagoj Šarić’s psiha/vm defines the trait
moved_out_value_is_trivially_destructible
. -
Artur Bać’s arturbac/small_vectors defines the concept
concepts::relocatable
.
Recall that the verb “relocate” means “take the object that used to be at src
,
get rid of it, and make an object at dst
with that same value.” Recall that this
is different from move-construction, and also different from move-assignment. Here’s
a Standard C++ definition of the P1144-alike library algorithm uninitialized_relocate
.
(For simplicity, I’m assuming raw pointers instead of iterators, and omitting all
exception-safety and error-handling.)
template<class T>
T *raw_uninitialized_relocate(T *first, T *last, T *dfirst) {
T *dlast = std::uninitialized_move(first, last, dfirst);
std::destroy(first, last);
return dlast;
}
The two different traits let us add two different optimization codepaths, like this:
template<class T>
T *raw_uninitialized_relocate(T *first, T *last, T *dfirst) {
if constexpr (my::is_trivially_relocatable_v<T>) {
size_t n = last - first;
std::memmove((void*)dfirst, (void*)first, n * sizeof(T));
return dfirst + n;
} else if constexpr (my::is_trivially_destructible_after_move_v<T>) {
return std::uninitialized_move(first, last, dfirst);
} else {
T *dlast = std::uninitialized_move(first, last, dfirst);
std::destroy(first, last);
return dlast;
}
}
Only is_trivially_relocatable
allows us to replace a whole swath of relocations with
memmove
, because only is_trivially_relocatable
says anything about the triviality
of the relocation operation as a whole. The optimization codepath for
is_trivially_destructible_after_move
doesn’t involve memmove
; it skips
the destructors of the moved-from elements, but it doesn’t have any insight into
what the move constructor might have done.
std::list
illustrates the difference
Recall that there are two implementation strategies for the STL’s std::list
.
-
The “sentinel-node” strategy, used by Microsoft STL. Every
list
ends with an additional allocated node, called the “sentinel.” Even a default-constructed (empty)list
manages a single heap-allocated sentinel node. -
The “dummy-node” strategy, used by libstdc++ and libc++. The
list
object itself contains a “dummy” node, consisting of only a prev/next pointer pair (no payload). Theback()
node’s next pointer points at that dummy node, and the dummy node’s prev pointer points at the heap-allocatedback()
node.
The relocation operation for each kind of list
looks like this. (Lines with arrows
represent one-way pointers; lines without arrows represent two-way prev/next pairs.
The elements highlighted in red are created or updated by the move constructor.)
Step | Sentinel node (MSVC) |
Dummy node (everyone else) |
---|---|---|
Initial state:src holds {A,B,C} |
||
Move-constructdst from src |
||
Destroy the moved-from src |
Notice that MSVC’s move-construction operation doesn’t need to touch any of the old heap-allocated nodes;
but it does need to allocate a new sentinel node for the moved-from src
. Contrariwise, the dummy-node
implementation doesn’t allocate, but it does need to update node C
’s next pointer to point to the
dummy node stored inside dst
(where previously it pointed to the dummy node stored inside src
).
Notice that in the dummy-node implementation we can safely skip the destructor of the moved-from src
;
it no longer controls any resources. Contrariwise, in MSVC’s sentinel-node implementation, skipping
the destructor of src
would leak memory.
Therefore:
-
In Microsoft STL,
is_trivially_relocatable<std::list<int>>
istrue
butis_trivially_destructible_after_move<std::list<int>>
isfalse
. -
In libc++ and libstdc++,
is_trivially_destructible_after_move<std::list<int>>
istrue
butis_trivially_relocatable<std::list<int>>
isfalse
.
Another example, and caveat
libstdc++’s deque
has a “never-empty” invariant similar to MSVC’s list
.
- In libstdc++,
is_trivially_relocatable<std::deque<int>>
istrue
butis_trivially_destructible_after_move<std::deque<int>>
isfalse
.
In fact, there’s a big caveat to mention at this point: many types (such as
for example libstdc++’s string
) are trivially destructible after move construction
but not trivially destructible after move assignment!
std::string src = "this string heap-allocates";
std::string dst = "this string heap-allocates too";
dst = std::move(src);
assert(src.capacity() > std::string().capacity());
So if you’re thinking of using a trait like is_trivially_destructible_after_move
,
you should consider your optimizations carefully: make sure you know what kind of
“move” operation you’re “after,” before assuming that it’s okay to skip a destructor call.
void inplace_vector<T>::operator=(inplace_vector&& rhs) {
size_t n = size();
if (n < rhs.size()) {
std::move(rhs.begin(), rhs.begin() + n, begin());
std::uninitialized_move(rhs.begin() + n, rhs.end(), begin() + n);
if constexpr (my::is_trivially_destructible_after_move<T>) {
// WRONG: we cannot skip the first n destructor calls!
} else {
std::destroy(rhs.begin(), rhs.end());
}
} ~~~~
}
The operator=
above leaks memory when T
is libstdc++’s string
.
Finally, it should go without saying that none of this holds for operations that “move”
from type T
into a different type U
. For example, this code is dangerously buggy:
void transfer(T *src, U *dst) {
std::construct_at(dst, std::move(*src));
if constexpr (my::is_trivially_destructible_after_move<T>) {
// WRONG: we cannot skip this destructor call!
} else {
std::destroy_at(src);
}
}
because it leaks memory when:
using T = std::shared_ptr<int>;
struct U { U(const T&) {} };