第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); // 派生的检查
}
};
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)
...
}
2
3
4
这些运算符也可能被间接调用(例如,通过调用sort()
函数):
std::vector<Value> coll;
... ;
std::sort(coll.begin(), coll.end()); // 使用operator<进行排序
2
3
自C++20起,也可以使用范围来调用:
std::ranges::sort(coll); // 使用operator<进行排序
问题在于,尽管大多数运算符是基于其他运算符定义的(它们都基于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); // 派生的检查
}
};
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)
因此,对于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&);
或者:
class TypeA {
public:
...
bool operator==(const TypeB&) const;
};
2
3
4
5
编译器将能够编译:
MyType a;
MyType b;
...
a == b; // 没问题:完全匹配
b == a; // 没问题,重写为:a == b
a != b; // 没问题,重写为:!(a == b)
b != a; // 没问题,重写为:!(a == b)
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;
};
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;
};
2
3
4
会生成相应的成员operator==
,因此实际上我们得到:
class Value {
...
auto operator<=> (const Value& rhs) const = default;
auto operator== (const Value& rhs) const = default; // 隐式生成
};
2
3
4
5
其效果是,这两个运算符都使用它们的默认实现,该实现逐个成员地比较对象。这意味着类中成员的顺序很重要。
因此,有了:
class Value {
...
auto operator<=> (const Value& rhs) const = default;
};
2
3
4
我们就获得了使用所有六个比较运算符所需的一切。
此外,即便将运算符声明为成员函数,对于生成的运算符也有如下规则:
- 如果比较成员时从不抛出异常,那么它们是
noexcept
的。 - 如果在编译期能够比较成员,那么它们是
constexpr
的。 - 得益于重写机制,第一个操作数的隐式类型转换也得到了支持。
这表明,一般而言,operator==
和operator<=>
处理的事情虽不同但相互关联:
operator==
定义相等性,可被相等运算符==
和!=
使用。operator<=>
定义顺序,可被关系运算符<
、<=
、>
和>=
使用。
注意,在默认定义或使用operator <=>
时,必须包含<compare>
头文件。
#include <compare>
不过,大多数标准类型(字符串、容器、<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; // 定义顺序(<、<=、>和>=)
}
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
这意味着你可以指定哪些成员按何种顺序进行比较很重要,或者实现特殊的比较行为。
这些基本运算符的工作方式是,如果一个表达式使用了某个比较运算符,但没有找到匹配的直接定义,那么该表达式会被重写,以便能够使用这些运算符。
与相等运算符调用的重写类似,关系运算符操作数的顺序也可能被重写,这可能会使第一个操作数进行隐式类型转换。例如,如果
x <= y
没有找到匹配的operator<=
定义,它可能会被重写为
(x <=> y) <= 0
甚至
0 <= (y <=> x)
从这种重写方式可以看出,新的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)
请注意,为了处理operator <=>
的结果,你必须包含一个特定的头文件:
#include <compare>
这适用于声明(默认定义)、实现或使用它的情况。例如:
#include <compare> // 用于调用<=>
auto x = 3 <=> 4; // 没有包含<compare>头文件时无法编译
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;
}
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;
}
};
2
3
4
5
6
7
8
然而,通常通过将运算符映射到基础类型的结果来定义它会更容易。因此,对于上面的成员operator<=>
,只返回其成员值的值和类别会更好:
class MyType {
...
auto operator<=> (const MyType& rhs) const {
return value <=> rhs.value;
}
};
2
3
4
5
6
这不仅返回了正确的值,还确保返回值根据成员值的类型具有正确的比较类别类型。
# 1.2.4 直接调用operator <=>
你可以直接调用任何已定义的operator <=>
:
MyType x, y;
...
x <=> y // 产生一个结果比较类别类型的值
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的关系比较
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
2
对于你自己定义的类型,你只需要将operator<=>
定义为成员函数或独立函数即可。
由于返回类型取决于比较类别,你可以检查特定的返回值:
if (x <=> y == std::partial_ordering::equivalent) // 总是可行
由于存在向较弱排序类型的隐式类型转换,即使operator<=>
返回strong_ordering
或weak_ordering
值,这段代码也能编译通过。
反之则不行。如果比较的结果是weak_ordering
或partial_ordering
值,你不能将其与strong_ordering
值进行比较。
if (x <=> y == std::strong_ordering::equal) // 可能无法编译
不过,与0进行比较总是可行的,而且通常更简便:
if (x <=> y == 0) // 总是可行
此外,由于关系运算符调用的新重写机制,operator<=>
可能会被间接调用:
if (!(x < y || y < x)) // 可能会调用operator<=>来检查相等性
或者:
if (x <= y && y <= x) // 可能会调用operator<=>来检查相等性
注意,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; // 最终排序成员
}
};
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
}
};
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用作返回类型
}
};
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; // 用作或转换为公共比较类型
}
};
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;
}
};
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);
}
};
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);
}
};
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;
};
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;
};
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;
};
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";
}
}
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
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;
};
2
3
4
5
6
7
8
那么:
D d1, d2;
d1 > d2; // 调用B::operator==,可能还会调用B::operator<
2
如果operator==
返回true
,我们就知道>
的结果为false
,就结束了。否则,调用operator<
来确定表达式是true
还是false
。
对于:
struct D : public B {
std::partial_ordering operator<=> (const D&) const = default;
};
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;
};
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;
};
2
3
4
这意味着我们有以下行为:
D d1, d2;
d1 > d2; // 错误:无法推断operator<=>的比较类别
d1 != d2; // 可行(注意:仅尝试operator<=>和基类的B::operator==)
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的类型
在这种情况下,编译器会按以下顺序尝试调用:
- 独立或成员
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);
}
};
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));
};
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";
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
该程序的输出如下:
r1: 1
r2: less
2
注意,lexicographical_compare_three_way()
目前还不支持范围(ranges)。你既不能将范围作为单个参数传递,也不能传递投影参数:
auto r3 = std::ranges::lexicographical_compare(v1, v2); // 可行
auto r4 = std::ranges::lexicographical_compare_three_way(v1, v2); // 错误
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";
}
}
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; // 可行
2
3
4
5
6
7
该类还声明了一个成员函数来比较对象:
class MyType {
...
bool operator==(const MyType& rhs) const;
...
};
2
3
4
5
然而,在C++20之前,这仅对第二个操作数启用隐式类型转换。因此,该类或其他一些代码引入了一个全局运算符,用于交换参数的顺序:
bool operator==(int i, const MyType& t) {
return t == i; // 直到C++17都可行
}
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)
}
2
3
不幸的是,重写后的语句是更好的匹配,因为它不需要隐式类型转换。目前我们还没有解决这个问题以支持向后兼容性的办法;不过,编译器已经开始对这样的代码发出警告。
如果你的代码只需要在C++20下工作,你可以直接删除这个独立函数。否则,你有两个选择:
- 使用显式转换:
bool operator==(int i, const MyType& t) {
return t == MyType{i}; // 直到C++17和在C++20中都可行
}
2
3
- 使用特性测试宏,一旦新特性可用就禁用该代码。
# 1.6.2 包含受保护成员的继承
对于具有默认比较运算符的派生类,如果基类将运算符定义为受保护成员,可能会出现问题。
注意,默认的比较运算符需要基类中比较操作的支持。因此,以下代码无法工作:
struct Base { };
struct Child : Base {
int i;
bool operator==(const Child& other) const = default;
};
Child c1, c2;
...
c1 == c2; // 错误
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;
};
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)中提出的重大修改也被接受。