Couldn’t keep yourself away, eh? Or maybe I couldn’t keep myself away. In any case: I’ve prepared for you a classic tale from everyone’s favourite book: ISO/IEC 14882:2020. It’s a tragedy, of course. All of them are. Now, gather ’round…

∗  ∗  ∗

One of the flagship features that shipped with C++20 a few years ago was an overhaul to comparisons. Gone are the days of writing six different operators (twelve, if you want to reverse the operands) just to do basic ordering and equality comparisons. Now, you only have to implement two—operator== and operator<=> (the “three-way comparison operator,” or more lovingly the “spaceship operator”)—and the benevolent compiler will graciously give you the rest for free.

Basically, operator<=> is a combined operator that tells you whether a given object x is less than, greater than, or equal to another object y (or, in some cases, if they are incomparable). Its return type, for our purposes, will be std::weak_ordering, which has static members less, equivalent, and greater. There are other types that add guarantees or allow for a partial order, but we’ll stick with this for simplicity.

struct T {
    std::weak_ordering operator<=>(const T&) const;
    bool operator==(const T&) const;
};
T t, s;
// the below are all legal:
bool b1 = t == s; // t == s
bool b2 = t != s; // !(t == s)
bool b3 = t <  s; // (t <=> s) == std::weak_ordering::less
bool b4 = t >  s; // (t <=> s) == std::weak_ordering::greater
bool b5 = t <= s; // (t <=> s) == std::weak_ordering::less || (t <=> s) == std::weak_ordering:equivalent
bool b6 = t >= s; // (t <=> s) == std::weak_ordering::greater || (t <=> s) == std::weak_ordering::equivalent

Another complementing feature of this is that operator== and operator<=> support parameter reversal—for example, if you provide bool T::operator==(const S&), you can compare T{} == S{} or S{} == T{} for free. And, if you use the other operators generated from operator== and operator<=>, they’ll also be reversible.

Note that you could choose to implement operator== with a call to operator<=> but, for sufficiently interesting types, this will usually be inefficient and miss possible short-circuits. This is why operator<=> doesn’t give you == for free.

So, enterprising experts we are, we’re going to move all our code to C++20. All of it. Day one. I hear you—it’s a good idea! C++ loves backwards compatibility, after all. Here’s some of the code we’re going to move over:

struct T {
    bool operator==(const T&);
    bool operator!=(const T&);
};

bool b = T{} != T{};

Great stuff. While C++20 affords us the ability to nix our operator!= altogether as mentioned earlier, we’re going to leave things as-is for now since, hypothetically, we didn’t read up about this feature carefully enough beforehand. Here we go, guns blazing, in glorious -std=c++20:

warning: ISO C++20 considers use of overloaded operator '!=' (with operand types 'T' and 'T') to be ambiguous despite there being a unique best viable function with non-reversed arguments [-Wambiguous-reversed-operator]
    bool b = T{} != T{};
             ~~~ ^  ~~~
note: candidate function with non-reversed arguments
    bool operator!=(const T&) { return true; }
         ^
note: ambiguous candidate function with reversed arguments
    bool operator==(const T&) { return true; }
         ^
1 warning generated.

Uh-oh. So, what Clang is telling us is that this technically shouldn’t compile, but it’s for a stupid reason so they’ll compile it for us anyway. How sweet of them! Even sweeter, GCC will compile it without even warning us.

The problem is something like this: when considering an expression like x != y, there are three candidates on the table:

  1. operator!=, the member function we wrote;
  2. operator==, the member function we wrote; and,
  3. operator==, but with the order of its operands reversed.

The latter two are called “rewritten” candidates since they involve rewriting x != y as !(x == y) and !(y == x) respectively. While we won’t get into the gory details of overload resolution—only the morbid and disturbing ones—here are the parts we care about right now:

Definition 1

In overload resolution for an expression f(E1, ..., En), a candidate function FF is called viable if:

  • the number of arguments given “matches” the number of parameters to FF;
  • its constraints (i.e., C++20 concepts/constraints) are satisfied by the expression; and
  • for each provided argument, there is some implicit conversion sequence that converts it to the type of the corresponding parameter of FF.
Definition 2

Let FF and GG be two viable candidates, and let ICSi(F)\operatorname{ICS}_i(F) represent the (possibly trivial) sequence of implicit conversions that converts the ithi^{\rm th} argument to the type of the ithi^{\rm th} parameter of FF. We say FF is better than GG if, for each ii, ICSi(F)\operatorname{ICS}_i(F) is not worse than ICSi(G)\operatorname{ICS}_i(G), and:

  • There is some jj such that ICSj(F)\operatorname{ICS}_j(F) is a “better” conversion sequence than ICSj(G)\operatorname{ICS}_j(G); or, otherwise,
  • (A few other things that we won’t discuss); or, otherwise,
  • GG is a rewritten candidate and FF is not; or, otherwise,
  • FF and GG are both rewritten candidates, but GG is a synthesized candidate with its parameter order reversed while FF is not; or, otherwise,
  • (A few more things that we won’t discuss).

There’s the problem—our operator!= member candidate is better than the non-reversed rewritten operator== candidate, but we can’t say that the operator!= member candidate or reversed rewritten operator== candidate are “better” than each other because the conversion sequences in the parameters for either one aren’t all not worse than the conversion sequences for the other.

In particular, the operator!= member candidate can be thought of as taking an implicit first parameter of type T& (say, *this) and second parameter of type const T&; on the other hand, the reversed rewritten operator== candidate swaps these and takes first parameter of type const T& and second parameter of type T&. The first candidate is better than the second one in the first parameter—what we mean by “better” in this case is, according to [over.ics.rank]/3.2.6, if reference parameter types of two overloads differ only by a top-level const (e.g., const int& and int&), the argument should prefer being bound to the one that’s no more const-qualified than itself. So, the left-hand side of T{} != T{} will prefer the first candidate, but the right-hand side will prefer the second candidate. Overload resolution is at an impasse—no candidate has strictly better conversion sequences than the other, and so the compiler ought to declare ambiguity and bail out.

That’s a problem, isn’t it? Like, obviously we want to use the != that we wrote. Even the compiler agrees with me, telling us that ours is the “unique best viable function.” It’s my understanding that compilers don’t typically make value judgments on the standard.

Well, as usual, I was being a touch dishonest. The (partial) overload resolution mechanism above is only correct per the initial release of C++20, or N4860. This caused a bit of a ruckus and, in the end, some folks on the C++ committee decided to address this retroactively in proposal P2468R2. The net effect of it is that, if a user-provided operator!= is present, the rewritten candidates for operator== won’t be considered for !=, period. So, with a modern compiler, the earlier snippet should compile on C++20. Fantastic! All’s well with the world again.

No—that’s not right. There’s a wrinkle. Let’s scale back that example even further:

struct T {
    bool operator==(const T&);
};

bool b = T{} == T{};

On a modern compiler, this will net us the following under C++20 (and, indeed, under C++23 as well):

warning: ISO C++20 considers use of overloaded operator '==' (with operand types 'T' and 'T') to be ambiguous despite there being a unique best viable function [-Wambiguous-reversed-operator]
    bool b = T{} == T{};
             ~~~ ^  ~~~
note: ambiguity is between a regular call to this operator and a call with the argument order reversed
    bool operator==(const T&) { return true; }
         ^
1 warning generated.

Hmph. It’s the same thing. Can you guess why? I bet you can. You’re so smart. It’s the same problem as earlier, really! Here—when considering an expression like x == y, there are two candidates on the table:

  1. operator==, the member function we wrote; and,
  2. operator==, but with the order of its operands reversed.

It’s the same thing! The const gets flip-flopped around between the two candidates and so the compiler hypothetically becomes incapable of determining which candidate is “better.” OK, look, I hear you yelling at your screen incessantly—stop that, now, it’s disrespectful—yelling that this whole shebang would’ve been avoided altogether had we just const-qualified our operators:

struct T {
    bool operator==(const T&) const;
    bool operator!=(const T&) const;
};

bool b1 = T{} == T{}; // OK
bool b2 = T{} != T{}; // OK

Doing this makes both parameter types the same, so reversing does nothing. And sure, yeah, you have a point. Really, all “morally correct” comparison operators should be const-qualified—of course, if not, your operators will fail some constraints nobody cares about, like std::equality_comparable, but that’s all. Like, come on. Please. I guarantee that your run-of-the-mill Programmer Andy regularly forgets about that and, moreover, I’d wager that you regularly forget about it too. That’s a whole lot of pretty-much-fine code that no longer compiles. Man.

Actually, while we’re at it, here’s a neat corollary: if you provide your own operator!=, it will not have any reversed rewritten candidates, even if you’ve also provided operator==. This is a consequence of P2468R2, i.e., the fix for the earlier problem. And, like… huh? Why? It would be pretty easy to tweak the wording to make it reversible. The standard’s already a million pages—what’s one more? To my understanding, the committee’s reasoning here is that, nowadays, you should only ever write operator<=> and operator== anyway, so it’s not worth giving you the “new” stuff for your “old” code. You can probably already tell I don’t fully buy that. There’s a bunch of existing code that implements the “old” operators, so why not just give it the fancy new stuff? It feels like it undermines the spirit of C++ not to.

Oh well. Better get cracking on that spaceship, I guess.