Should assignment affect is_trivially_relocatable
?
is_trivially_relocatable
?Consider the following piece of code that uses Bloomberg’s bsl::vector
:
#include <bsl_vector.h>
using namespace BloombergLP::bslmf;
struct T1 {
int i_;
T1(int i) : i_(i) {}
T1(const T1&) = default;
void operator=(const T1&) { puts("Assigned"); }
~T1() = default;
};
static_assert(!IsBitwiseMoveable<T1>::value);
int main() {
bsl::vector<T1> v = {1,2};
v.erase(v.begin());
}
bsl::vector::erase
uses T1::operator=
to shift element 2
leftward into
the position of element 1
(and then destroys the moved-from object in position 2
).
The program prints “Assigned.”
But that’s because type T1
is not trivially relocatable!
BSL calls the relevant trait
IsBitwiseMoveable
, notIsTriviallyRelocatable
; but that’s only because their codebase predates the C++11 meaning of “move.” Qt, a codebase of similar antiquity, upgraded their own terminology from “movable” to “relocatable” only in November 2020.I don’t give a Godbolt link here only because BSL isn’t available on Godbolt yet: #5933.
Let’s try the same thing with a trivially relocatable type T2
:
struct T2 : NestedTraitDeclaration<T2, IsBitwiseMoveable> {
int i_;
T2(int i) : i_(i) {}
T2(const T2&) = default;
void operator=(const T2&) { puts("Assigned"); }
~T2() = default;
};
static_assert(IsBitwiseMoveable<T2>::value);
int main() {
bsl::vector<T2> v = {1,2};
v.erase(v.begin());
}
Now BSL knows that elements of type T2
can be trivially (that is, bitwise)
relocated; so bsl::vector::erase
destroys element 1
and then uses memmove
to shift element 2
leftward into that vacant position.
The program prints nothing; T2::operator=
is never called.
Okay, what’s the moral of this story?
-
If you warrant a type as “trivially relocatable,” you must be prepared for library-writers not only to skip some construct/destroy pairs, but also to skip some assignment operations.
-
To put it from the library-writer’s point of view: Every trivially relocatable type has a “sane” assignment operator. Assigning a trivially relocatable type means no more or less than transferring its value. Library-writers can and do optimize based on this fact.
-
To put it from the P1144 compiler’s point of view: Suppose we have a type like
T1
that appears perfectly trivially relocatable except that it has a user-providedoperator=
. That user-provided assignment operator could do anything. We must consider the type non-trivially-relocatable. (Indeed,std::is_trivially_relocatable_v<T1>
is false, for the same reason thatbslmf::IsBitwiseMoveable<T1>
is false.)
Now, of course a type with a customized assignment operator can be explicitly warranted
as trivially relocatable; that’s exactly what we do in T2
(using BSL’s library-based opt-in).
Here’s the same example using Qt’s library syntax for the warrant (Godbolt):
#include <QList>
struct T2 {
int i_;
T2(int i) : i_(i) {}
T2(const T2&) = default;
void operator=(const T2&) { puts("Assigned"); }
~T2() = default;
};
Q_DECLARE_TYPEINFO(T2, Q_RELOCATABLE_TYPE);
static_assert(QTypeInfo<T2>::isRelocatable);
int main() {
QList<T2> v = {1,2,3};
v.erase(v.begin() + 1);
// does not print "Assigned"
}
And using P1144’s syntax for the warrant:
struct [[trivially_relocatable]] T2 {
int i_;
T2(int i) : i_(i) {}
T2(const T2&) = default;
void operator=(const T2&) { puts("Assigned"); }
~T2() = default;
};
static_assert(std::is_trivially_relocatable<T2>::value);
As of this writing, my libc++ fork follows BSL-and-Qt’s lead in optimizing vector::erase
for trivially relocatable types.
My newer libstdc++ fork does not, yet; but eventually it will. (Godbolt.)
“Wait, isn’t it technically non-conforming to use anything but assignment in vector::erase
, because
vector::erase
’s Complexity element specifically requires
the assignment operator of T
to be called a certain number of times?” — Well, yes, you’ve got me there,
for now. But we (STL) would really like it to be conforming, because we (BSL, Qt, Folly, …) already do it!
WG21 likes to say that C++ should “leave no room for a lower-level language”; it doesn’t really make sense
that a valuable optimization that every third-party library vendor already does should be forbidden
to std::vector
on a technicality.
My P3055R0 “Relax wording to permit relocation optimizations in the STL” (December 2023) aims to patch this hole. D3055R1 adds a few “stretch goal” patches on top of R0. Feedback welcome; send me an email!
Test yourself
-
As a type author: Suppose your type
Cat
has a defaulted copy constructor and defaulted destructor, but you rely on non-value-semantic side-effects ofCat::operator=
; your program will misbehave if an assignment operation is replaced with destroy-and-reconstruct. IsCat
“trivially relocatable”? -
As a type author: Your program’s correctness depends on the compiler’s never eliding assignments of
Cat
. Suppose you mark it with the attribute —struct [[trivially_relocatable]] Cat
— thus forcingis_trivially_relocatable_v<Cat>
to yieldtrue
. Is this “lying to the compiler”? Will your program behave correctly after that change? -
As the compiler (or the human reader): Suppose you see a type
struct Dog
(not marked with the attribute). All of its data members are trivially relocatable. Its destructor and move-constructor are defaulted. But its move-assignment operator is user-provided; you can’t tell exactly what it does. As the compiler (or the human reader), is it safe to assume thatDog
is trivially relocatable?
(No; yes; no; no.)