第17.4章 相等与序关系

English

1. 两个不同的东西穿着同一套标点

== 和 < 看起来是一对兄弟,其实不是。 相等是关于一个值的问题:“这两个是同一个吗?”。序是对一个集合的承诺: “这些可以排序,而且排序会终止”。一个类型可以诚实地回答第一个问题, 同时对第二个问题根本没有答案 —— 这就是 C++20 把它们拆成两个名字 (operator== 和 operator<=>) 和三种序类型的原因。

序 含义 你可以把这个类型放进
::std::strong_ordering 全序,而且等价的值不可区分(a == b 意味着它们可以互换) ::std::set、::std::map、任何有序容器
::std::weak_ordering 全序,但等价的值可以区分(大小写不敏感的字符串) 有序容器
::std::partial_ordering 有些对是无序的 任何需要排序的容器都不能用 —— 而这正是它的用处

2. 取代三十行的八行

对一个相等和序都是逐成员的类型,不要手写六个比较运算符。 写两个 = default 的友元;编译器把其余的都生成出来, 包括让 c > a 和 a != c 各自只需一个定义就能生效的 反序与改写候选。


#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. 那几行到底给了你什么

4. 你绝对不能写的那个比较

区间是典型的偏序:两个不相交的区间有先后,两个重叠的没有, 而任何 < 的排布都无法在不撒谎的前提下表达“无序”。 所以这个类型拿到的是一个返回 ::std::partial_ordering 的命名函数, 拿不到任何运算符:


[[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;
}

如果你给 interval 写了 operator<, 那么 ::std::set<interval> 会编译通过、会接受每一个元素, 然后会包含一些它再也找不到的元素 —— 因为对有序容器来说, 两个“既不更小也不更大”的区间就是同一个元素。这不是性能 bug,是数据丢失。 命名函数让调用者必须写 compare(a, b) == ::std::partial_ordering::unordered,然后自己做决定。

5. 比较器也是命名函数

同样的逻辑延伸到算法上。当你需要一种类型本身没有的序 —— “只按 patch 排”、 “大的在前” —— 传命名比较器,不要传 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 这些本来就是 noexcept 正确的函数对象, 有能在诊断信息里读懂、也能在代码库里搜索到的名字。 lambda 是匿名的,所以它不可能以名字出现在栈回溯里, 也不可能被复用而不被复制粘贴。只有当这个比较真的只存在一次时才写 lambda —— 而且即使那样也要标 noexcept。

6. 经验法则