三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

C++20三路比较运算符:强序、弱序与偏序的实战解析与健壮性提升

C++20三路比较运算符:强序、弱序与偏序的实战解析与健壮性提升

1. 项目概述:为什么我们需要关心“序”?

如果你写过C++,肯定没少和比较运算符打交道。<,>,==这些符号就像代码里的标点,天天见,但你真的了解它们背后的“秩序”吗?在C++20之前,我们可能只是机械地重载这些运算符,让自定义类型能排序、能放进std::setstd::map里。但很多时候,代码能编译、能跑,不代表它就是“健壮”的。一个隐藏的bug可能正潜伏在某个角落,等着在数据边界或特殊情况下给你致命一击。

C++20引入的三路比较运算符<=>(俗称“飞船运算符”)及其返回的三种“序”类型——std::strong_ordering,std::weak_ordering,std::partial_ordering——正是为了解决这些深层次的健壮性问题。这不仅仅是语法糖,更是一套关于“如何正确比较两个对象”的严谨数学和工程模型。选错了“序”,你的数据结构可能产生不符合直觉的行为,算法可能陷入死循环,甚至导致未定义行为。

这篇文章,我们就来彻底搞懂这三种“序”。我不会只停留在概念上,而是会通过大量实战代码,带你看看在不同场景下,选择不同的“序”会如何影响你的程序行为,以及如何利用它们来真正提升代码的健壮性。无论你是正在学习C++20新特性的新手,还是想优化现有代码库的老手,理解这一步都至关重要。

2. 核心概念拆解:强序、弱序与偏序到底在说什么?

在深入代码之前,我们必须先建立清晰的概念模型。这三种“序”描述的是两个元素之间比较关系的“强度”和“完整性”。

2.1 强序:最严格的“全等”比较

想象一下比较两个整数int aint b。结果只有三种可能:a < b,a == b,a > b。这三种情况互斥且完备,覆盖了所有可能性。更重要的是,如果a == b,那么在任何可观察的行为上,ab都是完全可互换的,不会产生任何副作用差异。这就是强序

核心特征:

  1. 反对称性:如果a < b为真,则b > a也为真。
  2. 传递性:如果a < bb < c,则a < c
  3. 连通性:对于任意两个元素,<,==,>三者必居其一。
  4. 相等即全等a == b意味着ab在所有方面都相等,可以互相替换而不改变程序语义。

实战意义:强序是要求最严格的。对于像intstd::string(按字典序)这样的类型,它们天生就是强序的。当你为自定义类型= default三路比较运算符时,如果所有成员都是强序可比的,编译器也会生成强序比较。

2.2 弱序:允许“等价但不全等”的比较

现在考虑一个更实际的场景:按姓名拼音排序一个学生列表。有两个学生,“张三”和“张叁”。在拼音排序下,它们可能被认为是“相等”的(都排在“张”开头相近位置)。但在现实意义上,他们是两个不同的个体。这种“在某种排序规则下视为相同,但实际并非同一对象”的关系,就是弱序

核心特征:

  1. 它同样满足反对称性和传递性。
  2. 关键区别在于“等价”不等于“相等”。我们用equivalent而非equal。如果ab等价(!(a < b) && !(b < a)),并不意味着a == b。它们只是在这个特定的排序规则下无法区分。

最常见的例子:std::string的大小写不敏感比较。

struct CaseInsensitiveString { std::string s; // 实现一个忽略大小写的弱序比较 std::weak_ordering operator<=>(const CaseInsensitiveString& other) const { // 使用自定义比较逻辑,例如将字符串转为小写后比较 // 这里简化处理,实际需考虑本地化等问题 auto cmp = [](char c1, char c2) { return std::tolower(c1) <=> std::tolower(c2); }; return std::lexicographical_compare_three_way(s.begin(), s.end(), other.s.begin(), other.s.end(), cmp); } bool operator==(const CaseInsensitiveString& other) const { // 注意:等价性比较(用于<=>)和相等性比较(用于==)可以是不同的! // 这里为了简单,假设==也是大小写不敏感的。但理论上,==可以更严格。 return std::equal(s.begin(), s.end(), other.s.begin(), other.s.end(), [](char c1, char c2) { return std::tolower(c1) == std::tolower(c2); }); } };

在这个例子里,“Hello”“hello”在弱序比较下是equivalent的,但它们并不是同一个字符串对象。std::set<CaseInsensitiveString>可以正常使用,但容器里不能同时存在“Hello”“hello”,因为它们等价。

关键心得:弱序是STL有序容器(如std::set,std::map,std::sort)的“最低要求”。容器不关心元素是否“全等”,只关心它们能否被稳定地排序。因此,为自定义类型实现弱序,是让它进入有序世界的关键。

2.3 偏序:存在“不可比”情况的比较

有些事物天生就无法在所有情况下进行比较。最经典的例子就是浮点数中的NaN(Not a Number)。任何与NaN的比较操作(<,>,==)都返回falseNaN既不小于、也不等于、也不大于任何其他浮点数(包括它自己)。这种存在“不可比”状态的关系,就是偏序

核心特征:

  1. 不满足连通性。存在元素对(a, b),使得a < ba == ba > b三者都不成立。这种状态称为unordered
  2. 它仍然满足反对称性和传递性(在可比元素之间)。

为什么这很重要?如果你的类包含了浮点数成员,并且使用= default来生成比较运算符,编译器会自动选择std::partial_ordering作为返回类型,因为浮点数的比较本身就是偏序。忽略这一点可能导致严重错误。

struct Vertex { float x, y, z; // 包含浮点数,默认是三路比较是 partial_ordering auto operator<=>(const Vertex&) const = default; }; void dangerous_code() { Vertex v1{1.0f, 2.0f, std::numeric_limits<float>::quiet_NaN()}; Vertex v2{1.0f, 2.0f, 3.0f}; auto cmp = v1 <=> v2; if (cmp == std::partial_ordering::less) { /* 可能永远不会进入 */ } else if (cmp == std::partial_ordering::greater) { /* 可能永远不会进入 */ } else if (cmp == std::partial_ordering::equivalent) { /* 可能永远不会进入 */ } else { // 必须处理 unordered 的情况! std::cout << "Vertices are not comparable due to NaN!\n"; } }

避坑指南:使用auto作为operator<=>的返回类型是个好习惯,编译器会为你选择最合适的序类型(强序、弱序或偏序)。但你必须意识到,当返回类型是partial_ordering时,你的比较结果可能有四种状态,而不是三种。在条件判断中必须显式处理std::partial_ordering::unordered,否则逻辑会出错。

3. 实战对比:不同“序”如何影响代码行为与健壮性

理论说再多,不如代码跑一跑。我们通过几个具体的、有对比性的例子,来看看选择不同的“序”类型,会如何实实在在地影响程序。

3.1 案例一:自定义“分数”类型

我们要实现一个表示分数Fraction的类,包含分子和分母。比较规则是按数值大小。

方案A:错误地使用强序(未规范化)

struct FractionBad { int nume; // 分子 int denom; // 分母 (假设总为正数) // 错误:直接比较交叉乘积,认为数值相等就是全等 std::strong_ordering operator<=>(const FractionBad& other) const { int64_t lhs = static_cast<int64_t>(nume) * other.denom; int64_t rhs = static_cast<int64_t>(other.nume) * denom; if (lhs < rhs) return std::strong_ordering::less; if (lhs > rhs) return std::strong_ordering::greater; return std::strong_ordering::equal; // 问题所在! } bool operator==(const FractionBad& other) const { return (*this <=> other) == std::strong_ordering::equal; } }; void test_strong_bad() { FractionBad a{1, 2}; // 1/2 FractionBad b{2, 4}; // 2/4,数值上等于1/2 std::set<FractionBad> s; s.insert(a); s.insert(b); // 插入成功!因为 a == b 为 false (1*4 != 2*2) std::cout << "Set size (strong, bad): " << s.size() << "\n"; // 输出 2 // 在集合中,数值相等的分数被当成了两个不同元素,这违背了数学直觉和集合语义。 }

问题分析:FractionBad声称自己是强序,意味着a == bab应可互换。但1/22/4在数值上相等,内部表示却不同。将它们同时插入一个std::set,集合会认为它们是两个不同的键,这显然不是我们想要的。强序的“相等即全等”承诺被打破了。

方案B:正确的弱序实现

struct FractionGood { int nume; int denom; private: // 辅助函数:计算最简分数并比较(模拟) int64_t value_for_compare() const { // 实际实现需要计算最大公约数进行约分,这里用交叉乘积模拟比较值 // 关键点:将分数规范化到一个“规范形式”进行比较 int64_t g = std::gcd(nume, denom); // C++17 int64_t canon_nume = nume / g; int64_t canon_denom = denom / g; // 返回一个用于比较的规范值(例如,将规范形式映射到一个整数) // 更简单的做法:直接比较约分后的交叉积 return canon_nume * canon_denom; // 这只是一个示意,实际比较逻辑应直接比较约分后的分子分母 } public: // 正确的做法:声明为弱序 std::weak_ordering operator<=>(const FractionGood& other) const { // 先约分,再比较约分后的分子和分母 auto [n1, d1] = normalized(); auto [n2, d2] = other.normalized(); if (auto cmp = n1 <=> n2; cmp != 0) return cmp; return d1 <=> d2; } bool operator==(const FractionGood& other) const { auto [n1, d1] = normalized(); auto [n2, d2] = other.normalized(); return n1 == n2 && d1 == d2; } private: std::pair<int, int> normalized() const { int g = std::gcd(nume, denom); return {nume / g, denom / g}; } }; void test_weak_good() { FractionGood a{1, 2}; FractionGood b{2, 4}; std::set<FractionGood> s; s.insert(a); auto [it, inserted] = s.insert(b); // 插入失败!因为 a 和 b 等价 std::cout << "Set size (weak, good): " << s.size() << "\n"; // 输出 1 std::cout << "Was b inserted? " << std::boolalpha << inserted << "\n"; // 输出 false }

健壮性提升:FractionGood诚实地声明为弱序。它在比较前先将分数化为最简形式(规范形式)。这样,1/22/4就有了相同的规范表示(1, 2),在比较时是等价的。std::set正确地将其视为同一个键,符合数学上的集合语义。这避免了数据重复和逻辑错误。

3.2 案例二:包含浮点数的复合类型

这是一个更容易踩坑的场景。假设我们有一个SensorReading类型,包含时间戳和数值读数。

struct SensorReading { std::chrono::system_clock::time_point timestamp; double value; // 可能为 NaN // 方案A:让编译器决定(偏序) auto operator<=>(const SensorReading&) const = default; // 方案B:手动实现,忽略NaN的偏序问题(危险!) // std::strong_ordering operator<=>(const SensorReading& other) const { // if (timestamp < other.timestamp) return std::strong_ordering::less; // if (timestamp > other.timestamp) return std::strong_ordering::greater; // // 直接比较value,如果value是NaN,这里的行为是未定义的(与NaN比较返回false) // if (value < other.value) return std::strong_ordering::less; // if (value > other.value) return std::strong_ordering::greater; // return std::strong_ordering::equal; // } }; void test_sensor() { using namespace std::chrono; SensorReading r1{system_clock::now(), 3.14}; SensorReading r2{system_clock::now() + seconds(1), std::numeric_limits<double>::quiet_NaN()}; auto cmp = r1 <=> r2; std::cout << "Comparison result (as int): "; if (cmp < 0) std::cout << "less\n"; else if (cmp > 0) std::cout << "greater\n"; else if (cmp == 0) std::cout << "equivalent\n"; else std::cout << "unordered\n"; // 当timestamp相同,value一方为NaN时,会进入这里 // 尝试排序 std::vector<SensorReading> readings = {r1, r2, SensorReading{system_clock::now(), 2.71}}; // std::sort(readings.begin(), readings.end()); // 危险!如果存在NaN,排序结果可能不符合严格弱序,导致未定义行为 // 更安全的做法:在排序前过滤掉NaN,或者使用自定义比较器处理NaN std::sort(readings.begin(), readings.end(), [](const SensorReading& a, const SensorReading& b) { if (a.timestamp != b.timestamp) return a.timestamp < b.timestamp; // 处理NaN:定义NaN比任何数字都大(或都小),使其可排序 bool a_is_nan = std::isnan(a.value); bool b_is_nan = std::isnan(b.value); if (a_is_nan && b_is_nan) return false; // 相等 if (a_is_nan) return false; // NaN 更大,所以 a > b if (b_is_nan) return true; // NaN 更大,所以 a < b return a.value < b.value; }); }

关键抉择与健壮性:

  • 方案A(auto = default:最安全。编译器看到double成员,会自动生成返回std::partial_ordering的比较。这迫使你意识到NaN的存在,并在使用比较结果时处理unordered状态。这是“诚实的”代码。
  • 方案B(手动强序):极其危险。它无视了double可能是NaN的事实,强行返回强序。当valueNaN时,比较逻辑失效(NaN < 3.14NaN == 3.14NaN > 3.14均为假),导致函数无法返回有效的strong_ordering值,这违反了比较运算符的语义约定,后续任何依赖于此的操作(如排序、插入容器)都将导致未定义行为。

核心教训:对于包含浮点数的类,除非你能百分百保证该浮点数永远不会是NaN(例如,来自经过严格校验的数据源),否则就应该接受并使用partial_ordering。试图“欺骗”编译器返回强序或弱序,是在代码里埋下了一颗定时炸弹。

3.3 案例三:模拟“弱序”场景——不区分大小写的字符串集合

让我们实现一个真正需要弱序的场景:一个不区分大小写的字符串集合。

class CaseInsensitiveCharTraits : public std::char_traits<char> { public: static bool eq(char c1, char c2) { return std::toupper(c1) == std::toupper(c2); } static bool lt(char c1, char c2) { return std::toupper(c1) < std::toupper(c2); } static int compare(const char* s1, const char* s2, size_t n) { while (n-- != 0) { char uc1 = std::toupper(*s1); char uc2 = std::toupper(*s2); if (uc1 < uc2) return -1; if (uc1 > uc2) return 1; ++s1; ++s2; } return 0; } }; using CaseInsensitiveString = std::basic_string<char, CaseInsensitiveCharTraits>; // C++20 下,我们可以直接利用新的比较特性 struct CaseInsensitiveKey { std::string key; // 自定义三路比较,返回 weak_ordering std::weak_ordering operator<=>(const CaseInsensitiveKey& other) const { auto cmp = [](char c1, char c2) { return std::toupper(c1) <=> std::toupper(c2); }; return std::lexicographical_compare_three_way(key.begin(), key.end(), other.key.begin(), other.key.end(), cmp); } // 注意:operator== 应该与 <=> 在等价性上保持一致 bool operator==(const CaseInsensitiveKey& other) const { return std::equal(key.begin(), key.end(), other.key.begin(), other.key.end(), [](char c1, char c2) { return std::toupper(c1) == std::toupper(c2); }); } }; void test_case_insensitive() { std::set<CaseInsensitiveKey> insensitive_set; insensitive_set.insert({"Hello"}); insensitive_set.insert({"HELLO"}); insensitive_set.insert({"world"}); std::cout << "Case-insensitive set contents:\n"; for (const auto& k : insensitive_set) { std::cout << " " << k.key << "\n"; // 可能只输出 "Hello" 和 "world" } std::cout << "Size: " << insensitive_set.size() << "\n"; // 很可能是 2 // 对比:普通字符串集合是强序 std::set<std::string> sensitive_set; sensitive_set.insert("Hello"); sensitive_set.insert("HELLO"); std::cout << "Case-sensitive set size: " << sensitive_set.size() << "\n"; // 输出 2 }

设计解析:CaseInsensitiveKey的核心在于其operator<=>返回std::weak_ordering。它明确告诉标准库和任何使用者:“我的比较规则认为‘Hello’‘HELLO’是等价的,但它们可能不是同一个字符串。” 这使得std::set能够正确地将其作为一个键来处理,避免了大小写不同导致的重复键问题,同时保持了集合的有序性。这正是弱序的典型应用:定义一种“宽松”的、用于排序的等价关系,而非严格的相等关系。

4. 深入原理与“为什么”:编译器如何生成与转换

理解了怎么用,我们再来深挖一层,看看编译器背后做了什么,以及为什么这些规则能提升健壮性。

4.1 编译器默认生成的规则

当你为类Xauto operator<=>(const X&) const = default;时,编译器会:

  1. 递归地比较每个基类和非静态数据成员,使用其自身的operator<=>
  2. 根据所有成员的比较结果,综合决定返回类型。规则是:
    • 如果所有成员都返回std::strong_ordering,则整体返回std::strong_ordering
    • 否则,如果所有成员都返回std::strong_orderingstd::weak_ordering,则整体返回std::weak_ordering
    • 否则(即存在返回std::partial_ordering的成员,比如double),整体返回std::partial_ordering
  3. 同时,编译器会自动生成==!=运算符(除非你显式提供了)。

这个自动推导规则是健壮性的第一道保障。它确保了你的默认比较操作不会“过度承诺”。如果一个类含有浮点数,编译器绝不会帮你生成一个强序比较,从而从源头上避免了将NaN误当作可比较值使用的风险。

4.2 序类型之间的安全转换

三种序类型之间存在隐式的、有方向的转换关系:

  • std::strong_ordering可以隐式转换为std::weak_orderingstd::partial_ordering
  • std::weak_ordering可以隐式转换为std::partial_ordering
  • 反之则不行(partial_ordering不能转为weak_orderingstrong_ordering)。

这体现了类型系统的力量。强序是要求最严格的,所以它可以“降级”为要求更宽松的弱序或偏序。但偏序包含了unordered这种特殊状态,无法向上转换为要求“所有元素可比”的弱序或强序。如果你尝试这样做,编译器会报错,阻止了不安全的操作。

std::strong_ordering strong_cmp = 5 <=> 3; // ok std::weak_ordering weak_cmp = strong_cmp; // ok, 强转弱 std::partial_ordering partial_cmp = weak_cmp; // ok, 弱转偏 // std::strong_ordering s = partial_cmp; // 错误!不允许从偏序转到强序 // std::weak_ordering w = partial_cmp; // 错误!不允许从偏序转到弱序

这种设计迫使你在处理可能不可比的数据时,必须显式地处理unordered状态,从而编写出更健壮的代码。

4.3 重载决议与代码简化

C++20的三路比较不仅引入了新的返回类型,还极大地简化了比较运算符的重载。以前你需要写6个(或者至少写==,<,然后用它们推导出其他的),现在通常只需要写==<=>两个。

更重要的是,operator<=>可以“反身生成”。如果你为AB类型定义了operator<=>(const A&, const B&),编译器会自动为你生成operator<=>(const B&, const A&),其结果是原比较的逆序。这消除了大量重复和容易出错的样板代码。

struct MyInt { int value; // 只需要定义一个三路比较 auto operator<=>(const MyInt&) const = default; }; // 编译器自动生成所有六个比较运算符:<, >, <=, >=, ==, != // 并且保证了它们之间的一致性。

一致性是健壮性的基石。手动实现多个运算符时,很容易不小心让a < ba > b同时为真,或者a == ba < b也为真,这会导致排序算法和容器行为错乱。编译器生成的操作符绝对保证数学上的一致性。

5. 常见陷阱、调试技巧与最佳实践

即使理解了概念,在实际编码中依然会遇到各种坑。这里分享一些我踩过的坑和总结的经验。

5.1 陷阱一:混淆“等价”与“相等”

这是使用弱序时最容易出错的地方。记住:对于弱序,!(a < b) && !(b < a)仅意味着ab等价,意味着a == b为真。operator==应该被单独定义,它可以(有时也应该)比<=>的等价性检查更严格。

struct Person { std::string name; // 用于排序和唯一标识(弱序) int id; // 真正的唯一标识符(强相等) // 按姓名排序(允许同名,但id不同) std::weak_ordering operator<=>(const Person& other) const { return name <=> other.name; } // 必须同时定义 ==,它应该比较id(或所有成员)以实现真正的相等 bool operator==(const Person& other) const { return id == other.id; // 或者 return name == other.name && id == other.id; } }; void test_person() { Person alice{"Alice", 1}; Person alice2{"Alice", 2}; std::set<Person> people_by_name; // 使用弱序比较器 people_by_name.insert(alice); people_by_name.insert(alice2); // 插入失败!因为 name 等价,set认为键已存在。 // 但 alice == alice2 是 false (id不同)。 // 如果我们需要一个允许同名但区分id的“有序集合”,就不能直接用std::set。 // 可能需要使用std::multiset,或者提供自定义的比较器,将id作为次级排序键。 }

最佳实践:始终同时考虑<=>==的语义。<=>定义了“排序键”,==定义了“对象同一性”。对于作为关联容器键的类型,确保基于<=>的等价性判断符合容器的去重逻辑。

5.2 陷阱二:在偏序上下文中忘记检查unordered

任何使用partial_ordering作为返回类型的比较,其结果都必须被谨慎处理。直接将其用于if (a < b)这样的条件是安全的,因为表达式a < b会被转换为(a <=> b) < 0,而如果a <=> b的结果是unordered,这个比较会返回false。但如果你直接使用<=>的结果,就必须检查。

double d1 = 1.0; double d2 = NAN; auto cmp = d1 <=> d2; // 错误用法:假设cmp是三种状态之一 switch (std::partial_ordering::_CmpInt(cmp)) { // 内部转换函数,仅作示例 case -1: /* less */ break; case 0: /* equivalent */ break; // 对于NaN,永远不会到这里 case 1: /* greater */ break; default: /* 应该处理unordered,但这里漏了 */ break; } // 正确用法:使用if-else链明确处理所有四种状态 if (cmp < 0) { /* d1 < d2 */ } else if (cmp > 0) { /* d1 > d2 */ } else if (cmp == 0) { /* d1 == d2 */ } else { /* d1 和 d2 不可比 (unordered) */ }

调试技巧:如果你发现一个包含浮点数的自定义类型在排序或查找时行为诡异(比如元素“消失”或顺序混乱),第一时间检查是否是因为NaN导致了unordered状态。可以在自定义的operator<=>中添加调试输出,或者使用std::isnan()在比较前检查数据。

5.3 陷阱三:性能考虑与operator<=>的实现

虽然<=>很强大,但并非所有情况都适合用它。对于某些类型,实现一个高效的<=>可能比分别实现<==更复杂或更低效。

struct BigStringPair { std::string str1; std::string str2; // 方案A:使用默认三路比较(可能低效) // auto operator<=>(const BigStringPair&) const = default; // 这会先比较str1,如果相等再比较str2。对于长字符串,如果str1不同,str2的比较就是浪费。 // 方案B:手动实现,优化常见情况 std::strong_ordering operator<=>(const BigStringPair& other) const { // 先比较大小,这是一个快速检查 if (str1.size() != other.str1.size()) return str1.size() <=> other.str1.size(); // 假设size比较能反映字典序,这不总是成立! // 实际上,我们需要比较内容。但这里演示的是优化思路。 // 更好的优化可能是先比较第一个字符,或者使用自定义的哈希/比较策略。 if (auto cmp = str1 <=> other.str1; cmp != 0) return cmp; return str2 <=> other.str2; } bool operator==(const BigStringPair& other) const { // == 可以单独优化,例如先比较地址,再比较大小,最后比较内容 return str1 == other.str1 && str2 == other.str2; } };

最佳实践:对于简单的、成员都是基础类型或已有高效比较的类型,毫不犹豫地使用= default。对于复杂的、需要特定比较逻辑(如不区分大小写、比较部分成员、处理特殊值如NaN)的类型,手动实现<=>==。在手动实现时,仔细思考比较的短路逻辑,将最可能区分出结果的、计算成本最低的比较放在前面。

5.4 如何为现有代码库引入三路比较

如果你在维护一个大型的、C++20之前的代码库,全面重写所有比较运算符可能不现实。可以采取渐进式策略:

  1. 为新类使用新特性:所有新编写的类,只要需要比较,就优先使用= default或手动实现<=>/==
  2. 逐步改造关键类:对于在性能关键路径上或经常用于排序/查找的现有类,可以考虑将其比较运算符更新为三路比较。确保新的实现与旧的operator<operator==在语义上完全一致。
  3. 利用operator<=>生成其他运算符:即使你不打算立刻使用<=>,也可以为现有类添加一个default<=>,让编译器为你生成一致的<,>,<=,>=运算符,减少维护负担和出错概率。
  4. 注意ABI兼容性:在已发布的库中修改比较运算符的签名(如返回类型)可能破坏二进制兼容性。评估影响范围。

理解并正确应用C++20的强序、弱序和偏序,是迈向更健壮、更清晰、更高效C++代码的关键一步。它迫使你更深入地思考数据的本质和比较的语义,从而在编译期就能捕获许多潜在的逻辑错误。从今天开始,在你下一个需要比较的自定义类型中,尝试使用auto operator<=>(...) const = default,感受一下现代C++带来的简洁与安全吧。

← 返回列表