专栏文章
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。
另外,如果您使用的是 并且也想体验 的话,那么可以参照这篇教程下载并配置较新的 GCC。完成所有步骤后,加入编译选项
-std=c++20 就能使用 了。当然, 与 也能用了。如果您使用 的话,这篇文章或许会对您有帮助。1.coroutines(协程)
这东西要细讲估计可以另写一篇文章,所以在这里只进行一个简单介绍。
注:如果有关协程的代码没有语法错误却无法成功编译,建议加上这句编译命令:
-fcoroutines -fno-exceptions,并检查是否引用了 <coroutine>。1.协程是什么
简单来说,协程就是一个可以暂停的函数。一般的函数都是返回之后就不执行了,但协程可以执行到一半,暂停,顺便返回一个值,被激活之后再继续从暂停的地方执行。而满足协程定义的函数,就是满足以下要求的函数:
- 至少用了 关键字(下文简称三大关键字)中的一个。
注:由于协程的实现与 密不可分,大部分功能都由 实现,所以先讲讲 。
2.Promise
从代码上来讲,一个 就是这个东西:
CPPstruct 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() {}
};
};
即,一个定义了 结构体,且这个结构体中包含特定函数的类。下面对这些函数进行解释:
- :顾名思义,类似于 “最开始要做什么”,用来判断是否一开始就要挂起协程。其中的 表示这个协程一开始不会被挂起(相当于暂停),如果是 则与之意思相反。
- :同上,不过是表示“最后要做什么”,用来判断是否结束后要挂起协程。
- :在 之前调用,用于创建返回的对象,并将其结果在一个局部变量中保存。该调用的结果将在协程首次暂停时返回给调用方。
- :如果这个协程因未捕捉的异常结束,则它将捕捉异常并调用此函数来处理异常,并执行 。
3.三大关键字
三大关键字指的是 。下面对这些关键字进行解释:
-
:若使用这个,则必须在 中加入 或 函数。作用是:结束协程并返回一个值。协程函数执行到此时,将会通过这两个函数中的一个返回值(值可以为空,视 后面加什么而定),并调用 ,相当于这个协程结束了。若返回值为空,则调用 函数,否则用 函数。
-
:若使用这个,则必须在 中加入 函数。作用是:暂停协程并返回一个值。注意: 函数的返回值必须得是 的,至于它是什么后面会讲。
-
:见下一小节。
4.co_await
先给出 与 的定义: 是指可以支持 运算符的类型,而 是指实现了 的类型。一般来说,两者是差不多的。
而这三个函数又是什么呢?其实可以根据它们意思来判断。具体来讲, 就是指这个 准备好了没, 就是把协程暂停,而 则是把协程恢复。
举例:
CPPstruct awaitable {
bool await_ready() {
// 一个 bool 型函数,返回零代表暂停,一代表不暂停。
return 0;
}
void await_suspend(std::coroutine_handle <>) {
// await_ready 返回零时调用。
// coroutine_handle 功能是恢复协程的执行与销毁协程,例如其中的 resume() 可以恢复挂起的协程,让其继续执行。
}
void await_resume() {
// await_ready 返回一时调用。
}
};
接下来理解 就不难了: 后面加一个 ,使用时先调用 ,之后再根据其返回值决定调用 或 。
5. 协程的流程
前面陆陆续续讲了很多,现在对前面内容进行一个总结,同时也捋捋协程的流程。
最开始,我们定义了一个 ,并将一个协程的返回类型设置为它。然后,这个协程开始工作,先调用了 并返回了类型为这个 的一个对象,之后调用 。若这个协程没有被挂起,那么它就继续执行,并且会在执行时遇到至少一个三大关键字。最终,这个协程执行完毕,调用 并结束。
6. 具体代码
提示:这里的代码仅起演示作用,很多功能还不完善,如 只能返回整型变量。不过,现在真正编写协程还是比较麻烦的,以后可能会实现几个类,让我们可以直接用 等关键字。
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.子模块与模块分区
使用模块时,若需实现的内容较多,可以使用子模块功能,即分别实现每个模块的内容,再合并。对于上一个例子中的
CPPhello_world 模块,我们可以把输出 Hello 与输出 World! 的功能分到子模块 hello 与 world 中,代码如下:// 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 中的 hello 与 world 前面加上冒号,代表这两个模块是 hello_world 模块的两个模块分区;再将 hello.cpp 中的 export module hello; 改为 export module hello_world:module; 即可。引用两句话:
- 模块分区内的所有声明和定义在将它导入的模块单元中均可见,无论它们是否被导出。
这意味着即使
hello 函数不用 export 修饰,在 hello_world.cpp 中仍然可以使用这个函数。但是要注意,此时 main.cpp 中是不能使用这个函数的。
- 模块分区可以是模块接口单元(如果模块声明中有 export)。它们必须被主模块接口单元在导入同时导出,并且它们导出的语句在模块被导入时均可见。
事实上,上文的
hello 模块分区就是模块接口单元。3. 分离声明与实现
在前面的例子上修改:把
CPPhello.cpp 修改为如下代码:export module hello_world:hello;
export void hello();
注意到
CPPmodule 关键词使得 hello_world:hello 模块中包含了一个 hello() 函数,但是在这份代码中只有这个函数的声明。我们还需要一个模块实现单元 hello_R.cpp:module;
#include <iostream>
module hello_world; // 接下来的代码是在 hello_world 模块中的。
void hello() { // 还记得引用的第一句话吗?由于 hello() 属于 hello_world 的模块分区 hello_world:hello 模块,因此这里可以访问到 hello 函数,在这里实现即可。
std::cout << "Hello ";
}
// 作为一个模块实现单元,这份代码中无需也不应该出现 export,因为它的功能是实现 hello 函数。
4. 演示代码
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. 模块的优势
- 传统的头文件在预处理阶段会被全部复制到源代码中,使得编译的速度较慢,且许多不需要的函数也被复制。而对于模块来说,被编译为二进制中间文件后,编译器只需在二进制中间文件里寻找用到的函数的声明与定义等,加快了编译速度。
(不过,许多旧的头文件还不支持模块化,待到旧的头文件也支持模块化后,就可以直接 研究懂了能不能教教我啊? 另一种方法是在全局模块中包含这些头文件,就像演示代码所做的那样。)
import <iostream> 了,进一步提升编译速度。另外,可以用 Module Map 使旧的头文件支持模块化,但是内部原理似乎很复杂,因此感兴趣的读者可以自行研究。- 头文件的包含有顺序先后之分,导致对于不同的函数重载会有不同结果,而模块的导入之间是没有顺序之分的。
- 导入的模块不具有传递性。对于头文件来说,底层的头文件中的内容可能会通过中间头文件传到了上层头文件中,导致难以预计的错误。而模块不存在这个方面的问题,若 A 导入 B,B 导入 C,则 C 对于 A 来说是不可见的。
3.concept(概念)与 requires(约束)
1. concept(概念)
一般会用在模板类编程中,也就是说,它一般会与 连用。它的作用是:让模板类中的形参符合一定条件。
这么说有些抽象,举一个例子:
CPPtemplate <typename T> // T 为形参。
auto f(T a){
return a;
}
假设这段代码想要返回的是一个整形变量的值,而不是浮点数变量之类的值。但是,这段代码中的形参 没有约束条件,这就会导致
CPPstd::cout<<f(0.1); 之类也能运行,不符合条件。这个时候,我们就可以用 了。
用法:template <typename V>
concept t = std::is_integral<V>::value;
template <t T>
auto f(T a){
return a;
}
这几句话的意思相当于告诉电脑 这个东西得是个整型变量,其中的 也很好理解:如果 是个整型变量,则 等于 ,否则为 。
这样一改,再运行刚刚的代码,就会发现编译错误了。
那么,要是我想让 这个东西是指针或整型变量呢?只需要在原来的基础上再加点东西就行了。
CPPtemplate <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;
那这也就意味着, 要同时是指针和整型变量。符不符合逻辑暂且不考虑,这么写已经违背原意了。不过这也说明,
| 在 语句中可以起到连接的作用,且它是符合短路求值的。顺带一提:还有几个与 类似的东西,如 等等,具体的在 库里有,使用 时可能也会用到。
2. requires(约束)
在 中, 主要是用于模板类编程的。它可以单独使用,也可以与 连用,实现更复杂的对形参的约束。
表达式用法:
requires(/*变量,如果没有可省略括号*/){/*要判断的语句*/};,若语句不可行返回假,否则返回真。注意:这里只会判断语句可不可行,不会具体分析语句自身。如果想具体分析语句的值,在大括号中再加个 requires(要分析的语句) 即可。那么它有什么用呢?回想一下前面 的用法,大概类似于这样:
CPPconcept t = 一个或一些约束,如 is_integral 之类的,一般为 bool 值。
而这个 表达式返回的恰好也是 值,所以,可以将它与 相结合,完成更为复杂的约束。
具体代码实现:
CPPtemplate <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。
}
当然, 表达式的威力远不如此。比如:
CPPtemplate <typename T>
concept t = requires(T a){
a.size();
};
template <t T>
这短短的几句代码便可以保证传入的类型一定定义了 函数。再比如, 库中的 也是用它定义的:
CPPtemplate <typename T>
concept range = requires(T& t){
ranges::begin(t);
ranges::end (t);
};
即,这个东西一定得有 和 迭代器。
另外, 也可以单独使用,用途与 类似,不过更简便。直接上代码:
CPPtemplate <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。举例:
CPPauto 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。那么,它有什么用呢?它可以用来重载运算符。例如,在要对结构体排序时,只要重载这样一个运算符, 等运算符就都自动重载了。
不过要注意的是,三路运算符有特殊的重载方法,比如,它其实是可以产生三种不同返回类型的。一般来说,可以采用下面的代码进行重载:
auto operator <=>(const Point&) const = default;这表示将
CPPPoint 这个类型的三路比较运算符按照默认比较顺序进行重载。即,若要比较两个这种类型的变量,则它会按声明顺序依次访问这种类型的子对象,若发现有不相等的则提前返回结果。举例: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';
}
不过,如果不想按照默认顺序重载运算符,是要自定义如何重载的。例如,如果想将
CPPPoint 类型按照 坐标从小到大进行排序, 坐标相同时按照 坐标从小到大排序,那么可以这样写:struct Point {
int x, y;
auto operator <=>(const Point& a){
if(y != a.y) return y <=> a.y;
else return x <=> a.x;
}
};
前面提到有三种比较类型又是什么意思呢?事实上,这种运算符可以返回三种类型:
CPPstd::strong_ordering
std::weak_ordering
std::partial_ordering
所以为了方便,重载时类型一般可以直接写
auto。它们之间的异同点如下:

特别地,若尝试比较不可比较的值,那么返回的结果是
std::partial_ordering 类型的 unordered 值。(等价的值能否被区分指是否存在一对等价的值 ,满足存在 函数使得 )
2. likely 与 unlikely
顾名思义, 指可能的, 指不大可能的。如下:
CPPdouble 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 库
这个库中主要包含了一些常用的数学变量,如 等等,保留了大约五十位小数。要想得到 的近似值,可以调用
std::numbers::sqrt2。使用时要注意:sqrt2 是 std::numbers 命名空间中的一个常量,直接调用 sqrt2 是无法得到 的近似值的。4.format
和 中的
CPPformat 差不多,返回值是 std::string。示例:std::cout << std::format("{} {}", "C++", 20);
// 输出:C++20。
std::format 由两部分组成:格式化字符串以及参数,分别对应代码中的 "{} {}" 与 "C++",20。-
格式化字符串中每一对
{}及其中间的字符被称为替换域,它的作用是依次将参数按照一定格式写入结果字符串中,如{}的作用就是不做更改地将参数写入。格式化字符串中的{{与}}将在结果字符串中被替换为{与},其它字符将被不加修改地复制到结果字符串中。替换域还能实现更有用的功能,例如,std::format("{:.5f}", 3.14f)实现保留五位小数(即精度为 ),与之等效的另一种写法为std::format("{:.{}f}", 3.14f, 5)。 -
参数需要保证
std::formatter<参数类型, 字符类型>满足格式化器,这意味着参数可被格式化字符串格式化,其中的字符类型可以省略,默认为char。标准库提供对基本算术类型的std::formatter特化,所以示例中参数可以为int类型。显然format支持多个参数,且它的参数数量多于替换域数量是合法的,此时多出的参数不会被操作。 -
如果参数不是基本算术类型的怎么办?例如,假如我想要让
std::format支持数组或是自己手写的一个类怎么办?此时我们可以手写std::formatter,因为这个类的功能就是对给定的类型定义格式化规则。代码(此处以数组举例):
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 的好处在于:- 速度快,尤其是对于浮点数。
- 使用比较简单,比如输出字符串 要用
printf("%s", a),输出整数 要用printf("%d", a),但无论 是哪种类型的,std::format("{}", a)都可以将 转化为字符串(如果它可以转化的话),此时就无需纠结于printf的格式指示符了。
顺带一提,如果想要实现参数的格式化并输出,可以使用 的
std::print,其用法与 std::format 类似,这里不再赘述。后记:
祝大家使用愉快!
参考资料:
相关推荐
评论
共 74 条评论,欢迎与作者交流。
正在加载评论...