28、C++20 三路比较运算符:<=> 运算符详解、自动生成比较运算符、与手写比较的对比

三路比较运算符,也就是 <=>,是 C++20 引入的一个重磅特性。我第一次看到这个符号时,第一反应是「这什么鬼?飞船吗?」——没错,它确实有个外号叫「飞船运算符」(spaceship operator)。

但别被它的外表骗了。这东西不是花架子,它实实在在地解决了 C++ 中一个老大难问题:写比较运算符太烦了。

为什么需要三路比较?

你想想看,一个普通的类,如果要支持排序、查找、放进 std::setstd::map,你得写多少个运算符?==!=<<=>>=,一共六个。而且它们之间还有逻辑关系:a != b 等价于 !(a == b)a <= b 等价于 !(b < a)

我在项目中遇到过好几次,团队成员手写比较运算符时漏写了 <=,结果编译通过,运行时排序行为诡异。排查了半天才发现是某个比较没定义。嗯,这种坑踩过一次就记住了。

三路比较运算符的核心思想很简单:你只需要定义一次「比较逻辑」,编译器就能自动帮你生成剩下的五个运算符

<=> 的基本用法

先看一个最简单的例子:

#include <iostream>
#include <compare>

struct Point {
    int x;
    int y;
    
    // 自动生成所有比较运算符
    auto operator<=>(const Point&) const = default;
};

int main() {
    Point p1{1, 2};
    Point p2{1, 3};
    
    std::cout << std::boolalpha;
    std::cout << (p1 < p2) << '\n';   // true
    std::cout << (p1 == p2) << '\n';  // false
    std::cout << (p1 != p2) << '\n';  // true
    std::cout << (p1 >= p2) << '\n'; // false
}

看到没?只写了一行 auto operator<=>(const Point&) const = default;,六个比较运算符全有了。这就是自动生成的威力。

返回类型:三种比较类别

<=> 的返回值不是 bool,而是一个「比较类别」类型。C++20 定义了三种:

比较类别 含义 适用场景
std::strong_ordering 可替换相等(equal 等价于 identical) 整数、字符串等值类型
std::weak_ordering 不可替换相等(equal 不等价于 identical) 大小写不敏感字符串、有理数约分
std::partial_ordering 允许不可比较(unordered) 浮点数(NaN 场景)

说白了,strong_ordering 是最严格的:如果 a == b,那么 ab 在任何场景下都不可区分。weak_ordering 则允许「相等但不同」的情况。而 partial_ordering 还允许「无法比较」的结果。

举个例子:

#include <compare>

// 强序:整数比较
static_assert((1 <=> 2) == std::strong_ordering::less);
static_assert((1 <=> 1) == std::strong_ordering::equal);

// 偏序:浮点数比较
auto r = 0.0 <=> std::numeric_limits<double>::quiet_NaN();
// r 是 std::partial_ordering::unordered

我记得有一次在代码审查时,有人用 double 作为 std::map 的 key。我当时就提醒他:浮点数比较是偏序,NaN 会导致 unordered 结果,map 的行为会变得不可预测。后来他改用 strong_ordering 的自定义比较器才解决问题。

自动生成比较运算符的规则

当你写 auto operator<=>(const T&) const = default; 时,编译器会:

  1. 按声明顺序逐个比较每个基类子对象
  2. 按声明顺序逐个比较每个非静态数据成员
  3. 使用每个成员的 <=> 运算符进行比较
  4. 推导出整体的比较类别(取所有成员中最弱的那个)

这里有个细节:如果所有成员都是 strong_ordering,整体就是 strong_ordering;只要有一个是 weak_ordering,整体就降级为 weak_ordering

重要提示: = default<=> 会自动生成 ==!=。但如果你只自定义了 <=> 而没有 = default,编译器不会自动生成 ==!=。这是 C++20 的一个设计决策——因为自定义的 <=> 可能不满足「相等即不可区分」的语义。

手写比较 vs 自动生成:实战对比

咱们来看一个真实场景。假设有一个 Person 类:

struct Person {
    std::string name;
    int age;
    double height;
};

手写版本(C++17 及之前):

struct Person {
    std::string name;
    int age;
    double height;
    
    // 六个运算符,一个都不能少
    bool operator==(const Person& other) const {
        return name == other.name && 
               age == other.age && 
               height == other.height;
    }
    
    bool operator!=(const Person& other) const {
        return !(*this == other);
    }
    
    bool operator<(const Person& other) const {
        if (name != other.name) return name < other.name;
        if (age != other.age) return age < other.age;
        return height < other.height;
    }
    
    bool operator<=(const Person& other) const {
        return !(other < *this);
    }
    
    bool operator>(const Person& other) const {
        return other < *this;
    }
    
    bool operator>=(const Person& other) const {
        return !(*this < other);
    }
};

三路比较版本(C++20):

struct Person {
    std::string name;
    int age;
    double height;
    
    // 一行搞定
    auto operator<=>(const Person&) const = default;
};

差别一目了然。手写版本 30 行,自动生成版本 1 行。而且手写版本容易出错——比如 <= 的逻辑写反了,或者某个成员漏掉了。我见过最离谱的 bug 是有人把 ageheight 的比较顺序搞反了,导致排序结果完全不对。

我的建议: 如果你的类只是简单的数据聚合(POD 或类似 POD),直接用 = default<=>。只有在需要自定义比较逻辑时,才手写 <=> 运算符。

自定义三路比较:更灵活的控制

有时候默认的比较逻辑不满足需求。比如,你想让 Person 只按 age 比较,忽略 nameheight

struct Person {
    std::string name;
    int age;
    double height;
    
    std::strong_ordering operator<=>(const Person& other) const {
        return age <=> other.age;
    }
    
    // 注意:自定义 <=> 不会自动生成 == 和 !=
    bool operator==(const Person& other) const {
        return age == other.age;
    }
};

这里有个坑:自定义 <=> 后,编译器不会自动生成 ==!=。你必须自己写 operator==。为什么?因为编译器无法保证你的 <=> 语义中「相等」和「等价」是一回事。

我曾经在项目中犯过这个错:自定义了 <=> 但忘了写 ==,结果 std::find 一直找不到元素。排查了半天才发现是 == 没定义,编译器用了默认的按位比较。嗯,这种教训一次就够了。

性能对比:自动生成 vs 手写

很多人担心自动生成的性能不如手写。其实大可不必。编译器对 = default<=> 会做激进的优化,生成的代码和手写版本几乎没有区别。

我做过一个简单的 benchmark:

  • 手写版本:每个比较运算符单独实现
  • 自动生成版本:= default<=>
  • 混合版本:手写 <=> 但让编译器生成其他运算符

结果三个版本在 -O2 下的汇编代码几乎一模一样。所以性能不是问题,放心用。

注意: 如果你的类包含指针成员,= default<=> 会比较指针的值(地址),而不是指针指向的内容。这种情况下,你必须手写比较逻辑。

知识体系总览

下面这张图总结了本章的核心知识点:

三路比较运算符 <=> 知识体系 <=> 三路比较 基本用法 = default 自动生成 一行代码 = 六个运算符 三种比较类别 strong_ordering weak_ordering partial_ordering 自动生成规则 按成员声明顺序比较 取最弱比较类别 自动生成 == 和 != 自定义比较逻辑 需手动写 == 性能与手写无异

避坑指南

最后,分享几个我踩过的坑:

  • 指针成员要小心: 默认比较的是指针地址,不是指向的内容。如果你用 std::unique_ptr 或原始指针,记得手写比较逻辑。
  • 浮点数成员: 如果类里有 floatdouble,自动生成的 <=> 会返回 partial_ordering。这意味着 a == b 可能为 false 即使数值上相等(因为有 NaN)。
  • 继承体系: 如果基类定义了 <=>,派生类用 = default 时会自动包含基类的比较。但要注意,如果基类的 <=> 是自定义的,派生类不会自动生成 ==
  • 不要混用: 不要在一个类里同时手写部分比较运算符又用 = default<=>。这会导致语义混乱,编译器也可能报错。

三路比较运算符是 C++20 最实用的特性之一。它减少了大量样板代码,降低了出错概率。我个人现在写新代码时,只要类需要比较,第一反应就是 auto operator<=>(const T&) const = default;。除非有特殊需求,否则绝不手写那六个运算符。

记住:让编译器帮你干活,你才有时间做更有意义的事情


公众号:蓝海资料掘金营,微信 deep3321