03 - 3-way comparison
Rewritten candidates
C++20 has made overload resolution smarter for comparison operators with the feature of rewritten candidates - if x != y
is not valid, compiler will try !(x == y)
and analogically for other comparison operators. Rewritten candidates are considered for both member and non-member overloads.
Thus:
The guideline to implement comparison operators as non-members is no longer needed.
You no longer need to implement so many operator overloads. The boilerplate work is now done by the compiler.
Some compiler errors have been improved.
Do rewritten candidates break existing special usages of operator overloading such as EDSL?
No. They are only considered if no implementation has been explicitly provided for remaining combinations.
Defaulted implementation
Another improvement is that you can now = default
all comparison operators if they are member functions. They will compare class members in order of their appearance (identical to std::tie
trick from previous lesson).
The spaceship operator
operator<=>
represents 3-way comparison. It provides more information so a bool
return type is not enough for it.
Before delving into what should be used instead of bool
, you should first understand logical concepts that motivated multiple choices for the return type.
Can I call
<=>
a TIE Fighter?
No. I have been informed that:
TIE Fighter is
|=|
<=>
is Darth Vader's TIE Fighter<==>
is TIE Bomber(=)
is Inquisitor's TIE Advanced Prototype
Theory
Equality
There are 2 categories of equality:
strong equality - if
x == y
, objects are indistinguishableweak equality - even if
x == y
, objects may differ
Examples:
Integers have strong equality. Two integers which compare equal are exactly the same.
Absolute value of integers has weak equality. Two integers can have equal absoule value (e.g.
3
and-3
) but they are distinguishable.Case-insensitive strings have weak equality.
"abc"
will compare equal to"ABC"
, but they are distinguishable.Comparing players by their score has weak equality - 2 players can compare equal due to the same score, but they are 2 different players.
In short, weak equality means that in a given scenario different objects may be given the same ranking (player score, case-insensitive strings) but the objects themselves are distinct (players reached the same score through different means, strings have letters with different case).
Ordering
There are 3 categories of ordering:
strong ordering - if both
x < y
andy < x
are false, objects are indistinguishableweak ordering - even if both
x < y
andy < x
are false, objects may differpartial ordering - there is a possibility that for some
x
andy
comparison has no answer
Examples:
Integers have strong ordering. If for some integers
x
andy
bothx < y
andy < x
are false, they are indistinguishable.Case-insensitive strings have weak ordering. Even if for some
x
andy
bothx < y
andy < x
are false, the strings may differ.Square roots in real numbers domain: for some real numbers (e.g.
-4
), there is no square root so for some pairs of numbers the order can not be determined.Partial ordering is present in floating-point numbers: some of them represent special values like
NaN
andinfinity
. For practical reasons, comparisons of any floating-point values never invoke undefined behavior but special values break expected consistency - any comparison involving at least 1 special value always returns false (this even includes cases like 2NaN
).-
Partial ordering is present in overload resolution:
const T
is more cv-qualified thanT
.volatile T
is more cv-qualified thanT
.const volatile T
is more cv-qualified thanconst T
.const volatile T
is more cv-qualified thanvolatile T
.There is no ordering specified between
const T
andvolatile T
. If a function has 2 such overloads and is givenT
, overload resolution will fail due to ambiguity.
How about partial equality?
There is no such thing, at least in C++.
Strong and weak orderings should always satisfy:
irreflexivity:
x < x
is always falseasymmetry: if
x < y
is true theny < x
is falsetransitivity: if
x < y
andy < z
thenx < z
must be trueexactly one of
x < y
,x > y
,x == y
must be true for anyx
andy
Exercise
Which comparison categories are present in the following situations?
points by the sum of their X, Y coordinates
files by their size
files by their paths
users by their unique ID
people by their birth date
people by their ancestry tree
Answers
weak ordering (points 3,5 and 4,4 are equal)
weak ordering (files with same size can be different)
weak equality (symlinks can create multiple paths for the same file), weak ordering if we additionally consider sorting paths as strings
strong equality (IDs are unique so 2 identical IDs refer to the same user) or strong ordering (if IDs are treated as numbers and can be sorted)
weak ordering (date can be the same for different people)
partial ordering (for some pairs of people we can not determine their common ancestor)
Practice
operator<=>
can return one of 3 ordering types, defined in <compare>
. The types themselves are not enumerations but classes (and values implemented as inline static constexpr
members) which allows to specify implicit convertions by writing non-explicit
converting constructors.
std::strong_ordering
is implicity-convertible to std::weak_ordering
which is implicity-convertible to std::partial_ordering
.
All values:
std::strong_ordering::less
std::strong_ordering::equal
std::strong_ordering::equivalent
std::strong_ordering::greater
std::weak_ordering::less
std::weak_ordering::equivalent
std::weak_ordering::greater
std::partial_ordering::less
std::partial_ordering::equivalent
std::partial_ordering::greater
std::partial_ordering::unordered
Equivalent elements are treated the same way for the purpose of comparison but are distinguishable. Equal elements are truly identical (thus this value is only present in std::strong_ordering
).
If every member of the class has defined operator<=>
and the operator<=>
definition is defaulted, you can use auto
as a return type and the compiler will pick the most powerful ordering category that is possible. Built-in types are considered to have the operator defined.
You can call operator<=>
and its result (one of standard library ordering types) can be compared with one of the terms listed above and also directly with literal 0
:
1 2 3 4 5 6 7 |
const auto cmp = x <=> y; // cmp will be std::*_ordering if (cmp < 0) std::cout << "x < y"; else if (cmp > 0) std::cout << "x > y"; else std::cout << "x == y"; |
What's the reason behind special treatment of
operator==
andoperator!=
?
Optimization. Take strings as an example. Determining which of "abc"
and "abCD"
is greater requires a loop that goes through multiple characters (potentially expensive operation if strings are long and many first characters are equal). Determining whether they are equal is instant because equality can start by comparing length and only consider looping through characters if lengths are the same. In other words, for some types 2-way equivalence check can be much faster than 3-way comparison. Thus, if operator<=>
is not = default
, operator==
should not be = default
too.
Fraction class
Weak ordering is used because two equal fractions can have different representation (e.g. 1/2 vs 2/4).
No operator is defaulted because the implementation is more complex than mere memberwise comparison.
operator==
is defined because the 2-way equivalence check can be more optimal than 3-way comparison.
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 |
// (all inside class body) bool operator==(fraction rhs) const { if (denominator() == rhs.denominator()) return numerator() == rhs.numerator(); return numerator() * rhs.denominator() == rhs.numerator() * denominator(); } std::weak_ordering operator<=>(fraction rhs) const { if (denominator() == rhs.denominator()) { if (denominator() > 0) return numerator() <=> rhs.numerator(); else return rhs.numerator() <=> numerator(); } const auto new_lhs_numerator = numerator() * rhs.denominator(); const auto new_rhs_numerator = rhs.numerator() * denominator(); if ((denominator() > 0) == (rhs.denominator() > 0)) return new_lhs_numerator <=> new_rhs_numerator; else return new_rhs_numerator <=> new_lhs_numerator; } |
Mixed-type comparisons
Thanks to rewritten candidates, the example from previous lesson can be simplified to this code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
class player { private: int id; // lots of other fields... (potentially expensive to construct) public: // [...] bool operator==(const player& rhs) const { return id == rhs.id; } bool operator==(int other_id) const { return id == other_id; } }; |
Lexicographical comparison
Just like in the previous lesson, you can use std::tie
trick to avoid writing bug-prone code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
#include <tuple> struct package { int rack; int shelf; int position; // [...] some other members that should not be used in comparison auto operator<=>(package rhs) const { return std::tie(rack, shelf, position) <=> std::tie(rhs.rack, rhs.shelf, rhs.position); } }; |
If there are no extra members and memberwise comparison is desired, the operator can be defined as = default
.
Recommendations
Using comparisons
-
Only call
operator<=>
if you truly need a 3-way comparison answer (for performance reasons). Examples:If you only need to know whether 2 objects are equal, use
operator==
.If you search only for a minimum element, comparing subsequent elements with the minimum one using
operator<
is enough.
Don't implement
operator<=>
just because you need comparison forstd::map
or other container. If the type makes no sense in ordering (e.g. a complex number class) but you need something for a container it's much better to use a function object that only affects the container.
Implementing comparisons
For any class type:
-
If you want just equality, implement only
operator==
.If you want it to compare all members in their order of appearance, you can
= default
it.
-
If you want ordering, implement
operator<=>
.If you want to compare all members in order of their appearance, you can
= default
it.If you want to optimize equality checks, you can additionally implement
operator==
.Don't define
operator<=>
withstd::partial_ordering
return type - it's very surprising when for some pair of objects allx < y
,x == y
andx > y
can returnfalse
. Instead, write a free function named exactlypartial_order
and use this function (this specific name will also be searched by standard library templates).
Advanced features
The standard library contains niebloids that can be used to compare objects and produce a result of specific comparison category (as long as the comparison is possible).
When writing generic code, it is recommended not to use operator<=>
as not every type may have it defined. Instead, it is recommended to use a template like the one presented below that will check for operator<=>
existence and fall back to other operators.
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 |
#include <compare> #include <type_traits> struct synth_three_way_t { template <typename T, std::totally_ordered_with<T> U> constexpr auto operator()(const T& lhs, const U& rhs) const { if constexpr (std::three_way_comparable_with<T, U>) { return lhs <=> rhs; } else { // Jonathan Müller used strong ordering here // I don't think we can assume that for arbitrary T and U if (lhs == rhs) return std::weak_ordering::equivalent; else if (lhs < rhs) return std::weak_ordering::less; else return std::weak_ordering::greater; } } }; inline constexpr synth_three_way_t synth_three_way; template <typename T, typename U = T> using synth_three_way_category = decltype(synth_three_way(std::declval<const T&>(), std::declval<const U&>())); |
Trivia
There was an idea to use the name <=>
for the header <compare>
[citation needed] but it was ultimately resigned from due to concern that =
may not be in the set of supported characters for paths on some implementations.
This lesson has been based on: