Comparison categories for narrow-contract comparators
WG21’s Library Working Group is studying the wording for Marshall Clow’s P0805R2 “Comparing Containers.” The basic idea is that since C++ permits us to write
float f = 3.14;
int i = 42;
assert(f < i);
then C++2a should permit us to write
std::vector<float> vf = { 3.14 };
std::vector<int> vi = { 42 };
assert(vf < vi);
Notice that P0805 does not interact at all with the brand-new C++2a feature of “operator spaceship,”
<=>
. (And this limited scope is good, IMHO.) But if, sometime before the C++2a ship date, we want
to explore those ramifications… well, C++2a permits us to write
float f = 3.14;
float g = 3.15;
static_assert(std::is_same_v<decltype(f <=> g),
std::partial_ordering>);
and so C++2a should also permit us to write
std::vector<float> vf = { 3.14 };
std::vector<float> vg = { 3.15 };
static_assert(std::is_same_v<decltype(vf <=> vg),
std::partial_ordering>);
But to do this (and notice that this is only the homogeneous case!) we’d have to relax
the constraints on
vector
’s operator<
and permit it to be instantiated with types whose operator<
does not provide a “total ordering relationship.”
That is, technically right now vf < vg
has undefined behavior; but it doesn’t matter because it
does the right thing in most cases (and in those cases where it doesn’t do the right thing — i.e.,
in cases involving NaN — the programmer simply doesn’t try to use it).
But in the operator-spaceship world of C++2a, this undefined behavior is suddenly brought to the
surface. We now have a standard way to detect whether a type’s operator<
provides a total
ordering relationship: we just examine decltype(declval<T>() <=> declval<T>())
and compare
it to std::strong_ordering
. So theoretically vector<float>
could now static_assert
, if it
wanted to. (Of course vendors won’t really do that, but they could.)
How does this relate to the Lakos Rule?
Before C++11, we had no way to warrant to the compiler that a given function never threw exceptions. In C++11, we gained that ability. We can mark
int alpha(int i) noexcept(true) {
return i;
}
int beta(int i) noexcept(false) {
return i ? 1/i : throw "oops";
}
But what do we do with
int gamma(int i) noexcept(????) {
return 1/i;
}
It never throws, per se, but it does sometimes have undefined behavior.
The Lakos Rule tells us that this kind of narrow-contract function
should not be marked noexcept (that is, it should be noexcept(false)
).
Stepanov’s '’Fundamentals of Generic Programming’’ (1998) raises in my mind the possibility of a similar rule for comparators. Consider:
std::strong_ordering one(int i, int j)
{
// Comparing numbers with different signs is perfectly fine.
return i <=> j;
}
std::partial_ordering two(int i, int j)
{
// Comparing numbers with different signs is
// well-defined, and yields "unordered."
if ((i < 0) != (j < 0))
return std::partial_ordering::unordered;
return i <=> j;
}
auto three(int i, int j) -> ????
{
// Comparing numbers with different signs is a throwing offense.
if ((i < 0) != (j < 0))
throw "oops";
return i <=> j;
}
auto four(int i, int j) -> ????
{
// Comparing numbers with different signs is undefined behavior.
if ((i < 0) != (j < 0))
return (1/0) <=> 0;
return i <=> j;
}
Somewhere between three
and four
, a “Lakos-ish” rule for comparison categories
would suggest that maybe this kind of comparator should no longer be considered
a “total order” from the point of view of the compiler (or the programmer, for that
matter). Should we mark four
as returning std::strong_ordering
, or would it in
some sense be more honest to mark it as std::partial_ordering
?
“Of course not! It never returns unordered
, so its range of possible return values
is exactly the range of std::strong_ordering
! Claiming it returns partial_ordering
is basically lying to the reader.”
Sure, agreed. But then, function gamma
above never throws an exception! Claiming that it
might throw an exception is basically lying to the reader.
“But gamma
might check its preconditions and throw in the UB case!”
But four
might check its preconditions and return unordered
in the UB case!
Is this exercise purely academic? No. Consider:
auto five(int *p, int *q) -> ????
{
// Comparing unrelated pointers yields an unspecified result.
return p <=> q;
}
auto six(std::deque<int>::iterator p, std::deque<int>::iterator q) -> ????
{
// Comparing iterators into different deques has undefined behavior.
return p < q ? std::strong_ordering::less :
p > q ? std::strong_ordering::greater :
std::strong_ordering::equal;
}
(Source for “unspecified result.”
Source for “undefined behavior” —
but notice that the table claims “<
is a total ordering relation” even as an earlier row
asserts that not all values are comparable via <
.)
C++2a has decided that five
should yield std::strong_ordering
— comparison of pointers
is considered “strong” even though it is not technically a total order (many pointer values
are not ordered, or not comparable, with others).
C++2a has not yet had to deal with six
, because no standard library types yet support
operator<=>
(except for std::vector<T>::iterator
on implementations where that’s a typedef
for T*
).
Personally, I find the Lakos Rule for noexcept
easy to explain
but annoying in practice;
I would like to see noexcept
used more liberally.
However, today I think I do want a “Lakos-ish Rule” for comparisons; I would like to see
strong_ordering
used more conservatively. Well, really I just want people to stop designing types without total orders!
But there’s clearly a reductio ad absurdum for “Lakos-ish Rules”:
int seven(int i) {
return 1 / i;
}
The absurdist might claim that int
is the wrong return type for function seven
, because it returns an int
only in the well-defined case. In the UB case, “it might return anything.” Clearly, then, any function with a narrow
contract should not only be marked noexcept(false)
, but also have std::any
as its return type!
And the counterargument reduces to absurdity my position on comparisons: the absurdist doesn’t literally want
everyone to write narrow-contract functions that return std::any
; really they just want people
to stop designing functions with narrow contracts!