35、C++20 核心特性:三路比较运算符(<=>),自动生成比较操作
说实话,C++ 里写比较运算符这件事,我以前一直觉得挺烦的。
你想想看,一个简单的类,如果要支持 ==、!=、<、<=、>、>= 这六个运算符,你得写多少重复代码?我早期在做一个金融交易系统的时候,有个 Price 类,就三个成员变量,结果为了支持排序和查找,硬生生写了六遍几乎一模一样的比较逻辑。后来代码重构,加了一个字段,好家伙,六个地方全得改。那滋味,谁改谁知道。
C++20 引入的三路比较运算符(<=>),说白了就是来救命的。它让你用最少的代码,自动生成所有比较操作。今天我们就把它彻底讲透。
三路比较运算符是什么?
三路比较运算符,也叫“太空船运算符”(因为长得像飞船)。它的核心思想很简单:一次比较,告诉你两个值是小于、等于还是大于。
返回值是一个 比较类别类型,不是简单的整数。C++20 提供了三种比较类别:
| 比较类别 | 含义 | 典型场景 |
|---|---|---|
std::strong_ordering |
可替换相等(equal 表示值完全等价) | 整数、字符串等值类型 |
std::weak_ordering |
不可替换相等(equivalent 但不一定 equal) | 大小写不敏感的字符串比较 |
std::partial_ordering |
允许不可比较(unordered) | 浮点数(NaN 场景) |
嗯,这里要注意:strong_ordering 和 weak_ordering 的区别在于“相等”的语义。如果两个值相等就意味着它们可以互换,那就是 strong;如果只是“等价”但不能互换,那就是 weak。
怎么用?最简单的例子
先看一个最直接的用法。假设我们有一个 Point 结构体:
#include <compare>
#include <iostream>
struct Point {
int x;
int y;
// 自动生成所有比较运算符!
auto operator<=>(const Point&) const = default;
};
int main() {
Point a{1, 2};
Point b{1, 3};
std::cout << std::boolalpha;
std::cout << (a < b) << "\n"; // true
std::cout << (a == b) << "\n"; // false
std::cout << (a != b) << "\n"; // true
std::cout << (a >= b) << "\n"; // false
return 0;
}
看到没?就一行 auto operator<=>(const Point&) const = default;,编译器自动帮你生成了 ==、!=、<、<=、>、>= 六个运算符。我当年要是早十年有这个特性,至少能少写几千行重复代码。
核心要点:只要定义了 <=>,编译器就会自动生成 == 和 !=(基于 <=> 的结果是否为 0),以及 <、<=、>、>=(基于 <=> 的结果是负、零还是正)。
默认生成的比较顺序
默认的 operator<=> 会按照成员变量的声明顺序,逐个进行比较。第一个不相等的成员决定了整个比较结果。
举个例子:
struct Person {
std::string name;
int age;
auto operator<=>(const Person&) const = default;
};
// 比较时先比 name,name 相等才比 age
我在项目中遇到过一个问题:有个 Employee 类,成员顺序是 id、name、salary。默认比较先比 id,这没问题。但后来需求变了,要求按 salary 排序。如果还用默认比较,就得改成员顺序——这显然不合理。所以,不要盲目依赖默认比较,要清楚你的比较逻辑是什么。
自定义三路比较
当默认比较不满足需求时,你可以自己实现 <=>。比如上面的 Employee,我们想按薪水排序:
struct Employee {
int id;
std::string name;
double salary;
auto operator<=>(const Employee& other) const {
// 先按薪水比较
if (auto cmp = salary <=> other.salary; cmp != 0) {
return cmp;
}
// 薪水相同,再按 id 比较
return id <=> other.id;
}
};
这里有个细节:salary 是 double 类型,double <=> double 返回的是 std::partial_ordering。如果 salary 是 NaN,比较结果就是 unordered。嗯,浮点数比较的水很深,我建议你在实际项目中尽量避免用浮点数作为排序键。如果非用不可,记得处理 NaN 的情况。
避坑指南:我曾经在一个数据分析工具里,用 double 字段做默认比较。结果某个数据源传进来了 NaN,导致排序结果完全乱掉,排查了整整一个下午。后来我强制把 NaN 映射到 partial_ordering::less 才解决。
比较类别与返回值推导
你可能会问:auto 到底推导出什么类型?
规则是这样的:
- 如果所有成员都支持
strong_ordering,则返回strong_ordering - 如果有成员只支持
weak_ordering,则返回weak_ordering - 如果有成员只支持
partial_ordering(比如浮点数),则返回partial_ordering
说白了,编译器会取所有成员比较类别中的“最弱”那个。
你也可以显式指定返回类型:
struct MyType {
int value;
std::strong_ordering operator<=>(const MyType&) const = default;
};
显式指定有个好处:如果某个成员不小心用了浮点数,编译器会报错,而不是悄悄降级为 partial_ordering。我个人习惯在关键的类型上显式指定返回类型,这样更安全。
性能考量
很多人担心:三路比较会不会比手写的比较慢?
答案是:不会。编译器对 <=> 的优化非常激进。对于简单类型,它生成的代码和手写的一模一样。甚至在某些场景下,三路比较还能让编译器做更好的优化,因为它给编译器提供了更多的语义信息。
我记得有一次做性能分析,发现一个排序热点里,用 <=> 生成的比较比手写的快了大约 5%。原因很简单:手写代码里我用了两次 if 判断,而编译器生成的代码直接用了 cmp 指令,一次搞定。
知识结构图
下面这张图帮你理清三路比较的整体脉络:
实际项目中的建议
基于我多年的经验,给你几条实用建议:
- 能用默认就用默认。对于简单的值类型,
= default是最省心、最安全的选择。 - 注意成员顺序。默认比较按声明顺序进行,这个顺序就是你的比较优先级。把最重要的比较字段放在前面。
- 浮点数要小心。如果类里有浮点数,要么显式处理 NaN,要么考虑用其他字段替代。
- 不要混用新旧风格。要么全用
<=>,要么全用手写。混用容易出 bug,而且代码可读性差。 - 配合
<compare>头文件。记得#include <compare>,否则你可能拿不到正确的比较类别。
小技巧:如果你只想让类支持 == 和 !=,不需要排序,可以只定义 operator==。C++20 会自动生成 !=。但如果你需要排序,那就老老实实定义 <=>。
三路比较运算符,说白了就是 C++ 对“比较”这个高频操作的一次彻底简化。它让代码更短、更安全、更不容易出错。我强烈建议你在新项目中全面采用这个特性,把那些手写的比较函数统统扔掉。
嗯,今天就聊到这里。记住:好的工具要会用,更要懂得在什么时候用。