第17.4章 相等与序关系
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. 那几行到底给了你什么
a != c在任何地方都没写。编译器替你改写成!(a == c), 所以只写==就是完整的。c > a和a <= b也没写。<=>生成<、>、<=、>=以及它们反序的形式。= default是逐成员的,而且关键的一点是: 它不是虚函数,也不能被做成虚函数:比较是一个值的属性, 而一个值只有一个类型(第18.3章)。- 这里两个都是
noexcept,因为成员是整数。如果某个成员的比较会抛, 默认出来的函数也不是noexcept,你写的那个noexcept会变成编译错误 —— 而这正是你想发现的事。
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. 经验法则
==和<=>优先用= default。 永远不要手写那六个。- 除非“等价但可区分”是真实场合,否则用
strong_ordering; 是就weak_ordering。 - 如果有些对是无序的,就不要定义任何运算符,
而是给类型一个返回
partial_ordering的命名函数。 - 永远不要定义互相矛盾的
==和<:a == b必须蕴含!(a < b) && !(b < a)。 - 比较必须是
noexcept、不分配、无副作用。会修改东西的比较器 是每个排序算法里的未定义行为来源。 - 已经有现成的比较器时,传命名比较器(
::std::ranges::greater),不要传 lambda。