This commit is contained in:
louzefeng
2024-07-09 18:38:56 +00:00
parent 8bafaef34d
commit bf99793fd0
6071 changed files with 1017944 additions and 0 deletions

View File

@@ -0,0 +1,357 @@
<audio id="audio" title="10 | 到底应不应该返回对象?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/23/6b/23910d81949a741e1d5f39b712f26f6b.mp3"></audio>
你好,我是吴咏炜。
前几讲里我们已经约略地提到了返回对象的问题,本讲里我们进一步展开这个话题,把返回对象这个问题讲深讲透。
## F.20
《C++ 核心指南》的 F.20 这一条款是这么说的 [1]
>
F.20: For “out” output values, prefer return values to output parameters
翻译一下:
>
在函数输出数值时,尽量使用返回值而非输出参数
这条可能会让一些 C++ 老手感到惊讶——在 C++11 之前的实践里,我们完全是采用相反的做法的啊!
在解释 F.20 之前,我们先来看看我们之前的做法。
### 调用者负责管理内存,接口负责生成
一种常见的做法是,接口的调用者负责分配一个对象所需的内存并负责其生命周期,接口负责生成或修改该对象。这种做法意味着对象可以默认构造(甚至只是一个结构),代码一般使用错误码而非异常。
示例代码如下:
```
MyObj obj;
ec = initialize(&amp;obj);
```
这种做法和 C 是兼容的,很多程序员出于惯性也沿用了 C 的这种做法。一种略为 C++ 点的做法是使用引用代替指针,这样在上面的示例中就不需要使用 `&amp;` 运算符了;但这样只是语法略有区别,本质完全相同。如果对象有合理的析构函数的话,那这种做法的主要问题是啰嗦、难于组合。你需要写更多的代码行,使用更多的中间变量,也就更容易犯错误。
假如我们已有矩阵变量 $\mathbf{A}$、$\mathbf{B}$ 和 $\mathbf{C}$,要执行一个操作
$$<br>
\mathbf{R} = \mathbf{A} \times \mathbf{B} + \mathbf{C}<br>
$$
那在这种做法下代码大概会写成:
```
error_code_t add(
matrix* result,
const matrix&amp; lhs,
const matrix&amp; rhs);
error_code_t multiply(
matrix* result,
const matrix&amp; lhs,
const matrix&amp; rhs);
error_code_t ec;
matrix temp;
ec = multiply(&amp;temp, a, b);
if (ec != SUCCESS) {
goto end;
}
matrix r;
ec = add(&amp;r, temp, c);
if (ec != SUCCESS) {
goto end;
}
end:
// 返回 ec 或类似错误处理
```
理论上该方法可以有一个变体,不使用返回值,而使用异常来表示错误。实践中,我从来没在实际系统中看到过这样的代码。
### 接口负责对象的堆上生成和内存管理
另外一种可能的做法是接口提供生成和销毁对象的函数,对象在堆上维护。`fopen``fclose` 就是这样的接口的实例。注意使用这种方法一般不推荐由接口生成对象,然后由调用者通过调用 `delete` 来释放。在某些环境里,比如 Windows 上使用不同的运行时库时,这样做会引发问题。
同样以上面的矩阵运算为例,代码大概就会写成这个样子:
```
matrix* add(
const matrix* lhs,
const matrix* rhs,
error_code_t* ec);
matrix* multiply(
const matrix* lhs,
const matrix* rhs,
error_code_t* ec);
void deinitialize(matrix** mat);
error_code_t ec;
matrix* temp = nullptr;
matrix* r = nullptr;
temp = multiply(a, b, &amp;ec);
if (!temp) {
goto end;
}
r = add(temp, c, &amp;ec);
if (!r) {
goto end;
}
end:
if (temp) {
deinitialize(&amp;temp);
}
// 返回 ec 或类似错误处理
```
可以注意到,虽然代码看似稍微自然了一点,但啰嗦程度却增加了,原因是正确的处理需要考虑到各种不同错误路径下的资源释放问题。这儿也没有使用异常,因为异常在这种表达下会产生内存泄漏,除非用上一堆 `try``catch`,但那样异常在表达简洁性上的优势就没有了,没有实际的好处。
不过,如果我们同时使用智能指针和异常的话,就可以得到一个还不错的变体。如果接口接受和返回的都是 `shared_ptr&lt;matrix&gt;`,那调用代码就简单了:
```
shared_ptr&lt;matrix&gt; add(
const shared_ptr&lt;matrix&gt;&amp; lhs,
const shared_ptr&lt;matrix&gt;&amp; rhs);
shared_ptr&lt;matrix&gt; multiply(
const shared_ptr&lt;matrix&gt;&amp; lhs,
const shared_ptr&lt;matrix&gt;&amp; rhs);
auto r = add(multiply(a, b), c);
```
调用这些接口必须要使用 `shared_ptr`,这不能不说是一个限制。另外,对象永远是在堆上分配的,在很多场合,也会有一定的性能影响。
### 接口直接返回对象
最直接了当的代码,当然就是直接返回对象了。这回我们看实际可编译、运行的代码:
```
#include &lt;armadillo&gt;
#include &lt;iostream&gt;
using arma::imat22;
using std::cout;
int main()
{
imat22 a{{1, 1}, {2, 2}};
imat22 b{{1, 0}, {0, 1}};
imat22 c{{2, 2}, {1, 1}};
imat22 r = a * b + c;
cout &lt;&lt; r;
}
```
这段代码使用了 Armadillo一个利用现代 C++ 特性的开源线性代数库 [2]。你可以看到代码非常简洁,完全表意(`imat22` 是元素类型为整数的大小固定为 2 x 2 的矩阵)。它有以下优点:
- 代码直观、容易理解。
- 乘法和加法可以组合在一行里写出来,无需中间变量。
- 性能也没有问题。实际执行中,没有复制发生,计算结果直接存放到了变量 `r` 上。更妙的是,因为矩阵大小是已知的,这儿不需要任何动态内存,所有对象及其数据全部存放在栈上。
Armadillo 是个比较复杂的库,我们就不以 Armadillo 的代码为例来进一步讲解了。我们可以用一个假想的 `matrix` 类来看看返回对象的代码是怎样编写的。
## 如何返回一个对象?
一个用来返回的对象,通常应当是可移动构造/赋值的,一般也同时是可拷贝构造/赋值的。如果这样一个对象同时又可以默认构造我们就称其为一个半正则semiregular的对象。如果可能的话我们应当尽量让我们的类满足半正则这个要求。
半正则意味着我们的 `matrix` 类提供下面的成员函数:
```
class matrix {
public:
// 普通构造
matrix(size_t rows, size_t cols);
// 半正则要求的构造
matrix();
matrix(const matrix&amp;);
matrix(matrix&amp;&amp;);
// 半正则要求的赋值
matrix&amp; operator=(const matrix&amp;);
matrix&amp; operator=(matrix&amp;&amp;);
};
```
我们先看一下在没有返回值优化的情况下 C++ 是怎样返回对象的。以矩阵乘法为例,代码应该像下面这样:
```
matrix operator*(const matrix&amp; lhs,
const matrix&amp; rhs)
{
if (lhs.cols() != rhs.rows()) {
throw runtime_error(
"sizes mismatch");
}
matrix result(lhs.rows(),
rhs.cols());
// 具体计算过程
return result;
}
```
注意对于一个本地变量我们永远不应该返回其引用或指针不管是作为左值还是右值。从标准的角度这会导致未定义行为undefined behavior从实际的角度这样的对象一般放在栈上可以被调用者正常覆盖使用的部分随便一个函数调用或变量定义就可能覆盖这个对象占据的内存。这还是这个对象的析构不做事情的情况如果析构函数会释放内存或破坏数据的话那你访问到的对象即使内存没有被覆盖也早就不是有合法数据的对象了……
回到正题。我们需要回想起,在[[第 3 讲]](https://time.geekbang.org/column/article/169268) 里说过的返回非引用类型的表达式结果是个纯右值prvalue。在执行 `auto r = …` 的时候,编译器会认为我们实际是在构造 `matrix r(…)`,而“…”部分是一个纯右值。因此编译器会首先试图匹配 `matrix(matrix&amp;&amp;)`,在没有时则试图匹配 `matrix(const matrix&amp;)`;也就是说,有移动支持时使用移动,没有移动支持时则拷贝。
## 返回值优化(拷贝消除)
我们再来看一个能显示生命期过程的对象的例子:
```
#include &lt;iostream&gt;
using namespace std;
// Can copy and move
class A {
public:
A() { cout &lt;&lt; "Create A\n"; }
~A() { cout &lt;&lt; "Destroy A\n"; }
A(const A&amp;) { cout &lt;&lt; "Copy A\n"; }
A(A&amp;&amp;) { cout &lt;&lt; "Move A\n"; }
};
A getA_unnamed()
{
return A();
}
int main()
{
auto a = getA_unnamed();
}
```
如果你认为执行结果里应当有一行“Copy A”或“Move A”的话你就忽视了返回值优化的威力了。即使完全关闭优化三种主流编译器GCC、Clang 和 MSVC都只输出两行
>
<p>`Create A`<br>
`Destroy A`</p>
我们把代码稍稍改一下:
```
A getA_named()
{
A a;
return a;
}
int main()
{
auto a = getA_named();
}
```
这回结果有了一点点小变化。虽然 GCC 和 Clang 的结果完全不变,但 MSVC 在非优化编译的情况下产生了不同的输出(优化编译——使用命令行参数 `/O1``/O2``/Ox`——则不变):
>
<p>`Create A`<br>
`Move A`<br>
`Destroy A`<br>
`Destroy A`</p>
也就是说,返回内容被移动构造了。
我们继续变形一下:
```
#include &lt;stdlib.h&gt;
A getA_duang()
{
A a1;
A a2;
if (rand() &gt; 42) {
return a1;
} else {
return a2;
}
}
int main()
{
auto a = getA_duang();
}
```
这回所有的编译器都被难倒了,输出是:
>
<p>`Create A`<br>
`Create A`<br>
`Move A`<br>
`Destroy A`<br>
`Destroy A`<br>
`Destroy A`</p>
关于返回值优化的实验我们就做到这里。下一步,我们试验一下把移动构造函数删除:
```
// A(A&amp;&amp;) { cout &lt;&lt; "Move A\n"; }
```
我们可以立即看到“Copy A”出现在了结果输出中说明目前结果变成拷贝构造了。
如果再进一步,把拷贝构造函数也删除呢(注:此时是标成 `= delete`,而不是简单注释掉——否则,就如我们在[[第 9 讲]](https://time.geekbang.org/column/article/176916) 讨论过的,编译器会默认提供拷贝构造和移动构造函数)?是不是上面的 `getA_unnamed``getA_named``getA_duang` 都不能工作了?
在 C++14 及之前确实是这样的。但从 C++17 开始,对于类似于 `getA_unnamed` 这样的情况即使对象不可拷贝、不可移动这个对象仍然是可以被返回的C++17 要求对于这种情况,对象必须被直接构造在目标位置上,不经过任何拷贝或移动的步骤 [3]。
## 回到 F.20
理解了 C++ 里的对返回值的处理和返回值优化之后,我们再回过头看一下 F.20 里陈述的理由的话,应该就显得很自然了:
>
A return value is self-documenting, whereas a `&amp;` could be either in-out or out-only and is liable to be misused.
返回值是可以自我描述的;而 `&amp;` 参数既可能是输入输出,也可能是仅输出,且很容易被误用。
我想我对返回对象的可读性,已经给出了充足的例子。对于其是否有性能影响这一问题,也给出了充分的说明。
我们最后看一下 F.20 里描述的例外情况:
- “对于非值类型,比如返回值可能是子对象的情况,使用 `unique_ptr``shared_ptr` 来返回对象。”也就是面向对象、工厂方法这样的情况,像[[第 1 讲]](https://time.geekbang.org/column/article/169225) 里给出的 `create_shape` 应该这样改造。
- “对于移动代价很高的对象,考虑将其分配在堆上,然后返回一个句柄(如 `unique_ptr`),或传递一个非 const 的目标对象的引用来填充(用作输出参数)。”也就是说不方便移动的,那就只能使用一个 RAII 对象来管理生命周期,或者老办法输出参数了。
- “要在一个内层循环里在多次函数调用中重用一个自带容量的对象:将其当作输入/输出参数并将其按引用传递。”这也是个需要继续使用老办法的情况。
## 内容小结
C++ 里已经对返回对象做了大量的优化,目前在函数里直接返回对象可以得到更可读、可组合的代码,同时在大部分情况下我们可以利用移动和返回值优化消除性能问题。
## 课后思考
请你考虑一下:
1. 你的项目使用了返回对象了吗?如果没有的话,本讲内容有没有说服你?
1. 这讲里我们没有深入讨论赋值;请你思考一下,如果例子里改成赋值,会有什么样的变化?
欢迎留言和我交流你的想法。
## 参考资料
[1] Bjarne Stroustrup and Herb Sutter (editors), “C++ core guidelines”, item F.20. [https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#Rf-out](https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#Rf-out) (非官方中文版可参见 [https://github.com/lynnboy/CppCoreGuidelines-zh-CN](https://github.com/lynnboy/CppCoreGuidelines-zh-CN))
[2] Conrad Sanderson and Ryan Curtin, Armadillo. [http://arma.sourceforge.net/](http://arma.sourceforge.net/)
[3] cppreference.com, “Copy elision”. [https://en.cppreference.com/w/cpp/language/copy_elision](https://en.cppreference.com/w/cpp/language/copy_elision)
[3a] cppreference.com, “复制消除”. [https://zh.cppreference.com/w/cpp/language/copy_elision](https://zh.cppreference.com/w/cpp/language/copy_elision)

View File

@@ -0,0 +1,518 @@
<audio id="audio" title="11 | Unicode进入多文字支持的世界" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/93/35/9369e57b6dfc8a3fbb9cc0f02fa7a235.mp3"></audio>
你好,我是吴咏炜。
这一讲我们来讲一个新话题Unicode。我们会从编码的历史谈起讨论编程中对中文和多语言的支持然后重点看一下 C++ 中应该如何处理这些问题。
## 一些历史
ASCII [1] 是一种创立于 1963 年的 7 位编码,用 0 到 127 之间的数值来代表最常用的字符,包含了控制字符(很多在今天已不再使用)、数字、大小写拉丁字母、空格和基本标点。它在编码上具有简单性,字母和数字的编码位置非常容易记忆(相比之下,设计 EBCDIC [2] 的人感觉是脑子进了水,哦不,进了穿孔卡片了;难怪它和 IBM 的那些过时老古董一起已经几乎被人遗忘。时至今日ASCII 可以看作是字符编码的基础,主要的编码方式都保持着与 ASCII 的兼容性。
<img src="https://static001.geekbang.org/resource/image/cc/35/cc7fb695569c7ea460c1b89fc7859735.gif" alt="">
ASCII 里只有基本的拉丁字母,它既没有带变音符的拉丁字母(如 é 和 ä ),也不支持像希腊字母(如 α、β、γ)、西里尔字母(如 Пушкин)这样的其他欧洲文字(也难怪,毕竟它是 American Standard Code for Information Interchange。很多其他编码方式纷纷应运而生包括 ISO 646 系列、ISO/IEC 8859 系列等等;大部分编码方式都是头 128 个字符与 ASCII 兼容,后 128 个字符是自己的扩展,总共最多是 256 个字符。每次只有一套方式可以生效称之为一个代码页code page。这种做法只能适用于文字相近、且字符数不多的国家。比如下图表示了 ISO-8859-1也称作 Latin-1和后面的 Windows 扩展代码页 1252下图中绿框部分为 Windows 的扩展),就只能适用于西欧国家。
<img src="https://static001.geekbang.org/resource/image/ab/95/ab06af7037bd09d229efbb693be42195.png" alt="">
最早的中文字符集标准是 1980 年的国标 GB2312 [3],其中收录了 6763 个常用汉字和 682 个其他符号。我们平时会用到编码 GB2312其实更正确的名字是 EUC-CN [4],它是一种与 ASCII 兼容的编码方式。它用单字节表示 ASCII 字符而用双字节表示 GB2312 中的字符;由于 GB2312 中本身也含有 ASCII 中包含的字符,在使用中逐渐就形成了“半角”和“全角”的区别。
国标字符集后面又有扩展,这个扩展后的字符集就是 GBK [5],是中文版 Windows 使用的标准编码方式。GB2312 和 GBK 所占用的编码位置可以参看下面的图(由 John M. Długosz 为 Wikipedia 绘制):
<img src="https://static001.geekbang.org/resource/image/da/0f/da18e20f4a929399d63a467760657c0f.png" alt="">
图中 GBK/1 和 GBK/2 为 GB2312 中已经定义的区域,其他的则是后面添加的字符,总共定义了两万多个编码点,支持了绝大部分现代汉语中还在使用的字。
Unicode [6] 作为一种统一编码的努力,诞生于八十年代末九十年代初,标准的第一版出版于 1991—1992 年。由于最初发明者的目标放得太低,只期望对活跃使用中的现代文字进行编码,他们认为 16 比特的“宽 ASCII”就够用了。这就导致了早期采纳 Unicode 的组织,特别是微软,在其操作系统和工具链中广泛采用了 16 比特的编码方式。在今天,微软的系统中宽字符类型 wchar_t 仍然是 16 位的,操作系统底层接口大量使用 16 位字符编码的 API说到 Unicode 编码时仍然指的是 16 位的编码 UTF-16这一不太正确的名字跟中文 GBK 编码居然可以被叫做 ANSI 相比实在是小巫见大巫了。在微软以外的世界Unicode 本身不作编码名称用,并且最主流的编码方式并不是 UTF-16而是和 ASCII 全兼容的 UTF-8。
早期 Unicode 组织的另一个决定是不同语言里的同一个字符使用同一个编码点,来减少总编码点的数量。中日韩三国使用的汉字就这么被统一了:像“将”、“径”、“网”等字,每个字在 Unicode 中只占一个编码点。这对网页的字体选择也造成了不少麻烦,时至今日我们仍然可以看到这个问题 [7]。不过这和我们的主题无关,就不再多费笔墨了。
## Unicode 简介
Unicode 在今天已经大大超出了最初的目标。到 Unicode 12.1 为止Unicode 已经包含了 137,994 个字符囊括所有主要语言使用中的和已经不再使用的并包含了表情符号、数学符号等各种特殊字符。仍然要指出一下Unicode 字符是根据含义来区分的而非根据字形。除了前面提到过中日韩汉字没有分开像斜体italics、小大写字母small caps等排版效果在 Unicode 里也没有独立的对应。不过,因为 Unicode 里包含了很多数学、物理等自然科学中使用的特殊符号,某些情况下你也可以找到对应的符号,可以用在聊天中耍酷,如 𝒷𝒶𝒹(但不适合严肃的排版)。
Unicode 的编码点是从 0x0 到 0x10FFFF一共 1,114,112 个位置。一般用“U+”后面跟 16 进制的数值来表示一个 Unicode 字符,如 U+0020 表示空格U+6C49 表示“汉”U+1F600 表示“😀”,等等(不足四位的一般写四位)。
Unicode 字符的常见编码方式有:
- UTF-32 [8]32 比特,是编码点的直接映射。
- UTF-16 [9]:对于从 U+0000 到 U+FFFF 的字符,使用 16 比特的直接映射;对于大于 U+FFFF 的字符,使用 32 比特的特殊映射关系——在 Unicode 的 16 比特编码点中 0xD8000xDFFF 是一段空隙,使得这种变长编码成为可能。在一个 UTF-16 的序列中,如果看到内容是 0xD8000xDBFF那这就是 32 比特编码的前 16 比特;如果看到内容是 0xDC000xDFFF那这是 32 比特编码的后 16 比特;如果内容在 0xD8000xDFFF 之外,那就是一个 16 比特的映射。
- UTF-8 [10]1 到 4 字节的变长编码。在一个合法的 UTF-8 的序列中,如果看到一个字节的最高位是 0那就是一个单字节的 Unicode 字符;如果一个字节的最高两比特是 10那这是一个 Unicode 字符在编码后的后续字节;否则,这就是一个 Unicode 字符在编码后的首字节,且最高位开始连续 1 的个数表示了这个字符按 UTF-8 的方式编码有几个字节。
在上面三种编码方式里,只有 UTF-8 完全保持了和 ASCII 的兼容性,目前得到了最广泛的使用。在我们下面讲具体编码方式之前,我们先看一下上面提到的三个字符在这三种方式下的编码结果:
- UTF-32U+0020 映射为 0x00000020U+6C49 映射为 0x00006C49U+1F600 映射为 0x0001F600。
- UTF-16U+0020 映射为 0x0020U+6C49 映射为 0x6C49而 U+1F600 会映射为 0xD83D DE00。
- UTF-8U+0020 映射为 0x20U+6C49 映射为 0xE6 B1 89而 U+1F600 会映射为 0xF0 9F 98 80。
Unicode 有好几种(上面还不是全部)不同的编码方式,上面的 16 比特和 32 比特编码方式还有小头党和大头党之争(“汉”按字节读取时是 6C 49 呢,还是 49 6C同时任何一种编码方式还需要跟传统的编码方式容易区分。因此Unicode 文本文件通常有一个使用 BOMbyte order mark字符的约定即字符 U+FEFF [11]。由于 Unicode 不使用 U+FFFE在文件开头加一个 BOM 即可区分各种不同编码:
- 如果文件开头是 0x00 00 FE FF那这是大头在前的 UTF-32 编码;
- 否则如果文件开头是 0xFF FE 00 00那这是小头在前的 UTF-32 编码;
- 否则如果文件开头是 0xFE FF那这是大头在前的 UTF-16 编码;
- 否则如果文件开头是 0xFF FE那这是小头在前的 UTF-16 编码(注意,这条规则和第二条的顺序不能相反);
- 否则如果文件开头是 0xEF BB BF那这是 UTF-8 编码;
- 否则,编码方式使用其他算法来确定。
编辑器可以(有些在配置之后)根据 BOM 字符来自动决定文本文件的编码。比如,我一般在 Vim 中配置 `set fileencodings=ucs-bom,utf-8,gbk,latin1`。这样Vim 在读入文件时,会首先检查 BOM 字符,有 BOM 字符按 BOM 字符决定文件编码;否则,试图将文件按 UTF-8 来解码(由于 UTF-8 有格式要求,非 UTF-8 编码的文件通常会导致失败);不行,则试图按 GBK 来解码(失败的概率就很低了);还不行,就把文件当作 Latin1 来处理(永远不会失败)。
在 UTF-8 编码下使用 BOM 字符并非必需,尤其在 Unix 上。但 Windows 上通常会使用 BOM 字符,以方便区分 UTF-8 和传统编码。
## C++ 中的 Unicode 字符类型
C++98 中有 `char``wchar_t` 两种不同的字符类型,其中 `char` 的长度是单字节,而 `wchar_t` 的长度不确定。在 Windows 上它是双字节,只能代表 UTF-16而在 Unix 上一般是四字节,可以代表 UTF-32。为了解决这种混乱目前我们有了下面的改进
- C++11 引入了 `char16_t``char32_t` 两个独立的字符类型(不是类型别名),分别代表 UTF-16 和 UTF-32。
- C++20 将引入 `char8_t` 类型,进一步区分了可能使用传统编码的窄字符类型和 UTF-8 字符类型。
- 除了 `string``wstring`,我们也相应地有了 `u16string``u32string`(和将来的 `u8string`)。
- 除了传统的窄字符/字符串字面量(如 `"hi"`)和宽字符/字符串字面量(如 `L"hi"`),引入了新的 UTF-8、UTF-16 和 UTF-32 字面量,分别形如 `u8"hi"``u"hi"``U"hi"`
- 为了确保非 ASCII 字符在源代码中可以简单地输入,引入了新的 Unicode 换码序列。比如,我们前面说到的三个字符可以这样表达成一个 UTF-32 字符串字面量:`U" \u6C49\U0001F600"`。要生成 UTF-16 或 UTF-8 字符串字面量只需要更改前缀即可。
使用这些新的字符(串)类型,我们可以用下面的代码表达出 UTF-32 和其他两种 UTF 编码间是如何转换的:
```
#include &lt;iomanip&gt;
#include &lt;iostream&gt;
#include &lt;stdexcept&gt;
#include &lt;string&gt;
using namespace std;
const char32_t unicode_max =
0x10FFFF;
void to_utf_16(char32_t ch,
u16string&amp; result)
{
if (ch &gt; unicode_max) {
throw runtime_error(
"invalid code point");
}
if (ch &lt; 0x10000) {
result += char16_t(ch);
} else {
char16_t first =
0xD800 |
((ch - 0x10000) &gt;&gt; 10);
char16_t second =
0xDC00 | (ch &amp; 0x3FF);
result += first;
result += second;
}
}
void to_utf_8(char32_t ch,
string&amp; result)
{
if (ch &gt; unicode_max) {
throw runtime_error(
"invalid code point");
}
if (ch &lt; 0x80) {
result += ch;
} else if (ch &lt; 0x800) {
result += 0xC0 | (ch &gt;&gt; 6);
result += 0x80 | (ch &amp; 0x3F);
} else if (ch &lt; 0x10000) {
result += 0xE0 | (ch &gt;&gt; 12);
result +=
0x80 | ((ch &gt;&gt; 6) &amp; 0x3F);
result += 0x80 | (ch &amp; 0x3F);
} else {
result += 0xF0 | (ch &gt;&gt; 18);
result +=
0x80 | ((ch &gt;&gt; 12) &amp; 0x3F);
result +=
0x80 | ((ch &gt;&gt; 6) &amp; 0x3F);
result += 0x80 | (ch &amp; 0x3F);
}
}
int main()
{
char32_t str[] =
U" \u6C49\U0001F600";
u16string u16str;
string u8str;
for (auto ch : str) {
if (ch == 0) {
break;
}
to_utf_16(ch, u16str);
to_utf_8(ch, u8str);
}
cout &lt;&lt; hex &lt;&lt; setfill('0');
for (char16_t ch : u16str) {
cout &lt;&lt; setw(4) &lt;&lt; unsigned(ch)
&lt;&lt; ' ';
}
cout &lt;&lt; endl;
for (unsigned char ch : u8str) {
cout &lt;&lt; setw(2) &lt;&lt; unsigned(ch)
&lt;&lt; ' ';
}
cout &lt;&lt; endl;
}
```
输出结果是:
>
<p>`0020 6c49 d83d de00`<br>
`20 e6 b1 89 f0 9f 98 80`</p>
## 平台区别
下面我们看一下在两个主流的平台上一般是如何处理 Unicode 编码问题的。
### Unix
现代 Unix 系统,包括 Linux 和 macOS 在内,已经全面转向了 UTF-8。这样的系统中一般直接使用 `char[]``string` 来代表 UTF-8 字符串,包括输入、输出和文件名,非常简单。不过,由于一个字符单位不能代表一个完整的 Unicode 字符,在需要真正进行文字处理的场合转换到 UTF-32 往往会更简单。在以前及需要和 C 兼容的场合,会使用 `wchar_t``uint32_t` 或某个等价的类型别名;在新的纯 C++ 代码里,就没有理由不使用 `char32_t``u32string` 了。
Unix 下输出宽字符串需要使用 `wcout`(这点和 Windows 相同),并且需要进行区域设置,如下所示:
```
std::locale::global(
std::locale(""));
std::wcout.imbue(std::locale());
```
由于没有什么额外好处反而可能在某些环境因为区域设置失败而引发问题Unix 平台下一般只用 `cout`,不用 `wcout`
### Windows
Windows 由于历史原因和保留向后兼容性的需要Windows 为了向后兼容性已经到了大规模放弃优雅的程度了),一直用 `char` 表示传统编码(如,英文 Windows 上是 Windows-1252简体中文 Windows 上是 GBK`wchar_t` 表示 UTF-16。由于传统编码一次只有一种、且需要重启才能生效要得到好的多语言支持在和操作系统交互时必须使用 UTF-16。
对于纯 Windows 编程,全面使用宽字符(串)是最简单的处理方式。当然,源代码和文本很少用 UTF-16 存储,通常还是 UTF-8除非是纯 ASCII否则必须加入 BOM 字符来和传统编码相区分)。这时可能会有一个小小的令人惊讶的地方:微软的编译器会把源代码里窄字符串字面量中的非 ASCII 字符转换成传统编码。换句话说,同样的源代码在不同编码的 Windows 下编译可能会产生不同的结果!如果你希望保留 UTF-8 序列的话,就应该使用 UTF-8 字面量(并在将来使用 `char8_t` 字符类型)。
```
#include &lt;stdio.h&gt;
template &lt;typename T&gt;
void dump(const T&amp; str)
{
for (char ch : str) {
printf(
"%.2x ",
static_cast&lt;unsigned char&gt;(ch));
}
putchar('\n');
}
int main()
{
char str[] = "你好";
char u8str[] = u8"你好";
dump(str);
dump(u8str);
}
```
下面展示的是以上代码在 Windows 下系统传统编码设置为简体中文时的编译、运行结果:
>
<p>`c4 e3 ba c3 00`<br>
`e4 bd a0 e5 a5 bd 00`</p>
Windows 下的 `wcout` 主要用在配合宽字符的输出,此外没什么大用处。原因一样,只有进行了正确的区域设置,才能输出跟该区域相匹配的宽字符串(不匹配的字符将导致后续输出全部消失!)。如果要输出中文,得写 `setlocale(LC_ALL, "Chinese_China.936");`,这显然就让“统一码”输出失去意义了。
但是(还是有个“但是”),如果你**只用** `wcout`,不用 `cout` 或任何使用窄字符输出到 `stdout` 的函数(如 `puts`),这时倒有个还不错的解决方案,可以在终端输出多语言。我也是偶然才发现这一用法,并且没有在微软的网站上找到清晰的文档……代码如下所示:
```
#include &lt;fcntl.h&gt;
#include &lt;io.h&gt;
#include &lt;iostream&gt;
int main()
{
_setmode(_fileno(stdout),
_O_WTEXT);
std::wcout
&lt;&lt; L"中文 Español Français\n";
std::wcout
&lt;&lt; "Narrow characters are "
"also OK on wcout\n";
// but not on cout...
}
```
由于窄字符在大部分 Windows 系统上只支持传统编码,要打开一个当前编码不支持的文件名称,就必需使用宽字符的文件名。微软的 `fstream` 系列类及其 `open` 成员函数都支持 `const wchar_t*` 类型的文件名,这是 C++ 标准里所没有的。
### 统一化处理
要想写出跨平台的处理字符串的代码,我们一般考虑两种方式之一:
- 源代码级兼容,但内码不同
- 源代码和内码都完全兼容
微软推荐的方式一般是前者。做 Windows 开发的人很多都知道 tchar.h 和 `_T` 宏,它们就起着类似的作用(虽然目的不同)。根据预定义宏的不同,系统会在同一套代码下选择不同的编码方式及对应的函数。拿一个最小的例子来说:
```
#include &lt;stdio.h&gt;
#include &lt;tchar.h&gt;
int _tmain(int argc, TCHAR* argv[])
{
_putts(_T("Hello world!\n"));
}
```
如果用缺省的命令行参数进行编译,上面的代码相当于:
```
#include &lt;stdio.h&gt;
int main(int argc, char* argv[])
{
puts("Hello world!\n");
}
```
而如果在命令行上加上了 `/D_UNICODE`,那代码则相当于:
```
#include &lt;stdio.h&gt;
int wmain(int argc, wchar_t* argv[])
{
_putws(L"Hello world!\n");
}
```
当然,这个代码还是只能在 Windows 上用,并且仍然不漂亮(所有的字符和字符串字面量都得套上 `_T`。后者无解前者则可以找到替代方案甚至自己写也不复杂。C++ REST SDK 中就提供了类似的封装,可以跨平台地开发网络应用。但可以说,这种方式是一种主要照顾 Windows 的开发方式。
相应的,对 Unix 开发者而言更自然的方式是全面使用 UTF-8仅在跟操作系统、文件系统打交道时把字符串转换成需要的编码。利用临时对象的生命周期我们可以像下面这样写帮助函数和宏。
utf8_to_native.hpp
```
#ifndef UTF8_TO_NATIVE_HPP
#define UTF8_TO_NATIVE_HPP
#include &lt;string&gt;
#if defined(_WIN32) || \
defined(_UNICODE)
std::wstring utf8_to_wstring(
const char* str);
std::wstring utf8_to_wstring(
const std::string&amp; str);
#define NATIVE_STR(s) \
utf8_to_wstring(s).c_str()
#else
inline const char*
to_c_str(const char* str)
{
return str;
}
inline const char*
to_c_str(const std::string&amp; str)
{
return str.c_str();
}
#define NATIVE_STR(s) \
to_c_str(s)
#endif
#endif // UTF8_TO_NATIVE_HPP
```
utf8_to_native.cpp
```
#include "utf8_to_native.hpp"
#if defined(_WIN32) || \
defined(_UNICODE)
#include &lt;windows.h&gt;
#include &lt;system_error&gt;
namespace {
void throw_system_error(
const char* reason)
{
std::string msg(reason);
msg += " failed";
std::error_code ec(
GetLastError(),
std::system_category());
throw std::system_error(ec, msg);
}
} /* unnamed namespace */
std::wstring utf8_to_wstring(
const char* str)
{
int len = MultiByteToWideChar(
CP_UTF8, 0, str, -1,
nullptr, 0);
if (len == 0) {
throw_system_error(
"utf8_to_wstring");
}
std::wstring result(len - 1,
L'\0');
if (MultiByteToWideChar(
CP_UTF8, 0, str, -1,
result.data(), len) == 0) {
throw_system_error(
"utf8_to_wstring");
}
return result;
}
std::wstring utf8_to_wstring(
const std::string&amp; str)
{
return utf8_to_wstring(
str.c_str());
}
#endif
```
在头文件里,定义了在 Windows 下会做 UTF-8 到 UTF-16 的转换;在其他环境下则不真正做转换,而是不管提供的是字符指针还是 `string` 都会转换成字符指针。在 Windows 下每次调用 `NATIVE_STR` 会生成一个临时对象,当前语句执行结束后这个临时对象会自动销毁。
使用该功能的代码是这样的:
```
#include &lt;fstream&gt;
#include "utf8_to_native.hpp"
int main()
{
using namespace std;
const char filename[] =
u8"测试.txt";
ifstream ifs(
NATIVE_STR(filename));
// 对 ifs 进行操作
}
```
上面这样的代码可以同时适用于现代 Unix 和现代 Windows任何语言设置下用来读取名为“测试.txt”的文件。
## 编程支持
结束之前,我们快速介绍一下其他的一些支持 Unicode 及其转换的 API。
### Windows API
上一节的代码在 Windows 下用到了 `MultiByteToWideChar` [12],从某个编码转到 UTF-16。Windows 也提供了反向的 `WideCharToMultiByte` [13],从 UTF-16 转到某个编码。从上面可以看到C 接口用起来并不方便,可以考虑自己封装一下。
### iconv
Unix 下最常用的底层编码转换接口是 iconv [14],提供 `iconv_open``iconv_close``iconv` 三个函数。这同样是 C 接口,实践中应该封装一下。
### ICU4C
ICU [15] 是一个完整的 Unicode 支持库提供大量的方法ICU4C 是其 C/C++ 的版本。ICU 有专门的字符串类型,内码是 UTF-16但可以直接用于 IO streams 的输出。下面的程序应该在所有平台上都有同样的输出(但在 Windows 上要求当前系统传统编码能支持待输出的字符):
```
#include &lt;iostream&gt;
#include &lt;string&gt;
#include &lt;unicode/unistr.h&gt;
#include &lt;unicode/ustream.h&gt;
using namespace std;
using icu::UnicodeString;
int main()
{
auto str = UnicodeString::fromUTF8(
u8"你好");
cout &lt;&lt; str &lt;&lt; endl;
string u8str;
str.toUTF8String(u8str);
cout &lt;&lt; "In UTF-8 it is "
&lt;&lt; u8str.size() &lt;&lt; " bytes"
&lt;&lt; endl;
}
```
### codecvt
C++11 曾经引入了一个头文件 &lt;codecvt&gt; [16] 用作 UTF 编码间的转换但很遗憾那个头文件目前已因为存在安全性和易用性问题被宣告放弃deprecated[17]。&lt;locale&gt; 中有另外一个 `codecvt` 模板 [18],本身接口不那么好用,而且到 C++20 还会发生变化,这儿也不详细介绍了。有兴趣的话可以直接看参考资料。
## 内容小结
本讲我们讨论了 Unicode以及 C++ 中对 Unicode 的支持。我们也讨论了在两大主流桌面平台上关于 Unicode 编码支持的一些惯用法。希望你在本讲之后,能清楚地知道 Unicode 和各种 UTF 编码是怎么回事。
## 课后思考
请思考一下:
1. 为什么说 UTF-32 处理会比较简单?
1. 你知道什么情况下 UTF-32 也并不那么简单吗?
1. 哪种 UTF 编码方式空间存储效率比较高?
欢迎留言一起讨论一下。
## 参考资料
[1] Wikipedia, “ASCII”. [https://en.wikipedia.org/wiki/ASCII](https://en.wikipedia.org/wiki/ASCII)
[2] Wikipedia, “EBCDIC”. [https://en.wikipedia.org/wiki/EBCDIC](https://en.wikipedia.org/wiki/EBCDIC)
[3] Wikipedia, “GB 2312”. [https://en.wikipedia.org/wiki/GB_2312](https://en.wikipedia.org/wiki/GB_2312)
[3a] 维基百科, “GB 2312”. [https://zh.wikipedia.org/zh-cn/GB_2312](https://zh.wikipedia.org/zh-cn/GB_2312)
[4] Wikipedia, “EUC-CN”. [https://en.wikipedia.org/wiki/Extended_Unix_Code#EUC-CN](https://en.wikipedia.org/wiki/Extended_Unix_Code#EUC-CN)
[4a] 维基百科, “EUC-CN”. [https://zh.wikipedia.org/zh-cn/EUC#EUC-CN](https://zh.wikipedia.org/zh-cn/EUC#EUC-CN)
[5] Wikipedia, “GBK”. [https://en.wikipedia.org/wiki/GBK_(character_encoding)](https://en.wikipedia.org/wiki/GBK_(character_encoding))
[5a] 维基百科, “汉字内码扩展规范”. [https://zh.wikipedia.org/zh-cn/汉字内码扩展规范](https://zh.wikipedia.org/zh-cn/%E6%B1%89%E5%AD%97%E5%86%85%E7%A0%81%E6%89%A9%E5%B1%95%E8%A7%84%E8%8C%83)
[6] Wikipedia, “Unicode”. [https://en.wikipedia.org/wiki/Unicode](https://en.wikipedia.org/wiki/Unicode)
[6a] 维基百科, “Unicode”. [https://zh.wikipedia.org/zh-cn/Unicode](https://zh.wikipedia.org/zh-cn/Unicode)
[7] 吴咏炜, “Specify LANG in a UTF-8 web page”. [http://wyw.dcweb.cn/lang_utf8.htm](http://wyw.dcweb.cn/lang_utf8.htm)
[8] Wikipedia, “UTF-32”. [https://en.wikipedia.org/wiki/UTF-32](https://en.wikipedia.org/wiki/UTF-32)
[9] Wikipedia, “UTF-16”. [https://en.wikipedia.org/wiki/UTF-16](https://en.wikipedia.org/wiki/UTF-16)
[10] Wikipedia, “UTF-8”. [https://en.wikipedia.org/wiki/UTF-8](https://en.wikipedia.org/wiki/UTF-8)
[11] Wikipedia, “Byte order mark”. [https://en.wikipedia.org/wiki/Byte_order_mark](https://en.wikipedia.org/wiki/Byte_order_mark)
[11a] 维基百科, “字节顺序标记”. [https://zh.wikipedia.org/zh-cn/位元組順序記號](https://zh.wikipedia.org/zh-cn/%E4%BD%8D%E5%85%83%E7%B5%84%E9%A0%86%E5%BA%8F%E8%A8%98%E8%99%9F)
[12] Microsoft, “MultiByteToWideChar function”. [https://docs.microsoft.com/en-us/windows/win32/api/stringapiset/nf-stringapiset-multibytetowidechar](https://docs.microsoft.com/en-us/windows/win32/api/stringapiset/nf-stringapiset-multibytetowidechar)
[13] Microsoft, “WideCharToMultiByte function”. [https://docs.microsoft.com/en-us/windows/win32/api/stringapiset/nf-stringapiset-widechartomultibyte](https://docs.microsoft.com/en-us/windows/win32/api/stringapiset/nf-stringapiset-widechartomultibyte)
[14] Wikipedia, “iconv”. [https://en.wikipedia.org/wiki/Iconv](https://en.wikipedia.org/wiki/Iconv)
[15] ICU Technical Committee, ICU—International Components for Unicode. [http://site.icu-project.org/](http://site.icu-project.org/)
[16] cppreference.com, “Standard library header &lt;codecvt&gt;”. [https://en.cppreference.com/w/cpp/header/codecvt](https://en.cppreference.com/w/cpp/header/codecvt)
[17] Alisdair Meredith, “Deprecating &lt;codecvt&gt;”. [http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/p0618r0.html](http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/p0618r0.html)
[18] cppreference.com, “std::codecvt”. [https://en.cppreference.com/w/cpp/locale/codecvt](https://en.cppreference.com/w/cpp/locale/codecvt)

View File

@@ -0,0 +1,271 @@
<audio id="audio" title="12 | 编译期多态:泛型编程和模板入门" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a1/3f/a1615e9148a1dafc417b600e454a7a3f.mp3"></audio>
你好,我是吴咏炜。
相信你对多态这个面向对象的特性应该是很熟悉了。我们今天来讲一个非常 C++ 的话题,编译期多态及其相关的 C++ 概念。
## 面向对象和多态
在面向对象的开发里,最基本的一个特性就是“多态” [1]——用相同的代码得到不同结果。以我们在[[第 1 讲]](https://time.geekbang.org/column/article/169225) 提到过的 `shape` 类为例,它可能会定义一些通用的功能,然后在子类里进行实现或覆盖:
```
class shape {
public:
virtual void draw(const position&amp;) = 0;
};
```
上面的类定义意味着所有的子类必须实现 `draw` 函数,所以可以认为 `shape` 是定义了一个接口(按 Java 的概念)。在面向对象的设计里,接口抽象了一些基本的行为,实现类里则去具体实现这些功能。当我们有着接口类的指针或引用时,我们实际可以唤起具体的实现类里的逻辑。比如,在一个绘图程序里,我们可以在用户选择一种形状时,把形状赋给一个 `shape` 的(智能)指针,在用户点击绘图区域时,执行 `draw` 操作。根据指针指向的形状不同,实际绘制出的可能是圆,可能是三角形,也可能是其他形状。
但这种面向对象的方式,并不是唯一一种实现多态的方式。在很多动态类型语言里,有所谓的“鸭子”类型 [2]
>
如果一只鸟走起来像鸭子、游起泳来像鸭子、叫起来也像鸭子,那么这只鸟就可以被当作鸭子。
在这样的语言里,你可以不需要继承来实现 `circle``triangle` 等类,然后可以直接在这个类型的变量上调用 `draw` 方法。如果这个类型的对象没有 `draw` 方法,你就会在执行到 `draw()` 语句的时候得到一个错误(或异常)。
鸭子类型使得开发者可以不使用继承体系来灵活地实现一些“约定”,尤其是使得混合不同来源、使用不同对象继承体系的代码成为可能。唯一的要求只是,这些不同的对象有“共通”的成员函数。这些成员函数应当有相同的名字和相同结构的参数(并不要求参数类型相同)。
听起来很抽象?我们来看一下 C++ 中的具体例子。
## 容器类的共性
容器类是有很多共性的。其中,一个最最普遍的共性就是,容器类都有 `begin``end` 成员函数——这使得通用地遍历一个容器成为可能。容器类不必继承一个共同的 Container 基类,而我们仍然可以写出通用的遍历容器的代码,如使用基于范围的循环。
大部分容器是有 `size` 成员函数的,在“泛型”编程中,我们同样可以取得一个容器的大小,而不要求容器继承一个叫 SizeableContainer 的基类。
很多容器具有 `push_back` 成员函数,可以在尾部插入数据。同样,我们不需要一个叫 BackPushableContainer 的基类。在这个例子里,`push_back` 函数的参数显然是都不一样的,但明显,所有的 `push_back` 函数都只接收一个参数。
我们可以清晰看到的是,虽然 C++ 的标准容器没有对象继承关系但彼此之间有着很多的同构性。这些同构性很难用继承体系来表达也完全不必要用继承来表达。C++ 的模板,已经足够表达这些鸭子类型。
当然作为一种静态类型语言C++ 是不会在运行时才报告“没找到 `draw` 方法”这类问题的。这类错误可以在编译时直接捕获,更精确地来说,是在模板实例化的过程中。
下面我们通过几个例子,来完整地看一下模板的定义、实例化和特化。
## C++ 模板
### 定义模板
学过算法的同学应该都知道求最大公约数的辗转相除法,代码大致如下:
```
int my_gcd(int a, int b)
{
while (b != 0) {
int r = a % b;
a = b;
b = r;
}
return a;
}
```
这里只有一个小小的问题C++ 的整数类型可不止 `int` 一种啊。为了让这个算法对像长整型这样的类型也生效,我们需要把它定义成一个模板:
```
template &lt;typename E&gt;
E my_gcd(E a, E b)
{
while (b != E(0)) {
E r = a % b;
a = b;
b = r;
}
return a;
}
```
这个代码里,基本上就是把 `int` 替换成了模板参数 `E`,并在函数的开头添加了模板的声明。我们对于“整数”这只鸭子的要求实际上是:
- 可以通过常量 `0` 来构造
- 可以拷贝(构造和赋值)
- 可以作不等于的比较
- 可以进行取余数的操作
对于标准的 `int``long``long long` 等类型及其对应的无符号类型,以上代码都能正常工作,并能得到正确的结果。
至于类模板的例子,我们可以直接参考[[第 2 讲]](https://time.geekbang.org/column/article/169263) 中的智能指针,这儿就不再重复了。
### 实例化模板
不管是类模板还是函数模板编译器在看到其定义时只能做最基本的语法检查真正的类型检查要在实例化instantiation的时候才能做。一般而言这也是编译器会报错的时候。
对于我们上面 `my_gcd` 的情况,如果提供的是一般的整数类型,那是不会有问题的。但如果我们提供一些其他类型的时候,就有可能出问题了。以 CLN一个高精度数字库为例我并不是推荐大家使用这个库如果我们使用它的 `cl_I` 高精度整数类型来调用 `my_gcd` 的话,出错信息大致如下:
<img src="https://static001.geekbang.org/resource/image/fc/0a/fcc96fe6227cb35be460e73bbd6d1b0a.png" alt="">
其原因是,虽然它的整数类 `cl_I` 设计得很像普通的整数,但这个类的对象不支持 `%` 运算符。出错的第 20 行是我们调用 `my_gcd` 的位置,而第 9 行是函数模板定义中执行取余数操作的位置。
实例化失败的话,编译当然就出错退出了。如果成功的话,模板的实例就产生了。在整个的编译过程中,可能产生多个这样的(相同)实例,但最后链接时,会只剩下一个实例。这也是为什么 C++ 会有一个单一定义的规则:如果不同的编译单元看到不同的定义的话,那链接时使用哪个定义是不确定的,结果就可能会让人吃惊。
模板还可以显式实例化和外部实例化。如果我们在调用 `my_gcd` 之前进行显式实例化——即,使用 `template` 关键字并给出完整的类型来声明函数:
```
template cln::cl_I
my_gcd(cln::cl_I, cln::cl_I);
```
那出错信息中的第二行就会显示要求实例化的位置。如果在显式实例化的形式之前加上 `extern` 的话,编译器就会认为这个模板已经在其他某个地方实例化,从而不再产生其定义(但代码用到的内联函数仍可能会导致实例化的发生,这个会随编译器和优化选项不同而变化)。在我们这个例子里,就意味着不会产生上面的编译错误信息了。当然,我们仍然会在链接时得到错误,因为我们并没有真正实例化这个模板。
类似的,当我们在使用 `vector&lt;int&gt;` 这样的表达式时,我们就在隐式地实例化 `vector&lt;int&gt;`。我们同样也可以选择用 `template class vector&lt;int&gt;;` 来显式实例化,或使用 `extern template class vector&lt;int&gt;;` 来告诉编译器不需要实例化。显式实例化和外部实例化通常在大型项目中可以用来集中模板的实例化,从而加速编译过程——不需要在每个用到模板的地方都进行实例化了——但这种方式有额外的管理开销,如果实例化了不必要实例化的模板的话,反而会导致可执行文件变大。因而,显式实例化和外部实例化应当谨慎使用。
### 特化模板
如果遇到像前面 CLN 那样的情况,我们需要使用的模板参数类型,不能完全满足模板的要求,应该怎么办?
我们实际上有好几个选择:
- 添加代码,让那个类型支持所需要的操作(对成员函数无效)。
- 对于函数模板,可以直接针对那个类型进行重载。
- 对于类模板和函数模板,可以针对那个类型进行特化。
对于 `cln::cl_I` 不支持 `%` 运算符这种情况,恰好上面的三种方法我们都可以用。
一、添加 `operator%` 的实现:
```
cln::cl_I
operator%(const cln::cl_I&amp; lhs,
const cln::cl_I&amp; rhs)
{
return mod(lhs, rhs);
}
```
在这个例子,这可能是最简单的解决方案了。但在很多情况下,尤其是对对象的成员函数有要求的情况下,这个方法不可行。
二、针对 `cl_I` 进行重载:
为通用起见,我不直接使用 `cl_I``mod` 函数,而用 `my_mod``my_gcd` 改造如下:
```
template &lt;typename E&gt;
E my_gcd(E a, E b)
{
while (b != E(0)) {
E r = my_mod(a, b);
a = b;
b = r;
}
return a;
}
```
然后,一般情况的 `my_mod` 显然就是:
```
template &lt;typename E&gt;
E my_mod(const E&amp; lhs,
const E&amp; rhs)
{
return lhs % rhs;
}
```
最后,针对 `cl_I`我们可以重载overload
```
cln::cl_I
my_mod(const cln::cl_I&amp; lhs,
const cln::cl_I&amp; rhs)
{
return mod(lhs, rhs);
}
```
三、针对 `cl_I` 进行特化:
同二类似但我们提供的不是一个重载而是特化specialization
```
template &lt;&gt;
cln::cl_I my_mod&lt;cln::cl_I&gt;(
const cln::cl_I&amp; lhs,
const cln::cl_I&amp; rhs)
{
return mod(lhs, rhs);
}
```
这个例子比较简单,特化和重载在行为上没有本质的区别。就一般而言,特化是一种更通用的技巧,最主要的原因是特化可以用在类模板和函数模板上,而重载只能用于函数。
不过我只是展示了一种可能性而已。通用而言Herb Sutter 给出了明确的建议:对函数使用重载,对类模板进行特化 [3]。
展示特化的更好的例子是 C++11 之前的静态断言。使用特化技巧可以大致实现 `static_assert` 的功能:
```
template &lt;bool&gt;
struct compile_time_error;
template &lt;&gt;
struct compile_time_error&lt;true&gt; {};
#define STATIC_ASSERT(Expr, Msg) \
{ \
compile_time_error&lt;bool(Expr)&gt; \
ERROR_##_Msg; \
(void)ERROR_##_Msg; \
}
```
上面首先声明了一个 struct 模板,然后仅对 `true` 的情况进行了特化,产生了一个 struct 的定义。这样。如果遇到 `compile_time_error&lt;false&gt;` 的情况——也就是下面静态断言里的 `Expr` 不为真的情况——编译就会失败报错,因为 `compile_time_error&lt;false&gt;` 从来就没有被定义过。
## “动态”多态和“静态”多态的对比
我前面描述了面向对象的“动态”多态,也描述了 C++ 里基于泛型编程的“静态”多态。需要看到的是两者解决的实际上是不太一样的问题。“动态”多态解决的是运行时的行为变化——就如我前面提到的选择了一个形状之后再选择在某个地方绘制这个形状——这个是无法在编译时确定的。“静态”多态或者“泛型”——解决的是很不同的问题让适用于不同类型的“同构”算法可以用同一套代码来实现实际上强调的是对代码的复用。C++ 里提供了很多标准算法都一样只作出了基本的约定然后对任何满足约定的类型都可以工作。以排序为例C++ 里的标准 `sort` 算法(以两参数的重载为例)只要求:
- 参数满足随机访问迭代器的要求。
- 迭代器指向的对象之间可以使用 `&lt;` 来比较大小,满足严格弱序关系。
- 迭代器指向的对象可以被移动。
它的性能超出 C 的 `qsort`因为编译器可以内联inline对象的比较操作而在 C 里面比较只能通过一个额外的函数调用来实现。此外C 的 `qsort` 函数要求数组指向的内容是可按比特复制的C++ 的 `sort` 则要求迭代器指向的内容是可移动的,可适用于更广的情况。
C++ 里目前有大量这样的泛型算法。随便列举几个:
- `sort`:排序
- `reverse`:反转
- `count`:计数
- `find`:查找
- `max`:最大值
- `min`:最小值
- `minmax`:最小值和最大值
- `next_permutation`:下一个排列
- `gcd`:最大公约数
- `lcm`:最小公倍数
- 等等
## 内容小结
本讲我们对模板、泛型编程和静态多态做了最基本的描述,并和动态多态做了一定的比较。如果你不熟悉模板和泛型编程的话,应该在本讲之后已经对其有了初步的了解,我们可以在下面几讲中进行更深入的讨论。
## 课后思考
请你在课后读一下参考资料,了解一下各种不同的多态,然后想一想:
- C++ 支持几种不同形式的多态?
- 为什么并非所有的语言都支持这些不同的多态方式?
欢迎你留言与我分享你的看法。
## 参考资料
[1] Wikipedia, “Polymorphism”. [https://en.wikipedia.org/wiki/Polymorphism_(computer_science)](https://en.wikipedia.org/wiki/Polymorphism_(computer_science))
[1a] 维基百科, “多态”. [https://zh.wikipedia.org/zh-cn/多型_(计算机科学)](https://zh.wikipedia.org/zh-cn/%E5%A4%9A%E5%9E%8B_(%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%A7%91%E5%AD%A6))
[2] Wikipedia, “Duck typing”. [https://en.wikipedia.org/wiki/Duck_typing](https://en.wikipedia.org/wiki/Duck_typing)
[2a] 维基百科, “鸭子类型”. [https://zh.wikipedia.org/zh-cn/鸭子类型](https://zh.wikipedia.org/zh-cn/%E9%B8%AD%E5%AD%90%E7%B1%BB%E5%9E%8B)
[3] Herb Sutter, “Why not specialize function templates?”. [http://www.gotw.ca/publications/mill17.htm](http://www.gotw.ca/publications/mill17.htm)

View File

@@ -0,0 +1,367 @@
<audio id="audio" title="13 | 编译期能做些什么?一个完整的计算世界" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/81/00/81fcf04cc42e895037ddd0d01faab000.mp3"></audio>
你好,我是吴咏炜。
上一讲我们简单介绍了模板的基本用法及其在泛型编程中的应用。这一讲我们来看一下模板的另外一种重要用途——编译期计算,也称作“模板元编程”。
## 编译期计算
首先我们给出一个已经被证明的结论C++ 模板是图灵完全的 [1]。这句话的意思是,使用 C++ 模板,你可以在编译期间模拟一个完整的图灵机,也就是说,可以完成任何的计算任务。
当然,这只是理论上的结论。从实际的角度,我们并不**想**、也不可能在编译期完成所有的计算,更不用说编译期的编程是很容易让人看不懂的——因为这并不是语言设计的初衷。即便如此,我们也还是需要了解一下模板元编程的基本概念:它仍然有一些实用的场景,并且在实际的工程中你也可能会遇到这样的代码。虽然我们在开篇就说过不要炫技,但使用模板元编程写出的代码仍然是可理解的,尤其是如果你对递归不发怵的话。
好,闲话少叙,我们仍然拿代码说话:
```
template &lt;int n&gt;
struct factorial {
static const int value =
n * factorial&lt;n - 1&gt;::value;
};
template &lt;&gt;
struct factorial&lt;0&gt; {
static const int value = 1;
};
```
上面定义了一个递归的阶乘函数。可以看出,它完全符合阶乘的递归定义:
$$<br>
\begin{aligned}<br>
0! &amp;= 1 \\\<br>
n! &amp;= n \times (n - 1)!<br>
\end{aligned}<br>
$$
除了顺序有特定的要求——先定义,才能特化——再加语法有点特别,代码基本上就是这个数学定义的简单映射了。
那我们怎么知道这个计算是不是在编译时做的呢?我们可以直接看编译输出。下面直接贴出对上面这样的代码加输出(`printf("%d\n", factorial&lt;10&gt;::value);`)在 x86-64 下的编译结果:
```
.LC0:
.string "%d\n"
main:
push rbp
mov rbp, rsp
mov esi, 3628800
mov edi, OFFSET FLAT:.LC0
mov eax, 0
call printf
mov eax, 0
pop rbp
ret
```
我们可以明确看到,编译结果里明明白白直接出现了常量 3628800。上面那些递归什么的完全都没有了踪影。
如果我们传递一个负数给 `factorial` 呢?这时的结果就应该是编译期间的递归溢出。如 GCC 会报告:
>
fatal error: template instantiation depth exceeds maximum of 900 (use -ftemplate-depth= to increase the maximum)
如果把 `int` 改成 `unsigned`,不同的编译器和不同的标准选项会导致不同的结果。有些情况下错误信息完全不变,有些情况下则会报负数不能转换到 `unsigned`。通用的解决方案是使用 `static_assert`,确保参数永远不会是负数。
```
template &lt;int n&gt;
struct factorial {
static_assert(
n &gt;= 0,
"Arg must be non-negative");
static const int value =
n * factorial&lt;n - 1&gt;::value;
};
```
这样,当 `factorial` 接收到一个负数作为参数时,就会得到一个干脆的错误信息:
>
error: static assertion failed: Arg must be non-negative
下面我们看一些更复杂的例子。这些例子不是为了让你真的去写这样的代码,而是帮助你充分理解编译期编程的强大威力。如果这些例子你都完全掌握了,那以后碰到小的模板问题,你一定可以轻松解决,完全不在话下。
回想上面的例子,我们可以看到,要进行编译期编程,最主要的一点,是需要把计算转变成类型推导。比如,下面的模板可以代表条件语句:
```
template &lt;bool cond,
typename Then,
typename Else&gt;
struct If;
template &lt;typename Then,
typename Else&gt;
struct If&lt;true, Then, Else&gt; {
typedef Then type;
};
template &lt;typename Then,
typename Else&gt;
struct If&lt;false, Then, Else&gt; {
typedef Else type;
};
```
`If` 模板有三个参数,第一个是布尔值,后面两个则是代表不同分支计算的类型,这个类型可以是我们上面定义的任何一个模板实例,包括 `If``factorial`。第一个 struct 声明规定了模板的形式,然后我们不提供通用定义,而是提供了两个特化。第一个特化是真的情况,定义结果 `type``Then` 分支;第二个特化是假的情况,定义结果 `type``Else` 分支。
我们一般也需要循环:
```
template &lt;bool condition,
typename Body&gt;
struct WhileLoop;
template &lt;typename Body&gt;
struct WhileLoop&lt;true, Body&gt; {
typedef typename WhileLoop&lt;
Body::cond_value,
typename Body::next_type&gt;::type
type;
};
template &lt;typename Body&gt;
struct WhileLoop&lt;false, Body&gt; {
typedef
typename Body::res_type type;
};
template &lt;typename Body&gt;
struct While {
typedef typename WhileLoop&lt;
Body::cond_value, Body&gt;::type
type;
};
```
这个循环的模板定义稍复杂点。首先,我们对循环体类型有一个约定,它必须提供一个静态数据成员,`cond_value`,及两个子类型定义,`res_type``next_type`
- `cond_value` 代表循环的条件(真或假)
- `res_type` 代表退出循环时的状态
- `next_type` 代表下面循环执行一次时的状态
这里面比较绕的地方是用类型来代表执行状态。如果之前你没有接触过函数式编程的话,这个在初学时有困难是正常的。把例子多看两遍,自己编译、修改、把玩一下,就会渐渐理解的。
排除这个抽象性,模板的定义和 `If` 是类似的,虽然我们为方便使用,定义了两个模板。`WhileLoop` 模板有两个模板参数,同样用特化来决定走递归分支还是退出循环分支。`While` 模板则只需要循环体一个参数,方便使用。
如果你之前模板用得不多的话,还有一个需要了解的细节,就是用 `::` 取一个成员类型、并且 `::` 左边有模板参数的话,得额外加上 `typename` 关键字来标明结果是一个类型。上面循环模板的定义里就出现了多次这样的语法。MSVC 在这方面往往比较宽松,不写 `typename` 也不会报错,但这是不符合 C++ 标准的用法。
为了进行计算,我们还需要通用的代表数值的类型。下面这个模板可以通用地代表一个整数常数:
```
template &lt;class T, T v&gt;
struct integral_constant {
static const T value = v;
typedef T value_type;
typedef integral_constant type;
};
```
`integral_constant` 模板同时包含了整数的类型和数值,而通过这个类型的 `value` 成员我们又可以重新取回这个数值。有了这个模板的帮忙,我们就可以进行一些更通用的计算了。下面这个模板展示了如何使用循环模板来完成从 1 加到 n 的计算:
```
template &lt;int result, int n&gt;
struct SumLoop {
static const bool cond_value =
n != 0;
static const int res_value =
result;
typedef integral_constant&lt;
int, res_value&gt;
res_type;
typedef SumLoop&lt;result + n, n - 1&gt;
next_type;
};
template &lt;int n&gt;
struct Sum {
typedef SumLoop&lt;0, n&gt; type;
};
```
然后你使用 `While&lt;Sum&lt;10&gt;::type&gt;::type::value` 就可以得到 1 加到 10 的结果。虽然有点绕,但代码实质就是在编译期间进行了以下的计算:
```
int result = 0;
while (n != 0) {
result = result + n;
n = n - 1;
}
```
估计现在你的头已经很晕了。但我保证,这一讲最难的部分已经过去了。实际上,到现在为止,我们讲的东西还没有离开 C++98。而我们下面几讲里很快就会讲到如何在现代 C++ 里不使用这种麻烦的方式也能达到同样的效果。
## 编译期类型推导
C++ 标准库在 &lt;type_traits&gt; 头文件里定义了很多工具类模板用来提取某个类型type在某方面的特点trait[2]。和上一节给出的例子相似,这些特点既是类型,又是常值。
为了方便地在值和类型之间转换,标准库定义了一些经常需要用到的工具类。上面描述的 `integral_constant` 就是其中一个(我的定义有所简化)。为了方便使用,针对布尔值有两个额外的类型定义:
```
typedef std::integral_constant&lt;
bool, true&gt; true_type;
typedef std::integral_constant&lt;
bool, false&gt; false_type;
```
这两个标准类型 `true_type``false_type` 经常可以在函数重载中见到。有一个工具函数常常会写成下面这个样子:
```
template &lt;typename T&gt;
class SomeContainer {
public:
static void destroy(T* ptr)
{
_destroy(ptr,
is_trivially_destructible&lt;
T&gt;());
}
private:
static void _destroy(T* ptr,
true_type)
{}
static void _destroy(T* ptr,
false_type)
{
ptr-&gt;~T();
}
};
```
类似上面,很多容器类里会有一个 `destroy` 函数,通过指针来析构某个对象。为了确保最大程度的优化,常用的一个技巧就是用 `is_trivially_destructible` 模板来判断类是否是可平凡析构的——也就是说,不调用析构函数,不会造成任何资源泄漏问题。模板返回的结果还是一个类,要么是 `true_type`,要么是 `false_type`。如果要得到布尔值的话,当然使用 `is_trivially_destructible&lt;T&gt;::value` 就可以,但此处不需要。我们需要的是,使用 `()` 调用该类型的构造函数,让编译器根据数值类型来选择合适的重载。这样,在优化编译的情况下,编译器可以把不需要的析构操作彻底全部删除。
`is_trivially_destructible` 这样的 trait 类有很多,可以用来在模板里决定所需的特殊行为:
- `is_array`
- `is_enum`
- `is_function`
- `is_pointer`
- `is_reference`
- `is_const`
- `has_virtual_destructor`
-
这些特殊行为判断可以是像上面这样用于决定不同的重载,也可以是直接用在模板参数甚至代码里(记得我们是可以直接得到布尔值的)。
除了得到布尔值和相对应的类型的 trait 模板,我们还有另外一些模板,可以用来做一些类型的转换。以一个常见的模板 `remove_const` 为例(用来去除类型里的 const 修饰),它的定义大致如下:
```
template &lt;class T&gt;
struct remove_const {
typedef T type;
};
template &lt;class T&gt;
struct remove_const&lt;const T&gt; {
typedef T type;
};
```
同样,它也是利用模板的特化,针对 const 类型去掉相应的修饰。比如,如果我们对 `const string&amp;` 应用 `remove_const`,就会得到 `string&amp;`,即,`remove_const&lt;const string&amp;&gt;::type` 等价于 `string&amp;`
这里有一个细节你要注意一下,如果对 `const char*` 应用 `remove_const` 的话,结果还是 `const char*`。原因是,`const char*` 是指向 `const char` 的指针,而不是指向 `char` 的 const 指针。如果我们对 `char * const` 应用 `remove_const` 的话,还是可以得到 `char*` 的。
### 简易写法
如果你觉得写 `is_trivially_destructible&lt;T&gt;::value``remove_const&lt;T&gt;::type` 非常啰嗦的话,那你绝不是一个人。在当前的 C++ 标准里,前者有增加 `_v` 的编译时常量,后者有增加 `_t` 的类型别名:
```
template &lt;class T&gt;
inline constexpr bool
is_trivially_destructible_v =
is_trivially_destructible&lt;
T&gt;::value;
```
```
template &lt;class T&gt;
using remove_const_t =
typename remove_const&lt;T&gt;::type;
```
至于什么是 `constexpr`,我们会单独讲。`using` 是现代 C++ 的新语法,功能大致与 `typedef` 相似,但 `typedef` 只能针对某个特定的类型,而 `using` 可以生成别名模板。目前我们只需要知道,在你需要 trait 模板的结果数值和类型时,使用带 `_v``_t` 后缀的模板可能会更方便,尤其是带 `_t` 后缀的类型转换模板。
## 通用的 fmap 函数模板
你应当多多少少听到过 map-reduce。抛开其目前在大数据应用中的具体方式不谈从概念本源来看map [3] 和 reduce [4] 都来自函数式编程。下面我们演示一个 map 函数(当然,在 C++ 里它的名字就不能叫 `map` 了),其中用到了目前为止我们学到的多个知识点:
```
template &lt;
template &lt;typename, typename&gt;
class OutContainer = vector,
typename F, class R&gt;
auto fmap(F&amp;&amp; f, R&amp;&amp; inputs)
{
typedef decay_t&lt;decltype(
f(*inputs.begin()))&gt;
result_type;
OutContainer&lt;
result_type,
allocator&lt;result_type&gt;&gt;
result;
for (auto&amp;&amp; item : inputs) {
result.push_back(f(item));
}
return result;
}
```
我们:
-`decltype` 来获得用 `f` 来调用 `inputs` 元素的类型(参考[[第 8 讲]](https://time.geekbang.org/column/article/176850)
-`decay_t` 来把获得的类型变成一个普通的值类型;
- 缺省使用 `vector` 作为返回值的容器,但可以通过模板参数改为其他容器;
- 使用基于范围的 for 循环来遍历 `inputs`,对其类型不作其他要求(参考[[第 7 讲]](https://time.geekbang.org/column/article/176842)
- 存放结果的容器需要支持 `push_back` 成员函数(参考[[第 4 讲]](https://time.geekbang.org/column/article/173167))。
下面的代码可以验证其功能:
```
vector&lt;int&gt; v{1, 2, 3, 4, 5};
int add_1(int x)
{
return x + 1;
}
auto result = fmap(add_1, v);
```
`fmap` 执行之后,我们会在 `result` 里得到一个新容器,其内容是 2, 3, 4, 5, 6。
## 内容小结
本讲我们介绍了模板元编程的基本概念和例子,其本质是**把计算过程用编译期的类型推导和类型匹配表达出来**;然后介绍 type traits 及其基本用法;最后我们演示了一个简单的高阶函数 map其实现中用到了我们目前已经讨论过的一些知识点。
## 课后思考
这一讲的内容可能有点烧脑,请你自行实验一下例子,并找一两个简单的算法用模板元编程的方法实现一下,看看能不能写出来。
如果有什么特别想法的话,欢迎留言和我分享交流。
## 参考资料
[1] Todd L. Veldhuizen, “C++ templates are Turing complete”. [http://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.14.3670](http://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.14.3670)
[2] cppreference.com, “Standard library header &lt;type_traits&gt;”. [https://en.cppreference.com/w/cpp/header/type_traits](https://en.cppreference.com/w/cpp/header/type_traits)
[2a] cppreference.com, “标准库头文件 &lt;type_traits&gt;”. [https://zh.cppreference.com/w/cpp/header/type_traits](https://zh.cppreference.com/w/cpp/header/type_traits)
[3] Wikipedia, “Map (higher-order function)”. [https://en.wikipedia.org/wiki/Map_(higher-order_function)](https://en.wikipedia.org/wiki/Map_(higher-order_function))
[4] Wikipedia, “Fold (higher-order function)”. [https://en.wikipedia.org/wiki/Fold_(higher-order_function)](https://en.wikipedia.org/wiki/Fold_(higher-order_function))

View File

@@ -0,0 +1,319 @@
<audio id="audio" title="14 | SFINAE不是错误的替换失败是怎么回事?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0e/0d/0e97f34df9d89f2c7567e11dbac7ea0d.mp3"></audio>
你好,我是吴咏炜。
我们已经连续讲了两讲模板和编译期编程了。今天我们还是继续这个话题讲的内容是模板里的一个特殊概念——替换失败非错substitution failure is not an error英文简称为 SFINAE。
## 函数模板的重载决议
我们之前已经讨论了不少模板特化。我们今天来着重看一个函数模板的情况。当一个函数名称和某个函数模板名称匹配时,重载决议过程大致如下:
- 根据名称找出所有适用的函数和函数模板
- 对于适用的函数模板,要根据实际情况对模板形参进行替换;替换过程中如果发生错误,这个模板会被丢弃
- 在上面两步生成的可行函数集合中,编译器会寻找一个最佳匹配,产生对该函数的调用
- 如果没有找到最佳匹配,或者找到多个匹配程度相当的函数,则编译器需要报错
我们还是来看一个具体的例子(改编自参考资料 [1])。虽然这例子不那么实用,但还是比较简单,能够初步说明一下。
```
#include &lt;stdio.h&gt;
struct Test {
typedef int foo;
};
template &lt;typename T&gt;
void f(typename T::foo)
{
puts("1");
}
template &lt;typename T&gt;
void f(T)
{
puts("2");
}
int main()
{
f&lt;Test&gt;(10);
f&lt;int&gt;(10);
}
```
输出为:
>
<p>`1`<br>
`2`</p>
我们来分析一下。首先看 `f&lt;Test&gt;(10);` 的情况:
- 我们有两个模板符合名字 `f`
- 替换结果为 `f(Test::foo)``f(Test)`
- 使用参数 `10` 去匹配,只有前者参数可以匹配,因而第一个模板被选择
再看一下 `f&lt;int&gt;(10)` 的情况:
- 还是两个模板符合名字 `f`
- 替换结果为 `f(int::foo)``f(int)`;显然前者不是个合法的类型,被抛弃
- 使用参数 `10` 去匹配 `f(int)`,没有问题,那就使用这个模板实例了
在这儿,体现的是 SFINAE 设计的最初用法:如果模板实例化中发生了失败,没有理由编译就此出错终止,因为还是可能有其他可用的函数重载的。
这儿的失败仅指函数模板的原型声明,即参数和返回值。函数体内的失败不考虑在内。如果重载决议选择了某个函数模板,而函数体在实例化的过程中出错,那我们仍然会得到一个编译错误。
## 编译期成员检测
不过,很快人们就发现 SFINAE 可以用于其他用途。比如,根据某个实例化的成功或失败来在编译期检测类的特性。下面这个模板,就可以检测一个类是否有一个名叫 `reserve`、参数类型为 `size_t` 的成员函数:
```
template &lt;typename T&gt;
struct has_reserve {
struct good { char dummy; };
struct bad { char dummy[2]; };
template &lt;class U,
void (U::*)(size_t)&gt;
struct SFINAE {};
template &lt;class U&gt;
static good
reserve(SFINAE&lt;U, &amp;U::reserve&gt;*);
template &lt;class U&gt;
static bad reserve(...);
static const bool value =
sizeof(reserve&lt;T&gt;(nullptr))
== sizeof(good);
};
```
在这个模板里:
- 我们首先定义了两个结构 `good``bad`;它们的内容不重要,我们只关心它们的大小必须不一样。
- 然后我们定义了一个 `SFINAE` 模板,内容也同样不重要,但模板的第二个参数需要是第一个参数的成员函数指针,并且参数类型是 `size_t`,返回值是 `void`
- 随后,我们定义了一个要求 `SFINAE*` 类型的 `reserve` 成员函数模板,返回值是 `good`;再定义了一个对参数类型无要求的 `reserve` 成员函数模板(不熟悉 `...` 语法的,可以看参考资料 [2]),返回值是 `bad`
- 最后,我们定义常整型布尔值 `value`,结果是 `true` 还是 `false`,取决于 `nullptr` 能不能和 `SFINAE*` 匹配成功,而这又取决于模板参数 `T` 有没有返回类型是 `void`、接受一个参数并且类型为 `size_t` 的成员函数 `reserve`
那这样的模板有什么用处呢?我们继续往下看。
## SFINAE 模板技巧
### enable_if
C++11 开始,标准库里有了一个叫 `enable_if` 的模板(定义在 &lt;type_traits&gt; 里),可以用它来选择性地启用某个函数的重载。
假设我们有一个函数,用来往一个容器尾部追加元素。我们希望原型是这个样子的:
```
template &lt;typename C, typename T&gt;
void append(C&amp; container, T* ptr,
size_t size);
```
显然,`container` 有没有 `reserve` 成员函数,是对性能有影响的——如果有的话,我们通常应该预留好内存空间,以免产生不必要的对象移动甚至拷贝操作。利用 `enable_if` 和上面的 `has_reserve` 模板,我们就可以这么写:
```
template &lt;typename C, typename T&gt;
enable_if_t&lt;has_reserve&lt;C&gt;::value,
void&gt;
append(C&amp; container, T* ptr,
size_t size)
{
container.reserve(
container.size() + size);
for (size_t i = 0; i &lt; size;
++i) {
container.push_back(ptr[i]);
}
}
template &lt;typename C, typename T&gt;
enable_if_t&lt;!has_reserve&lt;C&gt;::value,
void&gt;
append(C&amp; container, T* ptr,
size_t size)
{
for (size_t i = 0; i &lt; size;
++i) {
container.push_back(ptr[i]);
}
}
```
要记得之前我说过,对于某个 type trait添加 `_t` 的后缀等价于其 `type` 成员类型。因而,我们可以用 `enable_if_t` 来取到结果的类型。`enable_if_t&lt;has_reserve&lt;C&gt;::value, void&gt;` 的意思可以理解成:如果类型 `C``reserve` 成员的话,那我们启用下面的成员函数,它的返回类型为 `void`
`enable_if` 的定义(其实非常简单)和它的进一步说明,请查看参考资料 [3]。参考资料里同时展示了一个通用技巧,可以用在构造函数(无返回值)或不想手写返回值类型的情况下。但那个写法更绕一些,不是必需要用的话,就采用上面那个写出返回值类型的写法吧。
### decltype 返回值
如果只需要在某个操作有效的情况下启用某个函数,而不需要考虑相反的情况的话,有另外一个技巧可以用。对于上面的 `append` 的情况,如果我们想限制只有具有 `reserve` 成员函数的类可以使用这个重载,我们可以把代码简化成:
```
template &lt;typename C, typename T&gt;
auto append(C&amp; container, T* ptr,
size_t size)
-&gt; decltype(
declval&lt;C&amp;&gt;().reserve(1U),
void())
{
container.reserve(
container.size() + size);
for (size_t i = 0; i &lt; size;
++i) {
container.push_back(ptr[i]);
}
}
```
这是我们第一次用到 `declval` [4],需要简单介绍一下。这个模板用来声明一个某个类型的参数,但这个参数只是用来参加模板的匹配,不允许实际使用。使用这个模板,我们可以在某类型没有默认构造函数的情况下,假想出一个该类的对象来进行类型推导。`declval&lt;C&amp;&gt;().reserve(1U)` 用来测试 `C&amp;` 类型的对象是不是可以拿 `1U` 作为参数来调用 `reserve` 成员函数。此外我们需要记得C++ 里的逗号表达式的意思是按顺序逐个估值,并返回最后一项。所以,上面这个函数的返回值类型是 `void`
这个方式和 `enable_if` 不同,很难表示否定的条件。如果要提供一个专门给**没有** `reserve` 成员函数的 `C` 类型的 `append` 重载,这种方式就不太方便了。因而,这种方式的主要用途是避免错误的重载。
### void_t
`void_t` 是 C++17 新引入的一个模板 [5]。它的定义简单得令人吃惊:
```
template &lt;typename...&gt;
using void_t = void;
```
换句话说,这个类型模板会把任意类型映射到 `void`。它的特殊性在于,在这个看似无聊的过程中,编译器会检查那个“任意类型”的有效性。利用 `decltype``declval` 和模板特化,我们可以把 `has_reserve` 的定义大大简化:
```
template &lt;typename T,
typename = void_t&lt;&gt;&gt;
struct has_reserve : false_type {};
template &lt;typename T&gt;
struct has_reserve&lt;
T, void_t&lt;decltype(
declval&lt;T&amp;&gt;().reserve(1U))&gt;&gt;
: true_type {};
```
这里第二个 `has_reserve` 模板的定义实际上是一个偏特化 [6]。偏特化是类模板的特有功能,跟函数重载有些相似。编译器会找出所有的可用模板,然后选择其中最“特别”的一个。像上面的例子,所有类型都能满足第一个模板,但不是所有的类型都能满足第二个模板,所以第二个更特别。当第二个模板能被满足时,编译器就会选择第二个特化的模板;而只有第二个模板不能被满足时,才会回到第一个模板的通用情况。
有了这个 `has_reserve` 模板,我们就可以继续使用其他的技巧,如 `enable_if` 和下面的标签分发,来对重载进行限制。
### 标签分发
在上一讲,我们提到了用 `true_type``false_type` 来选择合适的重载。这种技巧有个专门的名字叫标签分发tag dispatch。我们的 `append` 也可以用标签分发来实现:
```
template &lt;typename C, typename T&gt;
void _append(C&amp; container, T* ptr,
size_t size,
true_type)
{
container.reserve(
container.size() + size);
for (size_t i = 0; i &lt; size;
++i) {
container.push_back(ptr[i]);
}
}
template &lt;typename C, typename T&gt;
void _append(C&amp; container, T* ptr,
size_t size,
false_type)
{
for (size_t i = 0; i &lt; size;
++i) {
container.push_back(ptr[i]);
}
}
template &lt;typename C, typename T&gt;
void append(C&amp; container, T* ptr,
size_t size)
{
_append(
container, ptr, size,
integral_constant&lt;
bool,
has_reserve&lt;C&gt;::value&gt;{});
}
```
回想起上一讲里 `true_type``false_type` 的定义,你应该很容易看出这个代码跟使用 `enable_if` 是等价的。当然,在这个例子,标签分发并没有使用 `enable_if` 显得方便。作为一种可以替代 `enable_if` 的通用惯用法,你还是需要了解一下。
另外,如果我们用 `void_t` 那个版本的 `has_reserve` 模板的话,由于模板的实例会继承 `false_type``true_type` 之一,代码可以进一步简化为:
```
template &lt;typename C, typename T&gt;
void append(C&amp; container, T* ptr,
size_t size)
{
_append(
container, ptr, size,
has_reserve&lt;C&gt;{});
}
```
### 静态多态的限制?
看到这儿,你可能会怀疑,为什么我们不能像在 Python 之类的语言里一样,直接写下面这样的代码呢?
```
template &lt;typename C, typename T&gt;
void append(C&amp; container, T* ptr,
size_t size)
{
if (has_reserve&lt;C&gt;::value) {
container.reserve(
container.size() + size);
}
for (size_t i = 0; i &lt; size;
++i) {
container.push_back(ptr[i]);
}
}
```
如果你试验一下,就会发现,在 `C` 类型没有 `reserve` 成员函数的情况下,编译是不能通过的,会报错。这是因为 C++ 是静态类型的语言,所有的函数、名字必须在编译时被成功解析、确定。在动态类型的语言里,只要语法没问题,缺成员函数要执行到那一行上才会被发现。这赋予了动态类型语言相当大的灵活性;只不过,不能在编译时检查错误,同样也是很多人对动态类型语言的抱怨所在……
那在 C++ 里,我们有没有更好的办法呢?实际上是有的。具体方法,下回分解。
## 内容小结
今天我们介绍了 SFINAE 和它的一些主要惯用法。虽然随着 C++ 的演化SFINAE 的重要性有降低的趋势,但我们仍需掌握其基本概念,才能理解使用了这一技巧的模板代码。
## 课后思考
这一讲的内容应该仍然是很烧脑的。请你务必试验一下文中的代码,加深对这些概念的理解。同样,有任何问题和想法,可以留言与我交流。
## 参考资料
[1] Wikipedia, “Substitution failure is not an error”. [https://en.wikipedia.org/wiki/Substitution_failure_is_not_an_error](https://en.wikipedia.org/wiki/Substitution_failure_is_not_an_error)
[2] cppreference.com, “Variadic functions”. [https://en.cppreference.com/w/c/variadic](https://en.cppreference.com/w/c/variadic)
[2a] cppreference.com, “变参数函数”. [https://zh.cppreference.com/w/c/variadic](https://zh.cppreference.com/w/c/variadic)
[3] cppreference.com, “std::enable_if”. [https://en.cppreference.com/w/cpp/types/enable_if](https://en.cppreference.com/w/cpp/types/enable_if)
[3a] cppreference.com, “std::enable_if”. [https://zh.cppreference.com/w/cpp/types/enable_if](https://zh.cppreference.com/w/cpp/types/enable_if)
[4] cppreference.com, “std::declval”. [https://en.cppreference.com/w/cpp/utility/declval](https://en.cppreference.com/w/cpp/utility/declval)
[4a] cppreference.com, “std::declval”. [https://zh.cppreference.com/w/cpp/utility/declval](https://zh.cppreference.com/w/cpp/utility/declval)
[5] cppreference.com, “std::void_t”. [https://en.cppreference.com/w/cpp/types/void_t](https://en.cppreference.com/w/cpp/types/void_t)
[5a] cppreference.com, “std::void_t”. [https://zh.cppreference.com/w/cpp/types/void_t](https://zh.cppreference.com/w/cpp/types/void_t)
[6] cppreference.com, “Partial template specialization”. [https://en.cppreference.com/w/cpp/language/partial_specialization](https://en.cppreference.com/w/cpp/language/partial_specialization)
[6a] cppreference.com, “部分模板特化”. [https://zh.cppreference.com/w/cpp/language/partial_specialization](https://zh.cppreference.com/w/cpp/language/partial_specialization)

View File

@@ -0,0 +1,520 @@
<audio id="audio" title="15 | constexpr一个常态的世界" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5c/a9/5cf14ab61909ce70e3a13cad4d556da9.mp3"></audio>
你好,我是吴咏炜。
我们已经连续讲了几讲比较累人的编译期编程了。今天我们还是继续这个话题但是相信今天学完之后你会感觉比之前几讲要轻松很多。C++ 语言里的很多改进,让我们做编译期编程也变得越来越简单了。
## 初识 constexpr
我们先来看一些例子:
```
int sqr(int n)
{
return n * n;
}
int main()
{
int a[sqr(3)];
}
```
想一想,这个代码合法吗?
看过之后,再想想这个代码如何?
```
int sqr(int n)
{
return n * n;
}
int main()
{
const int n = sqr(3);
int a[n];
}
```
还有这个?
```
#include &lt;array&gt;
int sqr(int n)
{
return n * n;
}
int main()
{
std::array&lt;int, sqr(3)&gt; a;
}
```
此外,我们前面模板元编程里的那些类里的 `static const int` 什么的,你认为它们能用在上面的几种情况下吗?
如果以上问题你都知道正确的答案,那恭喜你,你对 C++ 的理解已经到了一个不错的层次了。但问题依然在那里:这些问题的答案不直观。并且,我们需要一个比模板元编程更方便的进行编译期计算的方法。
在 C++11 引入、在 C++14 得到大幅改进的 `constexpr` 关键字就是为了解决这些问题而诞生的。它的字面意思是 constant expression常量表达式。存在两类 `constexpr` 对象:
- `constexpr` 变量(唉……😓)
- `constexpr` 函数
一个 `constexpr` 变量是一个编译时完全确定的常数。一个 `constexpr` 函数至少对于某一组实参可以在编译期间产生一个编译期常数。
注意一个 `constexpr` 函数不保证在所有情况下都会产生一个编译期常数(因而也是可以作为普通函数来使用的)。编译器也没法通用地检查这点。编译器唯一强制的是:
- `constexpr` 变量必须立即初始化
- 初始化只能使用字面量或常量表达式,后者不允许调用任何非 `constexpr` 函数
`constexpr` 的实际规则当然稍微更复杂些,而且随着 C++ 标准的演进也有着一些变化,特别是对 `constexpr` 函数如何实现的要求在慢慢放宽。要了解具体情况包括其在不同 C++ 标准中的限制,可以查看参考资料 [1]。下面我们也会回到这个问题略作展开。
`constexpr` 来改造开头的例子,下面的代码就完全可以工作了:
```
#include &lt;array&gt;
constexpr int sqr(int n)
{
return n * n;
}
int main()
{
constexpr int n = sqr(3);
std::array&lt;int, n&gt; a;
int b[n];
}
```
要检验一个 `constexpr` 函数能不能产生一个真正的编译期常量,可以把结果赋给一个 `constexpr` 变量。成功的话,我们就确认了,至少在这种调用情况下,我们能真正得到一个编译期常量。
## constexpr 和编译期计算
上面这些当然有点用。但如果只有这点用的话,就不值得我专门来写一讲了。更强大的地方在于,使用编译期常量,就跟我们之前的那些类模板里的 `static const int` 变量一样,是可以进行编译期计算的。
以[[第 13 讲]](https://time.geekbang.org/column/article/181608) 提到的阶乘函数为例,和那个版本基本等价的写法是:
```
constexpr int factorial(int n)
{
if (n == 0) {
return 1;
} else {
return n * factorial(n - 1);
}
}
```
然后,我们用下面的代码可以验证我们确实得到了一个编译期常量:
```
int main()
{
constexpr int n = factorial(10);
printf("%d\n", n);
}
```
编译可以通过,同时,如果我们看产生的汇编代码的话,一样可以直接看到常量 3628800。
这里有一个问题:在这个 `constexpr` 函数里,是不能写 `static_assert(n &gt;= 0)` 的。一个 `constexpr` 函数仍然可以作为普通函数使用——显然,传入一个普通 `int` 是不能使用静态断言的。替换方法是在 `factorial` 的实现开头加入:
```
if (n &lt; 0) {
throw std::invalid_argument(
"Arg must be non-negative");
}
```
如果你在 `main` 里写 `constexpr int n = factorial(-1);` 的话,就会看到编译器报告抛出异常导致无法得到一个常量表达式。建议你自己尝试一下。
## constexpr 和 const
初学 `constexpr` 时,一个很可能有的困惑是,它跟 `const` 用法上的区别到底是什么。产生这种困惑是正常的,毕竟 `const` 是个重载了很多不同含义的关键字。
`const` 的原本和基础的含义,自然是表示它修饰的内容不会变化,如:
```
const int n = 1:
n = 2; // 出错!
```
注意 `const` 在类型声明的不同位置会产生不同的结果。对于常见的 `const char*` 这样的类型声明,意义和 `char const*` 相同,是指向常字符的指针,指针指向的内容不可更改;但和 `char * const` 不同,那代表指向字符的常指针,指针本身不可更改。本质上,`const` 用来表示一个**运行时常量**。
在 C++ 里,`const` 后面渐渐带上了现在的 `constexpr` 用法,也代表**编译期常数**。现在——在有了 `constexpr` 之后——我们应该使用 `constexpr` 在这些用法中替换 `const` 了。从编译器的角度,为了向后兼容性,`const``constexpr` 在很多情况下还是等价的。但有时候,它们也有些细微的区别,其中之一为是否内联的问题。
### 内联变量
C++17 引入了内联inline变量的概念允许在头文件中定义内联变量然后像内联函数一样只要所有的定义都相同那变量的定义出现多次也没有关系。对于类的静态数据成员`const` 缺省是不内联的,而 `constexpr` 缺省就是内联的。这种区别在你用 `&amp;` 去取一个 `const int` 值的地址、或将其传到一个形参类型为 `const int&amp;` 的函数去的时候(这在 C++ 文档里的行话叫 ODR-use就会体现出来。
下面是个合法的完整程序:
```
#include &lt;iostream&gt;
struct magic {
static const int number = 42;
};
int main()
{
std::cout &lt;&lt; magic::number
&lt;&lt; std::endl;
}
```
我们稍微改一点:
```
#include &lt;iostream&gt;
#include &lt;vector&gt;
struct magic {
static const int number = 42;
};
int main()
{
std::vector&lt;int&gt; v;
// 调用 push_back(const T&amp;)
v.push_back(magic::number);
std::cout &lt;&lt; v[0] &lt;&lt; std::endl;
}
```
程序在链接时就会报错了,说找不到 `magic::number`注意MSVC 缺省不报错,但使用标准模式——`/Za` 命令行选项——也会出现这个问题)。这是因为 ODR-use 的类静态常量也需要有一个定义,在没有内联变量之前需要在某一个源代码文件(非头文件)中这样写:
```
const int magic::number = 42;
```
必须正正好好一个,多了少了都不行,所以叫 one definition rule。内联函数现在又有了内联变量以及模板则不受这条规则限制。
修正这个问题的简单方法是把 `magic` 里的 `static const` 改成 `static constexpr``static inline const`。前者可行的原因是,类的静态 constexpr 成员变量默认就是内联的。const 常量和类外面的 constexpr 变量不默认内联,需要手工加 `inline` 关键字才会变成内联。
### constexpr 变量模板
变量模板是 C++14 引入的新概念。之前我们需要用类静态数据成员来表达的东西,使用变量模板可以更简洁地表达。`constexpr` 很合适用在变量模板里表达一个和某个类型相关的编译期常量。由此type traits 都获得了一种更简单的表示方式。再看一下我们在[[第 13 讲]](https://time.geekbang.org/column/article/181608) 用过的例子:
```
template &lt;class T&gt;
inline constexpr bool
is_trivially_destructible_v =
is_trivially_destructible&lt;
T&gt;::value;
```
了解了变量也可以是模板之后,上面这个代码就很容易看懂了吧?这只是一个小小的语法糖,允许我们把 `is_trivially_destructible&lt;T&gt;::value` 写成 `is_trivially_destructible_v&lt;T&gt;`
### constexpr 变量仍是 const
一个 `constexpr` 变量仍然是 const 常类型。需要注意的是,就像 `const char*` 类型是指向常量的指针、自身不是 const 常量一样,下面这个表达式里的 `const` 也是不能缺少的:
```
constexpr int a = 42;
constexpr const int&amp; b = a;
```
第二行里,`constexpr` 表示 `b` 是一个编译期常量,`const` 表示这个引用是常量引用。去掉这个 `const` 的话,编译器就会认为你是试图将一个普通引用绑定到一个常数上,报一个类似下面的错误信息:
>
**error:** binding reference of type **int&amp;** to **const int** discards qualifiers
如果按照 const 位置的规则,`constexpr const int&amp; b` 实际该写成 `const int&amp; constexpr b`。不过,`constexpr` 不需要像 `const` 一样有复杂的组合,因此永远是写在类型前面的。
## constexpr 构造函数和字面类型
一个合理的 `constexpr` 函数,应当至少对于某一组编译期常量的输入,能得到编译期常量的结果。为此,对这个函数也是有些限制的:
- 最早,`constexpr` 函数里连循环都不能有,但在 C++14 放开了。
- 目前,`constexpr` 函数仍不能有 `try … catch` 语句和 `asm` 声明,但到 C++20 会放开。
- `constexpr` 函数里不能使用 `goto` 语句。
- 等等。
一个有意思的情况是一个类的构造函数。如果一个类的构造函数里面只包含常量表达式、满足对 `constexpr` 函数的限制的话(这也意味着,里面不可以有任何动态内存分配),并且类的析构函数是平凡的,那这个类就可以被称为是一个字面类型。换一个角度想,对 `constexpr` 函数——包括字面类型构造函数——的要求是,得让编译器能在编译期进行计算,而不会产生任何“副作用”,比如内存分配、输入、输出等等。
为了全面支持编译期计算C++14 开始,很多标准类的构造函数和成员函数已经被标为 `constexpr`,以便在编译期使用。当然,大部分的容器类,因为用到了动态内存分配,不能成为字面类型。下面这些不使用动态内存分配的字面类型则可以在常量表达式中使用:
- `array`
- `initializer_list`
- `pair`
- `tuple`
- `string_view`
- `optional`
- `variant`
- `bitset`
- `complex`
- `chrono::duration`
- `chrono::time_point`
- `shared_ptr`(仅限默认构造和空指针构造)
- `unique_ptr`(仅限默认构造和空指针构造)
-
下面这个玩具例子,可以展示上面的若干类及其成员函数的行为:
```
#include &lt;array&gt;
#include &lt;iostream&gt;
#include &lt;memory&gt;
#include &lt;string_view&gt;
using namespace std;
int main()
{
constexpr string_view sv{"hi"};
constexpr pair pr{sv[0], sv[1]};
constexpr array a{pr.first, pr.second};
constexpr int n1 = a[0];
constexpr int n2 = a[1];
cout &lt;&lt; n1 &lt;&lt; ' ' &lt;&lt; n2 &lt;&lt; '\n';
}
```
编译器可以在编译期即决定 `n1``n2` 的数值;从最后结果的角度,上面程序就是输出了两个整数而已。
## if constexpr
上一讲的结尾,我们给出了一个在类型参数 `C` 没有 `reserve` 成员函数时不能编译的代码:
```
template &lt;typename C, typename T&gt;
void append(C&amp; container, T* ptr,
size_t size)
{
if (has_reserve&lt;C&gt;::value) {
container.reserve(
container.size() + size);
}
for (size_t i = 0; i &lt; size;
++i) {
container.push_back(ptr[i]);
}
}
```
在 C++17 里,我们只要在 `if` 后面加上 `constexpr`,代码就能工作了 [2]。当然,它要求括号里的条件是个编译期常量。满足这个条件后,标签分发、`enable_if` 那些技巧就不那么有用了。显然,使用 `if constexpr` 能比使用其他那些方式,写出更可读的代码……
## output_container.h 解读
到了今天,我们终于把 output_container.h[3])用到的 C++ 语法特性都讲过了,我们就拿里面的代码来讲解一下,让你加深对这些特性的理解。
```
// Type trait to detect std::pair
template &lt;typename T&gt;
struct is_pair : std::false_type {};
template &lt;typename T, typename U&gt;
struct is_pair&lt;std::pair&lt;T, U&gt;&gt;
: std::true_type {};
template &lt;typename T&gt;
inline constexpr bool is_pair_v =
is_pair&lt;T&gt;::value;
```
这段代码利用模板特化([[第 12 讲]](https://time.geekbang.org/column/article/179363) 、[[第 14 讲]](https://time.geekbang.org/column/article/181636))和 `false_type``true_type` 类型([[第 13 讲]](https://time.geekbang.org/column/article/181608)),定义了 `is_pair`,用来检测一个类型是不是 `pair`。随后,我们定义了内联 `constexpr` 变量(本讲)`is_pair_v`,用来简化表达。
```
// Type trait to detect whether an
// output function already exists
template &lt;typename T&gt;
struct has_output_function {
template &lt;class U&gt;
static auto output(U* ptr)
-&gt; decltype(
std::declval&lt;std::ostream&amp;&gt;()
&lt;&lt; *ptr,
std::true_type());
template &lt;class U&gt;
static std::false_type
output(...);
static constexpr bool value =
decltype(
output&lt;T&gt;(nullptr))::value;
};
template &lt;typename T&gt;
inline constexpr bool
has_output_function_v =
has_output_function&lt;T&gt;::value;
```
这段代码使用 SFINAE 技巧([[第 14 讲]](https://time.geekbang.org/column/article/181636)),来检测模板参数 `T` 的对象是否已经可以直接输出到 `ostream`。然后,一样用一个内联 `constexpr` 变量来简化表达。
```
// Output function for std::pair
template &lt;typename T, typename U&gt;
std::ostream&amp; operator&lt;&lt;(
std::ostream&amp; os,
const std::pair&lt;T, U&gt;&amp; pr);
```
再然后我们声明了一个 `pair` 的输出函数(标准库没有提供这个功能)。我们这儿只是声明,是因为我们这儿有两个输出函数,且可能互相调用。所以,我们要先声明其中之一。
下面会看到,`pair` 的通用输出形式是“(x, y)”。
```
// Element output function for
// containers that define a key_type
// and have its value type as
// std::pair
template &lt;typename T, typename Cont&gt;
auto output_element(
std::ostream&amp; os,
const T&amp; element, const Cont&amp;,
const std::true_type)
-&gt; decltype(
std::declval&lt;
typename Cont::key_type&gt;(),
os);
// Element output function for other
// containers
template &lt;typename T, typename Cont&gt;
auto output_element(
std::ostream&amp; os,
const T&amp; element, const Cont&amp;,
...) -&gt; decltype(os);
```
对于容器成员的输出,我们也声明了两个不同的重载。我们的意图是,如果元素的类型是 `pair` 并且容器定义了一个 `key_type` 类型我们就认为遇到了关联容器输出形式为“x =&gt; y”而不是“(x, y)”)。
```
// Main output function, enabled
// only if no output function
// already exists
template &lt;
typename T,
typename = std::enable_if_t&lt;
!has_output_function_v&lt;T&gt;&gt;&gt;
auto operator&lt;&lt;(std::ostream&amp; os,
const T&amp; container)
-&gt; decltype(container.begin(),
container.end(), os)
```
主输出函数的定义。注意这儿这个函数的启用有两个不同的 SFINAE 条件:
-`decltype` 返回值的方式规定了被输出的类型必须有 `begin()``end()` 成员函数。
-`enable_if_t` 规定了只在被输出的类型没有输出函数时才启用这个输出函数。否则,对于 `string` 这样的类型,编译器发现有两个可用的输出函数,就会导致编译出错。
我们可以看到,用 `decltype` 返回值的方式比较简单,不需要定义额外的模板。但表达否定的条件还是要靠 `enable_if`。此外因为此处是需要避免有二义性的重载constexpr 条件语句帮不了什么忙。
```
using element_type =
decay_t&lt;decltype(
*container.begin())&gt;;
constexpr bool is_char_v =
is_same_v&lt;element_type, char&gt;;
if constexpr (!is_char_v) {
os &lt;&lt; "{ ";
}
```
对非字符类型,我们在开始输出时,先输出“{ ”。这儿使用了 `decay_t`,是为了把类型里的引用和 const/volatile 修饰去掉,只剩下值类型。如果容器里的成员是 `char`,这儿会把 `char&amp;``const char&amp;` 还原成 `char`
后面的代码就比较简单了。可能唯一需要留意的是下面这句:
```
output_element(
os, *it, container,
is_pair&lt;element_type&gt;());
```
这儿我们使用了标签分发技巧来输出容器里的元素。要记得,`output_element` 不纯粹使用标签分发,还会检查容器是否有 `key_type` 成员类型。
```
template &lt;typename T, typename Cont&gt;
auto output_element(
std::ostream&amp; os,
const T&amp; element, const Cont&amp;,
const std::true_type)
-&gt; decltype(
std::declval&lt;
typename Cont::key_type&gt;(),
os)
{
os &lt;&lt; element.first &lt;&lt; " =&gt; "
&lt;&lt; element.second;
return os;
}
template &lt;typename T, typename Cont&gt;
auto output_element(
std::ostream&amp; os,
const T&amp; element, const Cont&amp;,
...) -&gt; decltype(os)
{
os &lt;&lt; element;
return os;
}
```
`output_element` 的两个重载的实现都非常简单,应该不需要解释了。
```
template &lt;typename T, typename U&gt;
std::ostream&amp; operator&lt;&lt;(
std::ostream&amp; os,
const std::pair&lt;T, U&gt;&amp; pr)
{
os &lt;&lt; '(' &lt;&lt; pr.first &lt;&lt; ", "
&lt;&lt; pr.second &lt;&lt; ')';
return os;
}
```
同样,`pair` 的输出的实现也非常简单。
唯一需要留意的,是上面三个函数的输出内容可能还是容器,因此我们要将其实现放在后面,确保它能看到我们的通用输出函数。
要看一下用到 output_container 的例子,可以回顾[[第 4 讲]](https://time.geekbang.org/column/article/173167) 和[[第 5 讲]](https://time.geekbang.org/column/article/174434)。
## 内容小结
本讲我们介绍了编译期常量表达式和编译期条件语句,可以看到,这两种新特性对编译期编程有了很大的改进,可以让代码变得更直观。最后我们讨论了我们之前用到的容器输出函数 output_container 的实现,里面用到了多种我们目前讨论过的编译期编程技巧。
## 课后思考
请你仔细想一想:
1. 如果没有 constexpr 条件语句,这个容器输出函数需要怎样写?
1. 这种不使用 constexpr 的写法有什么样的缺点推而广之constexpr 条件语句的意义是什么?
## 参考资料
[1] cppreference.com, “constexpr specifier”. [https://en.cppreference.com/w/cpp/language/constexpr](https://en.cppreference.com/w/cpp/language/constexpr)
[1a] cppreference.com, “constexpr 说明符”. [https://zh.cppreference.com/w/cpp/language/constexpr](https://zh.cppreference.com/w/cpp/language/constexpr)
[2] cppreference.com, “if statement”, section “constexpr if”. [https://en.cppreference.com/w/cpp/language/if](https://en.cppreference.com/w/cpp/language/if)
[2a] cppreference.com, “if 语句”, “constexpr if” 部分. [https://zh.cppreference.com/w/cpp/language/if](https://zh.cppreference.com/w/cpp/language/if)
[3] 吴咏炜, output_container. [https://github.com/adah1972/output_container/blob/geektime/output_container.h](https://github.com/adah1972/output_container/blob/geektime/output_container.h)

View File

@@ -0,0 +1,488 @@
<audio id="audio" title="16 | 函数对象和lambda进入函数式编程" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f8/03/f88432c524c6b9c7deb65e3c53ff1c03.mp3"></audio>
你好,我是吴咏炜。
本讲我们将介绍函数对象尤其是匿名函数对象——lambda 表达式。今天的内容说难不难,但可能跟你的日常思维方式有较大的区别,建议你一定要试验一下文中的代码(使用 xeus-cling 的同学要注意xeus-cling 似乎不太喜欢有 lambda 的代码😓;遇到有问题时,还是只能回到普通的编译执行方式了)。
## C++98 的函数对象
函数对象function object[1] 自 C++98 开始就已经被标准化了。从概念上来说,函数对象是一个可以被当作函数来用的对象。它有时也会被叫做 functor但这个术语在范畴论里有着完全不同的含义还是不用为妙——否则玩函数式编程的人可能会朝着你大皱眉头的。
下面的代码定义了一个简单的加 **n** 的函数对象类(根据一般的惯例,我们使用了 `struct` 关键字而不是 `class` 关键字):
```
struct adder {
adder(int n) : n_(n) {}
int operator()(int x) const
{
return x + n_;
}
private:
int n_;
};
```
它看起来相当普通,唯一有点特别的地方就是定义了一个 `operator()`,这个运算符允许我们像调用函数一样使用小括号的语法。随后,我们可以定义一个实际的函数对象,如 C++11 形式的:
```
auto add_2 = adder(2);
```
或 C++98 形式的:
```
adder add_2(2);
```
得到的结果 `add_2` 就可以当作一个函数来用了。你如果写下 `add_2(5)` 的话,就会得到结果 7。
C++98 里也定义了少数高阶函数:你可以传递一个函数对象过去,结果得到一个新的函数对象。最典型的也许是目前已经从 C++17 标准里移除的 `bind1st``bind2nd` 了(在 &lt;functional&gt; 头文件中提供):
```
auto add_2 = bind2nd(plus&lt;int&gt;(), 2);
```
这样产生的 `add_2` 功能和前面相同,是把参数 `2` 当作第二个参数绑定到函数对象 `plus&lt;int&gt;`(它的 `operator()` 需要两个参数)上的结果。当然,`auto` 在 C++98 里是没有的,结果要赋给一个变量就有点别扭了,得写成:
```
binder2nd&lt;plus&lt;int&gt; &gt; add_2(
plus&lt;int&gt;(), 2);
```
因此,在 C++98 里我们通常会直接使用绑定的结果:
```
#include &lt;algorithm&gt;
#include &lt;functional&gt;
#include &lt;vector&gt;
using namespace std;
vector v{1, 2, 3, 4, 5};
transform(v.begin(), v.end(),
v.begin(),
bind2nd(plus&lt;int&gt;(), 2));
```
上面的代码会将容器里的每一项数值都加上 2`transform` 函数模板在 &lt;algorithm&gt; 头文件中提供)。可以验证结果:
```
v
```
>
`{ 3, 4, 5, 6, 7 }`
### 函数的指针和引用
除非你用一个引用模板参数来捕捉函数类型,传递给一个函数的函数实参会退化成为一个函数指针。不管是函数指针还是函数引用,你也都可以当成函数对象来用。
假设我们有下面的函数定义:
```
int add_2(int x)
{
return x + 2;
};
```
如果我们有下面的模板声明:
```
template &lt;typename T&gt;
auto test1(T fn)
{
return fn(2);
}
template &lt;typename T&gt;
auto test2(T&amp; fn)
{
return fn(2);
}
template &lt;typename T&gt;
auto test3(T* fn)
{
return (*fn)(2);
}
```
当我们拿 `add_2` 去调用这三个函数模板时,`fn` 的类型将分别被推导为 `int (*)(int)``int (&amp;)(int)``int (*)(int)`。不管我们得到的是指针还是引用,我们都可以直接拿它当普通的函数用。当然,在函数指针的情况下,我们直接写 `*value` 也可以。因而上面三个函数拿 `add_2` 作为实参调用的结果都是 `4`
很多接收函数对象的地方,也可以接收函数的指针或引用。但在个别情况下,需要通过函数对象的类型来区分函数对象的时候,就不能使用函数指针或引用了——原型相同的函数,它们的类型也是相同的。
## Lambda 表达式
Lambda 表达式 [2] 是一个源自阿隆佐·邱奇Alonzo Church——艾伦·图灵Alan Turing的老师——的术语。邱奇创立了 λ 演算 [3],后来被证明和图灵机是等价的。
我们先不看数学上的 λ 表达式,看一下上一节给出的代码在使用 lambda 表达式时可以如何简化。
```
auto add_2 = [](int x) {
return x + 2;
};
```
显然,定义 `add_2` 不再需要定义一个额外的类型了,我们可以直接写出它的定义。理解它只需要注意下面几点:
- Lambda 表达式以一对中括号开始(中括号中是可以有内容的;稍后我们再说)
- 跟函数定义一样,我们有参数列表
- 跟正常的函数定义一样,我们会有一个函数体,里面会有 `return` 语句
- Lambda 表达式一般不需要说明返回值(相当于 `auto`);有特殊情况需要说明时,则应使用箭头语法的方式(参见[[第 8 讲]](https://time.geekbang.org/column/article/176850)`[](int x) -&gt; int { … }`
- 每个 lambda 表达式都有一个全局唯一的类型,要精确捕捉 lambda 表达式到一个变量中,只能通过 `auto` 声明的方式
当然,我们想要定义一个通用的 `adder` 也不难:
```
auto adder = [](int n) {
return [n](int x) {
return x + n;
};
};
```
这次我们直接返回了一个 lambda 表达式,并且中括号中写了 `n` 来捕获变量 `n` 的数值。这个函数的实际效果和前面的 `adder` 函数对象完全一致。也就是说,捕获 `n` 的效果相当于在一个函数对象中用成员变量存储其数值。
纯粹为了满足你可能有的好奇心,上面的 `adder` 相当于这样一个 λ 表达式:
$$<br>
\mathrm{adder} = \lambda n.(\lambda x.(+ \ x \ n))<br>
$$
如果你去学 Lisp 或 Scheme 的话,你就会发现这些语言和 λ 表达式几乎是一一映射了。在 C++ 里,表达虽然稍微啰嗦一点,但也比较接近了。用我上面的 `adder` ,就可以得到类似于函数式编程语言里的 currying [4] 的效果——把一个操作(此处是加法)分成几步来完成。没见过函数式编程的,可能对下面的表达式感到奇怪吧:
```
auto seven = adder(2)(5);
```
不过,最常见的情况是,写匿名函数就是希望不需要起名字。以前面的把所有容器元素值加 2 的操作为例,使用匿名函数可以得到更简洁可读的代码:
```
transform(v.begin(), v.end(),
v.begin(),
[](int x) {
return x + 2;
});
```
到了可以使用 ranges已在 C++20 标准化)的时候,代码可以更短、更灵活。这个我们就留到后面再说了。
一个 lambda 表达式除了没有名字之外,还有一个特点是你可以立即进行求值。这就使得我们可以把一段独立的代码封装起来,达到更干净、表意的效果。
先看一个简单的例子:
```
[](int x) { return x * x; }(3)
```
这个表达式的结果是 3 的平方 9。即使这个看似无聊的例子都是有意义的因为它免去了我们定义一个 constexpr 函数的必要。只要能满足 constexpr 函数的条件,一个 lambda 表达式默认就是 constexpr 函数。
另外一种用途是解决多重初始化路径的问题。假设你有这样的代码:
```
Obj obj;
switch (init_mode) {
case init_mode1:
obj = Obj(…);
break;
case init_mode2;
obj = Obj(…);
break;
}
```
这样的代码,实际上是调用了默认构造函数、带参数的构造函数和(移动)赋值函数:既可能有性能损失,也对 `Obj` 提出了有默认构造函数的额外要求。对于这样的代码,有一种重构意见是把这样的代码分离成独立的函数。不过,有时候更直截了当的做法是用一个 lambda 表达式来进行改造,既可以提升性能(不需要默认函数或拷贝/移动),又让初始化部分显得更清晰:
```
auto obj = [init_mode]() {
switch (init_mode) {
case init_mode1:
return Obj(…);
break;
case init_mode2:
return Obj(…);
break;
}
}();
```
### 变量捕获
现在我们来细看一下 lambda 表达式中变量捕获的细节。
变量捕获的开头是可选的默认捕获符 `=``&amp;`,表示会自动按值或按引用捕获用到的本地变量,然后后面可以跟(逗号分隔):
- 本地变量名标明对其按值捕获(不能在默认捕获符 `=` 后出现;因其已自动按值捕获所有本地变量)
- `&amp;` 加本地变量名标明对其按引用捕获(不能在默认捕获符 `&amp;` 后出现;因其已自动按引用捕获所有本地变量)
- `this` 标明按引用捕获外围对象(针对 lambda 表达式定义出现在一个非静态类成员内的情况);注意默认捕获符 `=``&amp;` 号可以自动捕获 `this`(并且在 C++20 之前,在 `=` 后写 `this` 会导致出错)
- `*this` 标明按值捕获外围对象(针对 lambda 表达式定义出现在一个非静态类成员内的情况C++17 新增语法)
- `变量名 = 表达式` 标明按值捕获表达式的结果(可理解为 `auto 变量名 = 表达式`
- `&amp;变量名 = 表达式` 标明按引用捕获表达式的结果(可理解为 `auto&amp; 变量名 = 表达式`
从工程的角度,大部分情况不推荐使用默认捕获符。更一般化的一条工程原则是:**显式的代码比隐式的代码更容易维护。**当然,在这条原则上走多远是需要权衡的,你也不愿意写出非常啰嗦的代码吧?否则的话,大家就全部去写 C 了。
一般而言,按值捕获是比较安全的做法。按引用捕获时则需要更小心些,必须能够确保被捕获的变量和 lambda 表达式的生命期至少一样长,并在有下面需求之一时才使用:
- 需要在 lambda 表达式中修改这个变量并让外部观察到
- 需要看到这个变量在外部被修改的结果
- 这个变量的复制代价比较高
如果希望以移动的方式来捕获某个变量的话,则应考虑 `变量名 = 表达式` 的形式。表达式可以返回一个 prvalue 或 xvalue比如可以是 `std::move(需移动捕获的变量)`
上一节我们已经见过简单的按值捕获。下面是一些更多的演示变量捕获的例子。
按引用捕获:
```
vector&lt;int&gt; v1;
vector&lt;int&gt; v2;
auto push_data = [&amp;](int n) {
// 或使用 [&amp;v1, &amp;v2] 捕捉
v1.push_back(n);
v2.push_back(n)
};
push_data(2);
push_data(3);
```
这个例子很简单。我们按引用捕获 `v1``v2`,因为我们需要修改它们的内容。
按值捕获外围对象:
```
#include &lt;chrono&gt;
#include &lt;iostream&gt;
#include &lt;sstream&gt;
#include &lt;string&gt;
#include &lt;thread&gt;
using namespace std;
int get_count()
{
static int count = 0;
return ++count;
}
class task {
public:
task(int data) : data_(data) {}
auto lazy_launch()
{
return
[*this, count = get_count()]()
mutable {
ostringstream oss;
oss &lt;&lt; "Done work " &lt;&lt; data_
&lt;&lt; " (No. " &lt;&lt; count
&lt;&lt; ") in thread "
&lt;&lt; this_thread::get_id()
&lt;&lt; '\n';
msg_ = oss.str();
calculate();
};
}
void calculate()
{
this_thread::sleep_for(100ms);
cout &lt;&lt; msg_;
}
private:
int data_;
string msg_;
};
int main()
{
auto t = task{37};
thread t1{t.lazy_launch()};
thread t2{t.lazy_launch()};
t1.join();
t2.join();
}
```
这个例子稍复杂,演示了好几个 lambda 表达式的特性:
- `mutable` 标记使捕获的内容可更改(缺省不可更改捕获的值,相当于定义了 `operator()(…) const`
- `[*this]` 按值捕获外围对象(`task`
- `[count = get_count()]` 捕获表达式可以在生成 lambda 表达式时计算并存储等号后表达式的结果。
这样,多个线程复制了任务对象,可以独立地进行计算。请自行运行一下代码,并把 `*this` 改成 `this`,看看输出会有什么不同。
## 泛型 lambda 表达式
函数的返回值可以 auto但参数还是要一一声明的。在 lambda 表达式里则更进一步,在参数声明时就可以使用 `auto`(包括 `auto&amp;&amp;` 等形式)。不过,它的功能也不那么神秘,就是给你自动声明了模板而已。毕竟,在 lambda 表达式的定义过程中是没法写 `template` 关键字的。
还是拿例子说话:
```
template &lt;typename T1,
typename T2&gt;
auto sum(T1 x, T2 y)
{
return x + y;
}
```
跟上面的函数等价的 lambda 表达式是:
```
auto sum = [](auto x, auto y)
{
return x + y;
}
```
是不是反而更简单了?😂
你可能要问,这么写有什么用呢?问得好。简单来说,答案是可组合性。上面这个 `sum`,就跟标准库里的 `plus` 模板一样,是可以传递给其他接受函数对象的函数的,而 `+` 本身则不行。下面的例子虽然略有点无聊,也可以演示一下:
```
#include &lt;array&gt; // std::array
#include &lt;iostream&gt; // std::cout/endl
#include &lt;numeric&gt; // std::accumulate
using namespace std;
int main()
{
array a{1, 2, 3, 4, 5};
auto s = accumulate(
a.begin(), a.end(), 0,
[](auto x, auto y) {
return x + y;
});
cout &lt;&lt; s &lt;&lt; endl;
}
```
虽然函数名字叫 `accumulate`——累加——但它的行为是通过第四个参数可修改的。我们把上面的加号 `+` 改成星号 `*`,上面的计算就从从 1 加到 5 变成了算 5 的阶乘了。
## bind 模板
我们上面提到了 `bind1st``bind2nd` 目前已经从 C++ 标准里移除。原因实际上有两个:
- 它的功能可以被 lambda 表达式替代
- 有了一个更强大的 `bind` 模板 [5]
拿我们之前给出的例子:
```
transform(v.begin(), v.end(),
v.begin(),
bind2nd(plus&lt;int&gt;(), 2));
```
现在我们可以写成:
```
using namespace std::
placeholders; // for _1, _2...
transform(v.begin(), v.end(),
v.begin(),
bind(plus&lt;&gt;(), _1, 2));
```
原先我们只能把一个给定的参数绑定到第一个参数或第二个参数上,现在则可以非常自由地适配各种更复杂的情况!当然,`bind` 的参数数量,必须是第一个参数(函数对象)所需的参数数量加一。而 `bind` 的结果的参数数量则没有限制——你可以无聊地写出 `bind(plus&lt;&gt;(), _1, _3)(1, 2, 3)`,而结果是 4完全忽略第二个参数
你可能会问,它的功能是不是可以被 lambda 表达式替代呢。回答是“是”。对 `bind` 只需要稍微了解一下就好——在 C++14 之后的年代里,已经没有什么地方必须要使用 `bind` 了。
## function 模板
每一个 lambda 表达式都是一个单独的类型,所以只能使用 `auto` 或模板参数来接收结果。在很多情况下,我们需要使用一个更方便的通用类型来接收,这时我们就可以使用 `function` 模板 [6]。`function` 模板的参数就是函数的类型,一个函数对象放到 `function` 里之后,外界可以观察到的就只剩下它的参数、返回值类型和执行效果了。注意 `function` 对象的创建还是比较耗资源的,所以请你只在用 `auto` 等方法解决不了问题的时候使用这个模板。
下面是个简单的例子。
```
map&lt;string, function&lt;int(int, int)&gt;&gt;
op_dict{
{"+",
[](int x, int y) {
return x + y;
}},
{"-",
[](int x, int y) {
return x - y;
}},
{"*",
[](int x, int y) {
return x * y;
}},
{"/",
[](int x, int y) {
return x / y;
}},
};
```
这儿,由于要把函数对象存到一个 `map` 里,我们必须使用 `function` 模板。随后,我们就可以用类似于 `op_dict.at("+")(1, 6)` 这样的方式来使用 `function` 对象。这种方式对表达式的解析处理可能会比较有用。
## 内容小结
在这一讲中,我们了解了函数对象和 lambda 表达式的基本概念,并简单介绍了 `bind` 模板和 `function` 模板。它们在泛型编程和函数式编程中都是重要的基础组成部分,你应该熟练掌握。
## 课后思考
请:
1. 尝试一下,把文章的 lambda 表达式改造成完全不使用 lambda。
1. 体会一下lambda 表达式带来了哪些表达上的好处。
欢迎留言和我分享你的想法。
## 参考资料
[1] Wikipedia, “Function object”. [https://en.wikipedia.org/wiki/Function_object](https://en.wikipedia.org/wiki/Function_object)
[1a] 维基百科, “函数对象”. [https://zh.wikipedia.org/zh-cn/函数对象](https://zh.wikipedia.org/zh-cn/%E5%87%BD%E6%95%B0%E5%AF%B9%E8%B1%A1)
[2] Wikipedia, “Anonymous function”.[https://en.wikipedia.org/wiki/Anonymous_function](https://en.wikipedia.org/wiki/Anonymous_function)
[2a] 维基百科, “匿名函数”. [https://zh.wikipedia.org/zh-cn/匿名函数](https://zh.wikipedia.org/zh-cn/%E5%8C%BF%E5%90%8D%E5%87%BD%E6%95%B0)
[3] Wikipedia, “Lambda calculus”. [https://en.wikipedia.org/wiki/Lambda_calculus](https://en.wikipedia.org/wiki/Lambda_calculus)
[3a] 维基百科, “λ演算”. [https://zh.wikipedia.org/zh-cn/Λ演算](https://zh.wikipedia.org/zh-cn/%CE%9B%E6%BC%94%E7%AE%97)
[4] Wikipedia, “Currying”. [https://en.wikipedia.org/wiki/Currying](https://en.wikipedia.org/wiki/Currying)
[4a] 维基百科, “柯里化”. [https://zh.wikipedia.org/zh-cn/柯里化](https://zh.wikipedia.org/zh-cn/%E6%9F%AF%E9%87%8C%E5%8C%96)
[5] cppreference.com, “std::bind”. [https://en.cppreference.com/w/cpp/utility/functional/bind](https://en.cppreference.com/w/cpp/utility/functional/bind)
[5a] cppreference.com, “std::bind”. [https://zh.cppreference.com/w/cpp/utility/functional/bind](https://zh.cppreference.com/w/cpp/utility/functional/bind)
[6] cppreference.com, “std::function”. [https://en.cppreference.com/w/cpp/utility/functional/function](https://en.cppreference.com/w/cpp/utility/functional/function)
[6a] cppreference.com, “std::function”. [https://zh.cppreference.com/w/cpp/utility/functional/function](https://zh.cppreference.com/w/cpp/utility/functional/function)

View File

@@ -0,0 +1,460 @@
<audio id="audio" title="17 | 函数式编程:一种越来越流行的编程范式" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/10/3f/10dfc792f12f2882ba1329089b06323f.mp3"></audio>
你好,我是吴咏炜。
上一讲我们初步介绍了函数对象和 lambda 表达式,今天我们来讲讲它们的主要用途——函数式编程。
## 一个小例子
按惯例,我们还是从一个例子开始。想一下,如果给定一组文件名,要求数一下文件里的总文本行数,你会怎么做?
我们先规定一下函数的原型:
```
int count_lines(const char** begin,
const char** end);
```
也就是说,我们期待接受两个 C 字符串的迭代器,用来遍历所有的文件名;返回值代表文件中的总行数。
要测试行为是否正常,我们需要一个很小的 `main` 函数:
```
int main(int argc,
const char** argv)
{
int total_lines = count_lines(
argv + 1, argv + argc);
cout &lt;&lt; "Total lines: "
&lt;&lt; total_lines &lt;&lt; endl;
}
```
最传统的命令式编程大概会这样写代码:
```
int count_file(const char* name)
{
int count = 0;
ifstream ifs(name);
string line;
for (;;) {
getline(ifs, line);
if (!ifs) {
break;
}
++count;
}
return count;
}
int count_lines(const char** begin,
const char** end)
{
int count = 0;
for (; begin != end; ++begin) {
count += count_file(*begin);
}
return count;
}
```
我们马上可以做一个简单的“说明式”改造。用 `istream_line_reader` 可以简化 `count_file` 成:
```
int count_file(const char* name)
{
int count = 0;
ifstream ifs(name);
for (auto&amp;&amp; line :
istream_line_reader(ifs)) {
++count;
}
return count;
}
```
在这儿,要请你停一下,想一想如何进一步优化这个代码。然后再继续进行往下看。
如果我们使用之前已经出场过的两个函数,`transform` [1] 和 `accumulate` [2],代码可以进一步简化为:
```
int count_file(const char* name)
{
ifstream ifs(name);
istream_line_reader reader(ifs);
return distance(reader.begin(),
reader.end());
}
int count_lines(const char** begin,
const char** end)
{
vector&lt;int&gt; count(end - begin);
transform(begin, end,
count.begin(),
count_file);
return accumulate(
count.begin(), count.end(),
0);
}
```
这个就是一个非常函数式风格的结果了。上面这个处理方式恰恰就是 map-reduce。`transform` 对应 map`accumulate` 对应 reduce。而检查有多少行文本也成了代表文件头尾两个迭代器之间的“距离”distance
## 函数式编程的特点
在我们的代码里不那么明显的一点是函数式编程期望函数的行为像数学上的函数而非一个计算机上的子程序。这样的函数一般被称为纯函数pure function要点在于
- 会影响函数结果的只是函数的参数,没有对环境的依赖
- 返回的结果就是函数执行的唯一后果,不产生对环境的其他影响
这样的代码的最大好处是易于理解和易于推理,在很多情况下也会使代码更简单。在我们上面的代码里,`count_file``accumulate` 基本上可以看做是纯函数(虽然前者实际上有着对文件系统的依赖),但 `transform` 不行,因为它改变了某个参数,而不是返回一个结果。下一讲我们会看到,这会影响代码的组合性。
我们的代码中也体现了其他一些函数式编程的特点:
- 函数就像普通的对象一样被传递、使用和返回。
- 代码为说明式而非命令式。在熟悉函数式编程的基本范式后,你会发现说明式代码的可读性通常比命令式要高,代码还短。
- 一般不鼓励(甚至完全不使用)可变量。上面代码里只有 `count` 的内容在执行过程中被修改了,而且这种修改实际是 `transform` 接口带来的。如果接口像[[第 13 讲]](https://time.geekbang.org/column/article/181608) 展示的 `fmap` 函数一样返回一个容器的话就可以连这个问题都消除了。C++ 毕竟不是一门函数式编程语言,对灵活性的追求压倒了其他考虑。)
### 高阶函数
既然函数(对象)可以被传递、使用和返回,自然就有函数会接受函数作为参数或者把函数作为返回值,这样的函数就被称为高阶函数。我们现在已经见过不少高阶函数了,如:
- `sort`
- `transform`
- `accumulate`
- `fmap`
- `adder`
事实上C++ 里以 algorithm算法[3] 名义提供的很多函数都是高阶函数。
许多高阶函数在函数式编程中已成为基本的惯用法在不同语言中都会出现虽然可能是以不同的名字。我们在此介绍非常常见的三个map映射、reduce归并和 filter过滤
Map 在 C++ 中的直接映射是 `transform`(在 &lt;algorithm&gt; 头文件中提供)。它所做的事情也是数学上的映射,把一个范围里的对象转换成相同数量的另外一些对象。这个函数的基本实现非常简单,但这是一种强大的抽象,在很多场合都用得上。
Reduce 在 C++ 中的直接映射是 `accumulate`(在 &lt;numeric&gt; 头文件中提供)。它的功能是在指定的范围里,使用给定的初值和函数对象,从左到右对数值进行归并。在不提供函数对象作为第四个参数时,功能上相当于默认提供了加法函数对象,这时相当于做累加;提供了其他函数对象时,那当然就是使用该函数对象进行归并了。
Filter 的功能是进行过滤,筛选出符合条件的成员。它在当前 C++C++20 之前)里的映射可以认为有两个:`copy_if``partition`。这是因为在 C++20 带来 ranges 之前,在 C++ 里实现惰性求值不太方便。上面说的两个函数里,`copy_if` 是把满足条件的元素拷贝到另外一个迭代器里;`partition` 则是根据过滤条件来对范围里的元素进行分组,把满足条件的放在返回值迭代器的前面。另外,`remove_if` 也有点相近,通常用于删除满足条件的元素。它确保把不满足条件的元素放在返回值迭代器的前面(但不保证满足条件的元素在函数返回后一定存在),然后你一般需要使用容器的 `erase` 成员函数来将待删除的元素真正删除。
### 命令式编程和说明式编程
传统上 C++ 属于命令式编程。命令式编程里代码会描述程序的具体执行步骤。好处是代码显得比较直截了当缺点就是容易让人只见树木、不见森林只能看到代码啰嗦地怎么做how而不是做什么what更不用说为什么why了。
说明式编程则相反。以数据库查询语言 SQL 为例SQL 描述的是类似于下面的操作你想从什么地方from选择select满足什么条件where的什么数据并可选指定排序order by或分组group by条件。你不需要告诉数据库引擎具体该如何去执行这个操作。事实上在选择查询策略上大部分数据库用户都不及数据库引擎“聪明”正如大部分开发者在写出优化汇编代码上也不及编译器聪明一样。
这并不是说说明式编程一定就优于命令式编程。事实上,对于很多算法,命令式才是最自然的实现。以快速排序为例,很多地方在讲到函数式编程时会给出下面这个 Haskell一种纯函数式的编程语言的例子来说明函数式编程的简洁性
```
quicksort [] = []
quicksort (p:xs) = (quicksort left)
++ [p] ++ (quicksort right)
where
left = filter (&lt; p) xs
right = filter (&gt;= p) xs
```
这段代码简洁性确实没话说,但问题是,上面的代码的性能其实非常糟糕。真正接近 C++ 性能的快速排序,在 Haskell 里写出来一点不优雅,反而更丑陋 [4]。
所以,我个人认为,说明式编程跟命令式编程可以结合起来产生既优雅又高效的代码。对于从命令式编程成长起来的大部分程序员,我的建议是:
- 写表意的代码,不要过于专注性能而让代码难以维护——记住高德纳的名言:“过早优化是万恶之源。”
- 使用有意义的变量,但尽量不要去修改变量内容——变量的修改非常容易导致程序员的思维错误。
- 类似地,尽量使用没有副作用的函数,并让你写的代码也尽量没有副作用,用返回值来代表状态的变化——没有副作用的代码更容易推理,更不容易出错。
- 代码的隐式依赖越少越好,尤其是不要使用全局变量——隐式依赖会让代码里的错误难以排查,也会让代码更难以测试。
- 使用知名的高级编程结构,如基于范围的 for 循环、映射、归并、过滤——这可以让你的代码更简洁,更易于推理,并减少类似下标越界这种低级错误的可能性。
这些跟函数式编程有什么关系呢?——这些差不多都是来自函数式编程的最佳实践。学习函数式编程,也是为了更好地体会如何从这些地方入手,写出易读而又高性能的代码。
### 不可变性和并发
在多核的时代里函数式编程比以前更受青睐一个重要的原因是函数式编程对并行并发天然友好。影响多核性能的一个重要因素是数据的竞争条件——由于共享内存数据需要加锁带来的延迟。函数式编程强调不可变性immutability、无副作用天然就适合并发。更妙的是如果你使用高层抽象的话有时可以轻轻松松“免费”得到性能提升。
拿我们这一讲开头的例子来说,对代码做下面的改造,启用 C++17 的并行执行策略 [5],就能自动获得在多核环境下的性能提升:
```
int count_lines(const char** begin,
const char** end)
{
vector&lt;int&gt; count(end - begin);
transform(execution::par,
begin, end,
count.begin(),
count_file);
return reduce(
execution::par,
count.begin(), count.end());
}
```
我们可以看到,两个高阶函数的调用中都加入了 `execution::par`,来启动自动并行计算。要注意的是,我把 `accumulate` 换成了 `reduce` [6],原因是前者已经定义成从左到右的归并,无法并行。`reduce` 则不同,初始值可以省略,操作上没有规定顺序,并反过来要求对元素的归并操作满足交换律和结合率(加法当然是满足的),即:
$$<br>
\begin{aligned}<br>
A\ \otimes\ B &amp;= B\ \otimes\ A\\\<br>
(A\ \otimes\ B)\ \otimes\ C &amp;= A\ \otimes\ (B\ \otimes\ C)<br>
\end{aligned}<br>
$$
当然,在这个例子里,一般我们不会有海量文件,即使有海量文件,并行读取性能一般也不会快于顺序读取,所以意义并不是很大。下面这个简单的例子展示了并行 `reduce` 的威力:
```
#include &lt;chrono&gt;
#include &lt;execution&gt;
#include &lt;iostream&gt;
#include &lt;numeric&gt;
#include &lt;vector&gt;
using namespace std;
int main()
{
vector&lt;double&gt; v(10000000, 0.0625);
{
auto t1 = chrono::
high_resolution_clock::now();
double result = accumulate(
v.begin(), v.end(), 0.0);
auto t2 = chrono::
high_resolution_clock::now();
chrono::duration&lt;double, milli&gt;
ms = t2 - t1;
cout &lt;&lt; "accumulate: result "
&lt;&lt; result &lt;&lt; " took "
&lt;&lt; ms.count() &lt;&lt; " ms\n";
}
{
auto t1 = chrono::
high_resolution_clock::now();
double result =
reduce(execution::par,
v.begin(), v.end());
auto t2 = chrono::
high_resolution_clock::now();
chrono::duration&lt;double, milli&gt;
ms = t2 - t1;
cout &lt;&lt; "reduce: result "
&lt;&lt; result &lt;&lt; " took "
&lt;&lt; ms.count() &lt;&lt; " ms\n";
}
}
```
在我的电脑Core i7 四核八线程)上的某次执行结果是:
>
<p>`accumulate: result 625000 took 26.122 ms`<br>
`reduce: result 625000 took 4.485 ms`</p>
执行策略还比较新还没有被所有编译器支持。我目前测试下来MSVC 没有问题Clang 不行GCC 需要外部库 TBBThreading Building Blocks[7] 的帮助。我上面是用 GCC 编译的,命令行是:
>
`g++-9 -std=c++17 -O3 test.cpp -ltbb`
## Y 组合子
限于篇幅,这一讲我们只是很初浅地探讨了函数式编程。对于 C++ 的函数式编程的深入探讨是有整本书的(见参考资料 [8]而今天讲的内容在书的最前面几章就覆盖完了。在后面我们还会探讨部分的函数式编程话题今天我们只再讨论一个有点有趣、也有点烧脑的话题Y 组合子 [9]。第一次阅读的时候,如果觉得困难,可以跳过这一部分。
不过,我并不打算讨论 Haskell Curry 使用的 Y 组合子定义——这个比较复杂,需要写一篇完整的文章来讨论([10]),而且在 C++ 中的实用性非常弱。我们只看它解决的问题:如何在 lambda 表达式中表现递归。
回想一下我们用过的阶乘的递归定义:
```
int factorial(int n)
{
if (n == 0) {
return 1;
} else {
return n * factorial(n - 1);
}
}
```
注意里面用到了递归,所以你要把它写成 lambda 表达式是有点困难的:
```
auto factorial = [](int n) {
if (n == 0) {
return 1;
} else {
return n * ???(n - 1);
}
}
```
下面我们讨论使用 Y 组合子的解决方案。
我们首先需要一个特殊的高阶函数,定义为:
$$<br>
y(f) = f(y(f))<br>
$$
显然,这个定义有点奇怪。事实上,它是会导致无限展开的——而它的威力也在于无限展开。我们也因此必须使用惰性求值的方式才能使用这个定义。
然后,我们定义阶乘为:
$$<br>
\mathrm{fact}(n) = \mathrm{If\ IsZero}(n)\ \mathrm{then}\ 1\ \mathrm{else}\ n \times \mathrm{fact}(n 1)<br>
$$
假设 $\mathrm{fact}$ 可以表示成 $y(F)$,那我们可以做下面的变形:
$$<br>
\begin{aligned}<br>
y(F)(n) &amp;= \mathrm{If\ IsZero}(n)\ \mathrm{then}\ 1\ \mathrm{else}\ n \times y(F)(n 1)\\\<br>
F(y(F))(n) &amp;= \mathrm{If\ IsZero}(n)\ \mathrm{then}\ 1\ \mathrm{else}\ n \times y(F)(n 1)<br>
\end{aligned}<br>
$$
再把 $y(F)$ 替换成 $f$,我们从上面的第二个式子得到:
$$<br>
F(f)(n) = \mathrm{If\ IsZero}(n)\ \mathrm{then}\ 1\ \mathrm{else}\ n \times f(n 1)<br>
$$
我们得到了 $F$ 的定义,也就自然得到了 $\mathrm{fact}$ 的定义。而且,这个定义是可以用 C++ 表达出来的。下面是完整的代码实现:
```
#include &lt;functional&gt;
#include &lt;iostream&gt;
#include &lt;type_traits&gt;
#include &lt;utility&gt;
using namespace std;
// Y combinator as presented by Yegor Derevenets in P0200R0
// &lt;url:http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2016/p0200r0.html&gt;
template &lt;class Fun&gt;
class y_combinator_result {
Fun fun_;
public:
template &lt;class T&gt;
explicit y_combinator_result(
T&amp;&amp; fun)
: fun_(std::forward&lt;T&gt;(fun))
{
}
template &lt;class... Args&gt;
decltype(auto)
operator()(Args&amp;&amp;... args)
{
// y(f) = f(y(f))
return fun_(
std::ref(*this),
std::forward&lt;Args&gt;(args)...);
}
};
template &lt;class Fun&gt;
decltype(auto)
y_combinator(Fun&amp;&amp; fun)
{
return y_combinator_result&lt;
std::decay_t&lt;Fun&gt;&gt;(
std::forward&lt;Fun&gt;(fun));
}
int main()
{
// 上面的那个 F
auto almost_fact =
[](auto f, int n) -&gt; int {
if (n == 0)
return 1;
else
return n * f(n - 1);
};
// fact = y(F)
auto fact =
y_combinator(almost_fact);
cout &lt;&lt; fact(10) &lt;&lt; endl;
}
```
这一节不影响后面的内容,看不懂的可以暂时略过。😝
## 内容小结
本讲我们对函数式编程进行了一个入门式的介绍希望你对函数式编程的特点、优缺点有了一个初步的了解。然后我快速讨论了一个会烧脑的话题Y 组合子,让你对函数式编程的威力和难度也有所了解。
## 课后思考
想一想,你如何可以实现一个惰性的过滤器?一个惰性的过滤器应当让下面的代码通过编译,并且不会占用跟数据集大小相关的额外空间:
```
#include &lt;iostream&gt;
#include &lt;numeric&gt;
#include &lt;vector&gt;
using namespace std;
// filter_view 的定义
int main()
{
vector v{1, 2, 3, 4, 5};
auto&amp;&amp; fv = filter_view(
v.begin(), v.end(), [](int x) {
return x % 2 == 0;
});
cout &lt;&lt; accumulate(fv.begin(),
fv.end(), 0)
&lt;&lt; endl;
}
```
结果输出应该是 `6`
**提示:**参考 `istream_line_reader` 的实现。
告诉我你是否成功了,或者你遇到了什么样的特别困难。
## 参考资料
[1] cppreference.com, “std::transform”. [https://en.cppreference.com/w/cpp/algorithm/transform](https://en.cppreference.com/w/cpp/algorithm/transform)
[1a] cppreference.com, “std::transform”. [https://zh.cppreference.com/w/cpp/algorithm/transform](https://zh.cppreference.com/w/cpp/algorithm/transform)
[2] cppreference.com, “std::accumulate”. [https://en.cppreference.com/w/cpp/algorithm/accumulate](https://en.cppreference.com/w/cpp/algorithm/accumulate)
[2a] cppreference.com, “std::accumulate”. [https://zh.cppreference.com/w/cpp/algorithm/accumulate](https://zh.cppreference.com/w/cpp/algorithm/accumulate)
[3] cppreference.com, “Standard library header &lt;algorithm&gt;”. [https://en.cppreference.com/w/cpp/header/algorithm](https://en.cppreference.com/w/cpp/header/algorithm)
[3a] cppreference.com, “标准库头文件 &lt;algorithm&gt;”. [https://zh.cppreference.com/w/cpp/header/algorithm](https://zh.cppreference.com/w/cpp/header/algorithm)
[4] 袁英杰, “Immutability: The Dark Side”. [https://www.jianshu.com/p/13cd4c650125](https://www.jianshu.com/p/13cd4c650125)
[5] cppreference.com, “Standard library header &lt;execution&gt;”. [https://en.cppreference.com/w/cpp/header/execution](https://en.cppreference.com/w/cpp/header/execution)
[5a] cppreference.com, “标准库头文件 &lt;execution&gt;”. [https://zh.cppreference.com/w/cpp/header/execution](https://zh.cppreference.com/w/cpp/header/execution)
[6] cppreference.com, “std::reduce”. [https://en.cppreference.com/w/cpp/algorithm/reduce](https://en.cppreference.com/w/cpp/algorithm/reduce)
[6a] cppreference.com, “std::reduce”. [https://zh.cppreference.com/w/cpp/algorithm/reduce](https://zh.cppreference.com/w/cpp/algorithm/reduce)
[7] Intel, tbb. [https://github.com/intel/tbb](https://github.com/intel/tbb)
[8] Ivan Čukić, **Functional Programming in C++**. Manning, 2019, [https://www.manning.com/books/functional-programming-in-c-plus-plus](https://www.manning.com/books/functional-programming-in-c-plus-plus)
[9] Wikipedia, “Fixed-point combinator”. [https://en.wikipedia.org/wiki/Fixed-point_combinator](https://en.wikipedia.org/wiki/Fixed-point_combinator)
[10] 吴咏炜, “**Y** Combinator and C++”. [https://yongweiwu.wordpress.com/2014/12/14/y-combinator-and-cplusplus/](https://yongweiwu.wordpress.com/2014/12/14/y-combinator-and-cplusplus/)

View File

@@ -0,0 +1,442 @@
<audio id="audio" title="18 | 应用可变模板和tuple的编译期技巧" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/76/e0/76181e927a84617d78a7ca04e16195e0.mp3"></audio>
你好,我是吴咏炜。
今天我们讲一个特殊的专题,如何使用可变模板和 tuple 来完成一些常见的功能,尤其是编译期计算。
## 可变模板
可变模板 [1] 是 C++11 引入的一项新功能,使我们可以在模板参数里表达不定个数和类型的参数。从实际的角度,它有两个明显的用途:
- 用于在通用工具模板中转发参数到另外一个函数
- 用于在递归的模板中表达通用的情况(另外会有至少一个模板特化来表达边界情况)
我们下面就来分开讨论一下。
### 转发用法
以标准库里的 `make_unique` 为例,它的定义差不多是下面这个样子:
```
template &lt;typename T,
typename... Args&gt;
inline unique_ptr&lt;T&gt;
make_unique(Args&amp;&amp;... args)
{
return unique_ptr&lt;T&gt;(
new T(forward&lt;Args&gt;(args)...));
}
```
这样,它就可以把传递给自己的全部参数转发到模板参数类的构造函数上去。注意,在这种情况下,我们通常会使用 `std::forward`,确保参数转发时仍然保持正确的左值或右值引用类型。
稍微解释一下上面三处出现的 `...`
- `typename... Args` 声明了一系列的类型——`class...``typename...` 表示后面的标识符代表了一系列的类型。
- `Args&amp;&amp;... args` 声明了一系列的形参 `args`,其类型是 `Args&amp;&amp;`
- `forward&lt;Args&gt;(args)...` 会在编译时实际逐项展开 `Args``args` ,参数有多少项,展开后就是多少项。
举一个例子,如果我们需要在堆上传递一个 `vector&lt;int&gt;`,假设我们希望初始构造的大小为 100每个元素都是 `1`,那我们可以这样写:
```
make_unique&lt;vector&lt;int&gt;&gt;(100, 1)
```
模板实例化之后,会得到相当于下面的代码:
```
template &lt;&gt;
inline unique_ptr&lt;vector&lt;int&gt;&gt;
make_unique(int&amp;&amp; arg1, int&amp;&amp; arg2)
{
return unique_ptr&lt;vector&lt;int&gt;&gt;(
new vector&lt;int&gt;(
forward&lt;int&gt;(arg1),
forward&lt;int&gt;(arg2)));
}
```
如前所述,`forward&lt;Args&gt;(args)...` 为每一项可变模板参数都以同样的形式展开。项数也允许为零,那样,我们在调用构造函数时也同样没有任何参数。
### 递归用法
我们也可以用可变模板来实现编译期递归。下面就是个小例子:
```
template &lt;typename T&gt;
constexpr auto sum(T x)
{
return x;
}
template &lt;typename T1, typename T2,
typename... Targ&gt;
constexpr auto sum(T1 x, T2 y,
Targ... args)
{
return sum(x + y, args...);
}
```
在上面的定义里,如果 `sum` 得到的参数只有一个,会走到上面那个重载。如果有两个或更多参数,编译器就会选择下面那个重载,执行一次加法,随后你的参数数量就少了一个,因而递归总会终止到上面那个重载,结束计算。
要使用上面这个模板,我们就可以写出像下面这样的函数调用:
```
auto result = sum(1, 2, 3.5, x);
```
模板会这样依次展开:
```
sum(1 + 2, 3.5, x)
sum(3 + 3.5, x)
sum(6.5 + x)
6.5 + x
```
注意我们都不必使用相同的数据类型:只要这些数据之间可以应用 `+`,它们的类型无关紧要……
再看另一个复杂些的例子,函数的组合 [2]。如果我们有函数 $f$ 和 函数 $g$,要得到函数的联用 $g \circ f$,其满足:
$$<br>
(g \circ f)(x) = g(f(x))<br>
$$
我们能不能用一种非常简单的方式,写不包含变量 $x$ 的表达式来表示函数组合呢?答案是肯定的。
跟上面类似,我们需要写出递归的终结情况,单个函数的“组合”:
```
template &lt;typename F&gt;
auto compose(F f)
{
return [f](auto&amp;&amp;... x) {
return f(
forward&lt;decltype(x)&gt;(x)...);
};
}
```
上面我们仅返回一个泛型 lambda 表达式,保证参数可以转发到 `f`。记得我们在[[第 16 讲]](https://time.geekbang.org/column/article/184018) 讲过泛型 lambda 表达式,本质上就是一个模板,所以我们按转发用法的可变模板来理解上面的 `...` 部分就对了。
下面是正常有组合的情况:
```
template &lt;typename F,
typename... Args&gt;
auto compose(F f, Args... other)
{
return [f,
other...](auto&amp;&amp;... x) {
return f(compose(other...)(
forward&lt;decltype(x)&gt;(x)...));
};
}
```
在这个模板里,我们返回一个 lambda 表达式,然后用 `f` 捕捉第一个函数对象,用 `args...` 捕捉后面的函数对象。我们用 `args...` 继续组合后面的部分,然后把结果传到 `f` 里面。
上面的模板定义我实际上已经有所简化,没有保持值类别。完整的包含完美转发的版本,请看参考资料 [3] 中的 functional.h 实现。
下面我们来试验一下使用这个 `compose` 函数。我们先写一个对输入范围中每一项都进行平方的函数对象:
```
auto square_list =
[](auto&amp;&amp; container) {
return fmap(
[](int x) { return x * x; },
container);
};
```
我们使用了[[第 13 讲]](https://time.geekbang.org/column/article/181608) 中给出的 `fmap`,而不是标准库里的 `transform`,是因为后者接口非函数式,无法组合——它要求参数给出输出位置的迭代器,会修改迭代器指向的内容,返回结果也只是单个的迭代器;函数式的接口则期望不修改参数的内容,结果完全在返回值中。
我们这儿用了泛型 lambda 表达式,是因为组合的时候不能使用模板,只能是函数对象或函数(指针)——如果我们定义一个 `square_list` 模板的话,组合时还得显式实例化才行(写成 `square_list&lt;const vector&lt;int&gt;&amp;&gt;` 的样子),很不方便。
我们再写一个求和的函数对象:
```
auto sum_list =
[](auto&amp;&amp; container) {
return accumulate(
container.begin(),
container.end(), 0);
};
```
那先平方再求和,就可以这样简单定义了:
```
auto squared_sum =
compose(sum_list, square_list);
```
我们可以验证这个定义是可以工作的:
```
vector v{1, 2, 3, 4, 5};
cout &lt;&lt; squared_sum(v) &lt;&lt; endl;
```
我们会得到:
>
`55`
## tuple
上面的写法虽然看起来还不错,但实际上有个缺陷:被 compose 的函数除了第一个(最右边的),其他的函数只能接收一个参数。要想进一步推进类似的技巧,我们得首先解决这个问题。
在 C++ 里,要通用地用一个变量来表达多个值,那就得看多元组——`tuple` 模板了 [4]。`tuple` 算是 C++98 里的 `pair` 类型的一般化,可以表达任意多个固定数量、固定类型的值的组合。下面这段代码约略地展示了其基本用法:
```
#include &lt;algorithm&gt;
#include &lt;iostream&gt;
#include &lt;string&gt;
#include &lt;tuple&gt;
#include &lt;vector&gt;
using namespace std;
// 整数、字符串、字符串的三元组
using num_tuple =
tuple&lt;int, string, string&gt;;
ostream&amp;
operator&lt;&lt;(ostream&amp; os,
const num_tuple&amp; value)
{
os &lt;&lt; get&lt;0&gt;(value) &lt;&lt; ','
&lt;&lt; get&lt;1&gt;(value) &lt;&lt; ','
&lt;&lt; get&lt;2&gt;(value);
return os;
}
int main()
{
// 阿拉伯数字、英文、法文
vector&lt;num_tuple&gt; vn{
{1, "one", "un"},
{2, "two", "deux"},
{3, "three", "trois"},
{4, "four", "quatre"}};
// 修改第 0 项的法文
get&lt;2&gt;(vn[0]) = "une";
// 按法文进行排序
sort(vn.begin(), vn.end(),
[](auto&amp;&amp; x, auto&amp;&amp; y) {
return get&lt;2&gt;(x) &lt;
get&lt;2&gt;(y);
});
// 输出内容
for (auto&amp;&amp; value : vn) {
cout &lt;&lt; value &lt;&lt; endl;
}
// 输出多元组项数
constexpr auto size = \
tuple_size_v&lt;num_tuple&gt;;
cout &lt;&lt; "Tuple size is " &lt;&lt; size &lt;&lt; endl;
}
```
输出是:
>
<p>`2,two,deux`<br>
`4,four,quatre`<br>
`3,three,trois`<br>
`1,one,une`<br>
`Tuple size is 3`</p>
我们可以看到:
- `tuple` 的成员数量由尖括号里写的类型数量决定。
- 可以使用 `get` 函数对 `tuple` 的内容进行读和写。(当一个类型在 `tuple` 中出现正好一次时,我们也可以传类型取内容,即,对我们上面的三元组,`get&lt;int&gt;` 是合法的,`get&lt;string&gt;` 则不是。)
- 可以用 `tuple_size_v` (在编译期)取得多元组里面的项数。
如果我们要用一个三项的 `tuple` 去调用一个函数,我们可以写类似这样的代码:
```
template &lt;class F, class Tuple&gt;
constexpr decltype(auto) apply(
F&amp;&amp; f, Tuple&amp;&amp; t)
{
return f(
get&lt;0&gt;(forward&lt;Tuple&gt;(t)),
get&lt;1&gt;(forward&lt;Tuple&gt;(t)),
get&lt;2&gt;(forward&lt;Tuple&gt;(t)));
}
```
这似乎已经挺接近我们需要的形式了,但实际调用函数的参数项数会变啊……
我们已经有了参数的项数(使用 `tuple_size_v`),所以我们下面要做的是生成从 0 到项数减一之间的整数序列。标准库里已经定义了相关的工具,我们需要的就是其中的 `make_index_sequence` [5],其简化实现如下所示:
```
template &lt;class T, T... Ints&gt;
struct integer_sequence {};
template &lt;size_t... Ints&gt;
using index_sequence =
integer_sequence&lt;size_t, Ints...&gt;;
template &lt;size_t N, size_t... Ints&gt;
struct index_sequence_helper {
typedef
typename index_sequence_helper&lt;
N - 1, N - 1, Ints...&gt;::type
type;
};
template &lt;size_t... Ints&gt;
struct index_sequence_helper&lt;
0, Ints...&gt; {
typedef index_sequence&lt;Ints...&gt;
type;
};
template &lt;size_t N&gt;
using make_index_sequence =
typename index_sequence_helper&lt;
N&gt;::type;
```
正如一般的模板代码,它看起来还是有点绕的。其要点是,如果我们给出 `make_index_sequence&lt;N&gt;`,则结果是 `integer_sequence&lt;size_t, 0, 1, 2, …, N - 1&gt;`(一下子想不清楚的话,可以拿纸笔来模拟一下模板的展开过程)。而有了这样一个模板的帮助之后,我们就可以写出下面这样的函数(同样,这是标准库里的 `apply` 函数模板 [6] 的简化版本):
```
template &lt;class F, class Tuple,
size_t... I&gt;
constexpr decltype(auto)
apply_impl(F&amp;&amp; f, Tuple&amp;&amp; t,
index_sequence&lt;I...&gt;)
{
return f(
get&lt;I&gt;(forward&lt;Tuple&gt;(t))...);
}
template &lt;class F, class Tuple&gt;
constexpr decltype(auto)
apply(F&amp;&amp; f, Tuple&amp;&amp; t)
{
return apply_impl(
forward&lt;F&gt;(f),
forward&lt;Tuple&gt;(t),
make_index_sequence&lt;
tuple_size_v&lt;
remove_reference_t&lt;
Tuple&gt;&gt;&gt;{});
}
```
我们如果有一个三元组 `t`,类型为 `tuple&lt;int, string, string&gt;`,去 `apply` 到一个函数 `f`,展开后我们得到 `apply_impl(f, t, index_sequence&lt;0, 1, 2&gt;{})`,再展开后我们就得到了上面那个有 `get&lt;0&gt;``get&lt;1&gt;``get&lt;2&gt;` 的函数调用形式。换句话说,我们利用一个计数序列的类型,可以在编译时展开 `tuple` 里的各个成员,并用来调用函数。
## 数值预算
上面的代码有点复杂,而且似乎并没有完成什么很重要的功能。我们下面看一个源自实际项目的例子。需求是,我们希望快速地计算一串二进制数中 1 比特的数量。举个例子,如果我们有十进制的 31 和 254转换成二进制是 00011111 和 11111110那我们应该得到 5 + 7 = 12。
显然,每个数字临时去数肯定会慢,我们应该预先把每个字节的 256 种情况记录下来。因而,如何得到这些计数值是个问题。在没有编译期编程时,我们似乎只能用另外一个程序先行计算,然后把结果填进去——这就很不方便很不灵活了。有了编译期编程,我们就不用写死,而让编译器在编译时帮我们计算数值。
利用 constexpr 函数,我们计算单个数值完全没有问题。快速定义如下:
```
constexpr int
count_bits(unsigned char value)
{
if (value == 0) {
return 0;
} else {
return (value &amp; 1) +
count_bits(value &gt;&gt; 1);
}
}
```
可 256 个,总不见得把计算语句写上 256 遍吧?这就需要用到我们上面讲到的 `index_sequence` 了。我们定义一个模板,它的参数是一个序列,在初始化时这个模板会对参数里的每一项计算比特数,并放到数组成员里。
```
template &lt;size_t... V&gt;
struct bit_count_t {
unsigned char
count[sizeof...(V)] = {
static_cast&lt;unsigned char&gt;(
count_bits(V))...};
};
```
注意上面用 `sizeof...(V)` 可以获得参数的个数(在 `tuple_size_v` 的实现里实际也用到它了)。如果我们模板参数传 `0, 1, 2, 3`,结果里面就会有个含 4 项元素的数组,数值分别是对 0、1、2、3 的比特计数。
然后,我们当然就可以利用 `make_index_sequence` 来展开计算了,想产生几项就可以产生几项。不过,要注意到 `make_index_sequence` 的结果是个类型,不能直接用在 `bit_count_t` 的构造中。我们需要用模板匹配来中转一下:
```
template &lt;size_t... V&gt;
constexpr bit_count_t&lt;V...&gt;
get_bit_count(index_sequence&lt;V...&gt;)
{
return bit_count_t&lt;V...&gt;();
}
auto bit_count = get_bit_count(
make_index_sequence&lt;256&gt;());
```
得到 `bit_count` 后,我们要计算一个序列里的比特数就只是轻松查表相加了,此处不再赘述。
## 内容小结
今天我们讨论了在编译期处理不确定数量的参数和类型的基本语言特性,可变模板,以及可以操控可变模板的重要工具——`tuple``index_sequence`。用好这些工具,可以让我们轻松地完成一些编译期计算的工作。
## 课后思考
请考虑一下:
1. 我展示了 `compose` 带一个或更多参数的情况。你觉得 `compose` 不带任何参数该如何定义?它有意义吗?
1. 有没有可能不用 `index_sequence` 来初始化 `bit_count`?如果行,应该如何实现?
1. 作为一个挑战,你能自行实现出 `make_integer_sequence` 吗?
期待你的答案。
## 参考资料
[1] cppreference.com, “Parameter pack”. [https://en.cppreference.com/w/cpp/language/parameter_pack](https://en.cppreference.com/w/cpp/language/parameter_pack)
[1a] cppreference.com, “形参包”. [https://zh.cppreference.com/w/cpp/language/parameter_pack](https://zh.cppreference.com/w/cpp/language/parameter_pack)
[2] Wikipedia, “Function composition”. [https://en.wikipedia.org/wiki/Function_composition](https://en.wikipedia.org/wiki/Function_composition)
[2a] 维基百科, “复合函数”. [https://zh.wikipedia.org/zh-cn/复合函数](https://zh.wikipedia.org/zh-cn/%E5%A4%8D%E5%90%88%E5%87%BD%E6%95%B0)
[3] 吴咏炜, nvwa. [https://github.com/adah1972/nvwa](https://github.com/adah1972/nvwa)
[4] cppreference.com, “std::tuple”. [https://en.cppreference.com/w/cpp/utility/tuple](https://en.cppreference.com/w/cpp/utility/tuple)
[4a] cppreference.com, “std::tuple”. [https://zh.cppreference.com/w/cpp/utility/tuple](https://zh.cppreference.com/w/cpp/utility/tuple)
[5] cppreference.com, “std::integer_sequence”. [https://en.cppreference.com/w/cpp/utility/integer_sequence](https://en.cppreference.com/w/cpp/utility/integer_sequence)
[5a] cppreference.com, “std::integer_sequence”. [https://zh.cppreference.com/w/cpp/utility/integer_sequence](https://zh.cppreference.com/w/cpp/utility/integer_sequence)
[6] cppreference.com, “std::apply”. [https://en.cppreference.com/w/cpp/utility/apply](https://en.cppreference.com/w/cpp/utility/apply)
[6a] cppreference.com, “std::apply”. [https://zh.cppreference.com/w/cpp/utility/apply](https://zh.cppreference.com/w/cpp/utility/apply)

View File

@@ -0,0 +1,402 @@
<audio id="audio" title="19 | thread和future领略异步中的未来" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5e/78/5e0b0425d745eca5f4e4b39633a2cb78.mp3"></audio>
你好,我是吴咏炜。
编译期的烧脑我们先告个段落今天我们开始讲一个全新的话题——并发concurrency
## 为什么要使用并发编程?
在本世纪初之前,大部分开发人员不常需要关心并发编程;用到的时候,也多半只是在单处理器上执行一些后台任务而已。只有少数为昂贵的工作站或服务器进行开发的程序员,才会需要为并发性能而烦恼。原因无他,程序员们享受着摩尔定律带来的免费性能提升,而高速的 Intel 单 CPU 是性价比最高的系统架构,可到了 2003 年左右,大家骤然发现,“免费午餐”已经结束了 [1]。主频的提升停滞了:在 2001 年Intel 已经有了主频 2.0 GHz 的 CPU而 18 年后,我现在正在使用的电脑,主频也仍然只是 2.5 GHz虽然从单核变成了四核。服务器、台式机、笔记本、移动设备的处理器都转向了多核计算要求则从单线程变成了多线程甚至异构——不仅要使用 CPU还得使用 GPU。
如果你不熟悉进程和线程的话,我们就先来简单介绍一下它们的关系。我们编译完执行的 C++ 程序,那在操作系统看来就是一个进程了。而每个进程里可以有一个或多个线程:
- 每个进程有自己的独立地址空间,不与其他进程分享;一个进程里可以有多个线程,彼此共享同一个地址空间。
- 堆内存、文件、套接字等资源都归进程管理,同一个进程里的多个线程可以共享使用。每个进程占用的内存和其他资源,会在进程退出或被杀死时返回给操作系统。
- 并发应用开发可以用多进程或多线程的方式。多线程由于可以共享资源,效率较高;反之,多进程(默认)不共享地址空间和资源,开发较为麻烦,在需要共享数据时效率也较低。但多进程安全性较好,在某一个进程出问题时,其他进程一般不受影响;而在多线程的情况下,一个线程执行了非法操作会导致整个进程退出。
我们讲 C++ 里的并发,主要讲的就是多线程。它对开发人员的挑战是全方位的。从纯逻辑的角度,并发的思维模式就比单线程更为困难。在其之上,我们还得加上:
- 编译器和处理器的重排问题
- 原子操作和数据竞争
- 互斥锁和死锁问题
- 无锁算法
- 条件变量
- 信号量
- ……
即使对于专家并发编程都是困难的上面列举的也只是部分难点而已。对于并发的基本挑战Herb Sutter 在他的 Effective Concurrency 专栏给出了一个较为全面的概述 [2]。要对 C++ 的并发编程有全面的了解,则可以阅读曼宁出版的 **C++ Concurrency in Action**(有中文版,但翻译口碑不好)[3]。而我们今天主要要介绍的,则是并发编程的基本概念,包括传统的多线程开发,以及高层抽象 future姑且译为未来量的用法。
## 基于 thread 的多线程开发
我们先来看一个使用 `thread` 线程类 [4] 的简单例子:
```
#include &lt;chrono&gt;
#include &lt;iostream&gt;
#include &lt;mutex&gt;
#include &lt;thread&gt;
using namespace std;
mutex output_lock;
void func(const char* name)
{
this_thread::sleep_for(100ms);
lock_guard&lt;mutex&gt; guard{
output_lock};
cout &lt;&lt; "I am thread " &lt;&lt; name
&lt;&lt; '\n';
}
int main()
{
thread t1{func, "A"};
thread t2{func, "B"};
t1.join();
t2.join();
}
```
这是某次执行的结果:
>
<p>`I am thread B`<br>
`I am thread A`</p>
**一个平台细节:**在 Linux 上编译线程相关的代码都需要加上 `-pthread` 命令行参数。Windows 和 macOS 上则不需要。
代码是相当直截了当的,执行了下列操作:
1. 传递参数,起两个线程
1. 两个线程分别休眠 100 毫秒
1. 使用互斥量mutex锁定 `cout` ,然后输出一行信息
1. 主线程等待这两个线程退出后程序结束
以下几个地方可能需要稍加留意一下:
- `thread` 的构造函数的第一个参数是函数(对象),后面跟的是这个函数所需的参数。
- `thread` 要求在析构之前要么 `join`(阻塞直到线程退出),要么 `detach`(放弃对线程的管理),否则程序会异常退出。
- `sleep_for``this_thread` 名空间下的一个自由函数,表示当前线程休眠指定的时间。
- 如果没有 `output_lock` 的同步,输出通常会交错到一起。
建议你自己运行一下,并尝试删除 `lock_guard``join` 的后果。
`thread` 不能在析构时自动 `join` 有点不那么自然,这可以算是一个缺陷吧。在 C++20 的 `jthread` [5] 到来之前,我们只能自己小小封装一下了。比如:
```
class scoped_thread {
public:
template &lt;typename... Arg&gt;
scoped_thread(Arg&amp;&amp;... arg)
: thread_(
std::forward&lt;Arg&gt;(arg)...)
{}
scoped_thread(
scoped_thread&amp;&amp; other)
: thread_(
std::move(other.thread_))
{}
scoped_thread(
const scoped_thread&amp;) = delete;
~scoped_thread()
{
if (thread_.joinable()) {
thread_.join();
}
}
private:
thread thread_;
};
```
这个实现里有下面几点需要注意:
1. 我们使用了可变模板和完美转发来构造 `thread` 对象。
1. `thread` 不能拷贝,但可以移动;我们也类似地实现了移动构造函数。
1. 只有 joinable已经 `join` 的、已经 `detach` 的或者空的线程对象都不满足 joinable`thread` 才可以对其调用 `join` 成员函数,否则会引发异常。
使用这个 `scoped_thread` 类的话,我们就可以把我们的 `main` 函数改写成:
```
int main()
{
scoped_thread t1{func, "A"};
scoped_thread t2{func, "B"};
}
```
这虽然是个微不足道的小例子,但我们已经可以发现:
- 执行顺序不可预期,或者说不具有决定性。
- 如果没有互斥量的帮助,我们连完整地输出一整行信息都成问题。
我们下面就来讨论一下互斥量。
### mutex
互斥量的基本语义是,一个互斥量只能被一个线程锁定,用来保护某个代码块在同一时间只能被一个线程执行。在前面那个多线程的例子里,我们就需要限制同时只有一个线程在使用 `cout`,否则输出就会错乱。
目前的 C++ 标准中,事实上提供了不止一个互斥量类。我们先看最简单、也最常用的 `mutex` 类 [6]。`mutex` 只可默认构造,不可拷贝(或移动),不可赋值,主要提供的方法是:
- `lock`:锁定,锁已经被其他线程获得时则阻塞执行
- `try_lock`:尝试锁定,获得锁返回 `true`,在锁被其他线程获得时返回 `false`
- `unlock`:解除锁定(只允许在已获得锁时调用)
你可能会想到,如果一个线程已经锁定了某个互斥量,再次锁定会发生什么?对于 `mutex`,回答是危险的未定义行为。你不应该这么做。如果有特殊需要可能在同一线程对同一个互斥量多次加锁,就需要用到递归锁 `recursive_mutex` 了 [7]。除了允许同一线程可以无阻塞地多次加锁外(也必须有对应数量的解锁操作),`recursive_mutex` 的其他行为和 `mutex` 一致。
除了 `mutex``recursive_mutex`C++ 标准库还提供了:
- `timed_mutex`:允许锁定超时的互斥量
- `recursive_timed_mutex`:允许锁定超时的递归互斥量
- `shared_mutex`:允许共享和独占两种获得方式的互斥量
- `shared_timed_mutex`:允许共享和独占两种获得方式的、允许锁定超时的互斥量
这些我们就不做讲解了,需要的请自行查看参考资料 [8]。另外,&lt;mutex&gt; 头文件中也定义了锁的 RAII 包装类,如我们上面用过的 `lock_guard`。为了避免手动加锁、解锁的麻烦,以及在有异常或出错返回时发生漏解锁,我们一般应当使用 `lock_guard`,而不是手工调用互斥量的 `lock``unlock` 方法。C++ 里另外还有 `unique_lock`C++11`scoped_lock`C++17提供了更多的功能你在有更复杂的需求时应该检查一下它们是否合用。
### 执行任务,返回数据
如果我们要在某个线程执行一些后台任务,然后取回结果,我们该怎么做呢?
比较传统的做法是使用信号量或者条件变量。由于 C++17 还不支持信号量,我们要模拟传统的做法,只能用条件变量了。由于我的重点并不是传统的做法,条件变量 [9] 我就不展开讲了,而只是展示一下示例的代码。
```
#include &lt;chrono&gt;
#include &lt;condition_variable&gt;
#include &lt;functional&gt;
#include &lt;iostream&gt;
#include &lt;mutex&gt;
#include &lt;thread&gt;
#include &lt;utility&gt;
using namespace std;
class scoped_thread {
… // 定义同上,略
};
void work(condition_variable&amp; cv,
int&amp; result)
{
// 假装我们计算了很久
this_thread::sleep_for(2s);
result = 42;
cv.notify_one();
}
int main()
{
condition_variable cv;
mutex cv_mut;
int result;
scoped_thread th{work, ref(cv),
ref(result)};
// 干一些其他事
cout &lt;&lt; "I am waiting now\n";
unique_lock lock{cv_mut};
cv.wait(lock);
cout &lt;&lt; "Answer: " &lt;&lt; result
&lt;&lt; '\n';
}
```
可以看到,为了这个小小的“计算”,我们居然需要定义 5 个变量:线程、条件变量、互斥量、单一锁和结果变量。我们也需要用 `ref` 模板来告诉 `thread` 的构造函数,我们需要传递条件变量和结果变量的引用,因为 `thread` 默认复制或移动所有的参数作为线程函数的参数。这种复杂性并非逻辑上的复杂性,而只是实现导致的,不是我们希望的写代码的方式。
下面,我们就看看更高层的抽象,未来量 `future` [10],可以如何为我们简化代码。
## future
我们先把上面的代码直接翻译成使用 `async` [11](它会返回一个 `future`
```
#include &lt;chrono&gt;
#include &lt;future&gt;
#include &lt;iostream&gt;
#include &lt;thread&gt;
using namespace std;
int work()
{
// 假装我们计算了很久
this_thread::sleep_for(2s);
return 42;
}
int main()
{
auto fut = async(launch::async, work);
// 干一些其他事
cout &lt;&lt; "I am waiting now\n";
cout &lt;&lt; "Answer: " &lt;&lt; fut.get()
&lt;&lt; '\n';
}
```
完全同样的结果,代码大大简化,变量减到了只剩一个未来量,还不赖吧?
我们稍稍分析一下:
- `work` 函数现在不需要考虑条件变量之类的实现细节了,专心干好自己的计算活、老老实实返回结果就可以了。
- 调用 `async` 可以获得一个未来量,`launch::async` 是运行策略,告诉函数模板 `async` 应当在新线程里异步调用目标函数。在一些老版本的 GCC 里,不指定运行策略,默认不会起新线程。
- `async` 函数模板可以根据参数来推导出返回类型,在我们的例子里,返回类型是 `future&lt;int&gt;`
- 在未来量上调用 `get` 成员函数可以获得其结果。这个结果可以是返回值,也可以是异常,即,如果 `work` 抛出了异常,那 `main` 里在执行 `fut.get()` 时也会得到同样的异常,需要有相应的异常处理代码程序才能正常工作。
这里有两个要点,从代码里看不出来,我特别说明一下:
1. 一个 `future` 上只能调用一次 `get` 函数,第二次调用为未定义行为,通常导致程序崩溃。
1. 这样一来,自然一个 `future` 是不能直接在多个线程里用的。
上面的第 1 点是 `future` 的设计,需要在使用时注意一下。第 2 点则是可以解决的。要么直接拿 `future` 来移动构造一个 `shared_future` [12],要么调用 `future``share` 方法来生成一个 `shared_future`,结果就可以在多个线程里用了——当然,每个 `shared_future` 上仍然还是只能调用一次 `get` 函数。
### promise
我们上面用 `async` 函数生成了未来量,但这不是唯一的方式。另外有一种常用的方式是 `promise` [13],我称之为“承诺量”。我们同样看一眼上面的例子用 `promise` 该怎么写:
```
#include &lt;chrono&gt;
#include &lt;future&gt;
#include &lt;iostream&gt;
#include &lt;thread&gt;
#include &lt;utility&gt;
using namespace std;
class scoped_thread {
… // 定义同上,略
};
void work(promise&lt;int&gt; prom)
{
// 假装我们计算了很久
this_thread::sleep_for(2s);
prom.set_value(42);
}
int main()
{
promise&lt;int&gt; prom;
auto fut = prom.get_future();
scoped_thread th{work,
move(prom)};
// 干一些其他事
cout &lt;&lt; "I am waiting now\n";
cout &lt;&lt; "Answer: " &lt;&lt; fut.get()
&lt;&lt; '\n';
}
```
`promise``future` 在这里成对出现,可以看作是一个一次性管道:有人需要兑现承诺,往 `promise` 里放东西(`set_value`);有人就像收期货一样,到时间去 `future`(写到这里想到,期货英文不就是 future 么,是不是该翻译成期货量呢?😝)里拿(`get`)就行了。我们把 `prom` 移动给新线程,这样老线程就完全不需要管理它的生命周期了。
就这个例子而言,使用 `promise` 没有 `async` 方便,但可以看到,这是一种非常灵活的方式,你不需要在一个函数结束的时候才去设置 `future` 的值。仍然需要注意的是,一组 `promise``future` 只能使用一次,既不能重复设,也不能重复取。
`promise``future` 还有个有趣的用法是使用 `void` 类型模板参数。这种情况下,两个线程之间不是传递参数,而是进行同步:当一个线程在一个 `future&lt;void&gt;` 上等待时(使用 `get()``wait()`),另外一个线程可以通过调用 `promise&lt;void&gt;` 上的 `set_value()` 让其结束等待、继续往下执行。有兴趣的话,你可以自己试一下,我就不给例子了。
### packaged_task
我们最后要讲的一种 `future` 的用法是打包任务 `packaged_task` [14],我们同样给出完成相同功能的示例,让你方便对比一下:
```
#include &lt;chrono&gt;
#include &lt;future&gt;
#include &lt;iostream&gt;
#include &lt;thread&gt;
#include &lt;utility&gt;
using namespace std;
class scoped_thread {
… // 定义同上,略
};
int work()
{
// 假装我们计算了很久
this_thread::sleep_for(2s);
return 42;
}
int main()
{
packaged_task&lt;int()&gt; task{work};
auto fut = task.get_future();
scoped_thread th{move(task)};
// 干一些其他事
this_thread::sleep_for(1s);
cout &lt;&lt; "I am waiting now\n";
cout &lt;&lt; "Answer: " &lt;&lt; fut.get()
&lt;&lt; '\n';
}
```
打包任务里打包的是一个函数,模板参数就是一个函数类型。跟 `thread``future``promise` 一样,`packaged_task` 只能移动,不能复制。它是个函数对象,可以像正常函数一样被执行,也可以传递给 `thread` 在新线程中执行。它的特别地方,自然也是你可以从它得到一个未来量了。通过这个未来量,你可以得到这个打包任务的返回值,或者,至少知道这个打包任务已经执行结束了。
## 内容小结
今天我们看了一下并发编程的原因、难点,以及 C++ 里的进行多线程计算的基本类,包括线程、互斥量、未来量等。这些对象的使用已经可以初步展现并发编程的困难,但更麻烦的事情还在后头呢……
## 课后思考
请试验一下文中的代码,并思考一下,并发编程中哪些情况下会发生死锁?
如果有任何问题或想法,欢迎留言与我分享。
## 参考资料
[1] Herb Sutter, “The free lunch is over”. [http://www.gotw.ca/publications/concurrency-ddj.htm](http://www.gotw.ca/publications/concurrency-ddj.htm)
[2] Herb Sutter, “Effective concurrency”. [https://herbsutter.com/2010/09/24/effective-concurrency-know-when-to-use-an-active-object-instead-of-a-mutex/](https://herbsutter.com/2010/09/24/effective-concurrency-know-when-to-use-an-active-object-instead-of-a-mutex/)
[3] Anthony Williams, **C++ Concurrency in Action** (2nd ed.). Manning, 2019, [https://www.manning.com/books/c-plus-plus-concurrency-in-action-second-edition](https://www.manning.com/books/c-plus-plus-concurrency-in-action-second-edition)
[4] cppreference.com, “std::thread”. [https://en.cppreference.com/w/cpp/thread/thread](https://en.cppreference.com/w/cpp/thread/thread)
[4a] cppreference.com, “std::thread”. [https://zh.cppreference.com/w/cpp/thread/thread](https://zh.cppreference.com/w/cpp/thread/thread)
[5] cppreference.com, “std::jthread”. [https://en.cppreference.com/w/cpp/thread/jthread](https://en.cppreference.com/w/cpp/thread/jthread)
[6] cppreference.com, “std::mutex”. [https://en.cppreference.com/w/cpp/thread/mutex](https://en.cppreference.com/w/cpp/thread/mutex)
[6a] cppreference.com, “std::mutex”. [https://zh.cppreference.com/w/cpp/thread/mutex](https://zh.cppreference.com/w/cpp/thread/mutex)
[7] cppreference.com, “std::recursive_mutex”. [https://en.cppreference.com/w/cpp/thread/recursive_mutex](https://en.cppreference.com/w/cpp/thread/recursive_mutex)
[7a] cppreference.com, “std::recursive_mutex”. [https://zh.cppreference.com/w/cpp/thread/recursive_mutex](https://zh.cppreference.com/w/cpp/thread/recursive_mutex)
[8] cppreference.com, “Standard library header &lt;mutex&gt;”. [https://en.cppreference.com/w/cpp/header/mutex](https://en.cppreference.com/w/cpp/header/mutex)
[8a] cppreference.com, “标准库头文件 &lt;mutex&gt;”. [https://zh.cppreference.com/w/cpp/header/mutex](https://zh.cppreference.com/w/cpp/header/mutex)
[9] cppreference.com, “std::recursive_mutex”. [https://en.cppreference.com/w/cpp/thread/condition_variable](https://en.cppreference.com/w/cpp/thread/condition_variable)
[9a] cppreference.com, “std::recursive_mutex”. [https://zh.cppreference.com/w/cpp/thread/condition_variable](https://zh.cppreference.com/w/cpp/thread/condition_variable)
[10] cppreference.com, “std::future”. [https://en.cppreference.com/w/cpp/thread/future](https://en.cppreference.com/w/cpp/thread/future)
[10a] cppreference.com, “std::future”. [https://zh.cppreference.com/w/cpp/thread/future](https://zh.cppreference.com/w/cpp/thread/future)
[11] cppreference.com, “std::async”. [https://en.cppreference.com/w/cpp/thread/async](https://en.cppreference.com/w/cpp/thread/async)
[11a] cppreference.com, “std::async”. [https://zh.cppreference.com/w/cpp/thread/async](https://zh.cppreference.com/w/cpp/thread/async)
[12] cppreference.com, “std::shared_future”. [https://en.cppreference.com/w/cpp/thread/shared_future](https://en.cppreference.com/w/cpp/thread/shared_future)
[12a] cppreference.com, “std::shared_future”. [https://en.cppreference.com/w/cpp/thread/shared_future](https://en.cppreference.com/w/cpp/thread/shared_future)
[13] cppreference.com, “std::promise”. [https://en.cppreference.com/w/cpp/thread/promise](https://en.cppreference.com/w/cpp/thread/promise)
[13a] cppreference.com, “std::promise”. [https://zh.cppreference.com/w/cpp/thread/promise](https://zh.cppreference.com/w/cpp/thread/promise)
[14] cppreference.com, “std::packaged_task”. [https://en.cppreference.com/w/cpp/thread/packaged_task](https://en.cppreference.com/w/cpp/thread/packaged_task)
[14a] cppreference.com, “std::packaged_task”. [https://zh.cppreference.com/w/cpp/thread/packaged_task](https://zh.cppreference.com/w/cpp/thread/packaged_task)

View File

@@ -0,0 +1,341 @@
<audio id="audio" title="20 | 内存模型和atomic理解并发的复杂性" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/eb/1f/eb957e48a3d71d39cd22203c2f91ca1f.mp3"></audio>
你好,我是吴咏炜。
上一讲我们讨论了一些并发编程的基本概念今天我们来讨论一个略有点绕的问题C++ 里的内存模型和原子量。
## C++98 的执行顺序问题
C++98 的年代里,开发者们已经了解了线程的概念,但 C++ 的标准里则完全没有提到线程。从实践上估计大家觉得不提线程C++ 也一样能实现多线程的应用程序吧。不过,很多聪明人都忽略了,下面的事实可能会产生不符合直觉预期的结果:
- 为了优化的必要,编译器是可以调整代码的执行顺序的。唯一的要求是,程序的“可观测”外部行为是一致的。
- 处理器也会对代码的执行顺序进行调整(所谓的 CPU 乱序执行)。在单处理器的情况下,这种乱序无法被程序观察到;但在多处理器的情况下,在另外一个处理器上运行的另一个线程就可能会察觉到这种不同顺序的后果了。
对于上面的后一点,大部分开发者并没有意识到。原因有好几个方面:
- 多处理器的系统在那时还不常见
- 主流的 x86 体系架构仍保持着较严格的内存访问顺序
- 只有在数据竞争data race激烈的情况下才能看到“意外”的后果
举一个例子,假设我们有两个全局变量:
```
int x = 0;
int y = 0;
```
然后我们在一个线程里执行:
```
x = 1;
y = 2;
```
在另一个线程里执行:
```
if (y == 2) {
x = 3;
y = 4;
}
```
想一下,你认为上面的代码运行完之后,`x``y` 的数值有几种可能?
你如果认为有两种可能1、2 和 3、4 的话那说明你是按典型程序员的思维模式看问题的——没有像编译器和处理器一样处理问题。事实上1、4 也是一种结果的可能。有两个基本的原因可以造成这一后果:
- 编译器没有义务一定按代码里给出的顺序产生代码。事实上,跟据上下文调整代码的执行顺序,使其最有利于处理器的架构,是优化中很重要的一步。就单个线程而言,先执行 `x = 1` 还是先执行 `y = 2` 完全是件无关紧要的事:它们没有外部“可观察”的区别。
- 在多处理器架构中,各个处理器可能存在缓存不一致性问题。取决于具体的处理器类型、缓存策略和变量地址,对变量 `y` 的写入有可能先反映到主内存中去。之所以这个问题似乎并不常见,是因为常见的 x86 和 x86-64 处理器是在顺序执行方面做得最保守的——大部分其他处理器,如 ARM、DEC Alpha、PA-RISC、IBM Power、IBM z架构和 Intel Itanium 在内存序问题上都比较“松散”。x86 使用的内存模型基本提供了顺序一致性sequential consistency相对的ARM 使用的内存模型就只是松散一致性relaxed consistency。较为严格的描述请查看参考资料 [1] 和里面提供的进一步资料。
虽说 Intel 架构处理器的顺序一致性比较好,但在多处理器(包括多核)的情况下仍然能够出现写读序列变成读写序列的情况,产生意料之外的后果。参考资料 [2] 中提供了完整的例子,包括示例代码。对于缓存不一致性问题的一般中文介绍,可以查看参考资料 [3]。
### 双重检查锁定
在多线程可能对同一个单件进行初始化的情况下,有一个双重检查锁定的技巧,可基本示意如下:
```
// 头文件
class singleton {
public:
static singleton* instance();
private:
static singleton* inst_ptr_;
};
// 实现文件
singleton* singleton::inst_ptr_ =
nullptr;
singleton* singleton::instance()
{
if (inst_ptr_ == nullptr) {
lock_guard lock; // 加锁
if (inst_ptr_ == nullptr) {
inst_ptr_ = new singleton();
}
}
return inst_ptr_;
}
```
这个代码的目的是消除大部分执行路径上的加锁开销。原本的意图是:如果 `inst_ptr_` 没有被初始化,执行才会进入加锁的路径,防止单件被构造多次;如果 `inst_ptr_` 已经被初始化那它就会被直接返回不会产生额外的开销。虽然看上去很美但它一样有着上面提到的问题。Scott Meyers 和 Andrei Alexandrecu 详尽地分析了这个用法 [4],然后得出结论:即使花上再大的力气,这个用法仍然有着非常多的难以填补的漏洞。本质上还是上面说的,优化编译器会努力击败你试图想防止优化的努力,而多处理器会以令人意外的方式让代码走到错误的执行路径上去。他们分析得非常详细,建议你可以花时间学习一下。
### volatile
在某些编译器里,使用 `volatile` 关键字可以达到内存同步的效果。但我们必须记住,这不是 `volatile` 的设计意图,也不能通用地达到内存同步的效果。`volatile` 的语义只是防止编译器“优化”掉对内存的读写而已。它的合适用法,目前主要是用来读写映射到内存地址上的 I/O 操作。
由于 `volatile` 不能在多处理器的环境下确保多个线程能看到同样顺序的数据变化,在今天的通用应用程序中,不应该再看到 `volatile` 的出现。
## C++11 的内存模型
为了从根本上消除这些漏洞C++11 里引入了适合多线程的内存模型。我们可以在参考资料 [5] 里了解更多的细节。跟我们开发密切相关的是现在我们有了原子对象atomic和使用原子对象的获得acquire、释放release语义可以真正精确地控制内存访问的顺序性保证我们需要的内存序。
### 内存屏障和获得、释放语义
拿刚才的那个例子来说,如果我们希望结果只能是 1、2 或 3、4即满足程序员心中的完全存储序total store ordering我们需要在 `x = 1``y = 2` 两句语句之间加入内存屏障,禁止这两句语句交换顺序。我们在此种情况下最常用的两个概念是“获得”和“释放”:
- **获得**是一个对内存的**读**操作,当前线程的任何后面的读写操作都不允许重排到这个操作的**前面**去。
- **释放**是一个对内存的**写**操作,当前线程的任何前面的读写操作都不允许重排到这个操作的**后面**去。
具体到我们上面的第一个例子,我们需要把 `y` 声明成 `atomic&lt;int&gt;`。然后,我们在线程 1 需要使用释放语义:
```
x = 1;
y.store(2, memory_order_release);
```
在线程 2 我们对 `y` 的读取应当使用获得语义,但存储只需要松散内存序即可:
```
if (y.load(memory_order_acquire) ==
2) {
x = 3;
y.store(4, memory_order_relaxed);
}
```
我们可以用下图示意一下,每一边的代码都不允许重排越过黄色区域,且如果 `y` 上的释放早于 `y` 上的获取的话,释放前对内存的修改都在另一个线程的获取操作后可见:
<img src="https://static001.geekbang.org/resource/image/33/14/33484c6762bb98d91ce8d30a752e2614.png" alt="">
事实上,在我们把 `y` 改成 `atomic&lt;int&gt;` 之后,两个线程的代码一行不改,执行结果都会是符合我们的期望的。因为 `atomic` 变量的写操作缺省就是释放语义,读操作缺省就是获得语义(不严格的说法,精确表述见下面的内存序部分)。即
- `y = 2` 相当于 `y.store(2, memory_order_release)`
- `y == 2` 相当于 `y.load(memory_order_acquire) == 2`
但是,缺省行为可能是对性能不利的:我们并不需要在任何情况下都保证操作的顺序性。
另外我们应当注意一下acquire 和 release 通常都是配对出现的,目的是保证如果对同一个原子对象的 release 发生在 acquire 之前的话release 之前发生的内存修改能够被 acquire 之后的内存读取全部看到。
### atomic
刚才是对 atomic 用法的一个非正式介绍。下面我们对 atomic 做一个稍完整些的说明(更完整的见 [6])。
C++11 在 &lt;atomic&gt; 头文件中引入了 `atomic` 模板,对原子对象进行了封装。我们可以将其应用到任何类型上去。当然对于不同的类型效果还是有所不同的:对于整型量和指针等简单类型,通常结果是无锁的原子对象;而对于另外一些类型,比如 64 位机器上大小不是 1、2、4、8有些平台/编译器也支持对更大的数据进行无锁原子操作)的类型,编译器会自动为这些原子对象的操作加上锁。编译器提供了一个原子对象的成员函数 `is_lock_free`,可以检查这个原子对象上的操作是否是无锁的。
原子操作有三类:
- 读:在读取的过程中,读取位置的内容不会发生任何变动。
- 写:在写入的过程中,其他执行线程不会看到部分写入的结果。
- 读‐修改‐写:读取内存、修改数值、然后写回内存,整个操作的过程中间不会有其他写入操作插入,其他执行线程不会看到部分写入的结果。
&lt;atomic&gt; 头文件中还定义了内存序,分别是:
- `memory_order_relaxed`:松散内存序,只用来保证对原子对象的操作是原子的
- `memory_order_consume`:目前不鼓励使用,我就不说明了
- `memory_order_acquire`:获得操作,在读取某原子对象时,当前线程的任何后面的读写操作都不允许重排到这个操作的前面去,并且其他线程在对同一个原子对象释放之前的所有内存写入都在当前线程可见
- `memory_order_release`:释放操作,在写入某原子对象时,当前线程的任何前面的读写操作都不允许重排到这个操作的后面去,并且当前线程的所有内存写入都在对同一个原子对象进行获取的其他线程可见
- `memory_order_acq_rel`:获得释放操作,一个读‐修改‐写操作同时具有获得语义和释放语义,即它前后的任何读写操作都不允许重排,并且其他线程在对同一个原子对象释放之前的所有内存写入都在当前线程可见,当前线程的所有内存写入都在对同一个原子对象进行获取的其他线程可见
- `memory_order_seq_cst`:顺序一致性语义,对于读操作相当于获取,对于写操作相当于释放,对于读‐修改‐写操作相当于获得释放,**是所有原子操作的默认内存序**(除此之外,顺序一致性还保证了多个原子量的修改在所有线程里观察到的修改顺序都相同;我们目前的讨论暂不涉及多个原子量的修改)
`atomic` 有下面这些常用的成员函数:
- 默认构造函数(只支持零初始化)
- 拷贝构造函数被删除
- 使用内置对象类型的构造函数(不是原子操作)
- 可以从内置对象类型赋值到原子对象(相当于 `store`
- 可以从原子对象隐式转换成内置对象(相当于 `load`
- `store`,写入对象到原子对象里,第二个可选参数是内存序类型
- `load`,从原子对象读取内置对象,有个可选参数是内存序类型
- `is_lock_free`,判断对原子对象的操作是否无锁(是否可以用处理器的指令直接完成原子操作)
- `exchange`,交换操作,第二个可选参数是内存序类型(这是读‐修改‐写操作)
- `compare_exchange_weak``compare_exchange_strong`两个比较加交换CAS的版本你可以分别指定成功和失败时的内存序也可以只指定一个或使用默认的最安全内存序这是读修改写操作
- `fetch_add``fetch_sub`,仅对整数和指针内置对象有效,对目标原子对象执行加或减操作,返回其原始值,第二个可选参数是内存序类型(这是读‐修改‐写操作)
- `++``--`(前置和后置),仅对整数和指针内置对象有效,对目标原子对象执行增一或减一,操作使用顺序一致性语义,并注意返回的不是原子对象的引用(这是读‐修改‐写操作)
- `+=``-=`,仅对整数和指针内置对象有效,对目标原子对象执行加或减操作,返回操作之后的数值,操作使用顺序一致性语义,并注意返回的不是原子对象的引用(这是读‐修改‐写操作)
有了原子对象之后,我们可以轻而易举地把[[第 2 讲]](https://time.geekbang.org/column/article/169263) 中的 `shared_count` 变成线程安全。我们只需要包含 &lt;atomic&gt; 头文件,并把下面这行
```
long count_;
```
修改成
```
std::atomic_long count_;
```
即可(`atomic_long``atomic&lt;long&gt;` 的类型别名)。不过,由于我们并不需要 `++` 之后计数值影响其他行为,在 `add_count` 中执行简单的 `++`、使用顺序一致性语义略有浪费。更好的做法是将其实现成:
```
void add_count() noexcept
{
count_.fetch_add(
1, std::memory_order_relaxed);
}
```
#### is_lock_free 的可能问题
注意macOS 上在使用 Clang 时似乎不支持对需要加锁的对象使用 `is_lock_free` 成员函数,此时链接会出错。而 GCC 在这种情况下,需要确保系统上装了 libatomic。以 CentOS 7 下的 GCC 7 为例,我们可以使用下面的语句来安装:
>
`sudo yum install devtoolset-7-libatomic-devel`
然后,用下面的语句编译可以通过:
>
`g++ -pthread test.cpp -latomic`
Windows 下使用 MSVC 则没有问题。
### mutex
上一讲我们已经讨论了互斥量。今天,我们只需要补充两点:
- 互斥量的加锁操作(`lock`)具有获得语义
- 互斥量的解锁操作(`unlock`)具有释放语义
有了目前讲过的这些知识,我们终于可以实现一个真正安全的双重检查锁定了:
```
// 头文件
class singleton {
public:
static singleton* instance();
private:
static mutex lock_;
static atomic&lt;singleton*&gt;
inst_ptr_;
};
// 实现文件
mutex singleton::lock_;
atomic&lt;singleton*&gt;
singleton::inst_ptr_;
singleton* singleton::instance()
{
singleton* ptr = inst_ptr_.load(
memory_order_acquire);
if (ptr == nullptr) {
lock_guard&lt;mutex&gt; guard{lock_};
ptr = inst_ptr_.load(
memory_order_relaxed);
if (ptr == nullptr) {
ptr = new singleton();
inst_ptr_.store(
ptr, memory_order_release);
}
}
return inst_ptr_;
}
```
有个小地方注意一下:为了和 `inst_ptr_.load` 语句对称,我在 `inst_ptr_.store` 时使用了释放语义;不过,由于互斥量解锁本身具有释放语义,这么做并不是必需的。
## 并发队列的接口
在结束这一讲之前,我们来检查一下并发对编程接口的冲击。回想我们之前讲到标准库里 `queue` 有下面这样的接口:
```
template &lt;typename T&gt;
class queue {
public:
T&amp; front();
const T&amp; front() const;
void pop();
}
```
我们之前还问过为什么 `pop` 不直接返回第一个元素。可到了并发的年代,我们不禁要问,这样的接口设计到底明智吗?
**会不会在我们正在访问 `front()` 的时候,这个元素就被 `pop` 掉了?**
事实上,上面这样的接口是不可能做到并发安全的。并发安全的接口大概长下面这个样子:
```
template &lt;typename T&gt;
class queue {
public:
void wait_and_pop(T&amp; dest)
bool try_pop(T&amp; dest);
}
```
换句话说,要准备好位置去接收;然后如果接收成功了,才安安静静地在自己的线程里处理已经被弹出队列的对象。接收方式还得分两种,阻塞式的和非阻塞式的……
那我为什么要在内存模型和原子量这一讲里讨论这个问题呢?因为并发队列的实现,经常是用原子量来达到无锁和高性能的。单生产者、单消费者的并发队列,用原子量和获得、释放语义就能简单实现。对于多生产者或多消费者的情况,那实现就比较复杂了,一般会使用 `compare_exchange_strong``compare_exchange_weak`。讨论这个话题的复杂性,就大大超出了本专栏的范围了。你如果感兴趣的话,可以查看下面几项内容:
- nvwa::fc_queue [7] 给出了一个单生产者、单消费者的无锁并发定长环形队列,代码长度是几百行的量级。
- moodycamel::ConcurrentQueue [8] 给出了一个多生产者、多消费者的无锁通用并发队列,代码长度是几千行的量级。
- 陈皓给出了一篇很棒的对无锁队列的中文描述 [9],推荐阅读。
## 内容小结
在这一讲里,我们讨论了 C++ 对并发的底层支持,特别是内存模型和原子量。这些底层概念,是在 C++ 里写出高性能并发代码的基础。
## 课后思考
在传统 PC 上开发的程序员,应当比较少接触具有松散或弱内存一致性的系统,但原子量和普通变量的区别还是很容易在代码中表现出来的。请你尝试一下多个线程对一个原子量和一个普通全局变量做多次增一操作,观察最后的结果。
在 Intel 处理器架构上,唯一可见的重排是多处理器下的写读操作。大力推荐你尝试一下参考资料 [2] 中的例子Windows 和 Linux 下可直接运行macOS 下需要使用我的[修改版本](https://gist.github.com/adah1972/8ee7484647ea9a1795089219a3704574)或备用[下载链接](http://wyw.dcweb.cn/download.asp?path=&amp;file=ordering.cpp)来覆盖下载代码中的 gcc/ordering.cpp并修改预定义宏。另外一种改法就是把代码中的 `X``Y` 的类型改成 `atomic_int`,重排也就消失了。
如果遇到任何特别问题,欢迎留言与我交流。
## 参考资料
[1] Wikipedia, “Memory ordering”. [https://en.wikipedia.org/wiki/Memory_ordering](https://en.wikipedia.org/wiki/Memory_ordering)
[1a] 维基百科, “内存排序”. [https://zh.wikipedia.org/zh-cn/内存排序](https://zh.wikipedia.org/zh-cn/%E5%86%85%E5%AD%98%E6%8E%92%E5%BA%8F)
[2] Jeff Preshing, “Memory reordering caught in the act”. [https://preshing.com/20120515/memory-reordering-caught-in-the-act/](https://preshing.com/20120515/memory-reordering-caught-in-the-act/)
[3] 王欢明, 《多处理器编程:从缓存一致性到内存模型》. [https://zhuanlan.zhihu.com/p/35386457](https://zhuanlan.zhihu.com/p/35386457)
[4] Scott Meyers and Andrei Alexandrescu, “C++ and the perils of double-checked locking”. [https://www.aristeia.com/Papers/DDJ_Jul_Aug_2004_revised.pdf](https://www.aristeia.com/Papers/DDJ_Jul_Aug_2004_revised.pdf)
[5] cppreference.com, “Memory model”. [https://en.cppreference.com/w/cpp/language/memory_model](https://en.cppreference.com/w/cpp/language/memory_model)
[5a] cppreference.com, “内存模型”. [https://zh.cppreference.com/w/cpp/language/memory_model](https://zh.cppreference.com/w/cpp/language/memory_model)
[6] cppreference.com, “std::atomic”. [https://en.cppreference.com/w/cpp/atomic/atomic](https://en.cppreference.com/w/cpp/atomic/atomic)
[6a] cppreference.com, “std::atomic”. [https://zh.cppreference.com/w/cpp/atomic/atomic](https://zh.cppreference.com/w/cpp/atomic/atomic)
[7] 吴咏炜, nvwa. [https://github.com/adah1972/nvwa](https://github.com/adah1972/nvwa)
[8] Cameron Desrochers, moodycamel::ConcurrentQueue. [https://github.com/cameron314/concurrentqueue](https://github.com/cameron314/concurrentqueue)
[9] 陈皓, 《无锁队列的实现》. [https://coolshell.cn/articles/8239.html](https://coolshell.cn/articles/8239.html)