35、C++20 核心特性:三路比较运算符(<=>),自动生成比较操作

说实话,C++ 里写比较运算符这件事,我以前一直觉得挺烦的。

你想想看,一个简单的类,如果要支持 ==!=<<=>>= 这六个运算符,你得写多少重复代码?我早期在做一个金融交易系统的时候,有个 Price 类,就三个成员变量,结果为了支持排序和查找,硬生生写了六遍几乎一模一样的比较逻辑。后来代码重构,加了一个字段,好家伙,六个地方全得改。那滋味,谁改谁知道。

C++20 引入的三路比较运算符(<=>),说白了就是来救命的。它让你用最少的代码,自动生成所有比较操作。今天我们就把它彻底讲透。

三路比较运算符是什么?

三路比较运算符,也叫“太空船运算符”(因为长得像飞船)。它的核心思想很简单:一次比较,告诉你两个值是小于、等于还是大于。

返回值是一个 比较类别类型,不是简单的整数。C++20 提供了三种比较类别:

比较类别 含义 典型场景
std::strong_ordering 可替换相等(equal 表示值完全等价) 整数、字符串等值类型
std::weak_ordering 不可替换相等(equivalent 但不一定 equal) 大小写不敏感的字符串比较
std::partial_ordering 允许不可比较(unordered) 浮点数(NaN 场景)

嗯,这里要注意:strong_orderingweak_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 类,成员顺序是 idnamesalary。默认比较先比 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;
    }
};

这里有个细节:salarydouble 类型,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 指令,一次搞定。

知识结构图

下面这张图帮你理清三路比较的整体脉络:

三路比较运算符(<=>)知识结构 operator<=> 三种比较类别 strong_ordering weak_ordering partial_ordering 两种使用方式 = default(自动生成) 自定义实现

实际项目中的建议

基于我多年的经验,给你几条实用建议:

  1. 能用默认就用默认。对于简单的值类型,= default 是最省心、最安全的选择。
  2. 注意成员顺序。默认比较按声明顺序进行,这个顺序就是你的比较优先级。把最重要的比较字段放在前面。
  3. 浮点数要小心。如果类里有浮点数,要么显式处理 NaN,要么考虑用其他字段替代。
  4. 不要混用新旧风格。要么全用 <=>,要么全用手写。混用容易出 bug,而且代码可读性差。
  5. 配合 <compare> 头文件。记得 #include <compare>,否则你可能拿不到正确的比较类别。

小技巧:如果你只想让类支持 ==!=,不需要排序,可以只定义 operator==。C++20 会自动生成 !=。但如果你需要排序,那就老老实实定义 <=>

三路比较运算符,说白了就是 C++ 对“比较”这个高频操作的一次彻底简化。它让代码更短、更安全、更不容易出错。我强烈建议你在新项目中全面采用这个特性,把那些手写的比较函数统统扔掉。

嗯,今天就聊到这里。记住:好的工具要会用,更要懂得在什么时候用。


专注资料整理