CppGuide社区 CppGuide社区
首页
  • 🔥最新谷歌C++风格指南(含C++17/20)
  • 🔥C++17详解
  • 🔥C++20完全指南
  • 🔥C++23快速入门
🔥C++面试
  • 第1章 C++ 惯用法与Modern C++篇
  • 第2章 C++开发工具与调试进阶
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 网络通信协议设计
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 服务其他模块设计
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 🔥C++游戏编程入门(零基础学C++)
  • 🔥使用C++17从零开发一个调试器 (opens new window)
  • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
  • 🔥使用C++从零写一个C语言编译器 (opens new window)
  • 🔥从零用C语言写一个Redis
  • leveldb源码分析
  • libevent源码分析
  • Memcached源码分析
  • TeamTalk源码分析
  • 优质源码分享 (opens new window)
  • 🔥远程控制软件gh0st源码分析
  • 🔥Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • 高效Go并发编程
  • Go性能调优
  • Go项目架构设计
  • 🔥使用Go从零开发一个数据库
  • 🔥使用Go从零开发一个编译器 (opens new window)
  • 🔥使用Go从零开发一个解释器 (opens new window)
Rust编程指南
  • SQL零基础指南
  • MySQL开发与调试指南
GitHub (opens new window)
首页
  • 🔥最新谷歌C++风格指南(含C++17/20)
  • 🔥C++17详解
  • 🔥C++20完全指南
  • 🔥C++23快速入门
🔥C++面试
  • 第1章 C++ 惯用法与Modern C++篇
  • 第2章 C++开发工具与调试进阶
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 网络通信协议设计
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 服务其他模块设计
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 🔥C++游戏编程入门(零基础学C++)
  • 🔥使用C++17从零开发一个调试器 (opens new window)
  • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
  • 🔥使用C++从零写一个C语言编译器 (opens new window)
  • 🔥从零用C语言写一个Redis
  • leveldb源码分析
  • libevent源码分析
  • Memcached源码分析
  • TeamTalk源码分析
  • 优质源码分享 (opens new window)
  • 🔥远程控制软件gh0st源码分析
  • 🔥Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • 高效Go并发编程
  • Go性能调优
  • Go项目架构设计
  • 🔥使用Go从零开发一个数据库
  • 🔥使用Go从零开发一个编译器 (opens new window)
  • 🔥使用Go从零开发一个解释器 (opens new window)
Rust编程指南
  • SQL零基础指南
  • MySQL开发与调试指南
GitHub (opens new window)
  • C++20 完全指南 说明
  • 第1章 比较和<=>运算符
    • 1.1 运算符的设计动机
      • 1.1.1 C++20之前定义比较运算符
      • 1.1.2 自C++20起定义比较运算符
      • 1. operator ==意味着operator !=
      • 2. operator <=>
      • 实现operator <=>
    • 1.2 定义和使用比较
      • 1.2.1 使用operator<=>
      • 1.2.2 比较类别类型
      • 比较类别
      • 标准库中的比较类别类型
      • 1.2.3 将比较类别与operator<=>一起使用
      • 1.2.4 直接调用operator <=>
      • 1.2.5 处理多个排序标准
    • 1.3 定义operator<=>和operator==
      • 1.3.1 默认的operator==和operator<=>
      • 1.3.2 默认的operator<=>意味着默认的operator==
      • 1.3.3 默认operator<=>的实现
    • 1.4 重写表达式的重载决议
      • 调用相等运算符
      • 调用关系运算符
    • 1.5 在泛型代码中使用operator <=>
      • 1.5.1 compare_three_way
      • 1.5.2 算法lexicographical_compare_three_way()
    • 1.6 比较运算符的兼容性问题
      • 1.6.1 委托独立的比较运算符
      • 1.6.2 包含受保护成员的继承
    • 1.7 补充说明
  • 第2章 函数参数的占位符类型
  • 第3章 概念、要求和约束
  • 第4章 概念、需求和约束详解
  • 第5章 标准概念详解
  • 第6章 范围与视图
  • 第7章 范围和视图的实用工具
  • 第8章 视图类型详解
  • 第9章 跨度(Spans)
  • 第10章 格式化输出
  • 第11章 <chrono>中的日期和时区
  • 第12章 std::jthread和停止令牌
  • 第13章 并发特性
  • 第14章 协程
  • 第15章 协程详解
  • 第16章 模块
  • 第17章 Lambda扩展
  • 第18章 编译期计算
  • 第19章 非类型模板参数(NTTP)扩展
  • 第20章 新的类型特性
  • 第21章 核心语言的小改进
  • 第22章 泛型编程的小改进
  • 第23章 C++标准库的小改进
  • 第24章 已弃用和移除的特性
  • cpp20completeguides
zhangxf
2025-03-20
目录

第1章 比较和运算符

# 第1章 比较和<=>运算符

C++20简化了用户定义类型的比较定义,并引入了更好的处理方式。为此引入了新的<=>运算符(也称为飞船运算符 )。

本章将介绍自C++20起,如何利用这些新特性定义和处理比较操作。

# 1.1 <=>运算符的设计动机

我们先来看看自C++20起新的比较处理方式以及新<=>运算符的设计动机。

# 1.1.1 C++20之前定义比较运算符

在C++20之前,为了对某个类型的对象进行所有可能的比较操作提供全面支持,你必须为该类型定义六个运算符。

例如,如果你想比较Value类型(具有一个整型ID)的对象,就必须实现以下内容:

class Value {
private:
    long id;
    ...
public:
    ...
    // 相等运算符:
    bool operator== (const Value& rhs) const {
        return id == rhs.id;          // 基本的相等性检查
    }
    bool operator!= (const Value& rhs) const { return!(*this == rhs); // 派生的检查
    }

    // 关系运算符:
    bool operator< (const Value& rhs)    const {
        return id < rhs.id;            // 基本的顺序检查
    }
    bool operator<= (const Value& rhs) const { return!(rhs < *this);  // 派生的检查
    }
    bool operator> (const Value& rhs)    const {
        return rhs < *this;            // 派生的检查
    }
    bool operator>= (const Value& rhs) const { return!(*this < rhs);  // 派生的检查
    }
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

这使得你可以对Value对象(运算符所定义的对象)调用六个比较运算符中的任意一个,并传入另一个Value对象(作为参数rhs传递)。例如:

Value v1, v2;... ;
if (v1 <= v2) {     // 调用v1.operator<=(v2)
   ...
}
1
2
3
4

这些运算符也可能被间接调用(例如,通过调用sort()函数):

std::vector<Value> coll;
... ;
std::sort(coll.begin(), coll.end());   // 使用operator<进行排序
1
2
3

自C++20起,也可以使用范围来调用:

std::ranges::sort(coll);  // 使用operator<进行排序
1

问题在于,尽管大多数运算符是基于其他运算符定义的(它们都基于operator ==或operator <),但这些定义很繁琐,而且会让代码看起来很杂乱。

此外,对于一个实现良好的类型,你可能还需要做更多事情:

  • 如果运算符不会抛出异常,则使用noexcept声明它们。
  • 如果运算符可以在编译时使用,则使用constexpr声明它们。
  • 如果构造函数不是explicit的,则将运算符声明为“隐藏友元”(在类结构内部使用friend声明,这样两个操作数都成为参数,并支持隐式类型转换 )。
  • 使用[[nodiscard]]声明运算符,以便在返回值未被使用时发出警告。

例如:

// lang/valueold.hpp
class Value {
private:
    long id;
    ...
public:
    constexpr Value(long i) noexcept   // 支持隐式类型转换
        : id{i} {}
    ...
    // 相等运算符:
    [[nodiscard]] friend constexpr
    bool operator== (const Value& lhs, const Value& rhs) noexcept {
        return lhs.id == rhs.id;      // 基本的相等性检查
    }
    [[nodiscard]] friend constexpr
    bool operator!= (const Value& lhs, const Value& rhs) noexcept {
        return!(lhs == rhs);          // 派生的不等性检查
    }
    // 关系运算符:
    [[nodiscard]] friend constexpr
    bool operator< (const Value& lhs, const Value& rhs) noexcept {
        return lhs.id < rhs.id;       // 基本的顺序检查
    }
    [[nodiscard]] friend constexpr
    bool operator<= (const Value& lhs, const Value& rhs) noexcept {
        return!(rhs < lhs);          // 派生的检查
    }
    [[nodiscard]] friend constexpr
    bool operator> (const Value& lhs, const Value& rhs) noexcept {
        return rhs < lhs;             // 派生的检查
    }
    [[nodiscard]] friend constexpr
    bool operator>= (const Value& lhs, const Value& rhs) noexcept {
        return!(lhs < rhs);           // 派生的检查
    }
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

# 1.1.2 自C++20起定义比较运算符

自C++20起,比较运算符方面有了一些变化。

# 1. operator ==意味着operator !=

现在,要检查不等性,定义operator ==就足够了。

当编译器找不到与表达式a!=b匹配的声明时,它会重写该表达式,并查找!(a==b)。如果这不起作用,编译器还会尝试改变操作数的顺序,因此也会尝试!(b==a):

a != b   // 尝试:a!=b、!(a==b)和!(b==a)
1

因此,对于TypeA类型的a和TypeB类型的b,如果存在以下情况,编译器将能够编译a != b:

  • 一个独立的operator!=(TypeA, TypeB)。
  • 一个独立的operator==(TypeA, TypeB)。
  • 一个独立的operator==(TypeB, TypeA)。
  • 一个成员函数TypeA::operator!=(TypeB)。
  • 一个成员函数TypeA::operator==(TypeB)。
  • 一个成员函数TypeB::operator==(TypeA)。

直接调用已定义的operator !=是首选方式(但类型的顺序必须匹配)。改变操作数的顺序优先级最低。同时存在独立函数和成员函数会导致歧义错误。

因此,有了:

bool operator==(const TypeA&, const TypeB&);
1

或者:

class TypeA {
public:
   ...
    bool operator==(const TypeB&) const;
};
1
2
3
4
5

编译器将能够编译:

MyType a; 
MyType b;
...
a == b;   // 没问题:完全匹配
b == a;   // 没问题,重写为:a == b
a != b;   // 没问题,重写为:!(a == b)
b != a;   // 没问题,重写为:!(a == b)
1
2
3
4
5
6
7

请注意,由于重写,当重写将操作数转换为已定义成员函数的参数时,第一个操作数的隐式类型转换也是可能的。

请查看示例sentinel1.cpp,了解如何通过仅定义成员operator ==,在以不同操作数顺序调用!=时从该特性中受益。

# 2. operator <=>

对于所有关系运算符,并没有类似只定义operator <就足够的规则。然而,现在你只需要定义新的operator <=>。

事实上,以下代码足以让程序员使用所有可能的比较运算符:

// lang/value20.hpp

#include <compare>
class Value {
private:
    long id;
    ...
public:
    constexpr Value(long i) noexcept : id{i} {
    }
    ...
    // 启用所有相等和关系运算符的使用:
    auto operator<=> (const Value& rhs) const = default;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14

一般来说,operator ==通过定义==和!=来处理对象的相等性,而operator <=>通过定义关系运算符来处理对象的顺序。然而,通过使用=default声明operator<=>,我们使用了一个特殊规则,即默认的成员operator<=>:

class Value {
   ...
    auto operator<=> (const Value& rhs) const = default;
};
1
2
3
4

会生成相应的成员operator==,因此实际上我们得到:

class Value {
   ...
    auto operator<=> (const Value& rhs) const = default;
    auto operator== (const Value& rhs) const = default;   // 隐式生成
};
1
2
3
4
5

其效果是,这两个运算符都使用它们的默认实现,该实现逐个成员地比较对象。这意味着类中成员的顺序很重要。

因此,有了:

class Value {
   ...
    auto operator<=> (const Value& rhs) const = default;
};
1
2
3
4

我们就获得了使用所有六个比较运算符所需的一切。

此外,即便将运算符声明为成员函数,对于生成的运算符也有如下规则:

  • 如果比较成员时从不抛出异常,那么它们是noexcept的。
  • 如果在编译期能够比较成员,那么它们是constexpr的。
  • 得益于重写机制,第一个操作数的隐式类型转换也得到了支持。

这表明,一般而言,operator==和operator<=>处理的事情虽不同但相互关联:

  • operator==定义相等性,可被相等运算符==和!=使用。
  • operator<=>定义顺序,可被关系运算符<、<=、>和>=使用。

注意,在默认定义或使用operator <=>时,必须包含<compare>头文件。

#include <compare>
1

不过,大多数标准类型(字符串、容器、<utility>)的头文件通常都会包含这个头文件。

# 实现operator <=>

为了对生成的比较运算符拥有更多控制权,你可以自行定义operator==和operator<=>。例如:

// lang/value20def.hpp
#include <compare>
class Value {
private:
    long id;
    ...
public:
    constexpr Value(long i) noexcept : id{i} {
    }
    ...
    // 用于相等运算符:
    bool operator== (const Value& rhs) const {
        return id == rhs.id;           // 定义相等性(==和!=)
    }
    // 用于关系运算符:
    auto operator<=> (const Value& rhs) const {
        return id <=> rhs.id;         // 定义顺序(<、<=、>和>=)
    }
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

这意味着你可以指定哪些成员按何种顺序进行比较很重要,或者实现特殊的比较行为。

这些基本运算符的工作方式是,如果一个表达式使用了某个比较运算符,但没有找到匹配的直接定义,那么该表达式会被重写,以便能够使用这些运算符。

与相等运算符调用的重写类似,关系运算符操作数的顺序也可能被重写,这可能会使第一个操作数进行隐式类型转换。例如,如果

x <= y
1

没有找到匹配的operator<=定义,它可能会被重写为

(x <=> y) <= 0
1

甚至

0 <= (y <=> x)
1

从这种重写方式可以看出,新的operator<=>执行三向比较,会产生一个可与0进行比较的值:

  • 如果x<=>y的值等于0,那么x和y相等或等效。
  • 如果x<=>y的值小于0,那么x小于y。
  • 如果x<=>y的值大于0,那么x大于y。

不过要注意,operator<=>的返回类型不是整数值。返回类型是一种表示比较类别的类型,可能是强序(strong ordering)、弱序(weak ordering)或偏序(partial ordering)。这些类型支持与0进行比较以处理比较结果。

# 1.2 定义和使用比较

以下部分将详细介绍自C++20起比较运算符的处理细节。

# 1.2.1 使用operator<=>

operator <=>是一个新的二元运算符。对于所有定义了关系运算符的基本数据类型,它都有定义。和往常一样,它可以被用户定义为operator<=>()。

operator <=>的优先级高于所有其他比较运算符,这意味着在输出语句中使用它时需要加括号,但将其结果与其他值进行比较时则不需要:

std::cout << (0 < x <=> y) << "\n";    // 调用0 < (x <=> y)
1

请注意,为了处理operator <=>的结果,你必须包含一个特定的头文件:

#include <compare>
1

这适用于声明(默认定义)、实现或使用它的情况。例如:

#include <compare>     // 用于调用<=>
auto x = 3 <=> 4;      // 没有包含<compare>头文件时无法编译
1
2

大多数标准类型(字符串、容器、<utility>)的头文件通常都会包含这个头文件。不过,对于那些不需要这个头文件的值或类型调用该运算符时,你必须包含<compare>。

注意,operator <=>用于实现类型。在operator<=>的实现之外,程序员不应直接调用<=>。虽然可以这么做,但不应写成a<=>b < 0,而应写成a<b。

# 1.2.2 比较类别类型

新的operator <=>并不返回布尔值。相反,它的行为类似于三向比较,返回一个负值表示小于,一个正值表示大于,0表示相等或等效。这种行为与C函数strcmp()的返回值类似;然而,有一个重要区别:返回值不是整数值。相反,C++标准库提供了三种可能的返回类型,用于反映比较的类别。

# 比较类别

在比较两个值以确定它们的顺序时,会出现不同类别的行为:

  • 强序(也称为全序,strong ordering / total ordering):给定类型的任何值都小于、等于或大于该类型的任何其他值(包括其自身)。这类别的典型例子是整数值或常见的字符串类型。字符串s1小于、等于或大于字符串s2。如果这类别的一个值既不小于也不大于另一个值,那么这两个值相等。如果有多个对象,可以按升序或降序对它们进行排序(相等的值之间顺序任意)。
  • 弱序(weak ordering):给定类型的任何值都小于、等效于或大于该类型的任何其他值(包括其自身)。然而,等效的值不一定相等(具有相同的值)。这类别的一个典型例子是不区分大小写的字符串类型。字符串"hello"小于"hello1"且大于"hell"。然而,"hello"和"HELLO"是等效的,尽管这两个字符串并不相等。如果这类别的一个值既不小于也不大于另一个值,那么这两个值至少是等效的(它们甚至可能相等)。如果有多个对象,可以按升序或降序对它们进行排序(等效的值之间顺序任意)。
  • 偏序(partial ordering):给定类型的任何值都可能小于、等效于或大于该类型的任何其他值(包括其自身)。此外,两个值之间可能根本无法确定特定的顺序。这类别的一个典型例子是浮点类型,因为它们可能有特殊值NaN(“非数字”)。与NaN的任何比较都返回false。因此,在这种情况下,比较可能会得出两个值是无序的,并且比较运算符可能会返回四个值之一。如果有多个对象,可能无法按升序或降序对它们进行排序(除非确保不存在无法排序的值)。

# 标准库中的比较类别类型

对于不同的比较类别,C++20标准引入了以下类型:

  • std::strong_ordering,其值包括:
    • std::strong_ordering::less
    • std::strong_ordering::equal(也可写作std::strong_ordering::equivalent)
    • std::strong_ordering::greater
  • std::weak_ordering,其值包括:
    • std::weak_ordering::less
    • std::weak_ordering::equivalent
    • std::weak_ordering::greater
  • std::partial_ordering,其值包括:
    • std::partial_ordering::less
    • std::partial_ordering::equivalent
    • std::partial_ordering::greater
    • std::partial_ordering::unordered

注意,所有类型都有less、greater和equivalent这些值。不过,strong_ordering还有equal,它在这里与equivalent相同,而partial_ordering有unordered值,表示既不小于、也不等于、也不大于。

更强的比较类型可以隐式转换为更弱的比较类型。这意味着你可以将任何strong_ordering值用作weak_ordering或partial_ordering值(此时equal变为equivalent)。

# 1.2.3 将比较类别与operator<=>一起使用

新的operator <=>应该返回其中一个比较类别类型的值,该值表示比较的结果以及关于此结果是否能够创建强序/全序、弱序或偏序的信息。

例如,这是为MyType类型定义一个独立的operator<=>的方式:

std::strong_ordering operator<=> (MyType x, MyOtherType y) {
    if (xIsEqualToY)   return std::strong_ordering::equal;
    if (xIsLessThanY)   return std::strong_ordering::less;
    return std::strong_ordering::greater;
}
1
2
3
4
5

或者,作为一个更具体的例子,为MyType类型定义operator<=>:

class MyType {
   ...
    std::strong_ordering operator<=> (const MyType& rhs) const {
        return value == rhs.value? std::strong_ordering::equal :
               value < rhs.value? std::strong_ordering::less :
               std::strong_ordering::greater;
    }
};
1
2
3
4
5
6
7
8

然而,通常通过将运算符映射到基础类型的结果来定义它会更容易。因此,对于上面的成员operator<=>,只返回其成员值的值和类别会更好:

class MyType {
   ...
    auto operator<=> (const MyType& rhs) const {
        return value <=> rhs.value;
    }
};
1
2
3
4
5
6

这不仅返回了正确的值,还确保返回值根据成员值的类型具有正确的比较类别类型。

# 1.2.4 直接调用operator <=>

你可以直接调用任何已定义的operator <=>:

MyType x, y;
...
x <=> y      // 产生一个结果比较类别类型的值
1
2
3

如前所述,你应该只在实现operator<=>时直接调用operator <=>。不过,了解返回的比较类别会很有帮助。

同样如前所述,operator<=>为所有定义了关系运算符的基本类型预定义。例如:

int x = 17, y = 42;
x <=> y               // 产生std::strong_ordering::less
x <=> 17.0            // 产生std::partial_ordering::equivalent
&x <=> &x             // 产生std::strong_ordering::equal
&x <=> nullptr        // 错误:不支持与nullptr的关系比较
1
2
3
4
5

此外,现在C++标准库中所有提供关系运算符的类型也都提供operator<=>。例如:

std::string{ "hi " } <=> "hi "                                 // 产生std::strong_ordering::equal;
std::pair{42, 0.0} <=> std::pair{42, 7.7}   // 产生std::partial_ordering::less
1
2

对于你自己定义的类型,你只需要将operator<=>定义为成员函数或独立函数即可。

由于返回类型取决于比较类别,你可以检查特定的返回值:

if (x <=> y == std::partial_ordering::equivalent)   // 总是可行
1

由于存在向较弱排序类型的隐式类型转换,即使operator<=>返回strong_ordering或weak_ordering值,这段代码也能编译通过。

反之则不行。如果比较的结果是weak_ordering或partial_ordering值,你不能将其与strong_ordering值进行比较。

if (x <=> y == std::strong_ordering::equal)   // 可能无法编译
1

不过,与0进行比较总是可行的,而且通常更简便:

if (x <=> y == 0)             // 总是可行
1

此外,由于关系运算符调用的新重写机制,operator<=>可能会被间接调用:

if (!(x < y || y < x))   // 可能会调用operator<=>来检查相等性
1

或者:

if (x <= y && y <= x)    // 可能会调用operator<=>来检查相等性
1

注意,operator!=永远不会被重写为调用operator<=>。不过,它可能会调用由于默认的operator<=>成员而隐式生成的operator==成员。

# 1.2.5 处理多个排序标准

要基于多个属性计算operator<=>的结果,通常可以实现一系列子比较,直到结果不相等/不等效,或者到达要比较的最后一个属性:

class Person {
   ...
    auto operator<=> (const Person& rhs) const {
        auto cmp1 = lastname <=> rhs.lastname;   // 主要排序成员
        if (cmp1 != 0) return cmp1;              // 如果不相等则返回结果
        auto cmp2 = firstname <=> rhs.firstname; // 次要排序成员
        if (cmp2 != 0) return cmp2;              // 如果不相等则返回结果
        return value <=> rhs.value;              // 最终排序成员
    }
};
1
2
3
4
5
6
7
8
9
10

然而,如果属性具有不同的比较类别,返回类型将无法编译。例如,如果一个成员name是字符串,而一个成员value是双精度浮点数,就会有冲突的返回类型:

class Person {
    std::string name;
    double value;
   ...
    auto operator<=> (const Person& rhs) const { // 错误:推导的返回类型不同
        auto cmp1 = name <=> rhs.name;
        if (cmp1 != 0) return cmp1;      // 对于std::string返回strong_ordering
        return value <=> rhs.value;      // 对于double返回partial_ordering
    }
};
1
2
3
4
5
6
7
8
9
10

在这种情况下,你可以转换为最弱的比较类型。如果你知道最弱的比较类型,可以直接将其声明为返回类型:

class Person {
    std::string name;
    double value;
   ...
    std::partial_ordering operator<=> (const Person& rhs) const {   // 可行
        auto cmp1 = name <=> rhs.name;
        if (cmp1  != 0) return cmp1;      // strong_ordering转换为返回类型
        return value <=> rhs.value;       // partial_ordering用作返回类型
    }
};
1
2
3
4
5
6
7
8
9
10

如果你不知道比较类型(例如,它们的类型是模板参数),可以使用新的类型特性std::common_comparison_category<>,它可以计算最强的比较类别:

class Person {
    std::string name;
    double value;
   ...
    auto operator<=> (const Person& rhs) const       // 可行
        -> std::common_comparison_category_t<decltype(name <=> rhs.name),
                                            decltype(value <=> rhs.value)> {
        auto cmp1 = name <=> rhs.name;
        if (cmp1 != 0) return cmp1;       // 用作或转换为公共比较类型
        return value <=> rhs.value;       // 用作或转换为公共比较类型
    }
};
1
2
3
4
5
6
7
8
9
10
11
12

通过使用后置返回类型语法(前面是auto,->后面是返回类型),我们可以使用参数来计算比较类型。即使在这种情况下,你可以只用name而不是rhs.name,这种方法通常也是可行的(例如,对于独立函数也适用)。

如果你想提供比内部使用的更强的类别,则必须将内部比较的所有可能值映射到返回类型的值。如果无法映射某些值,这可能还包括一些错误处理。例如:

class Person {
    std::string name;
    double value;
   ...
    std::strong_ordering operator<=> (const Person& rhs) const {
        auto cmp1 = name <=> rhs.name;
        if (cmp1 != 0) return cmp1;      // 对于std::string返回strong_ordering
        auto cmp2 = value <=> rhs.value; // 对于double可能是partial_ordering
        // 将partial_ordering映射到strong_ordering:
        assert(cmp2 != std::partial_ordering::unordered); // 如果无序则为运行时错误
        return cmp2 == 0? std::strong_ordering::equal
               : cmp2 > 0? std::strong_ordering::greater
               : std::strong_ordering::less;
    }
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

C++标准库为此提供了一些辅助函数对象。例如,要映射浮点值,可以对要比较的两个值调用std::strong_order():

class Person {
    std::string name;
    double value;
   ...
    std::strong_ordering operator<=> (const Person& rhs) const {
        auto cmp1 = name <=> rhs.name;
        if (cmp1 != 0) return cmp1;  // 对于std::string返回strong_ordering
        // 将浮点比较结果映射到强序:
        return std::strong_order(value, rhs.value);
    }
};
1
2
3
4
5
6
7
8
9
10
11

如果可能,std::strong_order()会根据传入的参数产生一个std::strong_ordering值,如下所示:

  • 如果为传入的类型定义了strong_order(val1, val2),则使用它。
  • 否则,如果传入的值是浮点类型,则使用ISO/IEC/IEEE 60559中指定的totalOrder()的值(例如,-0小于+0,-NaN小于任何非NaN值,+NaN )。
  • 如果为传入的类型定义了新的函数对象std::compare_three_way{}(val1, val2),则使用它。

这是为浮点类型提供强序的最简单方法,即使在运行时操作数之一或两者可能为NaN的情况下也适用。

std::compare_three_way是一个用于调用operator <=>的新函数对象类型,就像std::less是用于调用operator <的函数对象类型一样。

对于其他具有较弱排序且定义了operator ==和operator <的类型,你可以相应地使用函数对象std::compare_strong_order_fallback():

class Person {
    std::string name;
    SomeType value;
    std::strong_ordering operator<=> (const Person& rhs) const {
        auto cmp1 = name <=> rhs.name;
        if (cmp1 != 0) return cmp1;  // 对于std::string返回strong_ordering
        // 将弱序/偏序比较结果映射到强序:
        return std::compare_strong_order_fallback(value, rhs.value);
    }
};
1
2
3
4
5
6
7
8
9
10

“映射比较类别类型的函数对象”表列出了所有可用的用于映射比较类别类型的辅助函数。

要为泛型类型定义operator<=>,你还应该考虑使用函数对象std::compare_three_way或算法std::lexicographical_compare_three_way()。

# 1.3 定义operator<=>和operator==

operator<=>和operator==都可以为你的数据类型进行定义:

  • 既可以定义为带一个参数的成员函数。
  • 也可以定义为带两个参数的独立函数。
std::中的函数对象 作用
strong_order()
weak_order()
partial_order()
compare_strong_order_fallback()
compare_weak_order_fallback()
compare_partial_order_fallback()
将结果映射为强序值,也适用于浮点值
将结果映射为弱序值,也适用于浮点值
将结果映射为偏序值
即使仅定义了==和<,也将结果映射为强序值
即使仅定义了==和<,也将结果映射为弱序值
即使仅定义了==和<,也将结果映射为偏序值

表1.1 映射比较类别类型的函数对象

# 1.3.1 默认的operator==和operator<=>

在类或数据结构内部(作为成员函数或友元函数),所有比较运算符都可以使用=default声明为默认。不过,这通常仅对operator==和operator<=>有意义。成员函数必须将第二个参数声明为常量左值引用(const &) 。友元函数则可以按值接受两个参数。

默认的运算符需要成员和可能的基类的支持:

  • 默认的operator ==需要成员和基类中对==的支持。
  • 默认的operator <=>需要成员和基类中对==以及已实现的operator <或默认的operator <=>的支持(详细内容见下文)。

对于生成的默认运算符,有以下规则:

  • 如果比较成员保证不抛出异常,那么该运算符是noexcept的。
  • 如果在编译时可以比较成员,那么该运算符是constexpr的。

对于空类,默认的运算符会将所有对象都视为相等:operator ==、operator <=和operator >=返回true,operator !=、operator <和operator >返回false,operator <=>返回std::strong_ordering::equal。

# 1.3.2 默认的operator<=>意味着默认的operator==

当且仅当operator<=>成员被定义为默认时,如果没有提供默认的operator==成员,那么会定义一个相应的operator==成员。所有方面(可见性、虚函数、属性、要求等)都会被沿用。例如:

template<typename T> class Type {
   ...
public:
    [[nodiscard]] virtual std::strong_ordering
    operator<=>(const Type&) const requires(!std::same_as<T,bool>) = default;
};
1
2
3
4
5
6

等同于以下代码:

template<typename T> class Type {
   ...
public:
    [[nodiscard]] virtual std::strong_ordering
    operator<=> (const Type&) const requires(!std::same_as<T,bool>) = default;
    [[nodiscard]] virtual bool
    operator== (const Type&) const requires(!std::same_as<T,bool>) = default;
};
1
2
3
4
5
6
7
8

例如,以下代码足以支持Coord类型对象的所有六个比较运算符:

// lang/coord.hpp
#include <compare>
struct Coord {
    double x{};
    double y{};
    double z{};
    auto operator<=>(const Coord&) const = default;
};
1
2
3
4
5
6
7
8

再次注意,成员函数必须是const的,并且参数必须声明为常量左值引用(const &)。

你可以如下使用这个数据结构:

// lang/coord.cpp
#include "coord.hpp"
#include <iostream>
#include <algorithm>

int main() {
    std::vector<Coord> coll{ {0, 5, 5}, {5, 0, 0}, {3, 5, 5}, {3, 0, 0}, {3, 5, 7} };
    std::sort(coll.begin(), coll.end());
    for (const auto& elem : coll) {
        std::cout << elem.x << "/ " << elem.y << "/ " << elem.z << "\n";
    }
}
1
2
3
4
5
6
7
8
9
10
11
12

该程序的输出如下:

0/5/5
3/0/0
3/5/5
3/5/7
5/0/0
1
2
3
4
5

# 1.3.3 默认operator<=>的实现

如果operator<=>是默认的,并且你有成员或基类,当调用关系运算符之一时,会发生以下情况:

  • 如果为成员或基类定义了operator<=>,则调用该运算符。
  • 否则,调用operator==和operator<,以确定(从成员或基类的角度来看):
    • 对象是否相等/等效(operator==返回true)。
    • 对象是小于还是大于。
    • 对象是否无序(仅在检查偏序时)。

在这种情况下,调用这些运算符的默认operator<=>的返回类型不能是auto。例如,考虑以下声明:

struct B {
    bool operator==(const B&) const;
    bool operator<(const B&) const;
};

struct D : public B {
    std::strong_ordering operator<=> (const D&) const = default;
};
1
2
3
4
5
6
7
8

那么:

D d1, d2;
d1 > d2;   // 调用B::operator==,可能还会调用B::operator<
1
2

如果operator==返回true,我们就知道>的结果为false,就结束了。否则,调用operator<来确定表达式是true还是false。

对于:

struct D : public B {
    std::partial_ordering operator<=> (const D&) const = default;
};
1
2
3

编译器甚至可能会调用两次operator<,以确定是否存在任何顺序关系。

对于:

struct B {
    bool operator==(const B&) const;
    bool operator<(const B&) const;
};

struct D : public B {
    auto operator<=> (const D&) const = default;
};
1
2
3
4
5
6
7
8

编译器不会编译任何使用关系运算符的调用,因为它无法确定基类的排序类别。在这种情况下,基类中也需要operator<=>。

不过,相等性检查是可行的,因为在D中,operator==会自动声明为等同于以下内容:

struct D : public B {
    auto operator<=> (const D&) const = default;
    bool operator== (const D&) const = default;
};
1
2
3
4

这意味着我们有以下行为:

D d1, d2;
d1 > d2;    // 错误:无法推断operator<=>的比较类别
d1 != d2;   // 可行(注意:仅尝试operator<=>和基类的B::operator==)
1
2
3

相等性检查始终仅使用基类的operator==(不过,它可能是根据默认的operator<=>生成的)。基类中的任何operator<或operator!=都会被忽略。如果D有一个B类型的成员,情况也是如此。

# 1.4 重写表达式的重载决议

最后,让我们详细说明在重写调用的支持下,使用比较运算符的表达式的求值过程。

# 调用相等运算符

为了编译x != y,编译器现在可能会尝试以下所有方式:

尝试方式 解释
x.operator!=(y) 调用x的成员operator!=
operator!=(x, y) 调用针对x和y的独立operator!=
!x.operator==(y) 调用x的成员operator==
! operator==(x, y) 调用针对x和y的独立operator==
!x.operator==(y) 调用由operator<=>为x生成的成员operator==
!y.operator==(x) 调用由operator<=>为y生成的成员operator==

尝试最后一种形式是为了支持第一个操作数的隐式类型转换,这要求该操作数是一个参数。

一般来说,编译器会尝试调用:

  • 独立的operator !=:operator!=(x, y)或成员operator !=:x.operator!=(y)。同时定义这两个operator !=会导致歧义错误。
  • 独立的operator ==:! operator==(x, y)或成员operator ==:!x.operator==(y)。注意,成员operator ==可能是由默认的operator<=>成员生成的。同样,同时定义这两个operator ==会导致歧义错误。如果成员operator==是由于默认的operator<=>而生成的,也是如此。

当需要对第一个操作数进行隐式类型转换时,编译器也会尝试重新排列操作数的顺序。考虑:

42 != y    // 42隐式转换为y的类型
1

在这种情况下,编译器会按以下顺序尝试调用:

  • 独立或成员operator !=。
  • 独立或成员operator ==(注意,成员operator ==可能是由默认的operator<=>成员生成的)。

注意,重写的表达式永远不会尝试调用成员operator !=。

# 调用关系运算符

对于关系运算符,我们有类似的行为,只是重写的语句会 fallback 到新的operator <=>,并将结果与0进行比较。该运算符的行为类似于一个三向比较函数,返回负值表示小于,返回0表示相等,返回正值表示大于(返回值不是数值,它只是一个支持相应比较的值)。

例如,为了编译x <= y,编译器现在可能会尝试以下所有方式:

尝试方式 解释
x.operator<=(y) 调用x的成员operator<=
operator<=(x, y) 调用针对x和y的独立operator<=
x.operator<=> (y) <= 0 调用x的成员operator<=>
operator<=> (x, y) <= 0 调用针对x和y的独立operator<=>
0 <= y.operator<=> (x) 调用y的成员operator<=>

同样,尝试最后一种形式是为了支持第一个操作数的隐式类型转换,为此它必须成为一个参数。

批注 1 原始的C++20标准在此处通过http://wg21.link/p2468r2进行了轻微修正。

# 1.5 在泛型代码中使用operator <=>

在泛型代码中,新的operator <=>带来了一些挑战。这是因为可能存在一些类型提供operator <=>,而另一些类型则提供部分或全部基本比较运算符。

# 1.5.1 compare_three_way

std::compare_three_way是一个用于调用operator <=>的新函数对象类型,就像std::less是用于调用operator <的函数对象类型一样。

你可以如下使用它:

  • 比较泛型类型的值。
  • 当你必须指定函数对象的类型时,将其作为默认类型。

例如:

template<typename T>
struct Value {
    T val{};
   ...
    auto operator<=> (const Value& v) const noexcept(noexcept(val<=>val)) {
        return std::compare_three_way{}(val<=>v.val);
    }
};
1
2
3
4
5
6
7
8

使用std::compare_three_way(与std::less类似)的好处在于,它甚至为原始指针定义了全序(而operator <=>或operator <并非如此)。因此,当使用可能是原始指针类型的泛型类型时,你应该使用它。

为了允许程序员前向声明operator<=>(),C++20还引入了类型特性std::compare_three_way_result以及别名模板std::compare_three_way_result_t:

template<typename T>
struct Value {
    T val{};
   ...
    std::compare_three_way_result_t<T,T>
    operator<=> (const Value& v) const noexcept(noexcept(val<=>val));
};
1
2
3
4
5
6
7

# 1.5.2 算法lexicographical_compare_three_way()

为了能够比较两个范围并生成匹配比较类别的值,C++20还引入了算法lexicographical_compare_three_way() 。这个算法对于为是集合的成员实现operator<=>特别有帮助。

例如:

// lib/lexicothreeway.cpp
#include <iostream>
#include <vector>
#include <algorithm>

std::ostream& operator<< (std::ostream& strm, std::strong_ordering val) {
    if (val < 0) return strm << "less ";
    if (val > 0) return strm << "greater ";
    return strm << "equal ";
}

int main() {
    std::vector v1{0, 8, 15, 47, 11};
    std::vector v2{0, 15, 8};
    auto r1 = std::lexicographical_compare(v1.begin(), v1.end(), v2.begin(), v2.end());
    auto r2 = std::lexicographical_compare_three_way(v1.begin(), v1.end(), v2.begin(), v2.end());
    std::cout << "r1: " << r1 << "\n";
    std::cout << "r2: " << r2 << "\n";
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

该程序的输出如下:

r1: 1
r2: less
1
2

注意,lexicographical_compare_three_way()目前还不支持范围(ranges)。你既不能将范围作为单个参数传递,也不能传递投影参数:

auto r3 = std::ranges::lexicographical_compare(v1, v2);            // 可行
auto r4 = std::ranges::lexicographical_compare_three_way(v1, v2);  // 错误
1
2

# 1.6 比较运算符的兼容性问题

引入新的比较规则后,事实证明C++20引入了一些问题,在从旧版本C++切换时可能会造成困扰。

# 1.6.1 委托独立的比较运算符

以下示例展示了最典型问题的本质:

// lang/spacecompat.cpp
#include <iostream>

class MyType {
private:
    int value;
public:
    MyType(int i)       // 从int隐式构造:
        : value{i} {}
    bool operator==(const MyType& rhs) const {
        return value == rhs.value;
    }
};

bool operator==(int i, const MyType& t) {
    return t == i;     // C++17中可行
}

int main() {
    MyType x = 42;
    if (0 == x) {
        std::cout << "0 == MyType{42} works\n";
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

我们有一个简单的类,它存储一个整数值,并具有一个隐式构造函数来初始化对象(隐式构造函数对于支持使用=进行初始化是必要的):

class MyType {
public:
    MyType(int i);     // 从int隐式构造
   ...
};

MyType x = 42;         // 可行
1
2
3
4
5
6
7

该类还声明了一个成员函数来比较对象:

class MyType {
   ...
    bool operator==(const MyType& rhs) const;
   ...
};
1
2
3
4
5

然而,在C++20之前,这仅对第二个操作数启用隐式类型转换。因此,该类或其他一些代码引入了一个全局运算符,用于交换参数的顺序:

bool operator==(int i, const MyType& t) {
    return t == i;     // 直到C++17都可行
}
1
2
3

将operator==()定义为“隐藏友元”(在类结构内部使用friend定义,这样两个操作数都成为参数,你可以直接访问成员,并且只有在至少一个参数类型匹配时才会执行隐式类型转换)对这个类来说会更好。然而,在C++20之前,上述代码的效果基本相同。

不幸的是,这段代码在C++20中不再起作用。它会导致无限递归。这是因为在全局函数内部,表达式t == i也可能调用全局的operator==()本身,因为编译器也会尝试将调用重写为i == t:

bool operator==(int i, const MyType& t) {
    return t == i;    // 除了t.operator(MyType{i})之外,还会找到operator==(i,t)
}
1
2
3

不幸的是,重写后的语句是更好的匹配,因为它不需要隐式类型转换。目前我们还没有解决这个问题以支持向后兼容性的办法;不过,编译器已经开始对这样的代码发出警告。

如果你的代码只需要在C++20下工作,你可以直接删除这个独立函数。否则,你有两个选择:

  • 使用显式转换:
bool operator==(int i, const MyType& t) {
    return t == MyType{i};     // 直到C++17和在C++20中都可行
}
1
2
3
  • 使用特性测试宏,一旦新特性可用就禁用该代码。

# 1.6.2 包含受保护成员的继承

对于具有默认比较运算符的派生类,如果基类将运算符定义为受保护成员,可能会出现问题。

注意,默认的比较运算符需要基类中比较操作的支持。因此,以下代码无法工作:

struct Base { };

struct Child : Base {
    int i;
    bool operator==(const Child& other) const = default;
};

Child c1, c2;
...
c1 == c2;      // 错误
1
2
3
4
5
6
7
8
9
10

因此,你还必须在基类中提供一个默认的operator ==。然而,如果你不希望基类为公共部分提供operator ==,一个明显的方法是在基类中添加一个受保护的默认operator ==:

struct Base {
    protected:
    bool operator==(const Base& other) const = default;
};

struct Child : Base {
    int i;
    bool operator==(const Child& other) const = default;
};
1
2
3
4
5
6
7
8
9

然而,在这种情况下,派生类的默认比较无法工作。当前实现默认运算符指定行为的编译器会拒绝这段代码,这里的规则过于严格。希望这个问题能很快得到修复(见http://wg21.link/cwg2568 (opens new window))。

作为一种解决方法,你必须自己实现派生类的运算符。

# 1.7 补充说明

关于隐式定义比较运算符的提议最初由Oleg Smolsky在http://wg21.link/n3950 (opens new window)中提出。随后Bjarne Stroustrup在http://wg21.link/n4175 (opens new window)中再次提出这个问题。然而,由Jens Maurer在http://wg21.link/p0221r2 (opens new window)中提出的最终提议措辞最终被否决,因为它是一个可选择不使用的特性(即,现有类型会自动拥有比较运算符,除非它们声明不想要这些运算符)。之后人们考虑了各种提议,Herb Sutter在http://wg21.link/p0515r0 (opens new window)中将这些提议汇总起来。

最终被接受的措辞由Herb Sutter、Jens Maurer和Walter E. Brown在http://wg21.link/p0515r3 (opens new window)和http://wg21.link/p0768r1 (opens new window)中提出。不过,Barry Revzin在http://wg21.link/p1185r2 (opens new window)、http://wg21.link/p1186r3 (opens new window)、http://wg21.link/p1614r2 (opens new window)和http://wg21.link/p1630r1 (opens new window)中提出的重大修改也被接受。

上次更新: 2025/03/20, 19:44:38
C++20 完全指南 说明
第2章 函数参数的占位符类型

← C++20 完全指南 说明 第2章 函数参数的占位符类型→

最近更新
01
C++语言面试问题集锦 目录与说明
03-27
02
第四章 Lambda函数
03-27
03
第二章 关键字static及其不同用法
03-27
更多文章>
Copyright © 2024-2025 沪ICP备2023015129号 张小方 版权所有
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式