Should the compiler sometimes reject a [[trivially_relocatable]]
warrant?
[[trivially_relocatable]]
warrant?This is the third in a series of at least three weekly blog posts. Each post
(I,
II,
III,
IV)
will explain
one of the problems facing P1144 “std::is_trivially_relocatable
”
and P2786R0 “Trivial relocatability options” as
we try to (1) resolve their technical differences and (2) convince the rest of the C++ Committee
that these resolutions are actually okay to ship.
Today’s topic is under what circumstances the compiler should “overrule” a [[trivially_relocatable]]
warrant
applied by the programmer.
What is a warrant?
The feature defined in P1144 (and P2786R0) has two sides: There’s the query or type-trait side, which lets the
programmer of a library function query whether a template parameter type T
is trivially relocatable or not;
and there’s the warrant or attribute side, which lets the programmer of a library type warrant that a specific
type (under their control) is in fact trivially relocatable, in cases where the compiler can’t figure that out on its own.
The closest existing analogue to P1144’s system is C++11 noexcept
. It has both a query side (you can ask
noexcept(T::f())
for an arbitrary expression T::f()
) and a warrant side (you can declare your own A::f() noexcept
).
Notice that the compiler treats the noexcept
warrant with the utmost respect: you can write
void f() noexcept { throw 42; }
Despite the “obvious” contradiction, the compiler will accept this code and report that noexcept(f()) == true
.
Also, the compiler can infer the correct warrant in simple cases: A(const A&) = default;
will be recognized by the
compiler as noexcept, without the programmer’s needing to use the keyword. Explicit manual warrants are only rarely
needed.
For comparison, the existing notion of “trivial copyability” has a query side (you can ask whether is_trivially_copyable_v<T>
),
but it has no warrant side: If the compiler doesn’t infer that your type is trivially copyable, then there’s no
way for you to convince it otherwise.
The Itanium ABI’s notion of “trivial for purposes of ABI” has a warrant side (you can mark your type
[[clang::trivial_abi]]
), but no query side (as far as I know,
there is no way to ask Clang whether an arbitrary type T
is trivial for purposes of ABI or not).
In C++ pre-’20, iterator types also followed this model: you could query the category of an iterator with
iterator_traits<T>::iterator_category
, and you could warrant the category of your own iterator by setting
T::iterator_category
appropriately. The STL would generally respect your warrant: if it claimed
bidirectional_iterator_tag
, you’d get the bidirectional versions of STL algorithms, even if your
type didn’t satisfy the requirements of a bidirectional iterator in detail.
In C++20, the Ranges STL distrusts user-provided warrants by default: a type that explicitly warrants
bidirectional_iterator_tag
can be sent downforward_iterator
codepaths if it’s not careful. Godbolt.
A few lucky features are purely syntactic. Consider three-way comparability. You can query a type’s
comparability with std::three_way_comparable<T>
, and you can make your own type three-way comparable by
giving it an operator<=>
. I wouldn’t call that a warrant, though, because it doesn’t stand for anything
beyond itself. There’s no (sensible) way for a type to provide <=>
and not actually be comparable, in the
way that a function can call itself noexcept
but actually throw, or call itself bidirectional_iterator_tag
but actually fail to support bidirectional operations.
Here’s an example of a type that calls itself [[trivially_relocatable]]
but is not actually trivially relocatable:
struct [[trivially_relocatable]] BadVector {
std::array<int, 10> data_;
const int *end_ = data_.data();
BadVector(BadVector&& rhs) : data_(rhs.data_), end_(data_.data() + rhs.size()) {}
void operator=(BadVector&& rhs) { data_ = rhs.data_; end_ += rhs.size() - size(); }
int size() const { return end_ - data_.data(); }
};
Both P1144 and P2786 permit BadVector
to compile, simply because there’s no way to forbid it.
This code is well-formed as long as you (and any libraries you call) never attempt to relocate BadVector
;
if you do, the behavior is simply undefined. (P2786R0 §9.2:
“If a user explicitly (and erroneously) marks as trivially relocatable a class with an invariant that
stores a pointer into an internal structure, then relocation will typically result in UB.”
P1144R6 §5.5:
“If a class type is declared with the trivially_relocatable
attribute, and the program relies on observable
side-effects of [its] relocation other than a copy of the underlying bytes, the behavior is undefined.”)
Reading this post will be easier if you keep in mind the distinction between “a type that is trivially relocatable” and “a type that the compiler knows is trivially relocatable.” P1144 exists because today the former set is vastly larger than the latter. But P1144 only shrinks that gap; it doesn’t close the gap entirely. By definition, P1144’s
std::is_trivially_relocatable_v
identifies only types that the compiler knows are trivially relocatable. It will always be possible to create a type that is (in principle) trivially relocatable without the compiler’s knowing that it is.The following
GoodVector
is (in principle) not only trivially relocatable but trivially copyable; yet the compiler doesn’t recognize it as either. (Godbolt.)struct GoodVector { std::array<int, 10> data_; boost::interprocess::offset_ptr<const int> end_ = data_.data(); GoodVector(GoodVector&& rhs) : data_(rhs.data_), end_(data_.data() + rhs.size()) {} void operator=(GoodVector&& rhs) { data_ = rhs.data_; end_ += rhs.size() - size(); } int size() const { return end_ - data_.data(); } };
What are warrants for, in practice?
Recall that simple Rule-of-Zero class types don’t need warrants; the compiler can detect whether a Rule-of-Zero type is trivially relocatable just like it can detect whether it’s trivially copyable. So 99% of your types won’t need (and therefore shouldn’t use) explicit warrants. Here’s an example of a type that doesn’t need any warrant.
struct UniqueFunc {
std::unique_ptr<Base> ptr_;
template<class T> UniqueFunc(T t) : ptr_(std::make_unique<Derived<T>>(std::move(t))) {}
void operator()() const { ptr_->call(); }
};
static_assert(std::is_trivially_relocatable_v<UniqueFunc>);
P1144 calls such types naturally trivially relocatable; P2786R0 §7.1 calls them implicitly trivially relocatable.
There are only two times I can think of when you do need a warrant. One is when your type isn’t Rule-of-Zero, but you know (you warrant) that it is in fact trivially relocatable. For example:
struct [[trivially_relocatable]] UniquePtr {
int *p_;
explicit UniquePtr(int *p) : p_(p) {}
UniquePtr(UniquePtr&& p) : p_(p.p_) { p.p_ = nullptr; }
void operator=(UniquePtr&& p) { p_ = p.p_; p.p_ = nullptr; }
~UniquePtr() { delete p_; }
};
struct [[trivially_relocatable]] GoodVector {
// as above
};
The other time is when your type is Rule-of-Zero, but the compiler can’t tell that it’s trivially relocatable because one of its members is of a third-party type that you — but not the compiler — know is trivially relocatable. For example:
struct [[trivially_relocatable]] BoostedFunc {
boost::movelib::unique_ptr<Base> ptr_;
template<class T> BoostedFunc(T t) : ptr_(new Derived<T>(std::move(t))) {}
void operator()() const { ptr_->call(); }
};
As humans, we know that boost::movelib::unique_ptr<Base>
can be trivially relocated;
but the compiler doesn’t know it, because the author of boost::movelib
didn’t mark it
with the attribute. (Unsurprising, since the attribute doesn’t exist yet. :) This will
be one of warrants’ major use-cases for years, if not decades, after the proposal is adopted.)
The “purist” approach is to leave your BoostedFunc
warrantless;
sacrifice the performance benefit in the short term; and submit a patch to the authors of
boost::movelib
to add warrants upstream. Then, sometime after upstream accepts your patch,
you can upgrade your third-party code and finally get that performance improvement you’ve been waiting for.
But that’s a complicated plan that doesn’t appeal to the average working programmer, compared
to “add the warrant to BoostedFunc
, maybe file a feature request upstream, and move on.”
What types are definitely never trivially relocatable?
Are there certain kinds of types where, even if a warrant is applied, the compiler should step in and say “Whoa, you can’t possibly mean that!”?
Certain kinds of types, such as types with vtables, mustn’t be considered naturally trivially relocatable. But if the programmer puts an explicit warrant on the type, is there ever a time when the compiler should overrule their decision?
Non-movable, non-destructible types?
P1144R6 (but not P2786R0, and not P1144R7) would reject a [[trivially_relocatable]]
warrant
applied to types:
-
that aren’t move-constructible at all, or
-
that aren’t destructible at all.
Since P1144 defines relocation as “moving from A to B, then destroying A,” it seemed obvious that a non-movable non-destructible type can’t be relocatable (at all, let alone trivially so).
However, P2786 points out that “trivially copyable” isn’t a kind of “copyable”: the following
NC
is trivially copyable without being either copy-constructible or destructible.
(Godbolt.)
struct NC {
int i;
NC();
NC(const NC&) = delete;
NC& operator=(const NC&) = delete;
~NC() = delete;
};
static_assert(std::is_trivially_copyable_v<NC>);
So why should “trivially relocatable” be a kind of “relocatable”?
I’m still leery of this idea, possibly because “relocate” is such a new verb.
If we can have types that are “trivially relocatable” without actually being relocatable,
then what does that imply for library facilities like std::vector
?
struct [[trivially_relocatable]] NR {
explicit NR(int);
NR(NR&&) = delete;
NR& operator=(NR&&) = delete;
};
std::vector<NR> v;
v.emplace_back(42); // OK?
v.reserve(v.capacity() + 1); // OK?
emplace_back
and reserve
will in practice use trivial relocation for types that are
trivially relocatable; does that mean that vendors will in practice support the calls
above, even though they’re not blessed by the Standard? Or, worse, will adopting this
notion of trivial relocatability lead to the Standard changing its specification of vector
to force vendors to support these “relocate-only” use-cases? That sounds like work,
and non-backward-compatible feature work at that, which is antithetical to P1144’s whole
direction.
We can look to std::copy
for precedent. As we discussed last week,
copy
will use memmove
for trivially copyable types. But it won’t use memmove
if you try to std::copy
from one array of NR
to another — it’ll simply fail to compile.
This suggests that it is reasonable for P1144R7 to permit non-movable non-destructible types to be
is_trivially_relocatable
— as long as the library users of that trait are carefully written
never to assume that is_trivially_relocatable<T>
implies is_move_constructible<T>
, just like
they’re carefully written today never to assume that is_trivially_copyable<T>
implies
is_copy_assignable<T>
.
In particular, P1144’s library algorithms std::relocate
and std::relocate_at
aren’t
intended to magically work for non-move-constructible types. They don’t need to SFINAE away,
but they aren’t required to memmove
things that can’t be move-constructed normally.
Types with non-trivially-relocatable data members?
Look again at our GoodVector
and BadVector
examples from above.
struct GoodVector {
std::array<int, 10> data_;
boost::interprocess::offset_ptr<const int> end_ = data_.data();
GoodVector(GoodVector&& rhs) : data_(rhs.data_), end_(data_.data() + rhs.size()) {}
void operator=(GoodVector&& rhs) { data_ = rhs.data_; end_ += rhs.size() - size(); }
int size() const { return end_ - data_.data(); }
};
struct BadVector {
std::array<int, 10> data_;
const int *end_ = data_.data();
BadVector(BadVector&& rhs) : data_(rhs.data_), end_(data_.data() + rhs.size()) {}
void operator=(BadVector&& rhs) { data_ = rhs.data_; end_ += rhs.size() - size(); }
int size() const { return end_ - data_.data(); }
};
GoodVector
is a trivially relocatable type (in the practical, Platonic sense); BadVector
is not.
But if you were to crack them open and look inside —
-
boost::interprocess::offset_ptr<const int>
itself is not trivially relocatable, even thoughGoodVector
is -
const int*
itself is trivially relocatable, even thoughBadVector
is not
This is why both P1144 and P2786 place the [[trivially_relocatable]]
annotation on the class type
as a whole, rather than framing it as a property of one member (e.g. the move constructor) in isolation.
Trivial relocatability is a holistic property of the whole type taken together.
Now, P2786R0 §7.6
proposes that if the programmer attaches the [[trivially_relocatable]]
annotation to a class
type with any non-trivially-relocatable data member, the compiler should reject that annotation — either
noisily (e.g. by emitting an error) or quietly (by quietly ignoring the attribute). This would affect
types such as our GoodVector
(because interprocess::offset_ptr
isn’t t.r.) and BoostedFunc
(because movelib::unique_ptr
, while t.r. in theory, is not recognized as such by the compiler).
P1144 of course doesn’t propose this wrinkle.
Very early in P1144’s history, when I implemented the first Clang patch, there was some discussion
on the review of precisely P2786R0’s design; in fact, I implemented
that design in my fork under the name [[clang::maybe_trivially_relocatable]]
. The semantics of
[[maybe_trivially_relocatable]]
are essentially the same as [[trivially_relocatable(bool)]]
:
The code
template<class T>
struct [[maybe_trivially_relocatable]] X {
T t;
};
is exactly equivalent to P1144’s
template<class T>
struct [[trivially_relocatable(std::is_trivially_relocatable_v<T>)]] X {
T t;
};
But [[maybe_trivially_relocatable]]
by itself is a “dull knife” —
it’s strictly less useful than the “sharp knife” of [[trivially_relocatable]]
. Specifically, without a “sharp” version of
[[trivially_relocatable]]
there’s no way to implement either GoodVector
or BoostedFunc
.
And besides being less practically useful, a dull knife is also dangerous:
The main reason why a dull knife is more dangerous is that it requires the wielder to use significantly more force when cutting than a sharp knife.
[…]
The safety of a knife really depends on the person using it and not the blade. But if you’re trying to minimize injury, remember that proper technique is important and a sharp knife is a safe knife.
Consider each of our FooVector
classes again:
struct SimpleVector {
std::array<int, 10> data_;
int size_ = 0;
SimpleVector(SimpleVector&&);
};
struct BadVector {
std::array<int, 10> data_;
const int *end_ = data_.data();
BadVector(BadVector&&);
};
struct GoodVector {
std::array<int, 10> data_;
boost::interprocess::offset_ptr<const int> end_ = data_.data();
GoodVector(GoodVector&&);
};
As humans, we can see that SimpleVector
and GoodVector
can be trivially relocated, and BadVector
cannot.
So we expect that the Right Thing will happen if we mark SimpleVector
and GoodVector
with [[trivially_relocatable]]
,
and don’t mark BadVector
. (We are correct!) This follows the basic etiquette for knife (and gun, and tool) use:
Place the blade on the things you intend to cut. Don’t place it on the things you don’t intend to cut.
(For “blade,” read “warrant”; for “cut,” read “advertise as trivially relocatable.”)
If the “dull knife” version, [[maybe_trivially_relocatable]]
, is available to us, then we have
two ways of going wrong:
-
Mark
SimpleVector
andGoodVector
with[[maybe_trivially_relocatable]]
.SimpleVector
does the Right Thing.GoodVector
remains non-trivially relocatable, because itsend_
member (in isolation) is not trivially relocatable. P2786R0 proposes to give us a hard error onGoodVector
— cf.struct Error
in §7.6.1. -
Mark
BadVector
, also, as[[maybe_trivially_relocatable]]
. After all, the compiler clearly has strongly held opinions about what makes a type non-trivially relocatable — let’s trust the compiler’s judgment! This attitude is good in general, but is awfully dangerous when it comes to[[maybe_trivially_relocatable]]
in particular. The compiler doesn’t detect anything wrong withBadVector
’s marking, and we get UB at runtime — exactly as we would have if we had applied the “sharp knife” of[[trivially_relocatable]]
toBadVector
. But the sharp knife commands respect: we know not to apply it unless we intend to cut.
Here’s a table of all the examples from this blog post, in one place. (Scroll up to find the definitions of each, or see this Godbolt.)
Don't mark | Mark (P1144) | Mark (P2786R0) | |
BadVector | OK | UB | UB |
BoostedFunc | OK | ✓ | Error |
GoodVector | OK | ✓ | Error |
SimpleVector | OK | ✓ | ✓ |
UniqueFunc | ✓ | ✓ | ✓ |
UniquePtr | OK | ✓ | ✓ |