C++20 的一些特性总结
前言
这篇文章不打算介绍所有更新的特性,而只打算介绍几个可能有用的特性以及比较重要的特性。若想了解所有更新的特性,建议去这里查看。注意:本文没有介绍 std::ranges
,原因是之前的一篇日报与另一篇日报已经进行了较为详细的介绍,若有需要可以参阅这两篇文章。
注:下文不会对编译器还没有实现的特性特别说明,因为不同的编译器支持的东西一般也不同,可能这里能编译的代码换个地方就编译失败了。因此,文章中的某些东西暂时还用不了,不过以后应该会实现。另外,若没有特别说明的话,本文代码所使用的编译器均为 GCC 13.1.0。
另外,如果您使用的是 -std=c++20
就能使用
感谢名单:圣嘉然,andyli,小菜鸟。
1.coroutines(协程)
这东西要细讲估计可以另写一篇文章,所以在这里只进行一个简单介绍。
注:如果有关协程的代码没有语法错误却无法成功编译,建议加上这句编译命令:-fcoroutines -fno-exceptions
,并检查是否引用了 <coroutine>
。
1.协程是什么
简单来说,协程就是一个可以暂停的函数。一般的函数都是返回之后就不执行了,但协程可以执行到一半,暂停,顺便返回一个值,被激活之后再继续从暂停的地方执行。而满足协程定义的函数,就是满足以下要求的函数:
- 至少用了
\texttt{co\_yield,co\_await,co\_return} 关键字(下文简称三大关键字)中的一个。
注:由于协程的实现与
2.Promise
从代码上来讲,一个
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() {}
};
};
即,一个定义了
3.三大关键字
三大关键字指的是
4.co_await
先给出
而这三个函数又是什么呢?其实可以根据它们意思来判断。具体来讲,
struct 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. 具体代码
提示:这里的代码仅起演示作用,很多功能还不完善,如
// 感谢 @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. 基础内容
来看一个简单的例子:
// 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!
的功能分到子模块 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. 分离声明与实现
在前面的例子上修改:把 hello.cpp
修改为如下代码:
export module hello_world:hello;
export void hello();
注意到 module
关键词使得 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. 演示代码
// 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 来说是不可见的。