P0732R0 and “trivially comparable”
UPDATE, 2019-02-04: The property which P0732R0 called “trivially comparable” was, in future revisions (and in the current C++2a Working Draft), given the new name “strong structural equality.” In other words, my bikeshedding of the name worked! Everywhere that this post refers to P0732R0 “trivial comparability,” you should read “strong structural equality,” which is not the same thing as trivial (as-if-by-memcmp) comparability in the intuitive sense.
Someone in SG14 asks, what do we all think about Jeff Snyder’s P0732R0 “Class Types in Non-Type Template Parameters”. Executive summary: I think it’s great.
First, the problem statement:
template<class T, T value>
int foo() {
return 42;
}
This function works great when called as foo<int, 7>()
or foo<char, 'A'>()
, but it
fails to compile
when invoked as foo<float, 3.14>()
or foo<std::string, "hello world">()
. Why is that?
Let’s look at how the compiler treats foo
in each case.
_Z3fooIiLi7EEiv:
movl $42, %eax
ret
_Z3fooIcLc65EEiv:
movl $42, %eax
ret
Notice that the name foo
is getting name-mangled
based on its template parameters. This is what ensures that foo<int, 7>
and foo<int, 8>
end up
with distinct names; and, contrariwise, ensures that foo<int, 7>
has the same name no matter which
translation unit we’re in. Notice that the name-mangling scheme for these primitive types is very
simple: we just name the type, and then express its value as a decimal integer. (If the value is
negative, we prefix the character n
instead of -
, since the latter isn’t a valid identifier
character.)
Notice also that no matter whether we write foo<int, 7>
or foo<int, 3+4>
, the compiler takes care
of making sure that the name is mangled into exactly the same, unique, unambiguous representation
of the value we were trying to express (namely, “seven”).
Converting a value into an unambiguous representation in a certain alphabet is closely related to serialization, marshalling, or what Python calls pickling. Different applications of serialization require different guarantees from the process. In the case of C++ template mangling, we require very stringent guarantees:
-
Obviously,
foo<6>
must be a different function fromfoo<7>
. We must havemangle(v1) != mangle(v2)
wheneverv1 != v2
. -
Vice versa,
foo<7>
andfoo<3+4>
must be the same function. We must havemangle(v1) == mangle(v2)
wheneverv1 == v2
.
Okay, fine, those weren’t very stringent. But they implicitly contain some interesting assumptions…
What does it mean to say “whenever v1 == v2
”? We’re talking about the behavior of operator==
there;
but we’re also implicitly talking about all of value semantics. For example, we are implicitly assuming
that v1 == v1
.
For which C++ types does v1
sometimes not equal v1
?
-
float
. For example,nan != nan
. -
String literals. For example, sometimes
"hello" != "hello"
.
So that’s why C++ has gone through so many revisions and still doesn’t let you write
f<float, 3.14>()
! Because what would we do with f<float, some_nan_value>
?
In fact, it gets worse. Are f<3.14f>
and f<3.13f + 0.01f>
the same entity, or not? Maybe it
would depend on your platform. Maybe it would depend on your current
rounding mode! And when we go to name-mangle the
thing, how sure are we that each compiler involved will output exactly, let’s say, _Z3fooIfLf3p14EEiv
,
and not _Z3fooIfLf3p1400000001EEiv
or _Z3fooIfLf3p139999984EEiv
? Floating-point is kind of a hot mess.
Floating-point template parameters would be too messy to live.
So what about P0732?
P0732 proposes that we permit template parameters of a user-defined type if and only if that type
is what is known as trivially comparable. This is kind of analogous to C++11’s notion of
trivially destructible (and so on). The type must have a defaulted comparison operator —
this is new in C++2a, thanks to Herb Sutter’s
P0515 “Consistent comparison” —
and that defaulted comparison operator must essentially be equivalent to memcmp
.
A type is trivially comparable if it is:
a scalar type for which the
<=>
operator returns eitherstd::strong_ordering
orstd::strong_equality
, ora non-union class type for which
- there is an
operator<=>
that is defined as defaulted within the definition of the class,operator<=>
returns eitherstd::strong_ordering
orstd::strong_equality
,- the type of each of its bases is trivially comparable, and
- the type of each of its non-static data members is either trivially comparable or an array of such a type (recursively).
Notice that according to P0515R3, all floating-point types have an operator<=>
that returns
std::partial_ordering
. So structs containing floating-point members will not be trivially comparable
(because memcmp
would do the wrong thing for them).
Now, one thing that P0732 probably gets wrong is that it forgets about padding bytes. So for example it would consider the following user-defined type to be trivially comparable:
struct Oops {
char ch;
// 3 padding bytes of indeterminate value
int i;
};
This is not a problem for name-mangling, because name-mangling always happens in a context where the
compiler can see the struct definition, enumerate the members one by one, and quietly skip over the
padding bytes (whose values will be indeterminate and thus must not contribute to the mangling).
However, the padding bytes do prevent us from using memcmp
at runtime to compare the values of
two struct Oops
es. The topic of one of my C++Now 2018 talks will be on ways to speed up
vector<T>::operator==
, std::hash<T>
, and so on, by doing the fast thing when
is_trivially_comparable<T>
can be detected at compile time. This approach would fall flat on its
face if we permitted is_trivially_comparable<Oops>
.
Now, maybe all we need is two distinct names — is_memberwise_comparable
for P0732’s purposes, and
is_memcmp_comparable
for vector::operator==
’s purposes. But it would be really really nice to
get the two notions unified into a single concept. Because is_trivially_copyable
already means “as if by memcpy
,” I think it would be nice for is_trivially_comparable
to mean
“as if by memcmp
.”
The problem with just saying that structs with padding aren’t trivially comparable, and otherwise
letting P0732 go through as written, is that it would prevent us from using types such as Oops
(or more realistically, std::pair<char, int>
) as template parameters! Worse, it would end up
being implementation-defined whether a certain type was usable as a template parameter or not;
and adding or removing or reordering the members of a struct might unexpectedly make it unusable.
Admittedly we have this problem already today, in that innocuous-looking changes could make a
structure non-literal or non-trivially-copyable; but I don’t like the idea of making it worse.
Final thoughts
I would prefer that P0732 do something about padding bytes, rather than pretend that they’re okay. I’m not sure what to do about them, though.
Finally, I am mildly concerned that we’re adding so many ways for the compiler to “guess” at the
triviality or “well-knownedness” of user-definable operations. For example, C++11 gave us
trivially copyable; but recently the Clang project has (wisely!) added a
new attribute [[trivial_abi]]
whose raison d’etre is for the programmer to force-override the compiler’s “wrong” guess at
trivial copyability — to say “I know this type looks non-trivial — and maybe it is — but trust me,
it’s okay to pass it in registers.” With P0732 in C++2a, might we soon need an attribute
[[trivially_comparable]]
in order to say, “I know I provided a non-defaulted operator==
,
but trust me, it’s okay to use this type as a template parameter”?