Thoughts on P3858R0 restart_lifetime<T>
restart_lifetime<T>
In the pre-Kona WG21 mailing is
P3858R0 “A Lifetime-Management Primitive for Trivially Relocatable Types” (std::restart_lifetime
)
(Sankel, Bauman, Halpern). A correspondent asks for my thoughts on it.
Recall that P2786 — adopted into the C++26 Committee Draft — uses the phrase “trivially relocatable” to refer to types that may require a non-trivial operation (i.e. more than a simple memcpy) to relocate from one address to another. Four NB comments from BG, CN, and US object to this, not only because it’s a misuse of terminology incompatible with well-established prior art, but also because that non-trivial operation is allowed to be specific to the dynamic type of the object. This means that there’s no way to use P2786 relocation to implement:
- Type-erasure, as required by e.g.
std::move_only_function
; see “Type-erasure, trivial relocatability, and lethal sirens” (2025-09-01). realloc
support, as required by some industry replacements forstd::vector
; notably Victor Shoup’s NTL.qsort
support, as required by some industry replacements forstd::sort
.
Observe that the C standard library’s realloc
and qsort
are mere special cases of type-erasure:
they can operate on objects of any type, as long as those types conform to certain preconditions
and explicitly supply implementations for certain other affordances.
In realloc
’s case, the only precondition is that the type can be trivially relocated (in the P1144 sense).
In qsort
’s case, the precondition is that the type can be trivially relocated (in the P1144 sense) and the
affordances you must supply are its size and its three-way comparison primitive cmp
.
P3858 attempts to tackle precisely the special case of realloc
; it does not solve (as far as I can tell)
the qsort
use-case, and it certainly cannot solve the move_only_function
use-case.
P3858’s idea is to imagine P2786’s trivially_relocate
function template has this internal implementation:
template<class T>
T* trivially_relocate(T* first, T* last, T* result)
{
static_assert(std::is_trivially_relocatable_v<T>); // P2786 semantics
static_assert(!std::is_const_v<T>);
std::memcpy(result, first, (last - first) * sizeof(T));
for (size_t i = 0; i < (last - first); ++i)
std::restart_lifetime<T>(result[i]);
return result + (last - first);
}
That is, we relocate a range of objects first by memcpying the bytes, and then by applying a
sort of “repair” operation to those bytes, such that they are once more suitable for interpretation
as a range of objects. P3858’s proposed repair operation, std::restart_lifetime<T>
, has more or
less the same abstract-machine specification as C++23’s
std::start_lifetime_as<T>
,
but its contract is wider: start_lifetime_as
works only on implicit-lifetime types and therefore
is guaranteed not to have any effect on the physical machine. P3858 restart_lifetime
needs to work
on arbitrary “trivially relocatable” types, and must be able to have a physical effect. For example,
on ARM64e, calling restart_lifetime<Animal>(&a)
must replace (and cryptographically re-sign) the
vptr of a
.
For details on ARM64e, see my blog post above.
P3858 does not solve type-erasure
P3858 preserves the idea that std::trivially_relocate
should have non-trivial
type-specific effects; it just wants to take those non-trivial type-specific effects
and factor them out into a very slightly more reusable function template that can
be used by the programmer to repair relocated object representations wherever they
might see them.
This doesn’t help with move_only_function
. There, we want to be able to write a
move constructor that can trivially relocate any trivially relocatable type, without
caring (or even being able to know) what its dynamic type is. We also want our own
class type to be trivially relocatable, despite its contents being type-erased.
Recall that that looks like this:
class [[trivially_relocatable]] MoveOnlyCallable {
struct {
int (*call_)(const void *data, int arg);
void (*destroy_)(void *data);
alignas(ALIGN) char data_[CAP]; // holds only trivially relocatable types
} s;
public:
template<class T, class DT = std::decay_t<T>>
static constexpr bool use_small_storage() {
return sizeof(DT) <= CAP &&
alignof(DT) <= ALIGN &&
std::is_trivially_relocatable_v<DT>;
// otherwise data_ holds a pointer to a heap-allocated DT
}
~~~~
MoveOnlyCallable(MoveOnlyCallable&& rhs) noexcept : s(rhs.s) {
rhs.s.call_ = call_empty;
rhs.s.destroy_ = destroy_empty;
}
~~~~
};
P3858 restart_lifetime<T>
doesn’t help with this problem at all. We can’t call restart_lifetime<T>
when we don’t know what T
is!
P3858 might not solve qsort
It’s unclear to me whether P3858 attempts to solve the qsort
use-case. Here’s a physically valid
implementation of sorting trivially relocatable types given P1144 (trivial) semantics for is_trivially_relocatable
:
template<class T>
requires std::is_trivially_relocatable_v<T> // P1144 semantics
void my_sort(T *first, T *last) {
auto *cmp = +[](const void *va, const void *vb) {
const T& a = *static_cast<const T*>(va);
const T& b = *static_cast<const T*>(vb);
return a < b;
};
std::qsort(first, last - first, sizeof(T), cmp);
}
// and fall back to a type-specific template for the general case
However, this implementation misbehaves disastrously given P2786 (non-trivial) semantics
for is_trivially_relocatable
. Our three-way comparison lambda calls T
’s operator<
,
which requires a
and b
to refer to physically well-formed T
objects. That will be
the case here only if qsort
has not yet relocated those object representations from
their original positions, or if we’ve manually called restart_lifetime<T>
on them.
Perhaps we could rewrite as:
template<class T>
requires std::is_trivially_relocatable_v<T> // P2786 semantics
&& std::is_replaceable_v<T> // because P2786; consider reference_wrapper
void my_sort(T *first, T *last) {
auto *cmp = +[](const void *va, const void *vb) {
const T& a = *std::restart_lifetime<T>(static_cast<void*>(va));
const T& b = *std::restart_lifetime<T>(static_cast<void*>(vb));
return a < b;
};
std::qsort(first, last - first, sizeof(T), cmp);
}
This is unattractive, because it requires cmp
to cast away const
and potentially
change the object representations of a
and b
(by writing new vptrs into them).
Perhaps it is also UB; it’s not clear to me whether P3858 intended to permit calling
restart_lifetime
multiple times on the same memory.
P3858 solves realloc
, at a cost
Custom containers such as NTL’s Vector<T>
want to use realloc
for trivially relocatable types. With P1144 (trivial) semantics
for trivial relocatability, they can just do it — and NTL does.
With P2786 (potentially non-trivial) semantics, as we have in the C++26 CD, they basically
can’t use realloc
, because there’s no way to tell the difference between a type that
is truly trivially relocatable and a type that is merely P2786-“trivially” relocatable
(requiring arbitrary type-specific repair afterward).
P3858R0 fixes up this deficiency of P2786, so that Vector<T>
can use realloc
,
as long as it’s careful to repair the buffer afterward.
In place of their current code:
// currently works for all P1144-trivially-relocatable types,
// but there is no type trait to safely identify such types
char *p = ((char *) _vec__rep.rep) - sizeof(_ntl_AlignedVectorHeader);
p = (char *) NTL_SNS_REALLOC(p, m, sizeof(T), sizeof(_ntl_AlignedVectorHeader));
if (!p) {
MemoryError();
}
_vec__rep = (T *) (p + sizeof(_ntl_AlignedVectorHeader));
NTL_VEC_HEAD(_vec__rep)->alloc = m;
NTL’s author would need to write:
// now works for all P2786-is_trivially_relocatable types,
// which is what the C++26 CD's type trait identifies
char *p = ((char *) _vec__rep.rep) - sizeof(_ntl_AlignedVectorHeader);
p = (char *) NTL_SNS_REALLOC(p, m, sizeof(T), sizeof(_ntl_AlignedVectorHeader));
if (!p) {
MemoryError();
}
T *newbuf = (T *) (p + sizeof(_ntl_AlignedVectorHeader));
size_t oldsize = NTL_VEC_HEAD(_vec__rep)->alloc;
for (size_t i=0; i < oldsize; ++i) {
std::restart_lifetime<T>(newbuf + i); // repair the object representations
}
_vec__rep = newbuf;
NTL_VEC_HEAD(_vec__rep)->alloc = m;
Without that extra O(n) loop, we’d have physically incorrect behavior when reallocating a Vector
of a type that was P2786-“trivially” relocatable without being truly trivially relocatable.
The library author needs to know about the pitfall, and manually avoid it.
Post-memcpy repair isn’t new
I think it’s important to point out that I’ve seen this notion of “post-memcpy repair”
in the wild before, and I haven’t been impressed by it. (Which is to say, I have a bias
against it.) We can see it for example in John Wellbelove’s ETL, where many types provide a
repair
method to do things like
fix up the pointer in a small string.
What ETL does not do is call T::repair
automatically during vector reallocation — but that’s
because ETL doesn’t provide any reallocating vector! (ETL targets embedded systems;
it uses no malloc
or new
at all, outside of unit tests.)
One reason I’m not impressed with ETL’s repair
in particular is that it seems (at least
in some modes) to use virtual methods, which seems unnecessarily inefficient. Ironically, following
the vptr of a memcpy’ed object is precisely what gets you into trouble on ARM64e!
Why am I sour on post-memcpy repair in general? First: You are making a lot of tedious and
error-prone work for yourself. You must ensure you call T::repair
resp. start_lifetime<T>
exactly when needed; otherwise you have a very subtle bug that is (by design) not detectable
by any type trait.
Our goal should not be to provide std::is_trivially_relocatable_v
as an error-prone replacement
for existing trivial-relocation idioms; our goal should be to make those existing idioms work safely,
out of the box, using std::is_trivially_relocatable_v
instead of hand-rolled traits and algorithms.
To do that, we have to adopt the existing idioms (as P1144 did), not
invent new ones (as P2786 did) hoping to repair them afterward to restore correctness.
It occurs to me that P3858’s proposal of “post-relocation repair” is a metaphor for P3858’s own existence.
Second: If you’re willing to do post-memcpy repair, why limit it to merely rewrite vptrs?
There’s nothing wrong a priori with the idea that we should be able to relocate objects
from A to B by copying the bytes and then performing a type-specific non-trivial repair;
it’s just that such a relocation will be by definition type-specific and non-trivial.
There are at least two recent proposals for type-specific non-trivial relocation.
Catmur & Bini’s P2785 “Relocating prvalues” (reloc
)
is the most well-thought-out; but its progress was halted by the unexpected death of its
primary author at the end of 2023.
That is: We have proposals on the table for how to do non-trivial relocations requiring “more than memcpy.” We shouldn’t take a half-measure like P3858 to deal with the relatively thin slice of types that P2786 got wrong; all we need to do is correct P2786 — or, if we can’t agree on that, then revert P2786 out of C++26.
P.S.: We can save the library API
Three NB comments (CA, RO, US) ask to remove some or all of P2786’s library API,
while two (US) ask LWG to finish adding P3516’s
library API (std::uninitialized_relocate
and friends). These are both good ideas.
P3516’s API is identical to P1144’s proposed API, as implemented in AMC, HPX, and Parlay.
P3516 carefully limits its dependence on P2786 to a single function
(std::relocate_at
) where that dependence is easy to excise. That means
we can revert P2786 and still benefit from std::uninitialized_relocate
in the library in C++26.
All we necessarily lose by reverting P2786 is its contextual keywords.
Three more NB comments (FR, US) object to those contextual keywords for one reason or another,
anyway.