首页 / 资讯中心 / 文章详情

C++20三路比较运算符<=>:从原理到工程实践的全面指南

C++20三路比较运算符<=>:从原理到工程实践的全面指南 ★ FEATURED ARTICLE
如果你维护过一个只有两三个字段的业务结构体你一定体会过“六个运算符一起写”是什么滋味。手写operator、operator再用它们组合出另外四个看起来不难可一旦字段多了或者排序规则改一次牵一发而动全身。C20 中的三路比较运算符也有人叫它太空飞船运算符就是冲着这个长期痛点来的。它用一个运算符表达“小于、等于、大于”三态结果并派生出一整套比较逻辑。这篇文章不会只贴标准用语我会从原理、返回值语义、默认生成、改写规则到实际踩坑一层层拆开适合正在升级 C20 或经常写基础数据类型的读者。1. 三路比较到底在算什么从三态结果说起1.1 六个比较运算符为什么是个历史包袱在 C17 及更早版本中要保证一个类型既能在排序算法里用、又能当容器的 key理论上你需要实现六个比较运算符。虽然成员函数之间可以互相转发但最常见的实现仍然长这样struct LegacyPoint { double x, y; bool operator(const LegacyPoint o) const { return x o.x y o.y; } bool operator!(const LegacyPoint o) const { return !(*this o); } bool operator(const LegacyPoint o) const { return x o.x || (x o.x y o.y); } bool operator(const LegacyPoint o) const { return *this o || *this o; } bool operator(const LegacyPoint o) const { return o *this; } bool operator(const LegacyPoint o) const { return o *this; } };这段代码看起来很规矩实际上问题不少第一六个函数维护成本很高后四个其实基本都是转发多一行代码就多一个出错机会第二只要有人为了省事漏写了某个运算符或者写错了方向就可能出现“a b和b a同时成立”的隐性 bug第三字段比较顺序藏在函数体中间排序规则没法从类定义里一眼看出。我在一个跨模块项目里遇到过类似问题两个模块各自定义了一套坐标类型的比较逻辑表面上看字段一样实际上一个按x再按y排另一个按y再按x排。两边单测都过合并之后按 key 查数据结果对不上。最后排查时不是在算法里找到问题而是在两个头文件里逐行对比重载函数才发现的。所以“六个运算符”这件事不是懒不懒的问题它直接关系到可维护性和一致性。1.2 三路比较的基本玩法和直观理解做的事可以浓缩成一句话给出a和b的三种关系之一——小于、等于、大于。基本用法如下#include compare int x 10; int y 20; auto r x y; if (r 0) { // x 小于 y } else if (r 0) { // x 等于 y } else { // x 大于 y }这里的r不是普通int而是一个“比较类别”对象可以是std::strong_ordering、std::weak_ordering或std::partial_ordering。它可以和 0 比较也能被用作泛型代码里的类型特征。对新手来说最简单也最不容易错的理解方式a b是一个“天平”它把两个对象放上去告诉你谁轻谁重。但要真正用熟练还得往深一层看——它返回的到底有没有序以及它和常规六个运算符是如何自动挂钩的。这两件事搞明白才算真正掌握这个特性。2. 三个比较类别强序、弱序、部分序2.1 三种类别各代表什么为什么不能统一用一个int返回三态结果因为业务上的“可比性”有三种不同含义语言需要把这些含义表达进类型系统里。三者的核心区别我用下面这张表说明类别是否全序等价是否可替换典型使用场景std::strong_ordering是是整数、字符串、版本号直接作为 map/set 的 keystd::weak_ordering是否不区分大小写、折叠 Unicode 的比较std::partial_ordering否否浮点数、带缺失值的数据所谓“可替换”意思是两个对象在比较意义上等价时它们的内容是否真的完全一样。用整数举例1 1返回std::strong_ordering::equal这两个对象不仅排序相同实际值也相同交换它们不影响任何结果。但如果是忽略大小写比较字符串ORDER和order在排序意义上是等价的作为原始字符串却不相等这就只能算弱序。浮点数是部分序的典型案例。1.0 2.0能正常给出小于或大于但和NaN比较时结果既不是小于也不是大于而是unordered。这意味着你不能把一个含NaN的浮点序列直接丢进假设全序的排序算法里否则逻辑上就站不住。2.2 默认生成的类别提升规则当我们让编译器生成默认的时标准库会根据每个成员的类别做一个“提升”操作。简单说所有成员都支持强序整体就是强序只要有一个成员只支持部分序整体就退化成部分序如果一个成员是弱序而另一个是强序整体会提升到弱序或部分序绝不会变强。struct Metric { int rank; // int 对应 std::strong_ordering double score; // double 对应 std::partial_ordering auto operator(const Metric) const default; };上面这个结构体编译器推导出的返回类型实际上是std::partial_ordering因为double成员的存在让整个类必须承认“两个 Metric 可能无法比较”。这不是编译器随便挑一个结果而是一个“取最弱保证”的原则一个成员无论如何可以比另一个成员可能有NaN整体就必须保守地承认不可比的可能。这个机制解释了为什么很多人在演示代码里直接写auto。如果你非要显式写明std::strong_ordering同时成员里又有浮点值遇到NaN时就会出现违反返回类型不变量的逻辑错误。编译器不一定在编译期拦住你但它会在运行期给你一个没法解释的错误排序。3. 默认实现让编译器替你写比较逻辑3.1 默认声明的写法和适用情况最省事的用法是在类定义里声明默认版本#include compare struct Version { int major 0; int minor 0; int patch 0; auto operator(const Version) const default; };这里的default会让编译器逐字段按声明顺序比较等价于先比major如果major不相等就直接返回结果相等再比minor再相等比patch。这个顺序和你手动写std::tie(major, minor, patch) std::tie(...)完全一致。用起来也很自然Version v1{1, 2, 0}; Version v2{1, 10, 0}; bool less v1 v2; // true bool equal v1 v2; // false这种写法最大的价值是把“比较语义”直接刻在类型声明上编译器在幕后生成统一逻辑避免了手写转发函数时的不一致。3.2 成员声明顺序就是比较顺序默认实现没那么多魔法它就是老老实实按你声明成员的先后位置逐项比较。举一个容易出错的例子struct UserKey { std::string group; int uid; auto operator(const UserKey) const default; };上面的排序规则是先按group排再按uid排。如果你在重构时把两个字段的声明位置换一下整个排序语义就变了。这个问题在代码评审里几乎看不到因为它不报错只有排序结果异常。我现在的习惯是凡是用默认的字段型类型类定义上方必须有一行注释写明“比较顺序A再 B再 C”否则三个月后连作者本人都可能忘记当初的字段顺序代表什么业务含义。3.3operator与运算符之间的配合严格说默认的operator并不会自动声明一个独立的operator函数a b在大多数场景下可以通过“重写候选”走(a b) 0这条路径来解析。但我在实际工程里仍然建议只要需要相等判断就把operator也用default显式声明一遍struct Version { int major 0; int minor 0; int patch 0; auto operator(const Version) const default; bool operator(const Version) const default; };主要原因有三个第一容器做 key 查找时用的频率非常高显式默认可以少一层推导第二它让代码意图更明确阅读者不需要去回忆重写规则第三不同编译器对“重写出来的”早期实现有细节差异显式声明能规避这类兼容性意外。总而言之负责排序负责相等两者分工明确配合起来最舒服。4. 运算符改写一个运算符顶过去五个4.1 从a b推导出关系运算的映射关系C20 里真正让好用起来的是编译器的一组“运算符改写规则”。你只要提供和其余关系运算符都可以被自动推导出来。常见的改写映射如下调用改写为a b(a b) 0a b(a b) 0a b(a b) 0a b(a b) 0a b可改写为(a b) 0或直接用operatora ! b通常改写为!(a b)这些改写不是在运行时做函数调用而是编译器的重载决议机制。只要候选函数里有合适的编译器就会生成对应的比较表达式。这也是为什么一个类型只需要写两行默认函数就能拥有完整比较能力的原因。不过有个前提如果返回的是std::partial_ordering它需要和 0 比较时两边都按照部分序规则处理。语言层面已经考虑好这些细节使用者不需要自己写if (cmp 0)之外的特殊分支。4.2 逆序参数与手写运算符的冲突改写规则还有一个很有用的点它天然处理了参数顺序的问题。a b和b a最终都可能落到同一个上你不用再像 C17 那样手动写反向转发。但要特别小心一种情况你既写了默认的又手写了一个对应的operator。比如struct Money { long cents; auto operator(const Money) const default; bool operator(const Money other) const { return cents other.cents; } };这种写法不是语法错误但会让重载决议里同时出现“手写的”和“由改写出来的”两个候选。在大多数情况下编译器能选出其中一个但一旦代码里出现money other和money other混用的场景行为可能不符合预期而且让阅读者怀疑到底是哪个函数生效。所以我的结论很直接一旦决定用就不要在同类型上手写关系运算符除非你有刻意制造不一样语义的理由并且要用注释明确说明。4.3 性能观察与常见误解很多第一次接触的人会担心性能觉得a b变成(a b) 0岂不是要多比较一次实际上编译器生成的代码和手写比较通常几乎没有差异。std::strong_ordering这类类别对象往往是空类型不会真的在运行时构造一个大结构优化器也会把(a b) 0看成普通的三态比较。如果这是你代码里的热点路径我的建议是不要靠猜直接拿实际的数据结构做 benchmark。默认生成的与手写std::tie比较在绝大多数编译器里会生成相同质量的汇编真正影响性能的往往不是你选择还是六个手写函数而是你比较的字段本身的数量和类型。5. 用户自定义比较当默认逻辑 hold 不住时5.1 自定义返回类型的正确姿势默认实现不是万能的。当你要调整比较顺序、忽略某些字段、或者在比较前做归一化时就需要手写函数体。一个常见的写法如下struct PhoneBookRecord { std::string surname; int id; std::strong_ordering operator(const PhoneBookRecord rhs) const { if (auto c surname rhs.surname; c ! 0) { return c; } return id rhs.id; } bool operator(const PhoneBookRecord rhs) const default; };这里我明确写成std::strong_ordering而不是auto。为什么我想表达一个业务不变量这种按“姓”再按“id”排序的记录永远是有全序的且等价的两个对象内容完全一致。明确返回类型既是给编译器看也是给后面维护的人看。注意一个细节c ! 0是必须的。如果你写成if (c 0)那语义就反了。手动实现时最常见的翻车点就在这里建议每个分支都先在注释里写清楚“先比什么再比什么”。5.2 弱序的典型场景不区分大小写的字符串需要处理“排序上一致、但内容不完全一致”的典型例子是大小写不敏感的比较struct CaseInsensitiveString { std::string value; std::weak_ordering operator(const CaseInsensitiveString rhs) const { int cmp compare_case_insensitive(value, rhs.value); if (cmp 0) return std::weak_ordering::less; if (cmp 0) return std::weak_ordering::greater; return std::weak_ordering::equivalent; } bool operator(const CaseInsensitiveString rhs) const { return equals_case_insensitive(value, rhs.value); } };为什么是std::weak_ordering而不是std::strong_ordering因为ORDER和order在忽略大小写后是等价的但它们作为字符串内容并不相同。选择弱序就是明确告诉使用者这个类型可以用于排序和查找但“等价”不代表两个对象的内部数据一模一样。这在实际工程里很容易被忽视。如果你拿这种 key 存进std::set两个大小写不同但内容忽略大小写后相同的字符串会被视为重复元素后插入的那个会被丢弃。如果你的业务要求是“既忽略大小写查找、又保留原始内容”那就必须在设计阶段想清楚你到底要的是等值还是等价。这个语义差异是弱序存在的价值也是它比其他特性更容易让人迷惑的地方。5.3 浮点数与部分序处理 unordered浮点数的 NaN 给了三路比较最特殊的一种结果。下面这段代码展示了如何检测不可比较状态#include compare #include limits double a 1.0; double b std::numeric_limitsdouble::quiet_NaN(); std::partial_ordering result a b; if (result std::partial_ordering::unordered) { // 这两个值不具备常见的 关系 }这个逻辑在浮点场景下是必要的。任何含NaN的数据如果直接丢进std::sort、std::set、std::map都可能出现不满足严格弱序的情况因为排序算法隐含假设任意两个元素都能比较。能识别并表达unordered但这不代表排序算法会自动帮你规避错误。正确的做法是在数据进入容器之前过滤掉这些不可比较值或者显式定义 NaN 的排序位置。6. 工程里要留意的那些坑踩坑与迁移记录6.1 那个被我搞错的成员声明顺序我第一次把一个大类型改成默认时遇到一个很隐蔽的问题。原来的手写比较顺序是先按region再按accountId但我重构时在类里把accountId声明在了前面编译器自动生成的比较顺序自然就变成了先按accountId再按region。代码能编译测试大部分也能过偏偏在跨区域统计时就乱了。这个教训让我把“成员声明顺序 排序顺序”这条规则直接写进了团队的代码规范。任何使用默认的类型声明成员时就要考虑语义顺序而不是按“哪个字段先有就写哪个”的习惯排列。现在我会先写注释再写成员最后让编译器默认生成。6.2 模板中提取比较类别在泛型代码里auto会让返回类型看起来不可见但其实可以用decltype拿到它。比如我需要判断一个T到底是不是强的全序类型#include compare #include type_traits template typename T using compare_t decltype( std::declvalconst T() std::declvalconst T() ); static_assert(std::is_same_vcompare_tVersion, std::strong_ordering);这种写法在写类型特征和泛型约束时很有用。你可以基于compare_tT判断这个类型到底适合做排序 key、还是只适合做普通比较。注意两侧必须是const T这对应成员的const限定符写错会导致推导失败。6.3 从旧代码平滑迁移升级到 C20 之后没必要一瞬间把全部业务类型都改成。我推荐的迁移路径是先从两三个字段的纯数据类开始比如版本号、坐标、状态枚举包装。给这些类型加上auto operator(...) const default;和bool operator(...)旧的六个运算符暂时不要删。跑一遍差异测试确认新比较逻辑和旧逻辑完全一致。等稳定后删掉旧的operator、operator等函数。如果类型里有浮点成员先明确 NaN 的决策再决定使用std::partial_ordering还是强制过滤。这样迁移的好处是每一步都有验证点。如果你一上来就把旧函数全删了出了问题排查范围会很大。6.4 团队评审时的比较检查单现在我们在代码评审里对使用的类型会重点看这几件事比较语义是强序、弱序还是部分序注释里是否写清楚。是否显式默认了operator不能只靠重写规则兜底。是否还有手写的关系运算符残留如果有理由是什么。成员声明顺序是否和业务排序规则一致。浮点成员是否做了 NaN 决策。是否有与相关的模板推导问题比如成员函数是否为const。这个检查单看起来简单但能挡住绝大多数和比较相关的隐蔽回归。毕竟编译器能替你生成的代码不等于它能替你思考业务语义消灭的是重复劳动而不是设计责任。最后说一个我自己的小习惯。现在我在工程里只要看到一个只有数据字段、比较语义又符合直觉的类型都会直接用auto operator(...) const default; bool operator(...) const default;这两行作为起点然后在测试里补一组比较表。真正有歧义的场景再用std::strong_ordering、std::weak_ordering、std::partial_ordering去显式写明语义。它不能替你设计业务规则但能把最重复、最容易出错的机制性问题交给编译器这件事本身就是我升级到 C20 最直接的理由。
阅读完成 · 觉得有帮助?
咨询建站