Throughout the battle of P1144 vs P2786 to introduce trivial relocation optimisation for C++, one of the points P1144 has raised against P2786 is that polymorphic objects should not be trivially relocated and P2786 should not allow this.
P2786R13 has been recently voted into C++26 and it does allow polymorphic objects to be trivially relocated.
A detailed technical insight into these questions would be helpful.
Throughout the battle of P1144 vs P2786 to introduce trivial relocation optimisation for C++, one of the points P1144 has raised against P2786 is that polymorphic objects should not be trivially relocated and P2786 should not allow this.
P2786R13 has been recently voted into C++26 and it does allow polymorphic objects to be trivially relocated.
A detailed technical insight into these questions would be helpful.
There isn't any particular reason in general why a type being polymorphic should disqualify it from being trivially relocatable.
After all, this:
struct B {
virtual ~B() = default;
virtual int f() = 0;
};
struct D : B {
int f() override { return 42; }
};
Really is conceptually similar to:
struct __vtable {
void (*dtor)(B*);
int (*f)(B*);
};
struct B {
__vtable* v;
~B() { v->dtor(this); }
int f() { return v->f(this); }
};
struct D : B {
D()
: B{.v={
.dtor=[](B* p) { static_cast<D*>(p)->~D(); },
.f=[](B* p) { return static_cast<D*>(p)->f(); }
}}
{ }
int f() { return 42; }
};
And trivially relocating the latter is totally fine.
The only argument P1144 makes against trivially relocating polymorphic types is linking to this blog, which doesn't make a coherent argument at all.
Now, the interesting part of this discussion isn't polymorphic types in general, but rather one particular situation. Recent ARM hardware (arm64e) permits something called vptr signing — where the vtable isn't just a pointer to bunch of function pointers (like I showed above) but rather also uses some bits of the pointer to point back to the object, for security reasons. This case cannot be memcpy-ied — because now the designation vtable will be invalid (it won't be properly signed).
This case is okay though, since the implementation knows what's going on and can cleanup the vtables after the fact. Making D
trivially relocatable still works — it's just that relocation in this case isn't just memcpy
.
However, the case that doesn't work is:
union U {
uint64_t u;
D d;
};
Here, the normal language rules would say that U
is trivially relocatable, but it's impossible to do the vptr fixup in this case — since you don't know if the U
in question is holding a uint64_t
(so fixup must not be performed) or a D
(so fixup must be performed).
That's why the ultimate rule in P2786R13 is:
A class is eligible for trivial relocation unless it [...] except that it is implementation-defined whether an otherwise-eligible union having one or more subobjects of polymorphic class type is eligible for trivial relocation.
For many implementations, U
is still trivially relocatable. On Apple hardware, it may not be. Either way, that shouldn't affect any program correctness — just what specific instructions get invoked to do relocation.
(Author of P1144 here.)
Why were polymorphic objects not trivially relocatable in the first place?
P1144 was (and still is, until the National Body Comment period of C++26 ends!) concerned mainly with formalizing existing practice and promoting performance and correctness. These aren't exactly "principles" such that I'll be able to trace each design decision back to exactly one of them, but I think that's useful background to keep in mind as we go.
As Barry said in his answer, a polymorphic object is basically the syntactic-sugar version of the following kind of class. Barry left out the special member functions, though, and the special member functions end up being very important in P1144-world!
struct __vtable {
void (*dtor)(B*);
int (*f)(B*);
};
struct __vtable __vtb = {
.dtor=[](B* p) { ~~~~ },
.f=[](B* p) { ~~~~ }
};
struct __vtable __vtd = {
.dtor=[](B* p) { ~~~~ },
.f=[](B* p) { ~~~~ }
};
struct B {
__vtable* v;
int i;
B() : v(__vtb), i(42) {}
B(const B& b) : v(__vtb), i(b.i) {}
B& operator=(const B& b) { i = b.i; return *this; }
~B() { v->dtor(this); }
int f() { return v->f(this); }
};
struct D : B {
D() : B() { v = __vtd; }
D(const D& d) : B(d) { v = __vtd; }
D& operator=(const D& d) { i = d.i; return *this; }
};
Notice that when we copy (or move) a B
into another B
, we don't just take the v
and i
members directly over. We take the i
over, but we must write a new value into v
. This is because of "slicing." And when we copy- (or move-) assign a B
into another B
, we don't just take the v
and i
members directly over; we take the i
over, but leave the v
member untouched.
None of the special member functions of B
can be defaulted. Of course they can be defaulted if we use C++'s core-language support (virtual
, inheritance, etc.), but if we were just implementing B
on our own, we couldn't just "copy over all the members"; that would be wrong.
In P1144-world, we say that a type is trivially relocatable if-and-only-if its relocation operation is tantamount to a bytewise memory copy. This is indispensable for real-world code, because real-world code is going to want to use is_trivially_relocatable
to gate optimizations involving memcpy, realloc, compare-and-swap, and other such "bytewise" facilities.
Barry writes:
Recent ARM hardware (arm64e) permits something called vptr signing — where the vtable isn't just a pointer to bunch of function pointers (like I showed above) but rather also uses some bits of the pointer to point back to the object, for security reasons. This case cannot be memcpy-ied — because now the designation vtable will be invalid (it won't be properly signed).
This case is okay though, since the implementation knows what's going on and can cleanup the vtables after the fact.
This is true only for a "sufficiently smart (i.e. non-trivial) implementation." Obviously if you're gating your optimizations today on your::is_trivially_relocatable
, and using memcpy
today, then you really cannot tolerate your::is_trivially_relocatable
returning true for polymorphic types. See for example Google Abseil's internal documentation:
// absl::is_trivially_relocatable<T>
//
// Detects whether a type is known to be "trivially relocatable" -- meaning it
// can be relocated from one place to another as if by memcpy/memmove.
// This implies that its object representation doesn't depend on its address,
// and also none of its special member functions do anything strange.
Notice "its object representation doesn't depend on its address."
So, as it currently stands, Abseil will not be able to use C++26's std::is_trivially_relocatable
out of the box. They'll have to rewrite their trait from the-moral-equivalent-of
// if P1144 had been adopted
template <class T>
struct is_trivially_relocatable
: std::is_trivially_relocatable<T> {};
to:
// because P2786 was adopted
template <class T>
struct is_trivially_relocatable
: std::bool_constant<
std::is_trivially_relocatable<T> &&
std::is_replaceable<T> &&
!std::is_polymorphic<T>
> {};
Which is of course doable; there's no physical reason that Abseil, Folly, AMC, etc. shouldn't just update their definitions in this way. It's just a very stupid situation we're in, where the leaders of WG21 are forcing them to do this for... um... reasons.
There's a timeline in P3559R0 "One trait or two?" that shows the adoption of the one-trait approach across Folly, Abseil, AMC, et al., and of course there's the Clang patch that was never adopted despite 22 thumbs-up reactions from the authors of P3236 "Please reject P2786".
Reminder: The three sub-issues we need to overcome here are:
B
's move-constructor is non-trivial. Relocating into a B
needs to pull a pointer to __vtb
out of thin air; it can't just copy over the v
member from the right-hand object unless that right-hand object is dynamically a B
and not, say, a D
.
Even if the right-hand object is a B
and not a D
, we still can't just copy over the v
member because it might be address-dependent (arm64e "ptrauth" signing).
B
's move-assignment operator is non-trivial, perhaps even virtual. This means B
is not "replaceable," in P2786 terminology.
P2786 solves the third problem by splitting absl::is_trivially_relocatable
into two weaker traits: std::is_trivially_relocatable
and std::is_replaceable
. So they get to claim that B
is in fact std::is_trivially_relocatable
(by this redefinition of the term) — it's merely not std::is_replaceable
. This is on the one hand a perfectly legitimate refactoring, and on the other hand a lot like that joke attributed to Abraham Lincoln about how many legs a horse would have if you called a tail a leg.
P2786 solves the second problem by permitting std::trivially_relocate
to perform a non-trivial operation. It doesn't necessarily copy bytewise. It is permitted to do something else in order to make the "type information" come out right. P2786R13's exact wording is:
[
trivially_relocate
's postcondition is that the destination range] contains objects (including subobjects) whose lifetime has begun and whose object representations are the original object representations of the corresponding objects in [the source range] except for any parts of the object representations used by the implementation to represent type information.
That is, vptrs and vbptrs are allowed to take on new values (or at least new object representations) as a result of the operation.
P2786 solves the first problem by "library UB": Whereas in P1144-world it's fine to use std::relocate_at
from a D
object into a B
object (it simply Does The Right Thing), in P2786-world it is "library UB" to use std::trivially_relocate
in the same way. The Godbolt from the blog post demonstrates this "library UB": the call to trivially_relocate
will compile, because is_trivially_relocatable<B>
is true in P2786-world, but if you actually execute that call at runtime, you get garbage. (And it's your fault for holding it wrong.) P2786R13's exact wording is:
Preconditions: [...] No element in [the source range] is a potentially-overlapping subobject.
Now, my impression is that the first problem is self-caused in the first place by P2786's providing of this preconditionful std::trivially_relocate
function. If you go through the high-level library API proposed in P3516, my impression is that you'll get the "it Just Works" behavior anyway. Alternatively, if you adopt within your own codebase the absl::is_trivially_relocatable
semantics that rejects polymorphic types, I believe you won't have to think about this stuff at all.
string
is not a polymorphic type. (2) It's wrong: libc++ and MSVC both have SSOstring
s that are trivially relocatable by both P1144 and P2786's definitions. See "What types are trivially relocatable in practice?" (2019-02-20). – Quuxplusone Commented Mar 5 at 15:51