The principle reason why equality and ordering are separated is performance. If you have a type whose ordering operations are user-defined, then more often than not, you can write a user-defined equality test operation that is more efficient at doing equality tests. And therefore, the language should encourage you to write it by not using operator<=>
for direct equality testing.
This only really applies to user-defined ordering/equality operations. Default ordering is member-wise, and default equality operations are also member-wise. And since ordering implies equality, it is reasonable that defaulting ordering also defaults equality.
Yes, they could make people spell it out, but there wasn't really a good reason for that. operator<=>
was meant to make it easy to opt-in to default ordering; making you write two declarations for something that's already implied by one of them doesn't make sense.
Indeed, C++20 unfortunately makes this code infinitely recursive.
Here's a reduced example:
struct F {
/*implicit*/ F(int t_) : t(t_) {}
// member: #1
bool operator==(F const& o) const { return t == o.t; }
// non-member: #2
friend bool operator==(const int& y, const F& x) { return x == y; }
private:
int t;
};
Let's just look at 42 == F{42}
.
In C++17, we only had one candidate: the non-member candidate (#2
), so we select that. Its body, x == y
, itself only has one candidate: the member candidate (#1
) which involves implicitly converting y
into an F
. And then that member candidate compares the two integer members and this is totally fine.
In C++20, the initial expression 42 == F{42}
now has two candidates: both the non-member candidate (#2
) as before and now also the reversed member candidate (#1
reversed). #2
is the better match - we exactly match both arguments instead of invoking a conversion, so it's selected.
Now, however, x == y
now has two candidates: the member candidate again (#1
), but also the reversed non-member candidate (#2
reversed). #2
is the better match again for the same reason that it was a better match before: no conversions necessary. So we evaluate y == x
instead. Infinite recursion.
Non-reversed candidates are preferred to reversed candidates, but only as a tiebreaker. Better conversion sequence is always first.
Okay great, how can we fix it? The simplest option is removing the non-member candidate entirely:
struct F {
/*implicit*/ F(int t_) : t(t_) {}
bool operator==(F const& o) const { return t == o.t; }
private:
int t;
};
42 == F{42}
here evaluates as F{42}.operator==(42)
, which works fine.
If we want to keep the non-member candidate, we can add its reversed candidate explicitly:
struct F {
/*implicit*/ F(int t_) : t(t_) {}
bool operator==(F const& o) const { return t == o.t; }
bool operator==(int i) const { return t == i; }
friend bool operator==(const int& y, const F& x) { return x == y; }
private:
int t;
};
This makes 42 == F{42}
still choose the non-member candidate, but now x == y
in the body there will prefer the member candidate, which then does the normal equality.
This last version can also remove the non-member candidate. The following also works without recursion for all test cases (and is how I would write comparisons in C++20 going forward):
struct F {
/*implicit*/ F(int t_) : t(t_) {}
bool operator==(F const& o) const { return t == o.t; }
bool operator==(int i) const { return t == i; }
private:
int t;
};
Best Answer
This is by design.
Only a defaulted
<=>
allows a synthesized==
to exist. The rationale is that classes likestd::vector
should not use a non-defaulted<=>
for equality tests. Using<=>
for==
is not the most efficient way to compare vectors.<=>
must give the exact ordering, whereas==
may bail early by comparing sizes first.If a class does something special in its three-way comparison, it will likely need to do something special in its
==
. Thus, instead of generating a potentially non-sensible default, the language leaves it up to the programmer.