专栏文章

C++20 的一些特性总结

科技·工程参与者 65已保存评论 74

文章操作

快速查看文章及其快照的属性,并进行相关操作。

当前评论
74 条
当前快照
1 份
快照标识符
@mhz5s7k6
此快照首次捕获于
2025/11/15 01:55
4 个月前
此快照最后确认于
2025/11/29 05:24
3 个月前
查看原文

前言

这篇文章不打算介绍所有更新的特性,而只打算介绍几个可能有用的特性以及比较重要的特性。若想了解所有更新的特性,建议去这里查看。注意:本文没有介绍 std::ranges,原因是之前的一篇日报另一篇日报已经进行了较为详细的介绍,若有需要可以参阅这两篇文章。
注:下文不会对编译器还没有实现的特性特别说明,因为不同的编译器支持的东西一般也不同,可能这里能编译的代码换个地方就编译失败了。因此,文章中的某些东西暂时还用不了,不过以后应该会实现。另外,若没有特别说明的话,本文代码所使用的编译器均为 GCC 13.1.0。
另外,如果您使用的是 Dev C++\text{Dev C++} 并且也想体验 C++20\text{C++}20 的话,那么可以参照这篇教程下载并配置较新的 GCC。完成所有步骤后,加入编译选项 -std=c++20 就能使用 C++20\text{C++}20 了。当然,C++14\text{C++}14C++17\text{C++}17 也能用了。如果您使用 Visual Studio Code\text{Visual Studio Code} 的话,这篇文章或许会对您有帮助。
感谢名单:圣嘉然andyli小菜鸟

1.coroutines(协程)

这东西要细讲估计可以另写一篇文章,所以在这里只进行一个简单介绍。
注:如果有关协程的代码没有语法错误却无法成功编译,建议加上这句编译命令:-fcoroutines -fno-exceptions,并检查是否引用了 <coroutine>
1.协程是什么
简单来说,协程就是一个可以暂停的函数。一般的函数都是返回之后就不执行了,但协程可以执行到一半,暂停,顺便返回一个值,被激活之后再继续从暂停的地方执行。而满足协程定义的函数,就是满足以下要求的函数:
  • 至少用了 co_yield,co_await,co_return\texttt{co\_yield,co\_await,co\_return} 关键字(下文简称三大关键字)中的一个。
注:由于协程的实现与 Promise\text{Promise} 密不可分,大部分功能都由 Promise\text{Promise} 实现,所以先讲讲 Promise\text{Promise}
2.Promise
从代码上来讲,一个 Promise\text{Promise} 就是这个东西:
CPP
struct Task {
    struct promise_type {
        auto get_return_object() {
            return std::coroutine_handle <promise_type>::from_promise(*this);
        }
        std::suspend_never initial_suspend() noexcept { // 需要保证协程刚开始时会执行这个函数,所以需要 noexcept 说明符保证它不会抛出异常。
            return {};
        }
        std::suspend_never final_suspend() noexcept {
            return {};
        }
        void unhandled_exception() {}
    };
};
即,一个定义了 promise_type\text{promise\_type} 结构体,且这个结构体中包含特定函数的类。下面对这些函数进行解释:
  • initial_suspend\texttt{initial\_suspend}:顾名思义,类似于 “最开始要做什么”,用来判断是否一开始就要挂起协程。其中的 suspend_never\texttt{suspend\_never} 表示这个协程一开始不会被挂起(相当于暂停),如果是 suspend_always\texttt{suspend\_always} 则与之意思相反。
  • final_suspend\texttt{final\_suspend}:同上,不过是表示“最后要做什么”,用来判断是否结束后要挂起协程。
  • get_return_object\texttt{get\_return\_object}:在 initial_suspend\texttt{initial\_suspend} 之前调用,用于创建返回的对象,并将其结果在一个局部变量中保存。该调用的结果将在协程首次暂停时返回给调用方。
  • unhandled_exception\texttt{unhandled\_exception}:如果这个协程因未捕捉的异常结束,则它将捕捉异常并调用此函数来处理异常,并执行 final_suspend\texttt{final\_suspend}
3.三大关键字
三大关键字指的是 co_yield,co_await,co_return\texttt{co\_yield,co\_await,co\_return}。下面对这些关键字进行解释:
  • co_return\texttt{co\_return}:若使用这个,则必须在 Promise\text{Promise} 中加入 return_void\texttt{return\_void}return_value\texttt{return\_value} 函数。作用是:结束协程并返回一个值。协程函数执行到此时,将会通过这两个函数中的一个返回值(值可以为空,视 co_return\texttt{co\_return} 后面加什么而定),并调用 final_suspend\texttt{final\_suspend},相当于这个协程结束了。若返回值为空,则调用 return_void\texttt{return\_void} 函数,否则用 return_value\texttt{return\_value} 函数。
  • co_yield\texttt{co\_yield}:若使用这个,则必须在 Promise\text{Promise} 中加入 yield_value\texttt{yield\_value} 函数。作用是:暂停协程并返回一个值。注意:yield_value\texttt{yield\_value} 函数的返回值必须得是 awaitable\text{awaitable} 的,至于它是什么后面会讲。
  • co_await\texttt{co\_await}:见下一小节。
4.co_await
先给出 awaitable\text{awaitable}awaiter\text{awaiter} 的定义:awaitable\text{awaitable} 是指可以支持 co_await\texttt{co\_await} 运算符的类型,而 awaiter\text{awaiter} 是指实现了 await_ready,await_suspend,await_resume\texttt{await\_ready,await\_suspend,await\_resume} 的类型。一般来说,两者是差不多的。
而这三个函数又是什么呢?其实可以根据它们意思来判断。具体来讲,await_ready\texttt{await\_ready} 就是指这个 awaiter\text{awaiter} 准备好了没,await_suspend\texttt{await\_suspend} 就是把协程暂停,而 await_resume\texttt{await\_resume} 则是把协程恢复。 举例:
CPP
struct awaitable {
    bool await_ready() {
        // 一个 bool 型函数,返回零代表暂停,一代表不暂停。
        return 0;
    }
    void await_suspend(std::coroutine_handle <>) {
        // await_ready 返回零时调用。
        // coroutine_handle 功能是恢复协程的执行与销毁协程,例如其中的 resume() 可以恢复挂起的协程,让其继续执行。
    }
    void await_resume() {
        // await_ready 返回一时调用。 
    }
};
接下来理解 co_await\texttt{co\_await} 就不难了:co_await\texttt{co\_await} 后面加一个 awaiter\text{awaiter},使用时先调用 await_ready\texttt{await\_ready},之后再根据其返回值决定调用 await_suspend\texttt{await\_suspend}await_resume\texttt{await\_resume}
5. 协程的流程
前面陆陆续续讲了很多,现在对前面内容进行一个总结,同时也捋捋协程的流程。
最开始,我们定义了一个 Promise\text{Promise},并将一个协程的返回类型设置为它。然后,这个协程开始工作,先调用了 get_return_object\texttt{get\_return\_object} 并返回了类型为这个 Promise\text{Promise} 的一个对象,之后调用 initial_suspend\texttt{initial\_suspend}。若这个协程没有被挂起,那么它就继续执行,并且会在执行时遇到至少一个三大关键字。最终,这个协程执行完毕,调用 final_suspend\texttt{final\_suspend} 并结束。
6. 具体代码
提示:这里的代码仅起演示作用,很多功能还不完善,如 co_return\texttt{co\_return} 只能返回整型变量。不过,现在真正编写协程还是比较麻烦的,以后可能会实现几个类,让我们可以直接用 co_await\texttt{co\_await} 等关键字。
CPP
// 感谢 @XQH0317 指出代码存在问题,已经修改。
#include <iostream>
#include <coroutine>
struct awaiter {
    bool await_ready() {
        std::cout << "Are you ready?\n";
        return 0;
    }
    void await_suspend(std::coroutine_handle <> handle) { // 协程被挂起。
        std::cout << "Suspend.\n";
    }
    void await_resume() { 
        std::cout << "Resume.\n";
    }
};
struct Task {
    struct promise_type {
        Task get_return_object() {
            std::cout << "Get return object.\n";
            return std::coroutine_handle <promise_type>::from_promise(*this);
        }
        std::suspend_never initial_suspend() noexcept {
            std::cout << "Start.\n";
            return {};
        }
        std::suspend_never final_suspend() noexcept {
            std::cout << "Finish.\n";
            return {};
        }
        void unhandled_exception() {}
        int return_value(int x) {
            std::cout << "Co_return " << x << ".\n";
            return x;
        }
        auto yield_value(int x) {
            std::cout << "Co_yield " << x << ".\n";
            return std::suspend_always();
        }
    };
    Task(std::coroutine_handle <promise_type> h) : handle(h) {}
    std::coroutine_handle <promise_type> handle;
};
Task f1(int n) {
	awaiter a;
    co_await a;
    for(int i = 2; i <= n; i += 2){
    	std::cout << "f1: ";
    	co_yield i;
    }
    co_return 0;
}
Task f2(int n) {
    for(int i = 1; i <= n; i += 2) {
    	std::cout << "f2: ";
    	co_yield i;
    }
}
int main() {
    int n;
    std::cin >> n;
    auto t1=f1(n);
    std::cout << '\n';
    auto t2=f2(n);
    std::cout << '\n';
    for(int i = 1; i <= n/2; ++i){
    	t1.handle.resume();
    	t2.handle.resume();
    }
    t1.handle.resume();
    if(n%2 == 1) t2.handle.destroy();
    // 将挂起的协程释放,可以用 handle.destroy()。
}
这份代码可以实现轮流执行代码中的 f1()f2(),读者可以尝试运行上述代码,加深对协程执行过程的理解。

2.module(模块)

注:如果有关模块的代码没有语法错误却无法成功编译,建议加上这句编译命令: -fmodules-ts。另外,建议不要使用某些 IDE 的一键编译,而是使用编译命令或编写脚本。(事实上,个人感觉 module 的一个缺点就是编译更麻烦了。)
另外,感谢 UnyieldingTrilobite的建议,现已重写这一小节。
1. 基础内容
来看一个简单的例子:
CPP
// hello_world.cpp。
module; // 开启一个全局模块片段。为了导入 <iostream> 库,必须声明这是全局模块。
#include <iostream>
export module hello_world; // 声明这份代码作为模块单元 hello_world 被导出。模块单元的名字不必与文件名相同。
export void hello_world() {
	std::cout << "Hello World!\n";
}
------------------------------------------------
// main.cpp。
#include <stdlib.h>
import hello_world; // 导入 hello_world 模块。
// 注意导入的模块不具有传递性,如 A 导入 B,B 导入 C,此时 A 是不能直接使用 C 中的内容的。
int main(){
	hello_world();
	system("pause");
}
这两份代码的功能是输出 Hello World!,使用命令 g++ -fmodules-ts -std=c++20 hello_world.cpp main.cpp -o main 编译,再运行 main.exe/main.out 即可得到预期结果。注意这里的顺序不能随意更改。
我们注意到 hello_world.cpp 的代码中包含 export 关键字,这意味着这份代码为模块接口单元。这个名词很好理解:它提供了一个可以调用的接口,即模块 hello_world。稍后将会看到,模块可以做到声明和定义分离,此时我们需要模块实现单元,它是没有 export 关键字的。
在编译时,对于每个模块声明单元,编译器会编译出一个二进制中间文件,当其它文件导入这些模块声明单元时,编译器会使用这些中间文件进行编译。
需要注意的是,clang 建议模块接口单元的后缀名为 .cppm,MSVC 建议后缀名使用 .ixx,而 GCC 则仍然使用 .cpp。由于 OIer 大多使用 GCC,因此下文所有模块接口单元的后缀名皆为 .cpp。另外,不同编译器编译出的二进制中间文件的后缀名也不同,且不同编译器编译出的二进制中间文件不能通用。
2.子模块与模块分区
使用模块时,若需实现的内容较多,可以使用子模块功能,即分别实现每个模块的内容,再合并。对于上一个例子中的 hello_world 模块,我们可以把输出 Hello 与输出 World! 的功能分到子模块 helloworld 中,代码如下:
CPP
// hello.cpp。world.cpp 与 hello.cpp 类似,故不再展示。
module;
#include <iostream>
export module hello; // 开启模块 hello 片段并导出,也即下面的代码属于 hello 模块。
export void hello(){ // 声明、定义并导出函数 hello()。
	std::cout << "Hello ";
}
------------------------------------------------
// hello_world.cpp。
export module hello_world; // 开启模块 hello_world 片段。此模块中导入了 hello 模块与 world 模块。
export import hello; // 之前提到导入的模块不具有传递性,因此若这里不加 export 关键字, main 中是无法使用 hello 中的 hello() 函数的。
export import world;
// main.cpp 中调用 hello() 与 world() 函数即可。
不过,C++ 标准中并没有子模块的概念,此时可以使用模块分区。模块分区的格式是 A:B,代表模块 B 为模块 A 的一个分区。上面的例子中,把 hello_world.cpp 中的 helloworld 前面加上冒号,代表这两个模块是 hello_world 模块的两个模块分区;再将 hello.cpp 中的 export module hello; 改为 export module hello_world:module; 即可。
引用两句话:
  • 模块分区内的所有声明和定义在将它导入的模块单元中均可见,无论它们是否被导出。
这意味着即使 hello 函数不用 export 修饰,在 hello_world.cpp 中仍然可以使用这个函数。但是要注意,此时 main.cpp 中是不能使用这个函数的。
  • 模块分区可以是模块接口单元(如果模块声明中有 export)。它们必须被主模块接口单元在导入同时导出,并且它们导出的语句在模块被导入时均可见。
事实上,上文的 hello 模块分区就是模块接口单元。
3. 分离声明与实现
在前面的例子上修改:把 hello.cpp 修改为如下代码:
CPP
export module hello_world:hello;
export void hello();
注意到 module 关键词使得 hello_world:hello 模块中包含了一个 hello() 函数,但是在这份代码中只有这个函数的声明。我们还需要一个模块实现单元 hello_R.cpp
CPP
module;
#include <iostream>
module hello_world; // 接下来的代码是在 hello_world 模块中的。
void hello() { // 还记得引用的第一句话吗?由于 hello() 属于 hello_world 的模块分区 hello_world:hello 模块,因此这里可以访问到 hello 函数,在这里实现即可。
	std::cout << "Hello ";
}
// 作为一个模块实现单元,这份代码中无需也不应该出现 export,因为它的功能是实现 hello 函数。
4. 演示代码
Hello World!\texttt{Hello World!}
CPP
// hello_R.cpp。
module;
#include <iostream>
module hello_world;
void hello() {
	std::cout << "Hello ";
}
------------------------------------------------
// hello.cpp。
export module hello_world:hello;
export void hello();
------------------------------------------------
// world.cpp。
module;
#include <iostream>
export module hello_world:world;
export void world(){
	std::cout << "World!\n";
}
------------------------------------------------
// hello_world.cpp。
export module hello_world;
export import :hello;
export import :world;
------------------------------------------------
// main.cpp。
#include <stdlib.h>
import hello_world;
int main(){
	hello(), world();
	system("pause");
}
编译命令:
g++ -fmodules-ts -std=c++20 hello_R.cpp hello.cpp world.cpp hello_world.cpp main.cpp -o main
.\main
编译时请确保当前目录下存在上述文件。
5. 模块的优势
  1. 传统的头文件在预处理阶段会被全部复制到源代码中,使得编译的速度较慢,且许多不需要的函数也被复制。而对于模块来说,被编译为二进制中间文件后,编译器只需在二进制中间文件里寻找用到的函数的声明与定义等,加快了编译速度。
(不过,许多旧的头文件还不支持模块化,待到旧的头文件也支持模块化后,就可以直接 import <iostream> 了,进一步提升编译速度。另外,可以用 Module Map 使旧的头文件支持模块化,但是内部原理似乎很复杂,因此感兴趣的读者可以自行研究。研究懂了能不能教教我啊? 另一种方法是在全局模块中包含这些头文件,就像演示代码所做的那样。)
  1. 头文件的包含有顺序先后之分,导致对于不同的函数重载会有不同结果,而模块的导入之间是没有顺序之分的。
  2. 导入的模块不具有传递性。对于头文件来说,底层的头文件中的内容可能会通过中间头文件传到了上层头文件中,导致难以预计的错误。而模块不存在这个方面的问题,若 A 导入 B,B 导入 C,则 C 对于 A 来说是不可见的。

3.concept(概念)与 requires(约束)

1. concept(概念)
concept\texttt{concept} 一般会用在模板类编程中,也就是说,它一般会与 template\texttt{template} 连用。它的作用是:让模板类中的形参符合一定条件。
这么说有些抽象,举一个例子:
CPP
template <typename T> // T 为形参。
auto f(T a){
	return a;
}
假设这段代码想要返回的是一个整形变量的值,而不是浮点数变量之类的值。但是,这段代码中的形参 T\text{T} 没有约束条件,这就会导致 std::cout<<f(0.1); 之类也能运行,不符合条件。这个时候,我们就可以用 concept\texttt{concept} 了。 用法:
CPP
template <typename V>
concept t = std::is_integral<V>::value;
template <t T>
auto f(T a){
	return a;
}
这几句话的意思相当于告诉电脑 T\text{T} 这个东西得是个整型变量,其中的 is_integral\texttt{is\_integral} 也很好理解:如果 V\text{V} 是个整型变量,则 value\texttt{value} 等于 11,否则为 00
这样一改,再运行刚刚的代码,就会发现编译错误了。
那么,要是我想让 T\text{T} 这个东西是指针或整型变量呢?只需要在原来的基础上再加点东西就行了。
CPP
template <typename T>
concept t = std::is_pointer<T>::value || std::is_integral<T>::value;
template <t T>
注意,这里的 || 不能写成 |,因为 | 在这里是起到连接作用的。也就是说,如果我把代码改成这样:
CPP
concept t = std::is_pointer<T>::value | std::is_integral<T>::value;
那这也就意味着,T\text{T} 要同时是指针和整型变量。符不符合逻辑暂且不考虑,这么写已经违背原意了。不过这也说明,|concept\texttt{concept} 语句中可以起到连接的作用,且它是符合短路求值的。
顺带一提:还有几个与 is_integral\texttt{is\_integral} 类似的东西,如 is_class\texttt{is\_class} 等等,具体的在 type_traits\texttt{type\_traits} 库里有,使用 concept\texttt{concept} 时可能也会用到。
2. requires(约束)
C++20\text{C++20} 中,requires\texttt{requires} 主要是用于模板类编程的。它可以单独使用,也可以与 concept\texttt{concept} 连用,实现更复杂的对形参的约束。
requires\texttt{requires} 表达式用法:requires(/*变量,如果没有可省略括号*/){/*要判断的语句*/};,若语句不可行返回假,否则返回真。注意:这里只会判断语句可不可行,不会具体分析语句自身。如果想具体分析语句的值,在大括号中再加个 requires(要分析的语句) 即可。
那么它有什么用呢?回想一下前面 concept\texttt{concept} 的用法,大概类似于这样:
CPP
concept t = 一个或一些约束,如 is_integral 之类的,一般为 bool 值。
而这个 requires\texttt{requires} 表达式返回的恰好也是 bool\texttt{bool} 值,所以,可以将它与 concept\texttt{concept} 相结合,完成更为复杂的约束。
具体代码实现:
CPP
template <typename T>
concept t = requires(T a, T b){
    a + b; // 如果加法是可行的,则返回 1。
};
template <t T>
auto f(T a, T b){
    return a + b;
}
void Requires(){
    std::cout << f(1, 2) << ' ' << f(1.5, 2.75);
    // std::cout << f(std::vector <int>(), std::vector <int>());
	// 编译错误,vector 与 vector 之间未定义加号运算符。
	// 输出:3 4.25。
}
当然,requires\texttt{requires} 表达式的威力远不如此。比如:
CPP
template <typename T>
concept t = requires(T a){
    a.size();
};
template <t T>
这短短的几句代码便可以保证传入的类型一定定义了 size\texttt{size} 函数。再比如,ranges\texttt{ranges} 库中的 range\texttt{range} 也是用它定义的:
CPP
template <typename T>
concept range = requires(T& t){
    ranges::begin(t);
    ranges::end  (t);
};
即,这个东西一定得有 begin\texttt{begin}end\texttt{end} 迭代器。
另外,requires\texttt{requires} 也可以单独使用,用途与 concept\texttt{concept} 类似,不过更简便。直接上代码:
CPP
template <typename T>
requires std::is_integral<T>::value
auto f(T a, T b) {
	return a * b;
}

4.其它可能有用的特性

1.三路比较运算符
三路比较运算符表达式的形式为
左操作数 <=> 右操作数
如果 左操作数 < 右操作数,那么 (a <=> b) < 0
如果 左操作数 > 右操作数,那么 (a <=> b) > 0
而如果左操作数和右操作数相等或等价,那么 (a <=> b) == 0
举例:
CPP
auto x = (9 <=> 7);
if(x > 0) std::cout << "9 > 7";
else if(x < 0) std::cout << "9 < 7";
else std::cout << "9 = 7";
输出:9 > 7
那么,它有什么用呢?它可以用来重载运算符。例如,在要对结构体排序时,只要重载这样一个运算符,>  < >\ \le\ <\ \ge 等运算符就都自动重载了。
不过要注意的是,三路运算符有特殊的重载方法,比如,它其实是可以产生三种不同返回类型的。一般来说,可以采用下面的代码进行重载:
auto operator <=>(const Point&) const = default;
这表示将 Point 这个类型的三路比较运算符按照默认比较顺序进行重载。即,若要比较两个这种类型的变量,则它会按声明顺序依次访问这种类型的子对象,若发现有不相等的则提前返回结果。举例:
CPP
struct Point {
	int x, y;
	auto operator <=>(const Point&)  const = default;
};
void Compare() {
	Point a = {1, 2}, b = {2, 3};
	if((a <=> b) < 0) std::swap(a, b);
	std::cout << a.x << ' ' << a.y << '\n';
	std::cout << b.x << ' ' << b.y << '\n';
}
不过,如果不想按照默认顺序重载运算符,是要自定义如何重载的。例如,如果想将 Point 类型按照 yy 坐标从小到大进行排序,yy 坐标相同时按照 xx 坐标从小到大排序,那么可以这样写:
CPP
struct Point {
	int x, y;
	auto operator <=>(const Point& a){
        if(y != a.y) return y <=> a.y;
        else return x <=> a.x;
    }
};
前面提到有三种比较类型又是什么意思呢?事实上,这种运算符可以返回三种类型:
CPP
std::strong_ordering
std::weak_ordering
std::partial_ordering
所以为了方便,重载时类型一般可以直接写 auto
它们之间的异同点如下:
特别地,若尝试比较不可比较的值,那么返回的结果是 std::partial_ordering 类型的 unordered 值。
``等价的值能否被区分""指是否存在一对等价的值 a,ba,b,满足存在 ff 函数使得 f(a)f(b)f(a) \not= f(b)
2. likely 与 unlikely
顾名思义,likely\text{likely} 指可能的,unlikely\text{unlikely} 指不大可能的。如下:
CPP
double pow(double x, long long n) {
	if (n > 0) [[likely]] //表示 n > 0 更有可能成立。
		return x * pow(x, n-1);
	else [[unlikely]]
		return 1;
}
long long fact(long long n) {
	if(n > 1) [[likely]] //表示 n > 1 更有可能成立。
		return n * fact(n-1);
	else [[unlikely]]
		return 1;
}
void Likely() {
	std::cout << pow(2, 10) << ' ' << fact(10) << '\n';
}
理论上,在代码中合理地使用这两个特性可以提高运行时的速度,但是若不合理使用则也会降低速度,建议谨慎使用。
3.numbers 库
这个库中主要包含了一些常用的数学变量,如 e,2,π,ln2e,\sqrt2,\pi,\ln 2 等等,保留了大约五十位小数。要想得到 2\sqrt{2} 的近似值,可以调用 std::numbers::sqrt2。使用时要注意:sqrt2std::numbers 命名空间中的一个常量,直接调用 sqrt2 是无法得到 2\sqrt{2} 的近似值的。
4.format
Python\text{Python} 中的 format 差不多,返回值是 std::string。示例:
CPP
std::cout << std::format("{} {}", "C++", 20);
// 输出:C++20。
std::format 由两部分组成:格式化字符串以及参数,分别对应代码中的 "{} {}""C++",20
  • 格式化字符串中每一对 {} 及其中间的字符被称为替换域,它的作用是依次将参数按照一定格式写入结果字符串中,如 {} 的作用就是不做更改地将参数写入。格式化字符串中的 {{}} 将在结果字符串中被替换为 {},其它字符将被不加修改地复制到结果字符串中。替换域还能实现更有用的功能,例如,std::format("{:.5f}", 3.14f) 实现保留五位小数(即精度为 55),与之等效的另一种写法为 std::format("{:.{}f}", 3.14f, 5)
  • 参数需要保证 std::formatter<参数类型, 字符类型> 满足格式化器,这意味着参数可被格式化字符串格式化,其中的字符类型可以省略,默认为 char。标准库提供对基本算术类型的 std::formatter 特化,所以示例中参数可以为 int 类型。显然 format 支持多个参数,且它的参数数量多于替换域数量是合法的,此时多出的参数不会被操作。
  • 如果参数不是基本算术类型的怎么办?例如,假如我想要让 std::format 支持数组或是自己手写的一个类怎么办?此时我们可以手写 std::formatter,因为这个类的功能就是对给定的类型定义格式化规则。代码(此处以数组举例):
CPP
template <typename T, size_t N>
struct std::formatter<T[N]>: std::formatter<T> {
    // 此处继承 std::formatter<T> 的意义在于继承 parse 函数,这个函数的功能是解析格式化字符串。
    template <class FormatContext> // 这个类的功能是提供格式化状态的访问。
    auto format(const T (&a)[N], FormatContext& FC) const {
        // format 函数的功能:格式化数组 a 并返回结果。
        auto it = std::formatter<T>::format(a[0], FC);
        for (size_t i = 1; i < N; i++) {
            it = ' ';
            it = std::formatter<T>::format(a[i], FC);
        }
        return it;
    }
};
使用 format 的好处在于:
  1. 速度快,尤其是对于浮点数。
  2. 使用比较简单,比如输出字符串 aa 要用 printf("%s", a),输出整数 aa 要用 printf("%d", a),但无论 aa 是哪种类型的,std::format("{}", a) 都可以将 aa 转化为字符串(如果它可以转化的话),此时就无需纠结于 printf 的格式指示符了。
顺带一提,如果想要实现参数的格式化并输出,可以使用 C++23\text{C++}23std::print,其用法与 std::format 类似,这里不再赘述。

后记:

祝大家使用愉快!
参考资料:
  1. cpp reference\text{cpp reference}中文版
  2. 知乎
  3. csdn\text{csdn}

评论

74 条评论,欢迎与作者交流。

正在加载评论...