Logo

Blog


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
class CIString
{
  std::string s;

  public:
  friend bool operator==(const CIString& a, const CIString& b)
  {
    return ci_compare(a.s.c_str(), b.s.c_str()) != 0;
  }
  friend bool operator<(const CIString& a, const CIString& b)
  {
    return ci_compare(a.s.c_str(), b.s.c_str()) < 0;
  }
  friend bool operator!=(const CIString& a, const CIString& b)
  {
    return !(a == b);
  }
  friend bool operator>(const CIString& a, const CIString& b) { return b < a; }
  friend bool operator>=(const CIString& a, const CIString& b)
  {
    return !(a < b);
  }
  friend bool operator<=(const CIString& a, const CIString& b)
  {
    return !(b < a);
  }

  friend bool operator==(const CIString& a, const char* b)
  {
    return ci_compare(a.s.c_str(), b) != 0;
  }
  friend bool operator<(const CIString& a, const char* b)
  {
    return ci_compare(a.s.c_str(), b) < 0;
  }
  friend bool operator!=(const CIString& a, const char* b) { return !(a == b); }
  friend bool operator>(const CIString& a, const char* b) { return b < a; }
  friend bool operator>=(const CIString& a, const char* b) { return !(a < b); }
  friend bool operator<=(const CIString& a, const char* b) { return !(b < a); }

  friend bool operator==(const char* a, const CIString& b)
  {
    return ci_compare(a, b.s.c_str()) != 0;
  }
  friend bool operator<(const char* a, const CIString& b)
  {
    return ci_compare(a, b.s.c_str()) < 0;
  }
  friend bool operator!=(const char* a, const CIString& b) { return !(a == b); }
  friend bool operator>(const char* a, const CIString& b) { return b < a; }
  friend bool operator>=(const char* a, const CIString& b) { return !(a < b); }
  friend bool operator<=(const char* a, const CIString& b) { return !(b < a); }
};

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
class CIString
{
  std::string s;

  public:
  auto operator<=>(const CIString& b) const  1  spaceship implementation
  {
    return ci_compare(s.c_str(), b.s.c_str());
  }
  auto operator<=>(const char* b) const { return ci_compare(s.c_str(), b); }

  bool
  operator==(const CIString& b) const = default;  2  opt-in for == and !=

  bool operator==(const char* b) const { return *this <=> b == 0; }
};

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 == and !=. With C++20 we can default both operators with = default 2. In case, we have a defaulted operator<=> the == and != 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
CIString    a{}, b{};
const char* ac = nullptr;

if(a == b) {}
if(a != b) {}
if(a > b) {}
if(a < b) {}
if(a >= b) {}
if(a <= b) {}
if(a == ac) {}
if(a != ac) {}
if(ac == a) {}
if(ac != a) {}

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
if(a.operator==(b)) {}
if(!a.operator==(b)) {} 1 Compiler rewrites the comparison

if(std::operator>(a.operator<=>(b), 0)) {} 3 
if(std::operator<(a.operator<=>(b), 0)) {}
if(std::operator>=(a.operator<=>(b), 0)) {}
if(std::operator<=(a.operator<=>(b), 0)) {}

if(a.operator==(ac)) {}
if(!a.operator==(ac)) {}

if(a.operator==(ac)) {} 2 Compiler rewrites the comparison
if(!a.operator==(ac)) {}

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 !a.operator==(ac).

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
template<typename>
class A
{
  public:
  bool operator==(int) { return false; } 1 

  friend bool operator==(int y, const A& x) { return true; }
};

int main()
{
  A<int>   a{};
  const bool res1 = (a == 4);
  const bool res2 = (4 == a);
}

Now we can play with C++ Insights by switching the Standard there. In C++17 mode for res1 and res2 we get:

1
2
const bool res1 = (a.operator==(4));
const bool res2 = (operator==(4, a));

But, if we switch to C++20 we get this:

1
2
const bool res1 = (a.operator==(4));
const bool res2 = (a.operator==(4));

Do you see the difference? Here the comparison-rewrite kicks in again and res1 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
struct A
{
  bool operator==(class B&) const { return true; }  1 
};

struct B
{
  bool operator==(const A&) const { return false; }  2 
};

int main()
{
  A a{};
  B b{};

  const bool res1 = a == b;
  const bool res2 = b == a;
}

The two classes A and B each have an operator==. From the comparison of a == b and b == a we expect the resulting code to be like this:

1
2
const bool res1 = a.operator==(b);
const bool res2 = b.operator==(a);

Which is true in C++17. In C++20 the result is this:

1
2
const bool res1 = a.operator==(b);
const bool res2 = a.operator==(b);

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:

1
2
const bool res1 = static_cast<const A>(a).operator==(b);
const bool res2 = static_cast<const B>(b).operator==(static_cast<const A>(a));

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 (a.operator@(b) becomes b.operator@(a))
  • we get the comparisons >, <, >=, <=, ==, != when opt-in for operator<=>(...) =default
  • we get the comparisons >, <, >=, <= when implement the spaceship-operator
  • we get == and != when opt-in for either of those, e.g. operator==(...) =default

Summary

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 operator==.

Andreas


  1. 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.