C++20 的一些特性总结

Carnival

2021-12-12 21:34:44

Tech. & Eng.

前言

这篇文章不打算介绍所有更新的特性,而只打算介绍几个可能有用的特性以及比较重要的特性。若想了解所有更新的特性,建议去这里查看。注意:本文没有介绍 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.协程是什么

简单来说,协程就是一个可以暂停的函数。一般的函数都是返回之后就不执行了,但协程可以执行到一半,暂停,顺便返回一个值,被激活之后再继续从暂停的地方执行。而满足协程定义的函数,就是满足以下要求的函数:

注:由于协程的实现与 \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! 的功能分到子模块 helloworld 中,代码如下:

// 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 修改为如下代码:

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. 模块的优势
  1. 传统的头文件在预处理阶段会被全部复制到源代码中,使得编译的速度较慢,且许多不需要的函数也被复制。而对于模块来说,被编译为二进制中间文件后,编译器只需在二进制中间文件里寻找用到的函数的声明与定义等,加快了编译速度。

(不过,许多旧的头文件还不支持模块化,待到旧的头文件也支持模块化后,就可以直接 import <iostream> 了,进一步提升编译速度。另外,可以用 Module Map 使旧的头文件支持模块化,但是内部原理似乎很复杂,因此感兴趣的读者可以自行研究。研究懂了能不能教教我啊? 另一种方法是在全局模块中包含这些头文件,就像演示代码所做的那样。)

  1. 头文件的包含有顺序先后之分,导致对于不同的函数重载会有不同结果,而模块的导入之间是没有顺序之分的。
  2. 导入的模块不具有传递性。对于头文件来说,底层的头文件中的内容可能会通过中间头文件传到了上层头文件中,导致难以预计的错误。而模块不存在这个方面的问题,若 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)