写在前面
C++20 是 C++ 语言发展史上一个里程碑式的版本,它带来的特性之多、影响之深,堪称"小 C++11"。而在 C++20 引入的众多特性中,有两个看起来不那么"炫酷"、但实际日常开发中每天都可能用到的工具——三路比较运算符(<=>,俗称太空船运算符) 和 std::format 格式化库——它们从最基础的层面改变了我们写比较逻辑和格式化输出的方式。
在 C++20 之前,如果你想让一个自定义类型支持所有的比较操作——<、<=、>、>=、==、!=,你需要手动重载六个运算符。这不仅是体力活,更严重的问题是:很难保证所有运算符的逻辑一致性。人不是机器,在写第六个运算符的时候,很可能已经忘了第一个是怎么写的,于是出现 a < b 和 b > a 结果不一致的 bug 简直是家常便饭。至于格式化输出,printf 的类型不安全问题和 iostream 的冗长语法同样让人头疼——printf("%s", 42) 编译时不会报错,但运行时直接崩溃;而 cout << "Hello, " << name << "! You are " << age << " years old." << endl; 这种写法,读起来费劲,写起来更费劲。
那么在本篇博客中,我们就要来学习这些内容~~
第一章 三路比较运算符(<=>)全景解析
1.1 为什么需要三路比较运算符
在 C++20 之前,想让一个自定义类型支持比较操作,需要写大量的重复代码。
假设有一个 Point 结构体,包含两个整数坐标 x 和 y。如果想让 Point 支持所有六种比较操作,需要写六个运算符重载:
struct Point {
int x;
int y;
// 需要重载 6 个比较运算符…
bool operator==(const Point& other) const {
return x == other.x && y == other.y;
}
bool operator!=(const Point& other) const {
return !(*this == other);
}
bool operator<(const Point& other) const {
if (x != other.x) return x < other.x;
return y < other.y;
}
bool operator<=(const Point& other) const {
return *this < other || *this == other;
}
bool operator>(const Point& other) const {
return !(*this <= other);
}
bool operator>=(const Point& other) const {
return !(*this < other);
}
};
这段代码有以下几个问题:
- 重复劳动:六个运算符,逻辑本质上是相同的,但必须逐个手写。对于一个大型项目中的几十个类型,工作量可想而知。
- 维护困难:如果 Point 以后增加了 z 坐标,需要修改所有六个运算符,漏掉任何一个都会导致不一致。
- 容易出错:operator> 的实现依赖于 operator<=,operator<= 又依赖于 operator< 和 operator==,这种依赖链条越长,出现逻辑错误的概率就越大。
- 性能问题:operator!= 调用 operator==,operator< 调用两次比较——这种间接调用在某些情况下会带来不必要的性能开销。
三路比较运算符 <=> 的诞生,就是为了从根本上解决这个问题。它的核心思想是:只需要实现一个比较函数,描述"当前对象比另一个对象小、相等、还是大",编译器就能自动推导出所有六种比较操作的结果。不管你的类型有多少个成员变量,只要定义好 <=>,剩下的交给编译器,请叫编译器为小聪明~~
1.2 三路比较运算符的基本概念
<=> 运算符的官方名称是"三路比较运算符"(Three-way Comparison Operator),但因为它看起来像一艘太空船(spaceship),所以大家更习惯叫它"太空船运算符"。
语法:
auto result = a <=> b;
这个表达式对 a 和 b 进行比较,然后返回一个比较结果对象。这个对象的类型不是 int,而是 std::strong_ordering、std::weak_ordering 或 std::partial_ordering 三者之一。这三种类型分别对应不同的比较语义严格程度,后面会详细解释。
核心用法:<=> 的返回结果可以跟字面量 0 进行比较,从而判断两个值之间的关系:
- (a <=> b) < 0 → a < b
- (a <=> b) == 0 → a == b
- (a <=> b) > 0 → a > b
- (a <=> b) <= 0 → a <= b
- (a <=> b) >= 0 → a >= b
为什么返回值不能直接用 int?这个问题很多人在第一次接触 <=> 时都会问。既然 <=> 的结果能跟 0 比较,那为什么不直接返回一个 int 呢?-1 表示小于,0 表示等于,1 表示大于,多简单?
答案在于:不是所有类型的比较都能用"小于、等于、大于"这三个状态来完整描述。浮点数就是一个典型的例子——NaN(Not a Number)和任何值比较都是"不可比较的"(unordered),既不是小于,也不是等于,也不是大于。如果用 int 返回,无法表达"不可比较"这个第四种状态。再比如,不区分大小写的字符串比较,"Hello" 和 "HELLO" 在忽略大小写时是"等价的",但它们不是"完全相同"的——你不能用 "Hello" 去替换 "HELLO" 而不改变程序行为。int 返回值无法区分"完全相等"和"等价但不等同"这两种不同的语义。
三种比较类别就是为了解决这些问题而设计的。它们本质上是对比较结果的一种类型层面的精细化分类,让编译器在编译期就能知道某个类型比较的语义严格程度,从而在生成代码时做出最优决策。
第一个完整的示例:
#include <iostream>
// 注意:基本类型的 <=> 运算符不需要引入 <compare> 头文件
// 但自定义类型使用 <=> 时,建议包含 <compare> 以使用比较类别类型
int main()
{
int a = 0, b = 0;
// 从标准输入读取两个整数
std::cout << "请输入两个整数: ";
std::cin >> a >> b;
// 使用三路比较运算符进行比较
// a <=> b 返回 std::strong_ordering 类型
// 因为 int 是基本类型,比较结果必然是强序
auto result = a <=> b;
// 将比较结果与字面量 0 进行比较
// 这里 result < 0 的 < 运算符是 std::strong_ordering 类型
// 与字面量 0 之间的比较,不是 int 之间的比较
if (result < 0) {
std::cout << "a < b" << '\\n';
}
else if (result == 0) {
// 注意:这里是 result == 0,不是 result == 0
// 0 是 int 字面量,但 result 是 std::strong_ordering 类型
// 之所以能比较,是因为 std::strong_ordering 重载了 operator==
// 这个重载接受一个 unspecified(未指定)类型的参数
// 只允许与整数字面量 0 进行比较
std::cout << "a == b" << '\\n';
}
else { // result > 0 的情况
std::cout << "a > b" << '\\n';
}
return 0;
}
关于 0 比较的细节:<=> 的返回结果只能跟字面量 0 比较,不能跟 1、-1 或其他任何整数值比较。这是因为 std::strong_ordering、std::weak_ordering、std::partial_ordering 这三个类型都重载了 operator==、operator<、operator<=、operator>、operator>=,这些重载的右操作数类型是一个未指定的整数类型(/*unspecified*/),这个类型只接受值为 0 的整数字面量。如果你写 result == 1,编译器会报错。这是设计上的故意为之——<=> 的结果语义是"小于、等于、大于",而不是"返回一个数值"。
1.3 为自定义类型重载 <=> 运算符
1.3.1 基本语法
<=> 运算符可以重载为类的成员函数,一般形式如下:
auto operator<=>(const T& other) const
- 参数:一个 const T& 类型的引用,指向被比较的另一个对象。用 const 引用是为了避免不必要的拷贝,同时保证不修改被比较的对象。
- 返回值:auto 让编译器自动推导返回类型。实际返回类型是 std::strong_ordering、std::weak_ordering 或 std::partial_ordering 之一,具体取决于成员变量的比较类别。
- const 限定符:表示这个比较操作不会修改当前对象。这是必须的,因为比较两个对象不应该改变它们的状态。
1.3.2 默认生成的 <=> 运算符
如果希望编译器自动生成 <=> 运算符,可以使用 = default 语法:
auto operator<=>(const T& other) const = default;
编译器默认生成的 operator<=> 会按照成员变量的声明顺序,依次对每个成员调用 <=> 进行比较:
默认生成的前提条件:类中的所有成员变量都必须支持 <=> 运算符。只要有一个成员不支持 <=>(比如一个没有重载 <=> 的自定义类型),编译器就无法生成 <=>,此时 = default 会导致编译错误。
示例:使用默认生成的 <=> 运算符:
#include <iostream>
#include <compare>
class Point {
int _x; // int 类型支持 <=>,返回 std::strong_ordering
int _y; // int 类型支持 <=>,返回 std::strong_ordering
public:
Point(int x = 0, int y = 0)
:_x(x)
,_y(y)
{}
// 让编译器自动生成 operator<=>
// 编译器会按声明顺序比较 _x 和 _y
// 先比较 _x,如果 _x 相等再比较 _y
// 因为 _x 和 _y 都是 int,所以返回类型会被推导为 std::strong_ordering
auto operator<=>(const Point& other) const = default;
};
int main()
{
Point p1{ 1, 2 }; // 创建第一个点 (1, 2)
Point p2{ 1, 3 }; // 创建第二个点 (1, 3)
// 直接使用 <=> 进行比较
auto ret = p1 <=> p2;
// 因为 _x 都是 1,相等,所以继续比较 _y
// _y 分别是 2 和 3,2 < 3,所以返回 std::strong_ordering::less
if (ret > 0)
std::cout << "p1 > p2" << '\\n';
else if (ret < 0)
std::cout << "p1 < p2" << '\\n';
else
std::cout << "p1 == p2" << '\\n';
// 编译器自动生成的其他比较运算符
// 当 operator<=> 被定义为 = default 时
// 编译器会自动生成 <, <=, >, >= 四个运算符
// 注意:== 和 != 不会自动生成,需要单独定义或使用operator==的 = default
// 在 C++20 中,如果定义了 operator<=>,但 operator== 没有被定义
// 编译器不会自动生成 operator==,因为 operator== 可以用更高效的方式实现
if (p1 < p2)
std::cout << "p1 < p2 (通过 <=> 生成)" << '\\n';
else if (p1 > p2)
std::cout << "p1 > p2 (通过 <=> 生成)" << '\\n';
else
std::cout << "p1 == p2 (通过 <=> 生成)" << '\\n';
return 0;
}
输出结果:
p1 < p2
p1 < p2 (通过 <=> 生成)
1.3.3 自定义 <=> 运算符的实现
不是所有情况都适合用 = default。有时候需要自定义比较逻辑,比如:
- 只比较某些成员变量,忽略其他成员
- 比较逻辑不是简单的逐成员比较
- 需要特殊的比较规则(如不区分大小写)
手动实现 <=> 运算符的通用模式:
#include <iostream>
#include <compare>
class Point {
int _x, _y;
public:
Point(int x = 0, int y = 0)
:_x(x)
,_y(y)
{}
// 手动实现 operator<=>
// 比较规则:先比较 _x,如果 _x 相等再比较 _y
auto operator<=>(const Point& other) const {
// 第一步:比较 _x
// 如果 _x <=> other._x 的结果不是 0(即不相等),直接返回这个结果
if (auto cmp = _x <=> other._x; cmp != 0) {
return cmp; // 返回 std::strong_ordering 类型
}
// 第二步:_x 相等,继续比较 _y
// 直接返回 _y <=> other._y 的结果
else {
return _y <=> other._y;
}
}
// 注意:当手动实现 operator<=> 时
// 编译器不会自动生成 operator==
// 需要手动实现 operator==,或者使用operator==的 = default 生成
bool operator==(const Point& other) const {
return _x == other._x && _y == other._y;
}
};
int main()
{
Point p1{ 2, 3 };
Point p2{ 1, 5 };
// 先比较 _x:2 <=> 1 = greater,直接返回 greater
// 所以结果是 p1 > p2
auto ret = p1 <=> p2;
if (ret > 0) std::cout << "p1 > p2" << '\\n';
else if (ret < 0) std::cout << "p1 < p2" << '\\n';
else std::cout << "p1 == p2" << '\\n';
return 0;
}
手动实现的关键点:
- 使用 auto 作为返回类型,让编译器自动推导。如果成员变量都是 int 这种强序类型,返回值就是 std::strong_ordering。
- 使用 if (auto cmp = …; cmp != 0) 这种带初始化语句的 if 表达式,这是 C++17 引入的语法,在这里非常适用——它把比较和判断合并到一行,代码更紧凑。
- 如果所有成员比较结果都是 0(相等),<=> 会自动返回 std::strong_ordering::equal。
- 手动实现 <=> 后,编译器不会自动生成 operator==,需要单独实现。这是因为 operator== 可能有更高效的实现方式(比如 string 可以先比较长度)。
1.4 编译器自动生成比较运算符的机制
这是 <=> 运算符最强大的特性:当你为一个类定义了 operator<=> 后,编译器会自动生成 !=、<、<=、>、>= 这几个运算符。具体来说:
- a != b 被重写为 !(a == b)
- a < b 被重写为 (a <=> b) < 0
- a <= b 被重写为 (a <=> b) <= 0
- a > b 被重写为 (a <=> b) > 0
- a >= b 被重写为 (a <=> b) >= 0
但有一个重要的例外:operator== 不会自动通过 <=> 生成。原因是 C++ 标准委员会认为,operator== 在很多情况下可以用更高效的方式实现,不应该强制使用 <=> 来生成。std::string 就是一个典型的例子——它在比较两个字符串是否相等时,可以先比较长度,如果长度不同直接返回 false,不需要逐字符比较。而 <=> 必须逐字符比较才能确定结果。所以 std::string 保留了自定义的 operator==,移除了 !=、<、<=、>、>=,让编译器通过 <=> 生成这些运算符。
运算符查找优先级:如果类中同时存在自定义的 operator== 和编译器生成的 operator==(通过 <=>),自定义版本优先。也就是说,如果显式声明了 operator==,编译器就不会通过 <=> 生成 operator==。对于 !=、<、<=、>、>= 也是同理——如果已经显式定义了,编译器就不会自动生成。
1.5 std::string 的 <=> 运算符实践
std::string 是 C++20 标准库中最早一批适配 <=> 的类型之一。在 C++20 中,std::string 新增了 operator<=>,同时移除了 !=、<、<=、>、>= 这几个运算符的重载,只保留了 operator== 的自定义实现。
#include <iostream>
#include <string>
#include <compare>
int main()
{
std::string s1 = "apple";
std::string s2 = "banana";
std::string s3 = "apple";
// 直接使用 <=> 进行比较
// std::string 的 operator<=> 返回 std::strong_ordering
// 因为字符串比较是逐字符比较 ASCII 值,是完全可替换的
auto cmp = s1 <=> s2;
if (cmp < 0) {
std::cout << "s1 < s2" << '\\n'; // 输出:s1 < s2
}
// 使用通过 <=> 生成的 < 运算符
if (s1 < s2) {
std::cout << "s1 < s2 (通过 <=> 生成)" << '\\n'; // 输出
}
// 使用通过 <=> 生成的 > 运算符
if (s2 > s1) {
std::cout << "s2 > s1 (通过 <=> 生成)" << '\\n'; // 输出
}
// 使用自定义的 operator==(不是通过 <=> 生成的)
// std::string 的 operator== 先比较长度,长度相等再比较字符
if (s1 == s3) {
std::cout << "s1 == s3" << '\\n'; // 输出
}
// 使用通过 <=> 生成的 != 运算符
if (s1 != s2) {
std::cout << "s1 != s2" << '\\n'; // 输出
}
return 0;
}
输出结果:
s1 < s2
s1 < s2 (通过 <=> 生成)
s2 > s1 (通过 <=> 生成)
s1 == s3
s1 != s2
第二章 比较类别(Comparison Category)深度解析
2.1 为什么需要三种比较类别
在 C++20 中,<=> 运算符的返回类型分为三种:std::strong_ordering、std::weak_ordering 和 std::partial_ordering。它们不是随便设计的,而是对应着数学中三种不同的比较关系。
用一句话概括三者的区别:
- 强序(strong_ordering):相等的值完全等价,可以互换。比如整数 5 和 5 是完全相同的,用哪个都一样。
- 弱序(weak_ordering):相等的值只是"等价"而非"完全相同",不能随意互换。比如不区分大小写的字符串比较,"Hello" 和 "HELLO" 是等价的,但你不能拿 "Hello" 去替代 "HELLO" 而不影响程序行为。
- 偏序(partial_ordering):允许出现"不可比较"的情况。比如浮点数 NaN 和任何值比较都是不可比较的。
这三种类型的继承关系是:strong_ordering 可以隐式转换为 weak_ordering,weak_ordering 可以隐式转换为 partial_ordering。也就是说,强序是弱序的子集,弱序是偏序的子集。这个转换规则意味着,如果你的类型满足强序条件,但函数签名要求返回 weak_ordering,编译器会自动帮你转换,不需要手动干预。
2.2 std::strong_ordering(强序)——最严格的比较
头文件:<compare>
官方参考:https://en.cppreference.com/w/cpp/utility/compare/strong_ordering
定义:std::strong_ordering 是三种比较类别中最严格的一种。它要求:如果两个值比较相等,那么它们必须是"不可区分的"(indistinguishable),即用其中一个替换另一个不会改变程序的行为。
四个静态成员常量:
| std::strong_ordering::less | 小于 | a < b |
| std::strong_ordering::equal | 等于(完全相等) | a == b |
| std::strong_ordering::equivalent | 等价 | 与 equal 语义完全相同 |
| std::strong_ordering::greater | 大于 | a > b |
注意 equal 和 equivalent 在 std::strong_ordering 中是完全相同的——它们都是"相等"的意思,提供两个名字只是为了和 std::weak_ordering 的接口保持一致。
适用场景:
- 整数类型(int、long、short 等)
- 字符类型(char、wchar_t、char8_t、char16_t、char32_t)
- 指针类型
- 枚举类型
- 所有成员都是强序类型的结构体/类
示例代码:
#include <iostream>
#include <compare>
int main()
{
int a = 5, b = 10;
// int 的 <=> 运算符返回 std::strong_ordering
auto res1 = a <=> b;
// 5 <=> 10 的结果是 std::strong_ordering::less
// 直接与枚举值比较
if (res1 == std::strong_ordering::less) {
std::cout << "a < b" << '\\n'; // 输出
}
// 使用比较类别类型定义的另一个 Point 结构体
struct Point {
int x;
int y;
// 使用 = default 让编译器自动生成
// 因为 x 和 y 都是 int,返回类型自动推导为 std::strong_ordering
auto operator<=>(const Point&) const = default;
};
Point p1{ 1, 2 }, p2{ 1, 2 };
auto res2 = p1 <=> p2;
// 两个点的 x 和 y 都相等,返回 std::strong_ordering::equal
if (res2 == std::strong_ordering::equal) {
std::cout << "p1 == p2" << '\\n'; // 输出
}
// 强序的核心性质:等价的对象可以完全互换
// 如果 p1 == p2,那么在任何地方用 p2 替换 p1
// 程序的执行结果不会发生任何变化
if (p1 == p2) {
// 这两行代码的行为完全相同,因为 p1 和 p2 是不可区分的
// some_function(p1); // 如果用 p2 替换 p1,行为不变
// some_function(p2); // 与上一行完全相同的行为
}
return 0;
}
2.3 std::weak_ordering(弱序)——等价但不等同
头文件:<compare>
官方参考:https://en.cppreference.com/w/cpp/utility/compare/weak_ordering
定义:std::weak_ordering 比强序稍弱。它要求:比较结果满足"小于、等价、大于"三种关系,但"等价"的值不一定是不可区分的。也就是说,两个等价的值可能在某些方面不同,不能保证互换后程序行为不变。
四个静态成员常量:
| std::weak_ordering::less | 小于 | a < b |
| std::weak_ordering::equivalent | 等价 | 等价但不一定相等,不能互换 |
| std::weak_ordering::greater | 大于 | a > b |
注意 std::weak_ordering 没有 equal 常量,只有 equivalent。这是设计上的刻意区分——在弱序中,“等价"不等于"相等”,所以用 equivalent 而不是 equal 来命名,从名字上就告诉开发者:这两个值只是"等价"而已,不要认为它们完全相同。
适用场景:
- 不区分大小写的字符串比较:"Hello" 和 "HELLO" 在忽略大小写时等价,但它们不是相同的字符串。
- 忽略某些非关键成员变量的比较:比如比较两个用户时,只比较 ID,忽略用户名的大小写差异。
- 按某种规范化形式比较:比如忽略空格、标点符号的比较。
经典实现:不区分大小写的字符串比较:
#include <string>
#include <compare>
#include <cctype> // 提供 std::tolower 函数
#include <iostream>
#include <vector>
#include <algorithm>
// 不区分大小写的字符串类
// 这个类演示了 weak_ordering 的典型应用场景
class CaseInsensitiveString {
public:
// 存储原始字符串
std::string s;
// 默认构造函数
CaseInsensitiveString() = default;
// 从 std::string 构造,使用 explicit 防止隐式转换
explicit CaseInsensitiveString(const std::string& str) : s(str) {}
// 从 C 风格字符串构造,使用 explicit 防止隐式转换
explicit CaseInsensitiveString(const char* str) : s(str) {}
// 三路比较运算符(不区分大小写)
// 返回类型是 std::weak_ordering 而不是 std::strong_ordering
// 因为不区分大小写的比较中,"等价"不等于"完全相同"
std::weak_ordering operator<=>(const CaseInsensitiveString& other) const {
return case_insensitive_compare(s, other.s);
}
// 相等运算符(不区分大小写)
// 注意:当手动实现 operator<=> 时,必须手动实现 operator==
// 因为编译器不会自动生成 operator==
bool operator==(const CaseInsensitiveString& other) const {
return case_insensitive_equal(s, other.s);
}
// 转换为普通字符串的访问函数
const std::string& str() const { return s; }
private:
// 不区分大小写的三路比较函数
// 参数:两个要比较的字符串 a 和 b
// 返回值:std::weak_ordering 类型
// – less: a 在忽略大小写的情况下小于 b
// – equivalent: a 和 b 在忽略大小写的情况下相等
// – greater: a 在忽略大小写的情况下大于 b
static std::weak_ordering case_insensitive_compare(
const std::string& a, const std::string& b) {
// 使用迭代器逐字符比较
auto it1 = a.begin(); // 指向字符串 a 的第一个字符
auto it2 = b.begin(); // 指向字符串 b 的第一个字符
// 同时遍历两个字符串,直到其中一个到达末尾
while (it1 != a.end() && it2 != b.end()) {
// 将当前字符转换为小写
// 使用 static_cast<unsigned char> 防止负值 char 导致 UB
char c1 = std::tolower(static_cast<unsigned char>(*it1));
char c2 = std::tolower(static_cast<unsigned char>(*it2));
// 比较小写形式的字符
if (c1 < c2) return std::weak_ordering::less;
if (c1 > c2) return std::weak_ordering::greater;
// 当前字符相等,继续比较下一个字符
++it1;
++it2;
}
// 两个字符串同时到达末尾,说明它们完全等价
if (it1 == a.end() && it2 == b.end()) {
return std::weak_ordering::equivalent;
}
// 如果 a 先到达末尾,说明 a 是 b 的前缀,a < b
if (it1 == a.end()) return std::weak_ordering::less;
// 如果 b 先到达末尾,说明 b 是 a 的前缀,a > b
return std::weak_ordering::greater;
}
// 不区分大小写的相等判断函数
// 参数:两个要比较的字符串 a 和 b
// 返回值:true 表示在忽略大小写的情况下相等,false 表示不等
static bool case_insensitive_equal(const std::string& a, const std::string& b) {
// 先比较长度,长度不同肯定不相等(优化)
if (a.size() != b.size()) return false;
// 长度相等,逐字符比较
auto it1 = a.begin();
auto it2 = b.begin();
while (it1 != a.end()) {
// 将当前字符转换为小写后比较
char c1 = std::tolower(static_cast<unsigned char>(*it1));
char c2 = std::tolower(static_cast<unsigned char>(*it2));
// 只要有一个字符不同,就返回 false
if (c1 != c2) return false;
++it1;
++it2;
}
return true;
}
};
// 使用示例
int main() {
CaseInsensitiveString s1("Hello");
CaseInsensitiveString s2("HELLO"); // 与 s1 不区分大小写时等价
CaseInsensitiveString s3("World");
CaseInsensitiveString s4("hello world");
// 使用 <=> 进行比较
// 因为 "Hello" 和 "HELLO" 忽略大小写后完全相同
// 所以返回 std::weak_ordering::equivalent
auto res = s1 <=> s2;
if (res == std::weak_ordering::equivalent) {
std::cout << "s1 和 s2 在忽略大小写时等价" << '\\n';
}
// 弱序的核心性质:等价的对象不一定能互换
// s1 和 s2 虽然是等价的,但它们存储的原始字符串不同
// s1.str() 返回 "Hello"
// s2.str() 返回 "HELLO"
// 如果用 s2 替换 s1,程序输出的字符串大小写会不同
std::cout << "s1.str() = " << s1.str() << '\\n'; // Hello
std::cout << "s2.str() = " << s2.str() << '\\n'; // HELLO
// 测试所有六种比较运算符
std::cout << std::boolalpha; // 让 bool 值输出为 true/false
std::cout << "s1 == s2: " << (s1 == s2) << '\\n'; // true(忽略大小写)
std::cout << "s1 != s3: " << (s1 != s3) << '\\n'; // true
std::cout << "s1 < s3: " << (s1 < s3) << '\\n'; // true(H < W)
std::cout << "s3 > s1: " << (s3 > s1) << '\\n'; // true
std::cout << "s1 <= s2: " << (s1 <= s2) << '\\n'; // true(等价)
std::cout << "s1 >= s2: " << (s1 >= s2) << '\\n'; // true(等价)
// 在容器中使用
// 因为 CaseInsensitiveString 定义了 operator<=>
// 所以它满足 std::sort 的要求:需要提供严格弱序(strict weak ordering)
std::vector<CaseInsensitiveString> words = { s3, s1, s4, s2 };
std::sort(words.begin(), words.end());
std::cout << "排序后: ";
for (const auto& word : words) {
std::cout << word.str() << " | ";
}
std::cout << '\\n';
return 0;
}
弱序的关键理解:弱序中的"等价"(equivalent)和强序中的"相等"(equal)是不同层次的概念。在弱序中,两个值等价意味着它们在比较标准下被认为是"一样的",但它们的实际内容可能不同。比如上面的例子中,"Hello" 和 "HELLO" 在不区分大小写的比较标准下是等价的,但它们的原始字符串内容不同。如果你用 s2 替换 s1,输出的大小写会发生变化,这就是"不可互换"的含义。
2.4 std::partial_ordering(偏序)——允许"不可比较"
头文件:<compare>
官方参考:https://en.cppreference.com/w/cpp/utility/compare/partial_ordering
定义:std::partial_ordering 是三种比较类别中最宽松的一种。它不仅允许"小于"、“等价”、"大于"三种结果,还允许第四种结果——“不可比较”(unordered)。这意味着在某些情况下,两个值之间不存在任何顺序关系。
五个静态成员常量:
| std::partial_ordering::less | 小于 | a < b |
| std::partial_ordering::equivalent | 等价 | a ≈ b |
| std::partial_ordering::greater | 大于 | a > b |
| std::partial_ordering::unordered | 不可比较 | a 和 b 之间没有顺序关系 |
适用场景:
- 浮点数:当其中一个值为 NaN 时,任何比较结果都是 unordered。
- 特殊数学概念:如复数的模长比较,两个复数可能不可比较。
- 业务逻辑中的特殊状态:比如一个"无效"的对象和任何"有效"对象比较时,结果应该是 unordered。
浮点数中的 NaN:NaN(Not a Number,非数)是 IEEE 754 浮点数标准中定义的一种特殊值,用于表示未定义或不可表示的数值结果。比如 0.0 / 0.0、sqrt(-1.0) 等操作的结果就是 NaN。NaN 一个非常反直觉的特性是:NaN 不等于任何值,包括它自己。NaN == NaN 的结果是 false。在 C++20 之前,用 < 比较 NaN 和其他浮点数时,结果是 false(因为 NaN 被认为不小于任何值),但这实际上是一种误导——!(a < b) && !(a > b) && !(a == b) 同时成立,这在数学上自相矛盾。<=> 的 unordered 结果就是为了正确处理这种情况而设计的。
示例代码:
#include <iostream>
#include <compare>
#include <cmath> // 提供 NAN 宏和 isnan 函数
int main()
{
// 浮点数的 <=> 比较
float a = 1.0f;
float b = NAN; // 非数(Not a Number)
// 1.0f <=> NaN 的结果是 std::partial_ordering::unordered
auto result = a <=> b;
if (result == std::partial_ordering::unordered) {
std::cout << "1.0f 和 NaN 不可比较" << '\\n';
}
// 自定义 Temperature 类,演示偏序的实际应用
struct Temperature {
float value; // 温度值
bool is_valid; // 是否有效(比如传感器是否正常工作)
// 自定义 operator<=>
// 比较规则:
// 1. 如果其中一个温度无效,返回 unordered(不可比较)
// 2. 如果两个都有效,比较实际的温度值
std::partial_ordering operator<=>(const Temperature& other) const {
// 如果当前温度或另一个温度无效,返回 unordered
if (!is_valid || !other.is_valid) {
return std::partial_ordering::unordered;
}
// 两个温度都有效,使用浮点数的 <=> 进行比较
else {
return value <=> other.value;
}
}
};
Temperature t1{ 36.5f, true }; // 有效温度 36.5°C
Temperature t2{ 37.2f, true }; // 有效温度 37.2°C
Temperature t3{ 0.0f, false }; // 无效温度(传感器故障)
// 比较两个有效温度
auto res1 = t1 <=> t2; // 返回 std::partial_ordering::less
if (res1 < 0) {
std::cout << "t1 温度低于 t2" << '\\n';
}
// 比较有效温度和无效温度
auto res2 = t1 <=> t3; // 返回 std::partial_ordering::unordered
if (res2 == std::partial_ordering::unordered) {
std::cout << "无法比较有效温度与无效温度" << '\\n';
}
// 使用编译器生成的比较运算符
// 注意:在偏序情况下,使用 < 或 > 时,如果结果是 unordered
// 那么 a < b、a > b、a == b 都返回 false
// 这是符合直觉的——既然不可比较,那当然既不小于也不大于也不等于
if (t1 < t2) {
std::cout << "t1 < t2" << '\\n'; // 输出
}
// 下面这个 if 不会输出,因为 unordered 状态下 < 返回 false
if (t1 < t3) {
std::cout << "t1 < t3(不会执行)" << '\\n';
}
return 0;
}
2.5 三种比较类别的对比总结
| 严格程度 | 最严格 | 中等 | 最宽松 |
| 可能的结果 | less, equal/equivalent, greater | less, equivalent, greater | less, equivalent, greater, unordered |
| 等价的值是否可互换 | 是,完全不可区分 | 否,可能不同 | 视情况而定 |
| 是否允许不可比较 | 否 | 否 | 是 |
| 隐式转换目标 | 可转为 weak 和 partial | 可转为 partial | 不可转换(最底层) |
| 典型应用 | 整数、指针、字符 | 不区分大小写字符串 | 浮点数、NaN |
| 数学对应 | 全序关系(Total Order) | 弱序关系(Weak Order) | 偏序关系(Partial Order) |
std::common_comparison_category:在泛型编程中,有时候需要自动推导出多个类型的比较类别的"公共"类型。比如一个类模板的成员变量类型可能是 int(强序)和 double(偏序),那么整个类的 <=> 返回类型应该是 std::partial_ordering(向下兼容)。std::common_comparison_category 这个类型萃取工具就是为了解决这个问题而设计的,它接受多个比较类别类型,返回它们中最"弱"的那个。
2.6 命名比较函数(Named Comparison Functions)
头文件:<compare>
官方参考:https://en.cppreference.com/w/cpp/utility/compare/named_comparison_functions
C++20 在 <compare> 头文件中提供了一组命名比较函数,用于将 <=> 的返回结果转换为布尔值。这些函数的作用和直接跟 0 比较是一样的,但有些人觉得函数名更直观、可读性更好。
六个命名比较函数:
| std::is_eq(cmp) | cmp == 0 | 是否相等 |
| std::is_neq(cmp) | cmp != 0 | 是否不相等 |
| std::is_lt(cmp) | cmp < 0 | 是否小于 |
| std::is_lteq(cmp) | cmp <= 0 | 是否小于等于 |
| std::is_gt(cmp) | cmp > 0 | 是否大于 |
| std::is_gteq(cmp) | cmp >= 0 | 是否大于等于 |
函数签名(以 is_eq 为例):
constexpr bool is_eq(std::partial_ordering cmp) noexcept;
- 参数:std::partial_ordering 类型(因为 strong_ordering 和 weak_ordering 可以隐式转换为 partial_ordering,所以这个函数可以接受所有三种比较类别的结果)。
- 返回值:bool 类型,表示比较结果是否满足对应的关系。
- constexpr:可以在编译期执行。
- noexcept:不会抛出异常。
示例:
#include <iostream>
#include <compare>
int main()
{
int a = 5, b = 3;
auto cmp = a <=> b;
// 使用命名比较函数判断结果
// 等价于 cmp > 0
if (std::is_gt(cmp)) {
std::cout << "5 > 3" << '\\n';
}
// 等价于 cmp == 0
if (std::is_eq(cmp)) {
std::cout << "5 == 3(不会执行)" << '\\n';
}
// 等价于 cmp != 0
if (std::is_neq(cmp)) {
std::cout << "5 != 3" << '\\n';
}
return 0;
}
使用建议:命名比较函数和直接跟 0 比较这两种方式在功能上是完全等价的,选择哪一种主要看个人偏好。在大多数情况下,直接跟 0 比较更简洁,代码也更短。但在复杂的泛型代码中,命名比较函数可以让代码的意图更清晰。
2.7 std::compare_three_way 函数对象
头文件:<compare>
官方参考:https://en.cppreference.com/w/cpp/utility/compare/compare_three_way
std::compare_three_way 是 C++20 引入的一个函数对象,它的作用类似于 std::less、std::greater 等比较函数对象,但它是专门为 <=> 三路比较运算符设计的。
类定义(简化):
struct compare_three_way {
template<class T, class U>
constexpr auto operator()(T&& t, U&& u) const
-> decltype(std::forward<T>(t) <=> std::forward<U>(u));
};
关键点:
- 模板参数:接受两个任意类型的参数 T 和 U,只要它们支持 <=> 运算符。
- 返回值:自动推导为 T 和 U 的 <=> 返回类型。
- constexpr:可以在编译期使用。
- 完美转发:使用 std::forward 保持参数的左右值属性。
示例:
#include <compare>
#include <iostream>
#include <algorithm>
#include <string>
#include <vector>
int main() {
// 创建一个 compare_three_way 函数对象
std::compare_three_way cmp;
// 对整数使用
int a = 5, b = 3;
// cmp(a, b) 等价于 a <=> b
// 返回 std::strong_ordering::greater
auto result1 = cmp(a, b);
std::cout << "5 <=> 3: " << std::is_gt(result1) << '\\n'; // true
// 对字符串使用
std::string s1 = "apple", s2 = "banana";
// cmp(s1, s2) 等价于 s1 <=> s2
// 返回 std::strong_ordering::less
auto result2 = cmp(s1, s2);
std::cout << "apple <=> banana: " << std::is_lt(result2) << '\\n'; // true
// 对浮点数使用
double x = 1.5, y = 2.5;
// 两个普通浮点数比较,返回 std::partial_ordering::less
auto result3 = cmp(x, y);
std::cout << "1.5 <=> 2.5: " << std::is_lt(result3) << '\\n'; // true
// 使用 compare_three_way 进行排序
std::vector<std::string> words = { "banana", "apple", "cherry", "date" };
// 在排序中使用 compare_three_way
// 注意:std::sort 需要的是 < 比较,所以我们用 cmp(a, b) < 0
std::sort(words.begin(), words.end(),
[](const auto& a, const auto& b) {
return std::compare_three_way{}(a, b) < 0;
});
std::cout << "排序后: ";
for (const auto& word : words) {
std::cout << word << " ";
}
std::cout << '\\n';
return 0;
}
compare_three_way 的优势:它提供了一个统一的接口来调用 <=> 运算符,在泛型编程中特别有用。比如,当编写一个模板函数,需要对不同类型的参数进行三路比较时,使用 std::compare_three_way 比直接写 a <=> b 更灵活,因为它可以处理更复杂的类型转换场景。
第三章 std::format 格式化库全景解析
嘿嘿大家,看到这个format,脑袋瓜其实立马就想到了python和spdlog哦,我们可是没少接触这个格式化~~,所以我们接下来学习C++中的这个知识点自然也就是水到渠成。
3.1 为什么需要 std::format
在 C++20 之前,C++ 中格式化字符串主要有两种方式:
C 风格的 printf 系列:
printf("Hello, %s! You are %d years old. Your salary is %.2f.\\n", name, age, salary);
优点:简洁、性能好。缺点:类型不安全——printf("%s", 42) 编译时不会报错,但运行时崩溃。格式化字符串和参数类型必须在运行时完全匹配,否则是未定义行为。
C++ 风格的 iostream 系列:
std::cout << "Hello, " << name << "! You are " << age << " years old. Your salary is " << std::fixed << std::setprecision(2) << salary << "." << std::endl;
优点:类型安全。缺点:语法冗长——为了格式化一个简单的句子,写了一大堆 << 操作符和操纵器(manipulator)。性能也较差,因为多次函数调用和难以优化的连续输出。
std::format 的目标:兼具 printf 的性能和简洁性,以及 iostream 的类型安全性。它使用类似 Python 的格式化语法,{} 作为占位符,格式说明符放在 : 后面,直观且强大。
3.2 std::format——最核心的格式化函数
头文件:<format>
官方参考:https://en.cppreference.com/w/cpp/utility/format/format
函数签名:
template<class... Args>
std::string format(std::format_string<Args...> fmt, Args&&... args);
参数详解:
| fmt | std::format_string<Args…> | 格式化字符串,包含 {} 占位符。这是一个编译期检查的字符串类型,如果格式字符串和参数类型不匹配,编译时会报错 |
| args… | Args&&… | 可变参数包,是要格式化的参数值。使用完美转发保持参数类型 |
返回值:
- 类型:std::string
- 说明:返回格式化后的字符串。如果格式化失败(比如格式字符串错误),抛出 std::format_error 异常。
基本用法示例:
#include <format>
#include <iostream>
#include <string>
#include <vector>
#include <list>
int main() {
std::string name = "Alice";
int age = 30;
double salary = 85000.5;
// 1. 默认位置参数(按顺序替换)
// {} 的个数和参数个数必须匹配
// 如果参数不够,编译期会报错
auto str1 = std::format("Hello, {}! You are {} years old.", name, age);
std::cout << str1 << '\\n';
// 输出:Hello, Alice! You are 30 years old.
// 2. 位置索引参数(可以指定顺序,重复使用)
// {0} 表示第一个参数,{1} 表示第二个参数
// 可以多次引用同一个参数
auto str2 = std::format("Hello, {0}! {0} is {1} years old.", name, age);
std::cout << str2 << '\\n';
// 输出:Hello, Alice! Alice is 30 years old.
// 3. 格式化说明(类似于 printf 的 %f, %.2f 等)
// {:.2f} 表示保留两位小数的浮点数
auto str3 = std::format("Salary: {:.2f}", salary);
std::cout << str3 << '\\n';
// 输出:Salary: 85000.50
// 4. C++23 开始支持 STL 容器的格式化
// 需要 C++23 编译器支持
std::vector<int> v = { 1, 2, 3, 4 };
std::list<double> lt = { 10.1, 10.2, 10.3 };
// 以下代码需要 C++23 支持
// auto str4 = std::format("vector: {}\\nlist: {}\\n", v, lt);
// std::cout << str4 << '\\n';
return 0;
}
位置索引的重要规则:不能混合使用显式索引和隐式索引。也就是说,要么全部使用 {}(隐式索引,按顺序匹配),要么全部使用 {0}、{1} 等(显式索引)。以下代码是错误的:
// 错误:混合使用隐式索引和显式索引
auto s3 = std::format("{} {1} {} {0}", "x", "y"); // 编译错误!
3.3 格式化说明符(Format Specifiers)深度解析
std::format 的格式化说明符是其最强大的部分。占位符的基本结构如下:
{[arg_id][:format_spec]}
其中 format_spec 的完整语法是:
[[fill]align][sign][#][0][width][.precision][type]
下面逐一解析每个组件。
3.3.1 填充和对齐(Fill and Align)
语法:[fill]align
其中 align 必须是以下三个选项之一:
| < | 左对齐 | 内容在字段左侧,右侧填充 |
| > | 右对齐 | 内容在字段右侧,左侧填充(默认行为) |
| ^ | 居中对齐 | 内容在字段中间,两侧均匀填充 |
fill 是可选的自定义填充字符,默认是空格。如果指定了填充字符,则必须同时指定对齐方式。
示例:
#include <format>
#include <iostream>
int main() {
// 基本对齐
// 指定宽度为 10,分别使用左对齐、右对齐、居中对齐
std::cout << std::format("|{:<10}|", "left") << '\\n';
// 输出:|left |
std::cout << std::format("|{:>10}|", "right") << '\\n';
// 输出:| right|
std::cout << std::format("|{:^10}|", "center") << '\\n';
// 输出:| center |
// 自定义填充字符
// 在冒号后面、对齐符号前面指定填充字符
std::cout << std::format("|{:*<10}|", "left") << '\\n';
// 输出:|left******|
std::cout << std::format("|{:_>10}|", "right") << '\\n';
// 输出:|_____right|
std::cout << std::format("|{:-^10}|", "center") << '\\n';
// 输出:|–center–|
// 数字对齐
// 数字默认右对齐
std::cout << std::format("|{:0>5}|", 42) << '\\n';
// 输出:|00042|
std::cout << std::format("|{:*<5}|", 42) << '\\n';
// 输出:|42***|
return 0;
}
3.3.2 符号控制(Sign)
语法:[sign]
| + | 总是显示符号 | 正数显示 +,负数显示 – |
| – | 仅显示负号 | 正数不显示符号,负数显示 -(默认行为) |
| (空格) | 正数前加空格,负数前加负号 | 正数前加一个空格,负数前加 – |
示例:
#include <format>
#include <iostream>
int main() {
int positive = 42;
int negative = –42;
// 默认行为:只显示负号
std::cout << std::format("默认: {} {}\\n", positive, negative);
// 输出:默认: 42 -42
// 总是显示符号
std::cout << std::format("总是显示符号: {:+} {:+}\\n", positive, negative);
// 输出:总是显示符号: +42 -42
// 空格模式:正数前加空格,负数前加负号
std::cout << std::format("空格模式: {: } {: }\\n", positive, negative);
// 输出:空格模式: 42 -42
// 浮点数符号控制
double fp_positive = 3.14;
double fp_negative = –2.71;
std::cout << std::format("浮点数: {:+} {:+}\\n", fp_positive, fp_negative);
// 输出:浮点数: +3.14 -2.71
return 0;
}
3.3.3 替代形式(#)
语法:#
# 标志的作用是请求"替代形式"的输出,对于不同的类型有不同的效果:
| 整数(b、o、x、X) | 显示进制前缀 | 0b、0、0x、0X |
| 浮点数(f、e、E、g、G) | 即使没有小数部分也显示小数点 | 10. 而不是 10 |
示例:
#include <format>
#include <iostream>
int main() {
int decimal = 42;
int hex = 255;
double fp = 10.0;
// 整数:显示进制前缀
std::cout << std::format("十六进制: {:#x}\\n", hex);
// 输出:十六进制: 0xff
std::cout << std::format("八进制: {:#o}\\n", decimal);
// 输出:八进制: 052
std::cout << std::format("二进制: {:#b}\\n", decimal);
// 输出:二进制: 0b101010
// 浮点数:强制显示小数点
std::cout << std::format("浮点数: {:#.0f}\\n", fp);
// 输出:浮点数: 10.
std::cout << std::format("科学计数: {:#.0e}\\n", fp);
// 输出:科学计数: 1.e+01
return 0;
}
3.3.4 零填充(0)
语法:0
0 标志相当于设置了填充字符为 0、对齐方式为右对齐。它只对数字类型有效。
示例:
#include <format>
#include <iostream>
int main() {
int num = 42;
// 宽度为 6,用 0 填充
std::cout << std::format("|{:06}|\\n", num);
// 输出:|000042|
// 宽度为 8,用 0 填充
std::cout << std::format("|{:08}|\\n", num);
// 输出:|00000042|
// 浮点数零填充
std::cout << std::format("|{:08.2f}|\\n", 3.14);
// 输出:|00003.14|
return 0;
}
3.3.5 宽度(Width)
语法:[width]
宽度指定字段的最小宽度。如果内容的实际长度小于宽度,会根据对齐方式在两侧填充。如果内容的实际长度大于宽度,宽度设置会被忽略,内容不会被截断。
动态宽度:宽度可以用嵌套的 {} 来指定,从参数列表中动态获取。
示例:
#include <format>
#include <iostream>
int main() {
std::string text = "hello";
int number = 123;
// 固定宽度
std::cout << std::format("|{:10}|\\n", text);
// 输出:|hello |
std::cout << std::format("|{:10}|\\n", number);
// 输出:| 123|
// 动态宽度(使用嵌套参数)
// {:{}} 中,第一个 {} 是内容,第二个 {} 是宽度值
int width = 15;
std::cout << std::format("|{:{}}|\\n", text, width);
// 输出:|hello |
std::cout << std::format("|{:{}}|\\n", number, width);
// 输出:| 123|
// 结合对齐
std::cout << std::format("|{:>10}|\\n", text);
// 输出:| hello|
std::cout << std::format("|{:<10}|\\n", number);
// 输出:|123 |
return 0;
}
3.3.6 精度(Precision)
语法:[.precision]
精度对于不同类型的含义不同:
| 浮点数(f、F、e、E) | 小数点后的位数 |
| 浮点数(g、G) | 有效数字的位数 |
| 字符串(s) | 输出的最大字符数(截断) |
| 整数 | 精度无效,会被忽略 |
示例:
#include <format>
#include <iostream>
int main() {
double pi = 3.141592653589793;
// 默认浮点数输出
std::cout << std::format("默认: {}\\n", pi);
// 输出:默认: 3.141592653589793
// 保留两位小数
std::cout << std::format("两位小数: {:.2f}\\n", pi);
// 输出:两位小数: 3.14
// 保留五位小数
std::cout << std::format("五位小数: {:.5f}\\n", pi);
// 输出:五位小数: 3.14159
// 科学计数法精度
std::cout << std::format("科学计数: {:.2e}\\n", pi);
// 输出:科学计数: 3.14e+00
// 字符串精度(最大字符数,从开头截断)
std::string long_text = "Hello, World!";
std::cout << std::format("截断: {:.5}\\n", long_text);
// 输出:截断: Hello
// 通用格式,保留几位有效数字
std::cout << std::format("通用: {:.4g}\\n", 123.456);
// 输出:通用: 123.5
std::cout << std::format("通用: {:.3g}\\n", 0.00123);
// 输出:通用: 0.00123
return 0;
}
3.3.7 类型说明符(Type)
语法:[type]
类型说明符指定如何解释和格式化数据。不同类别的类型有不同的可用选项。
整数类型说明符:
| d | 十进制(默认) | 42 |
| b | 二进制 | 101010 |
| B | 二进制(大写前缀) | 101010(配合 # 为 0B101010) |
| o | 八进制 | 52 |
| x | 十六进制(小写) | 2a |
| X | 十六进制(大写) | 2A |
| c | 输出为字符 | *(如果 num=42) |
浮点数类型说明符:
| f、F | 定点表示法 | 123.456789 |
| e | 科学计数法(小写 e) | 1.234568e+02 |
| E | 科学计数法(大写 E) | 1.234568E+02 |
| g | 通用格式(自动选择 f 或 e,取较短者,默认) | 123.457 |
| G | 通用格式(大写 E 版本) | 123.457 |
| a | 十六进制浮点数表示(小写) | 0x1.edd3c07fb4b69p+6 |
| A | 十六进制浮点数表示(大写) | 0X1.EDD3C07FB4B69P+6 |
字符和字符串类型说明符:
| 无 | 默认输出 | 字符原样输出 |
| d | 输出字符的 ASCII 值 | 'A' 输出为 65 |
| x、X | 输出字符的十六进制值 | 'A' 输出为 41 |
布尔类型说明符:
| 无 | 输出 true 或 false | true |
| s | 输出 true 或 false(字符串形式) | true |
| d | 输出数字形式 | 1 |
示例:
#include <format>
#include <iostream>
int main() {
int num = 42;
// 整数类型
std::cout << std::format("十进制: {}\\n", num);
// 输出:十进制: 42
std::cout << std::format("二进制: {:b}\\n", num);
// 输出:二进制: 101010
std::cout << std::format("二进制(大写): {:#B}\\n", num);
// 输出:二进制(大写): 0B101010
std::cout << std::format("八进制: {:o}\\n", num);
// 输出:八进制: 52
std::cout << std::format("十六进制(小写): {:x}\\n", num);
// 输出:十六进制(小写): 2a
std::cout << std::format("十六进制(大写): {:X}\\n", num);
// 输出:十六进制(大写): 2A
// 浮点数类型
double value = 123.456789;
std::cout << std::format("默认: {}\\n", value);
// 输出:默认: 123.456789
std::cout << std::format("定点: {:f}\\n", value);
// 输出:定点: 123.456789
std::cout << std::format("科学: {:e}\\n", value);
// 输出:科学: 1.234568e+02
std::cout << std::format("通用: {:g}\\n", value);
// 输出:通用: 123.457
std::cout << std::format("十六进制浮点: {:a}\\n", value);
// 输出:十六进制浮点: 0x1.edd3c07fb4b69p+6
// 大写版本
std::cout << std::format("科学(大写): {:E}\\n", value);
// 输出:科学(大写): 1.234568E+02
std::cout << std::format("十六进制浮点(大写): {:A}\\n", value);
// 输出:十六进制浮点(大写): 0X1.EDD3C07FB4B69P+6
// 字符和字符串
char ch = 'A';
const char* cstr = "Hello";
std::string str = "World";
std::cout << std::format("字符: {}\\n", ch);
std::cout << std::format("C字符串: {}\\n", cstr);
std::cout << std::format("字符串: {}\\n", str);
std::cout << std::format("字符ASCII: {:d}\\n", ch); // 65
std::cout << std::format("字符十六进制: {:x}\\n", ch); // 41
// 布尔类型
bool flag = true;
std::cout << std::format("默认: {}\\n", flag); // true
std::cout << std::format("数字: {:d}\\n", flag); // 1
std::cout << std::format("字符串: {:s}\\n", flag); // true
return 0;
}
3.4 format_to 和 format_to_n——直接写入缓冲区
3.4.1 std::format_to
函数签名:
template<class OutputIt, class... Args>
OutputIt format_to(OutputIt out, std::format_string<Args...> fmt, Args&&... args);
参数详解:
| out | OutputIt | 输出迭代器,指向要写入的目标位置。可以是任何满足输出迭代器要求的类型 |
| fmt | std::format_string<Args…> | 格式化字符串,同 std::format |
| args… | Args&&… | 可变参数包,要格式化的参数值 |
返回值:
- 类型:OutputIt(即传入的迭代器类型)
- 说明:返回指向最后一个写入字符之后的迭代器。这个返回值可以用来计算实际写入的字符数,或者继续进行后续写入。
与 std::format 的区别:std::format 返回一个 std::string,需要分配内存并拷贝字符串。std::format_to 直接将结果写入已有的缓冲区,避免了不必要的内存分配和拷贝,性能更好。在需要频繁格式化输出的高性能场景中,format_to 是更好的选择。
使用示例:
#include <format>
#include <string>
#include <vector>
#include <iostream>
#include <iterator> // std::back_inserter
int main() {
// 方法一:使用已分配空间的 string 的迭代器
std::string str1;
// 预先分配足够的空间
// 可以使用 formatted_size 预先计算需要的空间大小
str1.resize(100);
// format_to 将格式化结果写入 str1 的开头
// 返回值 iter 指向最后一个写入字符之后的位置
auto iter = std::format_to(str1.begin(), "Hello, {}! The answer is {}.", "World", 42);
// 计算实际写入的字符数,并调整 string 大小
// iter – str1.begin() 就是实际写入的字符数
str1.resize(iter – str1.begin());
std::cout << str1 << '\\n';
// 输出:Hello, World! The answer is 42.
// 方法二:使用 std::back_inserter(自动扩容)
// std::back_inserter 创建一个输出迭代器,每次赋值时自动调用 push_back
// 这样就不需要手动分配空间了
std::string str2;
// format_to 会自动将字符一个个追加到 str2 的末尾
std::format_to(std::back_inserter(str2), "Hello, {}! The answer is {}.", "World", 42);
std::cout << str2 << '\\n';
// 输出:Hello, World! The answer is 42.
// 方法三:直接输出到标准输出流
// std::ostream_iterator 包装了 std::cout
std::format_to(std::ostream_iterator<char>(std::cout),
"Hello, {}! The answer is {}.\\n", "World", 42);
// 输出:Hello, World! The answer is 42.
// std::back_inserter 的原理演示
std::vector<int> vec;
auto inserter = std::back_inserter(vec);
*inserter = 10; // 底层调用 vec.push_back(10)
*inserter = 20; // 底层调用 vec.push_back(20)
*inserter = 30; // 底层调用 vec.push_back(30)
for (int x : vec) {
std::cout << x << " "; // 输出:10 20 30
}
std::cout << '\\n';
return 0;
}
3.4.2 std::format_to_n
函数签名:
template<class OutputIt, class... Args>
format_to_n_result<OutputIt> format_to_n(OutputIt out, std::size_t n,
std::format_string<Args...> fmt,
Args&&... args);
参数详解:
| out | OutputIt | 输出迭代器,指向要写入的目标位置 |
| n | std::size_t | 最大输出字符数 |
| fmt | std::format_string<Args…> | 格式化字符串 |
| args… | Args&&… | 可变参数包 |
返回值:
- 类型:std::format_to_n_result<OutputIt>,这是一个结构体,包含两个成员:
- out:OutputIt 类型,指向实际写入的最后一个字符之后的位置
- size:std::ptrdiff_t 类型,如果输出没有被截断,则是格式化后的完整字符数;如果被截断了,则是 std::format_to_n_result::truncation_counter(一个特殊值,表示截断)
用途:在需要限制输出长度的场景中使用,防止缓冲区溢出。比如在写入固定大小的缓冲区时,或者需要确保输出不超过某个限制时。
使用示例:
#include <format>
#include <string>
#include <iostream>
int main() {
std::string str3;
// 预先分配固定大小的缓冲区
str3.resize(20);
// 限制最大输出为 20 个字符
// 如果格式化结果超过 20 个字符,多余的部分会被截断
auto result = std::format_to_n(str3.begin(), 20,
"Hello, {}! The answer is {}.", "World", 42);
// 输出被截断的字符串(最多 20 个字符)
std::cout << str3 << '\\n';
// 输出:Hello, World! The
// 可以通过 result.size 判断是否被截断
// 如果 result.size > 20,说明被截断了
std::cout << "完整长度: " << result.size << '\\n';
return 0;
}
3.4.3 std::formatted_size
函数签名:
template<class... Args>
std::size_t formatted_size(std::format_string<Args...> fmt, Args&&... args);
参数详解:
| fmt | std::format_string<Args…> | 格式化字符串 |
| args… | Args&&… | 可变参数包 |
返回值:
- 类型:std::size_t
- 说明:返回格式化后的字符串长度(字符数),不包含结尾的空字符('\\0')。这个函数不会实际生成格式化字符串,只是计算长度。
用途:在需要预先分配缓冲区大小的场景中非常有用。比如先用 formatted_size 计算需要的空间,然后分配空间,再用 format_to 写入。
使用示例:
#include <format>
#include <string>
#include <iostream>
int main() {
// 计算格式化后的字符串长度
// 注意:这个函数不会实际生成字符串,只是计算长度
std::size_t size = std::formatted_size("Hello, {}! The answer is {}.", "World", 42);
std::cout << "需要的字符数: " << size << '\\n';
// 输出:需要的字符数: 28
// 使用 formatted_size 预先分配空间
std::string str;
str.resize(size);
// 然后使用 format_to 写入
std::format_to(str.begin(), "Hello, {}! The answer is {}.", "World", 42);
std::cout << str << '\\n';
// 输出:Hello, World! The answer is 42.
return 0;
}
3.5 vformat 和 make_format_args——参数打包与转发
3.5.1 std::vformat
函数签名:
std::string vformat(std::string_view fmt, std::format_args args);
参数详解:
| fmt | std::string_view | 格式化字符串,注意这里是 string_view 而不是 format_string,所以不会在编译期检查格式字符串的正确性 |
| args | std::format_args | 打包后的格式化参数,通过 std::make_format_args 创建 |
返回值:
- 类型:std::string
- 说明:返回格式化后的字符串,同 std::format。
与 std::format 的区别:std::format 接受可变参数模板,直接在调用点展开参数。std::vformat 接受已经打包好的 std::format_args,适合在需要将格式化参数作为参数传递的场景中使用。比如在实现日志库时,你可能需要将格式化字符串和参数包一起传递,这时 vformat 就派上了用场。
3.5.2 std::make_format_args
函数签名:
template<class... Args>
format_args make_format_args(Args&... args);
参数详解:
| args… | Args&… | 要格式化的参数,注意是左值引用,不能是右值 |
返回值:
- 类型:std::format_args(实际上是 std::basic_format_args<std::format_context> 的别名)
- 说明:返回一个包含参数引用的打包对象。
重要注意事项:
参数必须是左值:std::make_format_args 的参数是左值引用,不能传递右值。这是因为 make_format_args 存储的是参数的引用,而不是拷贝。如果允许右值,会导致悬空引用——右值在表达式结束后就被销毁了,但 format_args 还持有指向它的引用。
生命周期管理:format_args 对象的生命周期不能超过它引用的参数的生命周期。也就是说,在 format_args 被使用期间,原始参数必须保持有效。
示例:
#include <format>
#include <iostream>
#include <string_view>
int main() {
std::string_view format_string = "Hello, {}! The answer is {}.";
// 注意:make_format_args 的参数必须是左值
// 不能写 std::make_format_args("hello world", 42) 这种形式
// 因为字符串字面量和整数字面量是右值
const char* str = "hello world"; // 左值
int x = 42; // 左值
// 将参数打包
auto args = std::make_format_args(str, x);
// 使用打包的参数进行格式化
std::string result = std::vformat(format_string, args);
std::cout << result << '\\n';
// 输出:Hello, hello world! The answer is 42.
return 0;
}
3.5.3 使用 vformat 实现日志库
vformat 的典型应用场景是实现日志库,因为日志库通常需要接受格式化字符串和可变参数,并将它们传递给底层的格式化函数:
#include <format>
#include <iostream>
#include <string_view>
// 使用 vformat 实现一个简单的日志库
class Logger {
public:
// 日志级别枚举
enum class Level { Debug, Info, Warning, Error };
// 核心日志函数
// 接受格式化字符串和可变参数包
template<typename... Args>
void log(Level level, std::string_view fmt, Args&&... args) {
// 将参数包打包为 format_args,然后传递给 vformat
// 注意:这里使用 args… 而不是 std::forward<Args>(args)…
// 因为 make_format_args 需要左值
auto formatted_msg = std::vformat(fmt, std::make_format_args(args...));
output_message(level, formatted_msg);
}
// 便捷函数:各个日志级别
template<typename... Args>
void debug(std::string_view fmt, Args&&... args) {
log(Level::Debug, fmt, std::forward<Args>(args)...);
}
template<typename... Args>
void info(std::string_view fmt, Args&&... args) {
log(Level::Info, fmt, std::forward<Args>(args)...);
}
template<typename... Args>
void warning(std::string_view fmt, Args&&... args) {
log(Level::Warning, fmt, std::forward<Args>(args)...);
}
template<typename... Args>
void error(std::string_view fmt, Args&&... args) {
log(Level::Error, fmt, std::forward<Args>(args)...);
}
private:
void output_message(Level level, const std::string& msg) {
// 将日志级别转换为字符串
const char* level_str = "";
switch (level) {
case Level::Debug: level_str = "DEBUG"; break;
case Level::Info: level_str = "INFO"; break;
case Level::Warning: level_str = "WARNING"; break;
case Level::Error: level_str = "ERROR"; break;
}
// 使用 std::format 输出格式化后的日志
// 包含时间戳(这里简化为固定格式)
std::cout << std::format("[{}] {}\\n", level_str, msg);
}
};
int main() {
Logger logger;
// 调试日志
logger.debug("This is a debug message");
// 输出:[DEBUG] This is a debug message
// 错误日志,带参数
logger.error("User '{}' failed to access resource {}. Retry count: {}",
"Bob", "/api/data", 3);
// 输出:[ERROR] User 'Bob' failed to access resource /api/data. Retry count: 3
return 0;
}
3.5.4 使用 vformat 实现延迟格式化
有时候,我们希望在稍后的时间点再执行格式化,而不是立即格式化。比如在性能关键路径上,日志消息可能不会被输出(日志级别过滤),我们希望避免不必要的格式化开销。这种情况下,可以使用 vformat 结合 std::function 实现延迟格式化:
#include <format>
#include <vector>
#include <functional>
#include <iostream>
// 延迟格式化类:将格式化操作推迟到执行时
class DeferredFormatter {
private:
// 存储一系列可调用对象,每个对象执行格式化操作
// 使用 std::function<std::string()> 来存储 lambda 表达式
std::vector<std::function<std::string()>> formatters_;
public:
// 添加一个延迟格式化条目
// 参数:格式化字符串和参数包
// 注意:这里按值捕获参数,确保 lambda 拥有参数的所有权
template<typename... Args>
void add_formatter(std::string_view fmt, Args&&... args) {
// emplace_back 直接在 vector 中构造 lambda
// 按值捕获 fmt 和 args…,这样 lambda 拥有自己的数据副本
// 在调用时执行 std::vformat
formatters_.emplace_back([=]() -> std::string {
return std::vformat(fmt, std::make_format_args(args...));
});
}
// 执行所有延迟的格式化操作
void execute_all() {
for (const auto& formatter : formatters_) {
std::cout << formatter() << '\\n';
}
}
};
int main() {
DeferredFormatter deferred;
// 添加格式化条目,但此时并不执行格式化
deferred.add_formatter("Initial: {}, {}", 100, "张三");
deferred.add_formatter("Updated: {}, {}", 200, "李四");
// 在后续的某个时间点,统一执行所有格式化
deferred.execute_all();
// 输出:
// Initial: 100, 张三
// Updated: 200, 李四
return 0;
}
3.6 std::formatter——为自定义类型提供格式化支持
3.6.1 基本概念
std::formatter 是一个模板类,需要特化来支持自定义类型的格式化。特化 std::formatter 需要实现两个核心函数:
- parse():解析格式说明符(: 后面的部分)。这个函数在编译期执行。
- format():执行实际的格式化操作。这个函数在运行时执行。
基本结构:
template<>
struct std::formatter<Type> {
// 解析格式说明符
// 参数:ctx 是格式解析上下文
// 返回值:指向格式说明符结束位置的迭代器
constexpr auto parse(std::format_parse_context& ctx) {
// … 解析逻辑
return ctx.begin(); // 返回解析结束位置
}
// 格式化函数
// 参数:value 是要格式化的值,ctx 是格式化上下文
// 返回值:指向输出结束位置的迭代器
auto format(const Type& value, std::format_context& ctx) const {
// … 格式化逻辑
return std::format_to(ctx.out(), "…");
}
};
3.6.2 为 Point 类型实现格式化
#include <iostream>
#include <format>
struct Point {
double x, y;
};
// 特化 std::formatter 对于 Point 类型
// 基础版本:不接受任何格式选项,直接输出 (x, y)
template<>
struct std::formatter<Point> {
// parse 函数:解析格式说明符
// 这个基础版本不接受任何格式选项
// 所以直接返回 ctx.begin(),表示不解析任何内容
constexpr auto parse(std::format_parse_context& ctx) {
// 返回指向当前解析位置的迭代器
// 如果格式说明符为空,ctx.begin() 指向 } 或字符串末尾
return ctx.begin();
}
// format 函数:执行实际的格式化
// 参数 p 是要格式化的 Point 对象
// 参数 ctx 是格式化上下文,包含输出迭代器等信息
auto format(const Point& p, std::format_context& ctx) const {
// 使用 std::format_to 将格式化结果输出到 ctx.out()
// ctx.out() 返回一个输出迭代器,指向要写入的位置
return std::format_to(ctx.out(), "({}, {})", p.x, p.y);
}
};
// 使用示例
int main() {
Point p{3.14, 2.71};
// 直接使用 std::format 格式化 Point
auto str = std::format("Point: {}", p);
std::cout << str << '\\n';
// 输出:Point: (3.14, 2.71)
return 0;
}
3.6.3 带自定义格式说明符的 formatter——Person 类
更复杂的场景是支持自定义格式选项。比如,让 Person 类支持 s(简短)、l(详细)、j(JSON)三种格式:
#include <format>
#include <iostream>
#include <string>
struct Person {
std::string name;
int age;
std::string city;
};
// 特化 std::formatter 对于 Person 类型
// 支持自定义格式选项:'s' = 简短, 'l' = 详细, 'j' = JSON
template<>
struct std::formatter<Person> {
// 格式选项:默认使用简短格式
char presentation_type = 's';
// parse 函数:解析格式说明符
// 在编译期执行,用于解析 : 后面的格式选项
constexpr auto parse(std::format_parse_context& ctx) {
// 获取当前解析位置的迭代器
auto it = ctx.begin();
// 如果到达结尾或者遇到 },说明没有格式说明符
// 使用默认的 's'(简短格式)
if (it == ctx.end() || *it == '}') {
return it;
}
// 解析单个字符的格式选项
switch (*it) {
case 's': // 简短格式:只显示名字
presentation_type = 's';
++it;
break;
case 'l': // 详细格式:名字 (年龄) – 城市
presentation_type = 'l';
++it;
break;
case 'j': // JSON 格式
presentation_type = 'j';
++it;
break;
default:
// 如果遇到不认识的格式说明符,抛出异常
throw std::format_error("无效的格式说明符");
}
// 检查格式说明符是否已结束
// 解析完一个字符后,下一个字符应该是 }
if (it != ctx.end() && *it != '}') {
throw std::format_error("格式说明符应以 } 结束");
}
return it;
}
// format 函数:执行实际的格式化
// 根据 presentation_type 选择不同的格式
auto format(const Person& p, std::format_context& ctx) const {
switch (presentation_type) {
case 's': // 简短格式:只显示名字
return std::format_to(ctx.out(), "{}", p.name);
case 'l': // 详细格式:名字 (年龄) – 城市
return std::format_to(ctx.out(), "{} ({}) – {}",
p.name, p.age, p.city);
case 'j': // JSON 格式
// 注意:使用 R"(…)" 原始字符串字面量
// 内部的引号不需要转义
// 但需要将 { 和 } 写为 {{ 和 }} 来转义
return std::format_to(ctx.out(),
R"({{"name":"{}","age":{},"city":"{}"}})",
p.name, p.age, p.city);
default:
// 理论上不会执行,因为 parse 已经验证过
return std::format_to(ctx.out(), "{}", p.name);
}
}
};
// 使用示例
int main() {
Person p{"Alice", 30, "Beijing"};
// 默认格式(简短)
std::cout << std::format("默认: {}\\n", p);
// 输出:默认: Alice
// 简短格式
std::cout << std::format("简短: {:s}\\n", p);
// 输出:简短: Alice
// 详细格式
std::cout << std::format("详细: {:l}\\n", p);
// 输出:详细: Alice (30) – Beijing
// JSON 格式
std::cout << std::format("JSON: {:j}\\n", p);
// 输出:JSON: {"name":"Alice","age":30,"city":"Beijing"}
return 0;
}
3.7 C++23 的 std::print 和 std::println
虽然 C++23 已经发布,但 std::print 和 std::println 是建立在 std::format 基础上的,作为 std::format 生态的补充,这里简单介绍一下。
头文件:<print>
函数签名:
template<class... Args>
void print(std::format_string<Args...> fmt, Args&&... args);
template<class... Args>
void println(std::format_string<Args...> fmt, Args&&... args);
说明:std::print 和 std::println 本质上就是 std::format + std::cout 的快捷方式。std::println 会在末尾自动添加换行符。它们结合了 std::format 的类型安全性和 printf 的简洁性。
使用示例:
#include <print> // C++23 新头文件
int main() {
std::string name = "Bob";
int age = 25;
// 使用 std::print 输出(不自动换行)
std::print("Hello, {}!", name); // 输出:Hello, Bob!
// 使用 std::println 输出(自动换行)
std::println("You are {} years old.", age); // 输出:You are 25 years old. (然后换行)
// 相当于:
std::cout << std::format("You are {} years old.\\n", age);
return 0;
}
第四章 最佳实践与常见陷阱
4.1 三路比较运算符的最佳实践
1. 优先使用 = default:如果类的比较逻辑是简单的逐成员比较,总是优先使用 = default。这不仅减少了代码量,还保证了比较逻辑的一致性。
2. 手动实现 <=> 时要注意返回类型:如果类中包含浮点数成员,= default 的 <=> 返回类型会自动推导为 std::partial_ordering。如果这不是你想要的(比如你确定 NaN 不会出现),可以手动实现并返回 std::strong_ordering。
3. 不要忘记 operator==:当手动实现 operator<=> 时,编译器不会自动生成 operator==。需要显式定义 operator==,或者使用 = default 生成。
4. 注意 operator<=> 的性能:<=> 的默认实现是逐成员比较,对于大型对象(如包含大量成员的类),性能可能不如手写优化版本。在性能关键路径上,可以考虑手动优化。
5. 使用 std::compare_three_way 进行泛型编程:在模板代码中,std::compare_three_way 提供了一个统一的接口,可以处理各种类型的 <=> 比较。
4.2 std::format 的最佳实践
1. 优先使用 std::format 而不是 printf 或 iostream:std::format 兼具类型安全、性能好、语法简洁的优点,是 C++20 中格式化字符串的首选方式。
2. 在性能敏感场景中使用 format_to:format_to 避免了 std::string 的创建和拷贝,直接写入缓冲区,性能更好。
3. 使用 formatted_size 预先分配缓冲区:当需要精确控制内存分配时,先用 formatted_size 计算大小,再分配缓冲区,最后用 format_to 写入。
4. 为自定义类型实现 std::formatter:如果自定义类型需要频繁进行格式化输出,实现 std::formatter 特化是值得的,这可以让你的类型无缝集成到 std::format 生态中。
5. 注意 std::make_format_args 的参数生命周期:确保 format_args 对象的生命周期不超过它引用的参数的生命周期。
4.3 常见陷阱
陷阱 1:<=> 返回类型与 int 混淆
auto result = a <=> b;
// 错误:result == 1 是编译错误
// if (result == 1) { … }
// 正确:比较的是关系,而不是数值
if (result > 0) { ... } // 或 result == std::strong_ordering::greater
陷阱 2:混合使用隐式和显式索引
// 错误:混合使用隐式索引 {} 和显式索引 {1}
// auto s = std::format("{} {1} {}", "a", "b"); // 编译错误!
// 正确:全部使用显式索引
auto s = std::format("{0} {1} {0}", "a", "b"); // 输出:"a b a"
陷阱 3:std::make_format_args 传递右值
// 错误:make_format_args 不接受右值
// auto args = std::make_format_args(42, "hello"); // 编译错误!
// 正确:使用左值
int x = 42;
const char* str = "hello";
auto args = std::make_format_args(x, str);
陷阱 4:浮点数 NaN 的比较
double nan = std::numeric_limits<double>::quiet_NaN();
double val = 1.0;
// 在 C++17 及之前:nan < val 返回 false,nan > val 返回 false,nan == val 返回 false
// 所有三个都比较完了都是 false,容易误导
// 在 C++20 中:(nan <=> val) == std::partial_ordering::unordered
// 明确表示"不可比较",更符合数学直觉
陷阱 5:std::formatter 的 parse 函数中忘记校验
// 在 parse 函数中,一定要校验格式说明符的合法性
// 如果遇到不认识的格式说明符,应该抛出 std::format_error
// 而不是默默地忽略它
总结
C++20 的三路比较运算符 <=> 和 std::format 格式化库,是 C++ 标准库中两个看似简单、实则影响深远的特性。<=> 从根本上改变了 C++ 中比较操作的实现方式,用一个运算符替代了原来需要六个运算符才能完成的工作,同时通过三种比较类型(strong_ordering、weak_ordering、partial_ordering)提供了精确的比较语义控制。std::format 则提供了一个现代、类型安全、高性能的文本格式化方案,统一了 printf 的简洁性和 iostream 的类型安全性。
从 <=> 的三种比较类别到 std::format 的完整格式说明符体系,从 = default 的自动生成到 std::formatter 的自定义类型扩展,这两个特性体现了 C++20 设计的核心理念:让编译器做更多的工作,让开发者写更少的代码,同时提供更强的类型安全和更清晰的语义表达。
在实际开发中,建议将这两个特性作为默认选择——用 <=> 替代传统的比较运算符重载,用 std::format 替代 printf 和 iostream 的格式化输出场景。这不仅能减少代码量、提高可维护性,还能避免很多常见的编程错误。




