Ch17.4: Equality and Ordering
1. Two Different Things Wearing the Same Glyphs
== and < look like siblings and are not. Equality is a
question about a value: "are these the same?". Ordering is a promise about a set: "these
can be sorted, and the sort terminates". A type can honestly answer the first and have no
answer to the second, which is why C++20 gave them separate names
(operator== and operator<=>) and three different ordering
types.
| Ordering | Means | You may put the type in |
|---|---|---|
::std::strong_ordering |
total order, and equivalent values are indistinguishable
(a == b implies they can be interchanged) |
::std::set, ::std::map, any sorted container |
::std::weak_ordering |
total order, but equivalent values are distinguishable (case-insensitive strings) | sorted containers |
::std::partial_ordering |
some pairs are unordered | nothing that sorts — and that is the useful part |
2. The Eight Lines That Replace Thirty
For a type whose equality and order are memberwise, do not write the six comparison
operators by hand. Write two defaulted friends; the compiler generates the rest, including
the reversed and rewritten candidates that make c > a and
a != c work from one definition each.
#include <fast_io.h>
#include <compare>
#include <cstdint>
struct semver
{
::std::int_least32_t major{};
::std::int_least32_t minor{};
::std::int_least32_t patch{};
/* one line each: the compiler generates memberwise == and a lexicographic <=> */
friend constexpr bool operator==(semver const &, semver const &) noexcept = default;
friend constexpr ::std::strong_ordering operator<=>(semver const &, semver const &) noexcept = default;
};
struct interval
{
::std::int_least32_t lo{};
::std::int_least32_t hi{};
};
/*
No operator<=> for interval: two overlapping intervals are neither before nor after each
other, so the comparison is partial. A named function says so; an operator would let anyone
put an interval in a sorted container and silently break its invariant.
*/
[[nodiscard]] inline constexpr ::std::partial_ordering compare(interval a, interval b) noexcept
{
if (a.hi < b.lo)
{
return ::std::partial_ordering::less;
}
if (b.hi < a.lo)
{
return ::std::partial_ordering::greater;
}
if (a.lo == b.lo && a.hi == b.hi)
{
return ::std::partial_ordering::equivalent;
}
return ::std::partial_ordering::unordered;
}
int main()
{
try
{
semver const a{1, 2, 0};
semver const b{1, 2, 0};
semver const c{1, 10, 0};
::fast_io::io::println("a == b: ", a == b, " a != c: ", a != c);
::fast_io::io::println("a < c: ", a < c, " c > a: ", c > a, " a <= b: ", a <= b);
::fast_io::io::println("(a <=> c) < 0: ", (a <=> c) < 0, " (a <=> b) == 0: ", (a <=> b) == 0);
::fast_io::io::println("compare({1,5},{6,9}) < 0: ", compare({1, 5}, {6, 9}) < 0);
::fast_io::io::println("compare({1,5},{3,7}) unordered: ", compare({1, 5}, {3, 7}) == ::std::partial_ordering::unordered);
::fast_io::io::println("compare({1,5},{1,5}) equivalent: ", compare({1, 5}, {1, 5}) == 0);
}
catch throws (::std::error e)
{
::fast_io::io::print("error: ");
::fast_io::io::println(e);
}
}
a == b: 1 a != c: 1
a < c: 1 c > a: 1 a <= b: 1
(a <=> c) < 0: 1 (a <=> b) == 0: 1
compare({1,5},{6,9}) < 0: 1
compare({1,5},{3,7}) unordered: 1
compare({1,5},{1,5}) equivalent: 1
3. What Those Lines Actually Give You
a != cis not written anywhere. The compiler rewrites!(a == c)for you, so==alone is complete.c > aanda <= bare also not written.<=>generates<,>,<=,>=, and the reversed forms.= defaultis memberwise and, crucially, is not virtual and cannot be made so: comparison is a property of a value, and a value has one type (Ch18.3).- Both are
noexcepthere because the members are integers. If a member's comparison can throw, the defaulted function is notnoexcepteither, and thenoexceptyou wrote becomes a compile error — which is exactly what you want to find out.
4. The Comparison You Must Not Write
An interval is the classic partial order: two disjoint intervals have an order, two
overlapping ones do not, and no arrangement of < can express
"unordered" without lying. So the type gets a named function that returns
::std::partial_ordering, and gets no operator at all:
[[nodiscard]] inline constexpr ::std::partial_ordering compare(interval a, interval b) noexcept
{
if (a.hi < b.lo)
{
return ::std::partial_ordering::less;
}
if (b.hi < a.lo)
{
return ::std::partial_ordering::greater;
}
if (a.lo == b.lo && a.hi == b.hi)
{
return ::std::partial_ordering::equivalent;
}
return ::std::partial_ordering::unordered;
}
If you had written operator< for interval, then
::std::set<interval> would compile, would accept every element, and
would contain elements that it can never find again — because two intervals that
are "not less and not greater" are, to a sorted container, the same element. That is not
a performance bug; it is data loss. The named function makes the caller write
compare(a, b) == ::std::partial_ordering::unordered and then decide.
5. Comparators Are Named Functions Too
The same logic reaches algorithms. When you need an order that the type does not have
— "sort by patch only", "largest first" — pass a
named comparator, not a lambda:
::std::ranges::sort(versions, ::std::ranges::greater{}); /* largest first */
auto const newest{*::std::ranges::max_element(versions, ::std::ranges::less{}, &semver::patch)};
::std::ranges::less, ::std::ranges::greater,
::std::ranges::equal_to and friends are already noexcept-correct
function objects with names you can read in a diagnostic and search for in a codebase. A
lambda is anonymous, so it cannot appear in a stack trace by name and cannot be reused
without being copy-pasted. Only write a lambda when the comparison genuinely exists once
— and then still make it noexcept.
6. Rules of Thumb
- Prefer
= defaultfor==and<=>. Never write the six by hand. - Use
strong_orderingunless equivalent-but-distinguishable values are a real case, thenweak_ordering. - If some pairs are unordered, define no operator and give the type a named
function returning
partial_ordering. - Never define
==and<that disagree with each other:a == bmust imply!(a < b) && !(b < a). - Comparisons must be
noexcept, must not allocate, and must not have side effects. A comparator that mutates is a source of undefined behaviour in every sorting algorithm. - Pass named comparators (
::std::ranges::greater), not lambdas, when one already exists.