前言
这篇文章不打算介绍所有更新的特性,而只打算介绍几个可能有用的特性以及比较重要的特性。若想了解所有更新的特性,建议去这里查看。注意:本文没有介绍 std::ranges
,原因是之前的一篇日报与另一篇日报已经进行了较为详细的介绍,若有需要可以参阅这两篇文章。
注:下文不会对编译器还没有实现的特性特别说明,因为不同的编译器支持的东西一般也不同,可能这里能编译的代码换个地方就编译失败了。因此,文章中的某些东西暂时还用不了,不过以后应该会实现。另外,若没有特别说明的话,本文代码所使用的编译器均为 GCC 13.1.0。
另外,如果您使用的是 \text{Dev C++} 并且也想体验 \text{C++}20 的话,那么可以参照这篇教程下载并配置较新的 GCC。完成所有步骤后,加入编译选项 -std=c++20
就能使用 \text{C++}20 了。当然,\text{C++}14 与 \text{C++}17 也能用了。如果您使用 \text{Visual Studio Code} 的话,这篇文章或许会对您有帮助。
感谢名单:圣嘉然,andyli,小菜鸟。
1.coroutines(协程)
这东西要细讲估计可以另写一篇文章,所以在这里只进行一个简单介绍。
注:如果有关协程的代码没有语法错误却无法成功编译,建议加上这句编译命令:-fcoroutines -fno-exceptions
,并检查是否引用了 <coroutine>
。
1.协程是什么
简单来说,协程就是一个可以暂停的函数。一般的函数都是返回之后就不执行了,但协程可以执行到一半,暂停,顺便返回一个值,被激活之后再继续从暂停的地方执行。而满足协程定义的函数,就是满足以下要求的函数:
- 至少用了 \texttt{co\_yield,co\_await,co\_return} 关键字(下文简称三大关键字)中的一个。
注:由于协程的实现与 \text{Promise} 密不可分,大部分功能都由 \text{Promise} 实现,所以先讲讲 \text{Promise}。
2.Promise
从代码上来讲,一个 \text{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() {}
};
};
即,一个定义了 \text{promise\_type} 结构体,且这个结构体中包含特定函数的类。下面对这些函数进行解释:
3.三大关键字
三大关键字指的是 \texttt{co\_yield,co\_await,co\_return}。下面对这些关键字进行解释:
4.co_await
先给出 \text{awaitable} 与 \text{awaiter} 的定义:\text{awaitable} 是指可以支持 \texttt{co\_await} 运算符的类型,而 \text{awaiter} 是指实现了 \texttt{await\_ready,await\_suspend,await\_resume} 的类型。一般来说,两者是差不多的。
而这三个函数又是什么呢?其实可以根据它们意思来判断。具体来讲,\texttt{await\_ready} 就是指这个 \text{awaiter} 准备好了没,\texttt{await\_suspend} 就是把协程暂停,而 \texttt{await\_resume} 则是把协程恢复。
举例:
struct awaitable {
bool await_ready() {
// 一个 bool 型函数,返回零代表暂停,一代表不暂停。
return 0;
}
void await_suspend(std::coroutine_handle <>) {
// await_ready 返回零时调用。
// coroutine_handle 功能是恢复协程的执行与销毁协程,例如其中的 resume() 可以恢复挂起的协程,让其继续执行。
}
void await_resume() {
// await_ready 返回一时调用。
}
};
接下来理解 \texttt{co\_await} 就不难了:\texttt{co\_await} 后面加一个 \text{awaiter},使用时先调用 \texttt{await\_ready},之后再根据其返回值决定调用 \texttt{await\_suspend} 或 \texttt{await\_resume}。
5. 协程的流程
前面陆陆续续讲了很多,现在对前面内容进行一个总结,同时也捋捋协程的流程。
最开始,我们定义了一个 \text{Promise},并将一个协程的返回类型设置为它。然后,这个协程开始工作,先调用了 \texttt{get\_return\_object} 并返回了类型为这个 \text{Promise} 的一个对象,之后调用 \texttt{initial\_suspend}。若这个协程没有被挂起,那么它就继续执行,并且会在执行时遇到至少一个三大关键字。最终,这个协程执行完毕,调用 \texttt{final\_suspend} 并结束。
6. 具体代码
提示:这里的代码仅起演示作用,很多功能还不完善,如 \texttt{co\_return} 只能返回整型变量。不过,现在真正编写协程还是比较麻烦的,以后可能会实现几个类,让我们可以直接用 \texttt{co\_await} 等关键字。
// 感谢 @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. 演示代码
\texttt{Hello World!}
// 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(概念)
这么说有些抽象,举一个例子:
```cpp
template <typename T> // T 为形参。
auto f(T a){
return a;
}
```
假设这段代码想要返回的是一个整形变量的值,而不是浮点数变量之类的值。但是,这段代码中的形参 $\text{T}$ 没有约束条件,这就会导致 `std::cout<<f(0.1);` 之类也能运行,不符合条件。这个时候,我们就可以用 $\texttt{concept}$ 了。
用法:
```cpp
template <typename V>
concept t = std::is_integral<V>::value;
template <t T>
auto f(T a){
return a;
}
```
这几句话的意思相当于告诉电脑 $\text{T}$ 这个东西得是个整型变量,其中的 $\texttt{is\_integral}$ 也很好理解:如果 $\text{V}$ 是个整型变量,则 $\texttt{value}$ 等于 $1$,否则为 $0$。
这样一改,再运行刚刚的代码,就会发现编译错误了。
那么,要是我想让 $\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;
```
那这也就意味着,$\text{T}$ 要同时是指针和整型变量。符不符合逻辑暂且不考虑,这么写已经违背原意了。不过这也说明,`|` 在 $\texttt{concept}$ 语句中可以起到连接的作用,且它是符合短路求值的。
顺带一提:还有几个与 $\texttt{is\_integral}$ 类似的东西,如 $\texttt{is\_class}$ 等等,具体的在 $\texttt{type\_traits}$ 库里有,使用 $\texttt{concept}$ 时可能也会用到。
##### 2\. requires(约束)
在 $\text{C++20}$ 中,$\texttt{requires}$ 主要是用于模板类编程的。它可以单独使用,也可以与 $\texttt{concept}$ 连用,实现更复杂的对形参的约束。
$\texttt{requires}$ 表达式用法:`requires(/*变量,如果没有可省略括号*/){/*要判断的语句*/};`,若语句不可行返回假,否则返回真。注意:这里只会判断语句可不可行,不会具体分析语句自身。如果想具体分析语句的值,在大括号中再加个 `requires(要分析的语句)` 即可。
那么它有什么用呢?回想一下前面 $\texttt{concept}$ 的用法,大概类似于这样:
```cpp
concept t = 一个或一些约束,如 is_integral 之类的,一般为 bool 值。
```
而这个 $\texttt{requires}$ 表达式返回的恰好也是 $\texttt{bool}$ 值,所以,可以将它与 $\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。
}
```
当然,$\texttt{requires}$ 表达式的威力远不如此。比如:
```cpp
template <typename T>
concept t = requires(T a){
a.size();
};
template <t T>
```
这短短的几句代码便可以保证传入的类型一定定义了 $\texttt{size}$ 函数。再比如,$\texttt{ranges}$ 库中的 $\texttt{range}$ 也是用它定义的:
```cpp
template <typename T>
concept range = requires(T& t){
ranges::begin(t);
ranges::end (t);
};
```
即,这个东西一定得有 $\texttt{begin}$ 和 $\texttt{end}$ 迭代器。
另外,$\texttt{requires}$ 也可以单独使用,用途与 $\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` 类型按照 $y$ 坐标从小到大进行排序,$y$ 坐标相同时按照 $x$ 坐标从小到大排序,那么可以这样写:
```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`。
它们之间的异同点如下:
![](https://cdn.luogu.com.cn/upload/image_hosting/j1bjqzfs.png)
特别地,若尝试比较不可比较的值,那么返回的结果是 `std::partial_ordering` 类型的 `unordered` 值。
($``$等价的值能否被区分$"$指是否存在一对等价的值 $a,b$,满足存在 $f$ 函数使得 $f(a) \not= f(b)$)
##### 2\. likely 与 unlikely
顾名思义,$\text{likely}$ 指可能的,$\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,\sqrt2,\pi,\ln 2$ 等等,保留了大约五十位小数。要想得到 $\sqrt{2}$ 的近似值,可以调用 `std::numbers::sqrt2`。使用时要注意:`sqrt2` 是 `std::numbers` 命名空间中的一个常量,直接调用 `sqrt2` 是无法得到 $\sqrt{2}$ 的近似值的。
##### 4\.format
和 $\text{Python}$ 中的 `format` 差不多,返回值是 `std::string`。示例:
```cpp
std::cout << std::format("{} {}", "C++", 20);
// 输出:C++20。
```
`std::format` 由两部分组成:格式化字符串以及参数,分别对应代码中的 `"{} {}"` 与 `"C++",20`。
+ 格式化字符串中每一对 `{}` 及其中间的字符被称为替换域,它的作用是依次将参数按照一定格式写入结果字符串中,如 `{}` 的作用就是不做更改地将参数写入。格式化字符串中的 `{{` 与 `}}` 将在结果字符串中被替换为 `{` 与 `}`,其它字符将被不加修改地复制到结果字符串中。替换域还能实现更有用的功能,例如,`std::format("{:.5f}", 3.14f)` 实现保留五位小数(即精度为 $5$),与之等效的另一种写法为 `std::format("{:.{}f}", 3.14f, 5)`。
+ 参数需要保证 `std::formatter<参数类型, 字符类型>` 满足[格式化器](https://zh.cppreference.com/w/cpp/named_req/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. 使用比较简单,比如输出字符串 $a$ 要用 `printf("%s", a)`,输出整数 $a$ 要用 `printf("%d", a)`,但无论 $a$ 是哪种类型的,`std::format("{}", a)` 都可以将 $a$ 转化为字符串(如果它可以转化的话),此时就无需纠结于 `printf` 的格式指示符了。
顺带一提,如果想要实现参数的格式化并输出,可以使用 $\text{C++}23$ 的 `std::print`,其用法与 `std::format` 类似,这里不再赘述。
## 后记:
祝大家使用愉快!
参考资料:
1. [$\text{cpp reference}$](https://en.cppreference.com/w/) 及[中文版](https://zh.cppreference.com/w/)
2. [知乎](https://zhuanlan.zhihu.com/p/137646370)
3. [$\text{csdn}$](https://blog.csdn.net/ctrigger/article/details/102905393)