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

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