C++20: Inside of a spaceship
With C++20 writing classes with comparison operators becomes much easier. Herb Sutter’s example from P0515 is
CIString which is a case-insensitive string class wrapper. It needs to provide all the functions (operators) to compare two
CIStrings and a
CIString with a
const char* and vice versa. Pre C++20 this requires 18 functions. With only 6 of them doing actual work by calling a compare function. All the others just forward to one of these 6. This is a somewhat optimized example to show how large such comparisons can grow. In case, we limit the comparisons only to
CIString then we would reduce the amount of functions to 12. However, for each type we want to compare
CIString with the number increases by 6.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53
This is a lot of code, with a lot of copy and past potential in it. Using the spaceship operator or consistent comparisons, however you like to call it, the code can be reduced by a lot:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
We are down to 2 functions plus the general
ci_compare function. As you can see, with a custom
<=> 1 we need to opt-in for
!=. With C++20 we can default both operators with
= default 2. In case, we have a defaulted
!= is provided by the compiler automatically.
Because of C++20s rewrite rules for comparisons, it is enough if we default
operator==. The same is true for the
const char* version. With just this 4 functions we get all the 18 comparison operations we had to spell out before. I very much like letting the compiler do the actual work and keep my code as minimalistic and simple as possible (of course, simplicity is in the beholder's eye).
Inside the spaceship
Now that we have this nice class which uses the spaceship-operator, let's peak behind it and have a look at how it works. Here are some comparisons, as an example:
1 2 3 4 5 6 7 8 9 10 11 12 13
With the help of C++ Insights we can get a glimpse of what is going on.
1 2 3 4 5 6 7 8 9 10 11 12 13
For the first two comparisons the operator of the class is invoked as usual. However, notice that we only defaulted
operator==. The compiler is smart enough to rewrite
a != b into
!a.operator==(b) 1. Isn't that outstanding? This is C++20 where the compiler may rewrite our comparisons for improved consistency. This has nothing to do with spaceship itself. It does also take effect, if there is no spaceship operator in the class or involved at all. You can see it as an independent feature which takes the burden of us to write an equal and unequal operator.
We can see the same pattern for the last two comparisons with
const char*. There the compiler does also swap arguments 2, as the original comparison was
ac != a and the compiler transforms it into
Now, between those comparisons we have others and some of them, as you can see, call for
std::operator and some comparison. Further you can see, that inside this call the compiler invokes
a.operator<=>(b), 0) 3 which is a direct call to the spaceship-operator of our
CIString class. This
std::operator> takes two arguments, first the result of the spaceship-operator and second an integer to which the result is then compared. All this comes with the new header
comparison and a C++20 able compiler.
Equality and inequality expressions can now find reversed and rewritten candidates
As great as the automatic rewrites of the compiler are, there are some (corner) cases in which they can bite us. This can be especially challenging when switching from pre C++20 to C++20, with the idea in mind to just change the compiler flag.
Have a look at this abbreviated example. It follows the pattern where we have a class-template with a
friend-operator inside for the equal comparison to make not just
q == 3 work but also the swapped version
3 == q:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
Now we can play with C++ Insights by switching the Standard there. In C++17 mode for
res2 we get:
But, if we switch to C++20 we get this:
Do you see the difference? Here the comparison-rewrite kicks in again and
res2 both call
a.operator==. Please notice, that 1 misses the
const qualifier. With this present, the used methods would be the same for all versions of C++.
The positive part is, that we do no longer need to write the friend in
A, which was not that easy to teach in the past. We can conclude, that in C++20 not only we need to write less code for such a comparison, the resulting code in the binary can also less, as the same function is used for
a == 4 and
4 == a. However, before it was most likely inlined.
In probably a lot of cases, this is exactly the behaviour we want. The behavior itself is also documented in the soon to be C++20 Standard N4849 in [diff.cpp17.over].
When questionable code causes trouble
Consider this example:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
The two classes
B each have an
operator==. From the comparison of
a == b and
b == a we expect the resulting code to be like this:
Which is true in C++17. In C++20 the result is this:
The reason for this is, that the comparison operator of
A 1 is a better match.
B asks for a
const-object 2, which would require the compiler to cast this non-
const object to a
const one. You can make that visible in C++ Insights by passing the option
--show-all-implicit-casts or enable it in the web-UI:
That cast itself in this case is absolutely no big deal. Because of the recent rewrite rules in C++20, it matters. When it comes to the best matching overload selection, this tiny modification from a
const to a non-
const object make the operator of
A the better match.
Needless to say, that the code was broken1 in the first place. As soon as someone would invoke it with an
const-object it will no longer compile. Another name for spaceship in C++20 is consistent comparisons. This term seems to apply here big time.
The C++20 rules for comparisons
With C++20 the rules are, that
- operators will be rewritten, if there is not better candidate (
- we get the comparisons
!=when opt-in for
- we get the comparisons
<=when implement the spaceship-operator
- we get
!=when opt-in for either of those, e.g.
If your code is correct in the first place, this also means
const-correct, then there is most likely no difference between the new and former Standards. In case, you like to prune your C++20 code-base, you can remove all
operator!=, if what they do is the opposite of
Here by broken I mean, that there should be no difference between the
const-ness of the two comparison operators. Either both have them or none. This brokenness becomes visible as soon as they are used with a
const-object. But on the other hand, as long as this is not the case, the code works. ↩