This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,363 @@
<audio id="audio" title="加餐 | 部分课后思考题答案合集" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b8/ea/b8a140e0b755ff313de8d8b73eecf8ea.mp3"></audio>
你好,我是吴咏炜。这一讲我为你整理了本专栏部分课后思考题的答案,给你作为参考。
## [第 2 讲](https://time.geekbang.org/column/article/169263)
**你觉得智能指针应该满足什么样的线程安全性?**
答:(不是真正的回答,只是描述一下标准中的智能指针的线程安全性。)
1. 多个不同线程同时访问不同的智能指针(不管是否指向同一个对象)是安全的。
1. 多个不同线程同时读取同一个智能指针是安全的。
1. 多个不同线程在同一个智能指针上执行原子操作(`atomic_load` 等)是安全的。
1. 多个不同线程根据同一个智能指针创建新的智能指针(增加引用计数)是安全的。
1. 只会有一个线程最后会(在引用计数表示已经无引用时)调用删除函数去销毁存储的对象。
其他操作潜在是不安全的,特别是在不同的线程对同一个智能指针执行 `reset` 等修改操作。
## [第 3 讲](https://time.geekbang.org/column/article/169268)
**为什么 `smart_ptr::operator=` 对左值和右值都有效,而且不需要对等号两边是否引用同一对象进行判断?**
答:我们使用值类型而非引用类型作为形参,这样实参永远会被移动(右值的情况)或复制(左值的情况),不可能和 `*this` 引用同一个对象。
## [第 4 讲](https://time.geekbang.org/column/article/173167)
**为什么 `stack`(或 `queue`)的 `pop` 函数返回类型为 `void`,而不是直接返回容器的 `top`(或 `front`)成员?**
答:这是 C++98 里、还没有移动语义时的设计。如果 `pop` 返回元素,而元素拷贝时发生异常的话,那这个元素就丢失了。因而容器设计成有分离的 `top`(或 `front`)和 `pop` 成员函数,分别执行访问和弹出的操作。
有一种可能的设计是把接口改成 `void pop(T&amp;)`,这增加了 `T` 必须支持默认构造和赋值的要求,在单线程为主的年代没有明显的好处,反而带来了对 `T` 的额外要求。
## [第 5 讲](https://time.geekbang.org/column/article/174434)
**为什么大部分容器都提供了 `begin`、`end` 等方法?**
答:容器提供了 `begin``end` 方法,就意味着是可以迭代(遍历)的。大部分容器都可以从头到尾遍历,因而也就需要提供这两个方法。
**为什么容器没有继承一个公用的基类?**
C++ 不是面向对象的语言,尤其在标准容器的设计上主要使用值语义,使用公共基类完全没有用处。
## [第 7 讲](https://time.geekbang.org/column/article/176842)
**目前这个输入行迭代器的行为,在什么情况下可能导致意料之外的后果?**
答:
```
#include &lt;fstream&gt;
#include &lt;iostream&gt;
#include "istream_line_reader.h"
using namespace std;
int main()
{
ifstream ifs{"test.cpp"};
istream_line_reader reader{ifs};
auto begin = reader.begin();
for (auto it = reader.begin();
it != reader.end(); ++it) {
cout &lt;&lt; *it &lt;&lt; '\n';
}
}
```
以上代码,因为 begin 多调用了一次,输出就少了一行……
**请尝试一下改进这个输入行迭代器,看看能不能消除这种意外。如果可以,该怎么做?如果不可以,为什么?**
答:很困难。比如,文件如果为空的话,从迭代器的行为角度,`begin()` 应该等于 `end()`——不预先读取一次的话,就无法获知这个结果。这样的改造总体看起来很不值,因此一般都不会选择这样做。
## [第 10 讲](https://time.geekbang.org/column/article/178940)
**这讲里我们没有深入讨论赋值;请你思考一下,如果例子里改成赋值,会有什么样的变化?**
答:返回对象部分的讨论没有变化。对象的移动赋值操作应当实现成无异常,以确保数据不会丢失。
返回值优化在赋值情况下会失效。更一般的情况下,除非需要持续更新某个变量,比如在 `vector` 尾部追加数据,尽量对变量进行一次性赋值、不后续修改。这样的代码更容易推理,更不容易在后续修改中出错,也更能让编译器做(返回值)优化。
## [第 11 讲](https://time.geekbang.org/column/article/179357)
**为什么说 UTF-32 处理会比较简单?**
UTF-32 下一个字符就是一个基本的处理单位一般不会出现一个字符跨多个处理单位的情况UTF-8 和 UTF-16 下会发生)。
**你知道什么情况下 UTF-32 也并不那么简单吗?**
Unicode 下有所谓的修饰字符,用来修饰前一个字符。按 Unicode 的处理规则,这些字符应该和基本字符一起处理(如断行之类)。所以 UTF-32 下也不可以在任意单位处粗暴断开处理。
**哪种 UTF 编码方式空间存储效率比较高?**
答:视存储的内容而定。
比如,如果内容以 ASCII 为主(如源代码),那 UTF-8 效率最高。如果内容以一般的中文文本为主,那 UTF-16 效率最高。
## [第 12 讲](https://time.geekbang.org/column/article/179363)
**为什么并非所有的语言都支持这些不同的多态方式?**
答:排除设计缺陷的情况,语言支持哪些多态方式,基本上取决于语言本身在类型方面的特性。
以 Python 为例,它是动态类型的语言。所以它不会有真正的静态多态。但和静态类型的面向对象语言(如 Java不同它的运行期多态不需要继承。没有参数化多态初看是个缺陷但由于 Python 的动态参数系统允许默认参数和可变参数,并没有什么参数化多态能做得到而 Python 做不到的事。
## [第 17 讲](https://time.geekbang.org/column/article/185189)
**想一想,你如何可以实现一个惰性的过滤器?**
答:
```
#include &lt;iterator&gt;
using namespace std;
template &lt;typename I, typename F&gt;
class filter_view {
public:
class iterator {
public:
typedef ptrdiff_t
difference_type;
typedef
typename iterator_traits&lt;
I&gt;::value_type value_type;
typedef
typename iterator_traits&lt;
I&gt;::pointer pointer;
typedef
typename iterator_traits&lt;
I&gt;::reference reference;
typedef forward_iterator_tag
iterator_category;
iterator(I current, I end, F cond)
: current_(current)
, end_(end)
, cond_(cond)
{
if (current_ != end_ &amp;&amp;
!cond_(*current_)) {
++*this;
}
}
iterator&amp; operator++()
{
while (current_ != end_) {
++current_;
if (cond_(*current_)) {
break;
}
}
return *this;
}
iterator operator++(int)
{
auto temp = *this;
++*this;
return temp;
}
reference operator*() const
{
return *current_;
}
pointer operator-&gt;() const
{
return &amp;*current_;
}
bool operator==(const iterator&amp; rhs)
{
return current_ == rhs.current_;
}
bool operator!=(const iterator&amp; rhs)
{
return !operator==(rhs);
}
private:
I current_;
I end_;
F cond_;
};
filter_view(I begin, I end,
F cond)
: begin_(begin)
, end_(end)
, cond_(cond)
{}
iterator begin() const
{
return iterator(begin_, end_, cond_);
}
iterator end() const
{
return iterator(end_, end_, cond_);
}
private:
I begin_;
I end_;
F cond_;
};
```
## [第 18 讲](https://time.geekbang.org/column/article/185899)
**我展示了 `compose` 带一个或更多参数的情况。你觉得 `compose` 不带任何参数该如何定义?它有意义吗?**
答:
```
inline auto compose()
{
return [](auto&amp;&amp; x) -&gt; decltype(auto)
{
return std::forward&lt;decltype(x)&gt;(x);
};
}
```
这个函数把参数原封不动地传回。它的意义相当于加法里的 0乘法里的 1。
在普通的加法里,你可能不太需要 0但在一个做加法的地方如果别人想告诉你不要做任何操作传给你一个 0 是最简单的做法。
**有没有可能不用 `index_sequence` 来初始化 `bit_count`?如果行,应该如何实现?**
答:似乎没有通用的办法,因为目前 constexpr 要求在构造时直接初始化对象的内容。
但是,到了 C++20允许 constexpr 对象里存在平凡默认构造的成员之后,就可以使用下面的写法了:
```
template &lt;size_t N&gt;
struct bit_count_t {
constexpr bit_count_t()
{
for (auto i = 0U; i &lt; N; ++i) {
count[i] = count_bits(i);
}
}
unsigned char count[N];
};
constexpr bit_count_t&lt;256&gt;
bit_count;
```
当前已经发布的编译器中,我测下来只有 Clang 能(在 C++17 模式下编译通过此代码。GCC 10 能在使用命令行选项 `-std=c++2a` 时编译通过此代码。
**作为一个挑战,你能自行实现出 `make_integer_sequence` 吗?**
答 1
```
template &lt;class T, T... Ints&gt;
struct integer_sequence {};
template &lt;class T&gt;
struct integer_sequence_ns {
template &lt;T N, T... Ints&gt;
struct integer_sequence_helper {
using type =
typename integer_sequence_helper&lt;
N - 1, N - 1,
Ints...&gt;::type;
};
template &lt;T... Ints&gt;
struct integer_sequence_helper&lt;
0, Ints...&gt; {
using type =
integer_sequence&lt;T, Ints...&gt;;
};
};
template &lt;class T, T N&gt;
using make_integer_sequence =
typename integer_sequence_ns&lt;T&gt;::
template integer_sequence_helper&lt;
N&gt;::type;
```
如果一开始写成 `template &lt;class T, T N, T... Ints&gt; struct integer_sequence_helper` 的话就会遇到错误“non-type template argument specializes a template parameter with dependent type T非类型的模板实参特化了一个使用依赖类型的T的模板形参。这是目前的 C++ 标准所不允许的写法,改写成嵌套类形式可以绕过这个问题。
答 2
```
template &lt;class T, T... Ints&gt;
struct integer_sequence {};
template &lt;class T, T N, T... Is&gt;
auto make_integer_sequence_impl()
{
if constexpr (N == 0) {
return integer_sequence&lt;
T, Is...&gt;();
} else {
return make_integer_sequence_impl&lt;
T, N - 1, N - 1, Is...&gt;();
}
}
template &lt;class T, T N&gt;
using make_integer_sequence =
decltype(
make_integer_sequence_impl&lt;
T, N&gt;());
```
这又是一个 `constexpr` 能简化表达的例子。
## [第 19 讲](https://time.geekbang.org/column/article/186689)
**并发编程中哪些情况下会发生死锁?**
答:多个线程里,如果没有或不能事先约定访问顺序,同时进行可阻塞的资源访问,访问顺序可以形成一个环,就会引发死锁。
可阻塞的资源访问可能包括(但不限于):
- 互斥量上的 `lock` 调用
- 条件变量上的 `wait` 调用
- 对线程的 `join` 调用
-`future``get` 调用
## [第 27 讲](https://time.geekbang.org/column/article/193523)
**你觉得 C++ REST SDK 的接口好用吗?如果好用,原因是什么?如果不好用,你有什么样的改进意见?**
答:举几个可能的改进点。
C++ REST SDK 的 `uri::decode` 接口设计有不少问题:
- 最严重的,不能对 query string 的等号左边的部分进行 `decode`;只能先 `split_query``decode`,此时等号左边已经在 `map` 里,不能修改——要修改需要建一个新的 `map`
- 目前的实现对“+”不能重新还原成空格。
换个说法,目前的接口能正确处理“/search?q=query%20string”这样的请求但不能正确处理“/search?%71=query+string”这样的请求。
应当有一个 `split_query_and_decode` 接口,同时执行分割和解码。
另外,`json` 的接口也还是不够好用,最主要是没有使用初始化列表的构造。构造复杂的 JSON 结构有点啰嗦了。
`fstream::open_ostream` 缺省行为跟 `std::ofstream` 不一样应该是个 bug。应当要么修正接口接口缺省参数里带上 `trunc`),要么修正实现(跟 `std::ofstream` 一样把 `out` 当成 `out|trunc`)。
## [第 28 讲](https://time.geekbang.org/column/article/194005)
**“概念”可以为开发具体带来哪些好处?反过来,负面的影响又可能会是什么?**
答:对于代码严谨、具有形式化思维的人,“概念”是个福音,它不仅可以大量消除 SFINAE 的使用,还能以较为精确和形式化的形式在代码里写出对类型的要求,使得代码变得清晰、易读。
但反过来说,“概念”比鸭子类型更严格。在代码加上概念约束后,相关代码很可能需要修改才能满足概念的要求,即使之前在实际使用中可能已经完全没有问题。从迭代器的角度,实际使用中最小功能集是构造、可复制、`*`、前置 `++`、与 sentinel 类型对象的 `!=`(单一形式)。而为了满足迭代器概念,则要额外确保满足以下各点:
- 可默认初始化
- 在 iterator 类型和 sentinel 类型之间,需要定义完整的四个 `==``!=` 运算符
- 定义迭代器的标准内部类型,如 `difference_type`
以上就是今天的全部内容了,希望能对你有所帮助!如果你有更多问题,还是请你在留言区中提出,我会一一解答。

View File

@@ -0,0 +1,386 @@
<audio id="audio" title="01 | 堆、栈、RAIIC++里该如何管理资源?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/91/83/9160af89dda171e0a29644f9a05e6a83.mp3"></audio>
你好,我是吴咏炜。
今天我们就正式开启了C++的学习之旅,作为第一讲,我想先带你把地基打牢。我们来学习一下内存管理的基本概念,大致的学习路径是:先讲堆和栈,然后讨论 C++ 的特色功能 RAII。掌握这些概念是能够熟练运用 C++ 的基础。
## 基本概念
**堆**,英文是 heap在内存管理的语境下指的是动态分配内存的区域。这个堆跟数据结构里的堆不是一回事。这里的内存被分配之后需要手工释放否则就会造成内存泄漏。
C++ 标准里一个相关概念是自由存储区,英文是 free store特指使用 `new``delete` 来分配和释放内存的区域。一般而言,这是堆的一个子集:
- `new``delete` 操作的区域是 free store
- `malloc``free` 操作的区域是 heap
`new``delete` 通常底层使用 `malloc``free` 来实现,所以 free store 也是 heap。鉴于对其区分的实际意义并不大在本专栏里除非另有特殊说明我会只使用堆这一术语。
**栈**,英文是 stack在内存管理的语境下指的是函数调用过程中产生的本地变量和调用数据的区域。这个栈和数据结构里的栈高度相似都满足“后进先出”last-in-first-out 或 LIFO
**RAII**,完整的英文是 Resource Acquisition Is Initialization是 C++ 所特有的资源管理方式。有少量其他语言,如 D、Ada 和 Rust 也采纳了 RAII但主流的编程语言中 C++ 是唯一一个依赖 RAII 来做资源管理的。
RAII 依托栈和析构函数,来对所有的资源——包括堆内存在内——进行管理。对 RAII 的使用,使得 C++ 不需要类似于 Java 那样的垃圾收集方法也能有效地对内存进行管理。RAII 的存在,也是垃圾收集虽然理论上可以在 C++ 使用,但从来没有真正流行过的主要原因。
接下来,我将会对堆、栈和 RAII 进行深入的探讨。
## 堆
从现代编程的角度来看,使用堆,或者说使用动态内存分配,是一件再自然不过的事情了。下面这样的代码,都会导致在堆上分配内存(并构造对象)。
```
// C++
auto ptr = new std::vector&lt;int&gt;();
```
```
// Java
ArrayList&lt;int&gt; list = new ArrayList&lt;int&gt;();
```
```
# Python
lst = list()
```
从历史的角度,动态内存分配实际上是较晚出现的。由于动态内存带来的不确定性——内存分配耗时需要多久?失败了怎么办?等等——至今仍有很多场合会禁用动态内存,尤其在实时性要求比较高的场合,如飞行控制器和电信设备。不过,由于大家多半对这种用法比较熟悉,特别是从 C 和 C++ 以外的其他语言开始学习编程的程序员,所以提到内存管理,我们还是先讨论一下使用堆的编程方式。
在堆上分配内存,有些语言可能使用 `new` 这样的关键字,有些语言则是在对象的构造时隐式分配,不需要特殊关键字。不管哪种情况,程序通常需要牵涉到三个可能的内存管理器的操作:
1. 让内存管理器分配一个某个大小的内存块
1. 让内存管理器释放一个之前分配的内存块
1. 让内存管理器进行垃圾收集操作,寻找不再使用的内存块并予以释放
C++ 通常会做上面的操作 1 和 2。Java 会做上面的操作 1 和 3。而 Python 会做上面的操作 1、2、3。这是语言的特性和实现方式决定的。
**需要略加说明的是,上面的三个操作都不简单,并且彼此之间是相关的。**
第一,分配内存要考虑程序当前已经有多少未分配的内存。内存不足时要从操作系统申请新的内存。内存充足时,要从可用的内存里取出一块合适大小的内存,做簿记工作将其标记为已用,然后将其返回给要求内存的代码。
需要注意到,绝大部分情况下,可用内存都会比要求分配的内存大,所以代码只被允许使用其被分配的内存区域,而剩余的内存区域仍属于未分配状态,可以在后面的分配过程中使用。另外,如果内存管理器支持垃圾收集的话,分配内存的操作还可能会触发垃圾收集。
第二,释放内存不只是简单地把内存标记为未使用。对于连续未使用的内存块,通常内存管理器需要将其合并成一块,以便可以满足后续的较大内存分配要求。毕竟,目前的编程模式都要求申请的内存块是连续的。
第三,垃圾收集操作有很多不同的策略和实现方式,以实现性能、实时性、额外开销等各方面的平衡。由于 C++ 里通常都不使用垃圾收集,所以就不是我们专栏的重点,不再展开讲解。
下面这张图展示了一个简单的分配过程:
<img src="https://static001.geekbang.org/resource/image/18/5a/1814fb6093744c64ac9d3861fb4d3a5a.png" alt=""><br>
<img src="https://static001.geekbang.org/resource/image/a7/3b/a7b72d6062c5cd798a2de991bffd713b.png" alt="">
注意在图 1e 的状态下,内存管理器是满足不了长度大于 4 的内存分配要求的;而在图 1f 的状态,则长度小于等于 7 的单个内存要求都可以得到满足。
当然,这只是一个简单的示意,只是为了让你能够对这个过程有一个大概的感性认识。在不考虑垃圾收集的情况下,内存需要手工释放;在此过程中,内存可能有碎片化的情况。比如,在图 1d 的情况下,虽然总共剩余内存为 6但却满足不了长度大于 4 的内存分配要求。
幸运的是,大部分软件开发人员都不需要担心这个问题。内存分配和释放的管理,是内存管理器的任务,一般情况下我们不需要介入。我们只需要正确地使用 `new``delete`。每个 `new` 出来的对象都应该用 `delete` 来释放,就是这么简单。
但真的很简单、可以高枕无忧了吗?
事实说明,漏掉 `delete` 是一种常见的情况,这叫“内存泄漏”——相信你一定听到过这个说法。为什么呢?
我们还是看一些代码例子。
```
void foo()
{
bar* ptr = new bar();
delete ptr;
}
```
这个很简单吧,但是却存在两个问题:
1. 中间省略的代码部分也许会抛出异常,导致最后的 `delete ptr` 得不到执行。
1. 更重要的,这个代码不符合 C++ 的惯用法。在 C++ 里,这种情况下有 99% 的可能性不应该使用堆内存分配,而应使用栈内存分配。这样写代码的,估计可能是从 Java 转过来的🤭——但我真见过这样的代码。
而更常见、也更合理的情况,是分配和释放不在一个函数里。比如下面这段示例代码:
```
bar* make_bar(…)
{
bar* ptr = nullptr;
try {
ptr = new bar();
}
catch (...) {
delete ptr;
throw;
}
return ptr;
}
void foo()
{
bar* ptr = make_bar(…)
delete ptr;
}
```
这样的话,会漏 `delete` 的可能性是不是大多了?有关这个问题的解决方法,我们在下一讲还会提到。
好,堆我们暂时就讨论到这儿。下面,我们看看更符合 C++ 特性的栈内存分配。
## 栈
我们先来看一段示例代码,来说明 C++ 里函数调用、本地变量是如何使用栈的。当然,这一过程取决于计算机的实际架构,具体细节可能有所不同,但原理上都是相通的,都会使用一个后进先出的结构。
```
void foo(int n)
{
}
void bar(int n)
{
int a = n + 1;
foo(a);
}
int main()
{
bar(42);
}
```
这段代码执行过程中的栈变化,我画了下面这张图来表示:
<img src="https://static001.geekbang.org/resource/image/6e/3b/6ef3d653af7fa6c9728ea4bea348093b.png" alt="">
在我们的示例中,栈是向上增长的。在包括 x86 在内的大部分计算机体系架构中,栈的增长方向是低地址,因而上方意味着低地址。任何一个函数,根据架构的约定,只能使用进入函数时栈指针向上部分的栈空间。当函数调用另外一个函数时,会把参数也压入栈里(我们此处忽略使用寄存器传递参数的情况),然后把下一行汇编指令的地址压入栈,并跳转到新的函数。新的函数进入后,首先做一些必须的保存工作,然后会调整栈指针,**分配出本地变量所需的空间**,随后执行函数中的代码,并在执行完毕之后,根据调用者压入栈的地址,返回到调用者未执行的代码中继续执行。
注意到了没有,本地变量所需的内存就在栈上,跟函数执行所需的其他数据在一起。当函数执行完成之后,这些内存也就自然而然释放掉了。我们可以看到:
- 栈上的分配极为简单,移动一下栈指针而已。
- 栈上的释放也极为简单,函数执行结束时移动一下栈指针即可。
- 由于后进先出的执行过程,不可能出现内存碎片。
顺便说一句,图 2 中每种颜色都表示某个函数占用的栈空间。这部分空间有个特定的术语叫做栈帧stack frame。GCC 和 Clang 的命令行参数中提到 frame 的,如 `-fomit-frame-pointer`,一般就是指栈帧。
前面例子的本地变量是简单类型C++ 里称之为 POD 类型Plain Old Data。对于有构造和析构函数的非 POD 类型,栈上的内存分配也同样有效,只不过 C++ 编译器会在生成代码的合适位置,插入对构造和析构函数的调用。
这里尤其重要的是编译器会自动调用析构函数包括在函数执行发生异常的情况。在发生异常时对析构函数的调用还有一个专门的术语叫栈展开stack unwinding。事实上如果你用 MSVC 编译含异常的 C++ 代码,但没有使用上一讲说过的 `/EHsc` 参数,编译器就会报告:
>
warning C4530: C++ exception handler used, but unwind semantics are not enabled. Specify /EHsc
下面是一段简短的代码,可以演示栈展开:
```
#include &lt;stdio.h&gt;
class Obj {
public:
Obj() { puts("Obj()"); }
~Obj() { puts("~Obj()"); }
};
void foo(int n)
{
Obj obj;
if (n == 42)
throw "life, the universe and everything";
}
int main()
{
try {
foo(41);
foo(42);
}
catch (const char* s) {
puts(s);
}
}
```
执行代码的结果是:
>
<p>`Obj()`<br>
`~Obj()`<br>
`Obj()`<br>
`~Obj()`<br>
`life, the universe and everything`</p>
也就是说,不管是否发生了异常,`obj` 的析构函数都会得到执行。
在 C++ 里,所有的变量缺省都是值语义——如果不使用 `*``&amp;` 的话,变量不会像 Java 或 Python 一样引用一个堆上的对象。对于像智能指针这样的类型,你写 `ptr-&gt;call()``ptr.get()`,语法上都是对的,并且 `-&gt;``.` 有着不同的语法作用。而在大部分其他语言里,访问成员只用 `.`,但在作用上实际等价于 C++ 的 `-&gt;`。这种值语义和引用语义的区别,是 C++ 的特点,也是它的复杂性的一个来源。要用好 C++,就需要理解它的值语义的特点。
对堆和栈有了基本了解之后,我们继续往下,聊一聊 C++ 的重要特性 RAII。
## RAII
C++ 支持将对象存储在栈上面。但是,在很多情况下,对象不能,或不应该,存储在栈上。比如:
- 对象很大;
- 对象的大小在编译时不能确定;
- 对象是函数的返回值,但由于特殊的原因,不应使用对象的值返回。
常见情况之一是,在工厂方法或其他面向对象编程的情况下,返回值类型是基类(的指针或引用)。下面的例子,是对工厂方法的简单演示:
```
enum class shape_type {
circle,
triangle,
rectangle,
};
class shape { … };
class circle : public shape { … };
class triangle : public shape { … };
class rectangle : public shape { … };
shape* create_shape(shape_type type)
{
switch (type) {
case shape_type::circle:
return new circle(…);
case shape_type::triangle:
return new triangle(…);
case shape_type::rectangle:
return new rectangle(…);
}
}
```
这个 `create_shape` 方法会返回一个 `shape` 对象,对象的实际类型是某个 `shape` 的子类,圆啊,三角形啊,矩形啊,等等。这种情况下,函数的返回值只能是指针或其变体形式。如果返回类型是 `shape`,实际却返回一个 `circle`编译器不会报错但结果多半是错的。这种现象叫对象切片object slicing是 C++ 特有的一种编码错误。这种错误不是语法错误,而是一个对象复制相关的语义错误,也算是 C++ 的一个陷阱了,大家需要小心这个问题。
那么,我们怎样才能确保,在使用 `create_shape` 的返回值时不会发生内存泄漏呢?
答案就在析构函数和它的栈展开行为上。我们只需要把这个返回值放到一个本地变量里,并确保其析构函数会删除该对象即可。一个简单的实现如下所示:
```
class shape_wrapper {
public:
explicit shape_wrapper(
shape* ptr = nullptr)
: ptr_(ptr) {}
~shape_wrapper()
{
delete ptr_;
}
shape* get() const { return ptr_; }
private:
shape* ptr_;
};
void foo()
{
shape_wrapper ptr_wrapper(
create_shape(…));
}
```
如果你好奇 `delete` 空指针会发生什么的话,那答案是,这是一个合法的空操作。在 `new` 一个对象和 `delete` 一个指针时编译器需要干不少活的,它们大致可以如下翻译:
```
// new circle(…)
{
void* temp = operator new(sizeof(circle));
try {
circle* ptr =
static_cast&lt;circle*&gt;(temp);
ptr-&gt;circle(…);
return ptr;
}
catch (...) {
operator delete(ptr);
throw;
}
}
```
```
if (ptr != nullptr) {
ptr-&gt;~shape();
operator delete(ptr);
}
```
也就是说,`new` 的时候先分配内存(失败时整个操作失败并向外抛出异常,通常是 `bad_alloc`),然后在这个结果指针上构造对象(注意上面示意中的调用构造函数并不是合法的 C++ 代码);构造成功则 `new` 操作整体完成,否则释放刚分配的内存并继续向外抛构造函数产生的异常。`delete` 时则判断指针是否为空,在指针不为空时调用析构函数并释放之前分配的内存。
回到 `shape_wrapper` 和它的析构行为。在析构函数里做必要的清理工作,这就是 RAII 的基本用法。这种清理并不限于释放内存,也可以是:
- 关闭文件(`fstream` 的析构就会这么做)
- 释放同步锁
- 释放其他重要的系统资源
例如,我们应该使用:
```
std::mutex mtx;
void some_func()
{
std::lock_guard&lt;std::mutex&gt; guard(mtx);
// 做需要同步的工作
}
```
而不是:
```
std::mutex mtx;
void some_func()
{
mtx.lock();
// 做需要同步的工作……
// 如果发生异常或提前返回,
// 下面这句不会自动执行。
mtx.unlock();
}
```
顺便说一句,上面的 `shape_wrapper` 差不多就是个最简单的智能指针了。至于完整的智能指针,我们留到下一讲继续学习。
## 内容小结
本讲我们讨论了 C++ 里内存管理的一些基本概念,强调栈是 C++ 里最“自然”的内存使用方式,并且,使用基于栈和析构函数的 RAII可以有效地对包括堆内存在内的系统资源进行统一管理。
## 课后思考
最后留给你一道思考题。`shape_wrapper` 和智能指针比起来,还缺了哪些功能?欢迎留言和我分享你的观点。
## 参考资料
[1] Wikipedia, “Memory management”. [https://en.wikipedia.org/wiki/Memory_management](https://en.wikipedia.org/wiki/Memory_management)
[2] Wikipedia, “Stack-based memory allocation”. [https://en.wikipedia.org/wiki/Stack-based_memory_allocation](https://en.wikipedia.org/wiki/Stack-based_memory_allocation)
[3] Wikipedia, “Resource acquisition is initialization”. [https://en.wikipedia.org/wiki/RAII](https://en.wikipedia.org/wiki/RAII)
[3a] 维基百科, “RAII”. [https://zh.wikipedia.org/zh-cn/RAII](https://zh.wikipedia.org/zh-cn/RAII)
[4] Wikipedia, “Call stack”. [https://en.wikipedia.org/wiki/Call_stack](https://en.wikipedia.org/wiki/Call_stack)
[5] Wikipedia, “Object slicing”. [https://en.wikipedia.org/wiki/Object_slicing](https://en.wikipedia.org/wiki/Object_slicing)
[6] Stack Overflow, “Why does the stack address grow towards decreasing memory addresses?” [https://stackoverflow.com/questions/4560720/why-does-the-stack-address-grow-towards-decreasing-memory-addresses](https://stackoverflow.com/questions/4560720/why-does-the-stack-address-grow-towards-decreasing-memory-addresses)
注意:有些条目虽然有中文版,但内容太少;此处单独标出中文版条目的,则是内容比较全面、能够补充本专栏内容的情况。

View File

@@ -0,0 +1,685 @@
<audio id="audio" title="02 | 自己动手实现C++的智能指针" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b0/9a/b0ad5b326e15129171b567a6f5763d9a.mp3"></audio>
你好,我是吴咏炜。
上一讲,我们描述了一个某种程度上可以当成智能指针用的类 `shape_wrapper`。使用那个智能指针,可以简化资源的管理,从根本上消除资源(包括内存)泄漏的可能性。这一讲我们就来进一步讲解,如何将 `shape_wrapper` 改造成一个完整的智能指针。你会看到,智能指针本质上并不神秘,其实就是 RAII 资源管理功能的自然展现而已。
在学完这一讲之后,你应该会对 C++ 的 `unique_ptr``shared_ptr` 的功能非常熟悉了。同时,如果你今后要创建类似的资源管理类,也不会是一件难事。
## 回顾
我们上一讲给出了下面这个类:
```
class shape_wrapper {
public:
explicit shape_wrapper(
shape* ptr = nullptr)
: ptr_(ptr) {}
~shape_wrapper()
{
delete ptr_;
}
shape* get() const { return ptr_; }
private:
shape* ptr_;
};
```
这个类可以完成智能指针的最基本的功能:对超出作用域的对象进行释放。**但它缺了点东西:**
1. 这个类只适用于 `shape`
1. 该类对象的行为不够像指针
1. 拷贝该类对象会引发程序行为异常
下面我们来逐一看一下怎么弥补这些问题。
## 模板化和易用性
要让这个类能够包装任意类型的指针,我们需要把它变成一个类模板。这实际上相当容易:
```
template &lt;typename T&gt;
class smart_ptr {
public:
explicit smart_ptr(T* ptr = nullptr)
: ptr_(ptr) {}
~smart_ptr()
{
delete ptr_;
}
T* get() const { return ptr_; }
private:
T* ptr_;
};
```
`shape_wrapper` 比较一下,我们就是在开头增加模板声明 `template &lt;typename T&gt;`,然后把代码中的 `shape` 替换成模板参数 `T` 而已。这些修改非常简单自然吧?模板本质上并不是一个很复杂的概念。这个模板使用也很简单,把原来的 `shape_wrapper` 改成 `smart_ptr&lt;shape&gt;` 就行。
目前这个 `smart_ptr` 的行为还是和指针有点差异的:
- 它不能用 `*` 运算符解引用
- 它不能用 `-&gt;` 运算符指向对象成员
- 它不能像指针一样用在布尔表达式里
不过,这些问题也相当容易解决,加几个成员函数就可以:
```
template &lt;typename T&gt;
class smart_ptr {
public:
T&amp; operator*() const { return *ptr_; }
T* operator-&gt;() const { return ptr_; }
operator bool() const { return ptr_; }
}
```
## 拷贝构造和赋值
拷贝构造和赋值,我们暂且简称为拷贝,这是个比较复杂的问题了。关键还不是实现问题,而是我们该如何定义其行为。假设有下面的代码:
```
smart_ptr&lt;shape&gt; ptr1{create_shape(shape_type::circle)};
smart_ptr&lt;shape&gt; ptr2{ptr1};
```
对于第二行,究竟应当让编译时发生错误,还是可以有一个更合理的行为?我们来逐一检查一下各种可能性。
最简单的情况显然是禁止拷贝。我们可以使用下面的代码:
```
template &lt;typename T&gt;
class smart_ptr {
smart_ptr(const smart_ptr&amp;)
= delete;
smart_ptr&amp; operator=(const smart_ptr&amp;)
= delete;
};
```
禁用这两个函数非常简单,但却解决了一种可能出错的情况。否则,`smart_ptr&lt;shape&gt; ptr2{ptr1};` 在编译时不会出错,但在运行时却会有未定义行为——由于会对同一内存释放两次,通常情况下会导致程序崩溃。
我们是不是可以考虑在拷贝智能指针时把对象拷贝一份?不行,通常人们不会这么用,因为使用智能指针的目的就是要减少对象的拷贝啊。何况,虽然我们的指针类型是 `shape`,但实际指向的却应该是 `circle``triangle` 之类的对象。在 C++ 里没有像 Java 的 `clone` 方法这样的约定;一般而言,并没有通用的方法可以通过基类的指针来构造出一个子类的对象来。
我们要么试试在拷贝时转移指针的所有权?大致实现如下:
```
template &lt;typename T&gt;
class smart_ptr {
smart_ptr(smart_ptr&amp; other)
{
ptr_ = other.release();
}
smart_ptr&amp; operator=(smart_ptr&amp; rhs)
{
smart_ptr(rhs).swap(*this);
return *this;
}
T* release()
{
T* ptr = ptr_;
ptr_ = nullptr;
return ptr;
}
void swap(smart_ptr&amp; rhs)
{
using std::swap;
swap(ptr_, rhs.ptr_);
}
};
```
在拷贝构造函数中,通过调用 `other``release` 方法来释放它对指针的所有权。在赋值函数中,则通过拷贝构造产生一个临时对象并调用 `swap` 来交换对指针的所有权。实现上是不复杂的。
如果你学到的赋值函数还有一个类似于 `if (this != &amp;rhs)` 的判断的话那种用法更啰嗦而且异常安全性不够好——如果在赋值过程中发生异常的话this 对象的内容可能已经被部分破坏了,对象不再处于一个完整的状态。
**上面代码里的这种惯用法(见参考资料 [1])则保证了强异常安全性:**赋值分为拷贝构造和交换两步异常只可能在第一步发生而第一步如果发生异常的话this 对象完全不受任何影响。无论拷贝构造成功与否,结果只有赋值成功和赋值没有效果两种状态,而不会发生因为赋值破坏了当前对象这种场景。
如果你觉得这个实现还不错的话,那恭喜你,你达到了 C++ 委员会在 1998 年时的水平:上面给出的语义本质上就是 C++98 的 `auto_ptr` 的定义。如果你觉得这个实现很别扭的话,也恭喜你,因为 C++ 委员会也是这么觉得的:`auto_ptr` 在 C++17 时已经被正式从 C++ 标准里删除了。
上面实现的最大问题是,它的行为会让程序员非常容易犯错。一不小心把它传递给另外一个 `smart_ptr`,你就不再拥有这个对象了……
## “移动”指针?
在下一讲我们将完整介绍一下移动语义。这一讲,我们先简单看一下 `smart_ptr` 可以如何使用“移动”来改善其行为。
我们需要对代码做两处小修改:
```
template &lt;typename T&gt;
class smart_ptr {
smart_ptr(smart_ptr&amp;&amp; other)
{
ptr_ = other.release();
}
smart_ptr&amp; operator=(smart_ptr rhs)
{
rhs.swap(*this);
return *this;
}
};
```
看到修改的地方了吗?我改了两个地方:
- 把拷贝构造函数中的参数类型 `smart_ptr&amp;` 改成了 `smart_ptr&amp;&amp;`;现在它成了移动构造函数。
- 把赋值函数中的参数类型 `smart_ptr&amp;` 改成了 `smart_ptr`,在构造参数时直接生成新的智能指针,从而不再需要在函数体中构造临时对象。现在赋值函数的行为是移动还是拷贝,完全依赖于构造参数时走的是移动构造还是拷贝构造。
根据 C++ 的规则如果我提供了移动构造函数而没有手动提供拷贝构造函数那后者自动被禁用记住C++ 里那些复杂的规则也是为方便编程而设立的)。于是,我们自然地得到了以下结果:
```
smart_ptr&lt;shape&gt; ptr1{create_shape(shape_type::circle)};
smart_ptr&lt;shape&gt; ptr2{ptr1}; // 编译出错
smart_ptr&lt;shape&gt; ptr3;
ptr3 = ptr1; // 编译出错
ptr3 = std::move(ptr1); // OK可以
smart_ptr&lt;shape&gt; ptr4{std::move(ptr3)}; // OK可以
```
这个就自然多了。
这也是 C++11 的 `unique_ptr` 的基本行为。
## 子类指针向基类指针的转换
哦,我撒了一个小谎。不知道你注意到没有,一个 `circle*` 是可以隐式转换成 `shape*` 的,但上面的 `smart_ptr&lt;circle&gt;` 却无法自动转换成 `smart_ptr&lt;shape&gt;`。这个行为显然还是不够“自然”。
不过,只需要额外加一点模板代码,就能实现这一行为。在我们目前给出的实现里,只需要增加一个构造函数即可——这也算是我们让赋值函数利用构造函数的好处了。
```
template &lt;typename U&gt;
smart_ptr(smart_ptr&lt;U&gt;&amp;&amp; other)
{
ptr_ = other.release();
}
```
这样,我们自然而然利用了指针的转换特性:现在 `smart_ptr&lt;circle&gt;` 可以移动给 `smart_ptr&lt;shape&gt;`,但不能移动给 `smart_ptr&lt;triangle&gt;`。不正确的转换会在代码编译时直接报错。
需要注意,上面这个构造函数不被编译器看作移动构造函数,因而不能自动触发删除拷贝构造函数的行为。如果我们想消除代码重复、删除移动构造函数的话,就需要把拷贝构造函数标记成 `= delete` 了(见“拷贝构造和赋值”一节)。不过,更通用的方式仍然是同时定义标准的拷贝/移动构造函数和所需的模板构造函数。下面的引用计数智能指针里我们就需要这么做。
至于非隐式的转换,因为本来就是要写特殊的转换函数的,我们留到这一讲的最后再讨论。
## 引用计数
`unique_ptr` 算是一种较为安全的智能指针了。但是,一个对象只能被单个 `unique_ptr` 所拥有,这显然不能满足所有使用场合的需求。一种常见的情况是,多个智能指针同时拥有一个对象;当它们全部都失效时,这个对象也同时会被删除。这也就是 `shared_ptr` 了。
`unique_ptr``shared_ptr` 的主要区别如下图所示:
<img src="https://static001.geekbang.org/resource/image/07/c8/072fc41e503d22c3ab2bf6a3801903c8.png" alt="">
多个不同的 `shared_ptr` 不仅可以共享一个对象,在共享同一对象时也需要同时共享同一个计数。当最后一个指向对象(和共享计数)的 `shared_ptr` 析构时,它需要删除对象和共享计数。我们下面就来实现一下。
我们先来写出共享计数的接口:
```
class shared_count {
public:
shared_count();
void add_count();
long reduce_count();
long get_count() const;
};
```
这个 `shared_count` 类除构造函数之外有三个方法:一个增加计数,一个减少计数,一个获取计数。注意上面的接口增加计数不需要返回计数值;但减少计数时需要返回计数值,以供调用者判断是否它已经是最后一个指向共享计数的 `shared_ptr` 了。由于真正多线程安全的版本需要用到我们目前还没学到的知识,我们目前先实现一个简单化的版本:
```
class shared_count {
public:
shared_count() : count_(1) {}
void add_count()
{
++count_;
}
long reduce_count()
{
return --count_;
}
long get_count() const
{
return count_;
}
private:
long count_;
};
```
现在我们可以实现我们的引用计数智能指针了。首先是构造函数、析构函数和私有成员变量:
```
template &lt;typename T&gt;
class smart_ptr {
public:
explicit smart_ptr(T* ptr = nullptr)
: ptr_(ptr)
{
if (ptr) {
shared_count_ =
new shared_count();
}
}
~smart_ptr()
{
if (ptr_ &amp;&amp;
!shared_count_
-&gt;reduce_count()) {
delete ptr_;
delete shared_count_;
}
}
private:
T* ptr_;
shared_count* shared_count_;
};
```
构造函数跟之前的主要不同点是会构造一个 `shared_count` 出来。析构函数在看到 `ptr_` 非空时(此时根据代码逻辑,`shared_count` 也必然非空),需要对引用数减一,并在引用数降到零时彻底删除对象和共享计数。原理就是这样,不复杂。
当然,我们还有些细节要处理。为了方便实现赋值(及其他一些惯用法),我们需要一个新的 `swap` 成员函数:
```
void swap(smart_ptr&amp; rhs)
{
using std::swap;
swap(ptr_, rhs.ptr_);
swap(shared_count_,
rhs.shared_count_);
}
```
赋值函数可以跟前面一样,保持不变,但拷贝构造和移动构造函数是需要更新一下的:
```
smart_ptr(const smart_ptr&amp; other)
{
ptr_ = other.ptr_;
if (ptr_) {
other.shared_count_
-&gt;add_count();
shared_count_ =
other.shared_count_;
}
}
template &lt;typename U&gt;
smart_ptr(const smart_ptr&lt;U&gt;&amp; other)
{
ptr_ = other.ptr_;
if (ptr_) {
other.shared_count_
-&gt;add_count();
shared_count_ =
other.shared_count_;
}
}
template &lt;typename U&gt;
smart_ptr(smart_ptr&lt;U&gt;&amp;&amp; other)
{
ptr_ = other.ptr_;
if (ptr_) {
shared_count_ =
other.shared_count_;
other.ptr_ = nullptr;
}
}
```
除复制指针之外,对于拷贝构造的情况,我们需要在指针非空时把引用数加一,并复制共享计数的指针。对于移动构造的情况,我们不需要调整引用数,直接把 `other.ptr_` 置为空,认为 `other` 不再指向该共享对象即可。
不过,上面的代码有个问题:它不能正确编译。编译器会报错,像:
>
fatal error: ptr_ is a private member of smart_ptr&lt;circle&gt;
错误原因是模板的各个实例间并不天然就有 friend 关系,因而不能互访私有成员 `ptr_``shared_count_`。我们需要在 `smart_ptr` 的定义中显式声明:
```
template &lt;typename U&gt;
friend class smart_ptr;
```
此外,我们之前的实现(类似于单一所有权的 `unique_ptr` )中用 `release` 来手工释放所有权。在目前的引用计数实现中,它就不太合适了,应当删除。但我们要加一个对调试非常有用的函数,返回引用计数值。定义如下:
```
long use_count() const
{
if (ptr_) {
return shared_count_
-&gt;get_count();
} else {
return 0;
}
}
```
这就差不多是一个比较完整的引用计数智能指针的实现了。我们可以用下面的代码来验证一下它的功能正常:
```
class shape {
public:
virtual ~shape() {}
};
class circle : public shape {
public:
~circle() { puts("~circle()"); }
};
int main()
{
smart_ptr&lt;circle&gt; ptr1(new circle());
printf("use count of ptr1 is %ld\n",
ptr1.use_count());
smart_ptr&lt;shape&gt; ptr2;
printf("use count of ptr2 was %ld\n",
ptr2.use_count());
ptr2 = ptr1;
printf("use count of ptr2 is now %ld\n",
ptr2.use_count());
if (ptr1) {
puts("ptr1 is not empty");
}
}
```
这段代码的运行结果是:
>
<p>`use count of ptr1 is 1`<br>
`use count of ptr2 was 0`<br>
`use count of ptr2 is now 2`<br>
`ptr1 is not empty`<br>
`~circle()`</p>
上面我们可以看到引用计数的变化,以及最后对象被成功删除。
## 指针类型转换
对应于 C++ 里的不同的类型强制转换:
- static_cast
- reinterpret_cast
- const_cast
- dynamic_cast
智能指针需要实现类似的函数模板。实现本身并不复杂,但为了实现这些转换,我们需要添加构造函数,允许在对智能指针内部的指针对象赋值时,使用一个现有的智能指针的共享计数。如下所示:
```
template &lt;typename U&gt;
smart_ptr(const smart_ptr&lt;U&gt;&amp; other,
T* ptr)
{
ptr_ = ptr;
if (ptr_) {
other.shared_count_
-&gt;add_count();
shared_count_ =
other.shared_count_;
}
}
```
这样我们就可以实现转换所需的函数模板了。下面实现一个 `dynamic_pointer_cast` 来示例一下:
```
template &lt;typename T, typename U&gt;
smart_ptr&lt;T&gt; dynamic_pointer_cast(
const smart_ptr&lt;U&gt;&amp; other)
{
T* ptr =
dynamic_cast&lt;T*&gt;(other.get());
return smart_ptr&lt;T&gt;(other, ptr);
}
```
在前面的验证代码后面我们可以加上:
```
smart_ptr&lt;circle&gt; ptr3 =
dynamic_pointer_cast&lt;circle&gt;(ptr2);
printf("use count of ptr3 is %ld\n",
ptr3.use_count());
```
编译会正常通过,同时能在输出里看到下面的结果:
>
use count of ptr3 is 3
最后,对象仍然能够被正确删除。这说明我们的实现是正确的。
## 代码列表
为了方便你参考,下面我给出了一个完整的 `smart_ptr` 代码列表:
```
#include &lt;utility&gt; // std::swap
class shared_count {
public:
shared_count() noexcept
: count_(1) {}
void add_count() noexcept
{
++count_;
}
long reduce_count() noexcept
{
return --count_;
}
long get_count() const noexcept
{
return count_;
}
private:
long count_;
};
template &lt;typename T&gt;
class smart_ptr {
public:
template &lt;typename U&gt;
friend class smart_ptr;
explicit smart_ptr(T* ptr = nullptr)
: ptr_(ptr)
{
if (ptr) {
shared_count_ =
new shared_count();
}
}
~smart_ptr()
{
if (ptr_ &amp;&amp;
!shared_count_
-&gt;reduce_count()) {
delete ptr_;
delete shared_count_;
}
}
smart_ptr(const smart_ptr&amp; other)
{
ptr_ = other.ptr_;
if (ptr_) {
other.shared_count_
-&gt;add_count();
shared_count_ =
other.shared_count_;
}
}
template &lt;typename U&gt;
smart_ptr(const smart_ptr&lt;U&gt;&amp; other) noexcept
{
ptr_ = other.ptr_;
if (ptr_) {
other.shared_count_-&gt;add_count();
shared_count_ = other.shared_count_;
}
}
template &lt;typename U&gt;
smart_ptr(smart_ptr&lt;U&gt;&amp;&amp; other) noexcept
{
ptr_ = other.ptr_;
if (ptr_) {
shared_count_ =
other.shared_count_;
other.ptr_ = nullptr;
}
}
template &lt;typename U&gt;
smart_ptr(const smart_ptr&lt;U&gt;&amp; other,
T* ptr) noexcept
{
ptr_ = ptr;
if (ptr_) {
other.shared_count_
-&gt;add_count();
shared_count_ =
other.shared_count_;
}
}
smart_ptr&amp;
operator=(smart_ptr rhs) noexcept
{
rhs.swap(*this);
return *this;
}
T* get() const noexcept
{
return ptr_;
}
long use_count() const noexcept
{
if (ptr_) {
return shared_count_
-&gt;get_count();
} else {
return 0;
}
}
void swap(smart_ptr&amp; rhs) noexcept
{
using std::swap;
swap(ptr_, rhs.ptr_);
swap(shared_count_,
rhs.shared_count_);
}
T&amp; operator*() const noexcept
{
return *ptr_;
}
T* operator-&gt;() const noexcept
{
return ptr_;
}
operator bool() const noexcept
{
return ptr_;
}
private:
T* ptr_;
shared_count* shared_count_;
};
template &lt;typename T&gt;
void swap(smart_ptr&lt;T&gt;&amp; lhs,
smart_ptr&lt;T&gt;&amp; rhs) noexcept
{
lhs.swap(rhs);
}
template &lt;typename T, typename U&gt;
smart_ptr&lt;T&gt; static_pointer_cast(
const smart_ptr&lt;U&gt;&amp; other) noexcept
{
T* ptr = static_cast&lt;T*&gt;(other.get());
return smart_ptr&lt;T&gt;(other, ptr);
}
template &lt;typename T, typename U&gt;
smart_ptr&lt;T&gt; reinterpret_pointer_cast(
const smart_ptr&lt;U&gt;&amp; other) noexcept
{
T* ptr = reinterpret_cast&lt;T*&gt;(other.get());
return smart_ptr&lt;T&gt;(other, ptr);
}
template &lt;typename T, typename U&gt;
smart_ptr&lt;T&gt; const_pointer_cast(
const smart_ptr&lt;U&gt;&amp; other) noexcept
{
T* ptr = const_cast&lt;T*&gt;(other.get());
return smart_ptr&lt;T&gt;(other, ptr);
}
template &lt;typename T, typename U&gt;
smart_ptr&lt;T&gt; dynamic_pointer_cast(
const smart_ptr&lt;U&gt;&amp; other) noexcept
{
T* ptr = dynamic_cast&lt;T*&gt;(other.get());
return smart_ptr&lt;T&gt;(other, ptr);
}
```
如果你足够细心的话,你会发现我在代码里加了不少 `noexcept`。这对这个智能指针在它的目标场景能正确使用是十分必要的。我们会在下面的几讲里回到这个话题。
## 内容小结
这一讲我们从 `shape_wrapper` 出发,实现了一个基本完整的带引用计数的智能指针。这个智能指针跟标准的 `shared_ptr` 比,还缺了一些东西(见参考资料 [2]),但日常用到的智能指针功能已经包含在内。现在,你应当已经对智能指针有一个较为深入的理解了。
## 课后思考
这里留几个问题,你可以思考一下:
1. 不查阅 `shared_ptr` 的文档,你觉得目前 `smart_ptr` 应当添加什么功能吗?
1. 你想到的功能在标准的 `shared_ptr` 里吗?
1. 你觉得智能指针应该满足什么样的线程安全性?
欢迎留言和我交流你的看法。
## 参考资料
[1] Stack Overflow, GManNickGs answer to “What is the copy-and-swap idiom?”. [https://stackoverflow.com/a/3279550/816999](https://stackoverflow.com/a/3279550/816999)
[2] cppreference.com, “std::shared_ptr”. [https://en.cppreference.com/w/cpp/memory/shared_ptr](https://en.cppreference.com/w/cpp/memory/shared_ptr)

View File

@@ -0,0 +1,564 @@
<audio id="audio" title="03 | 右值和移动究竟解决了什么问题?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/78/82/7833d1a19392ab5cdb1cdfd3bd7b3f82.mp3"></audio>
你好,我是吴咏炜。
从上一讲智能指针开始,我们已经或多或少接触了移动语义。本讲我们就完整地讨论一下移动语义和相关的概念。移动语义是 C++11 里引入的一个重要概念;理解这个概念,是理解很多现代 C++ 里的优化的基础。
## 值分左右
我们常常会说C++ 里有左值和右值。这话不完全对。标准里的定义实际更复杂规定了下面这些值类别value categories
<img src="https://static001.geekbang.org/resource/image/18/3c/18b692072537d4ce179d3857a8a0133c.png" alt="">
我们先理解一下这些名词的字面含义:
- 一个 lvalue 是通常可以放在等号左边的表达式,左值
- 一个 rvalue 是通常只能放在等号右边的表达式,右值
- 一个 glvalue 是 generalized lvalue广义左值
- 一个 xvalue 是 expiring lvalue将亡值
- 一个 prvalue 是 pure rvalue纯右值
还是有点晕是吧我们暂且抛开这些概念只看其中两个lvalue 和 prvalue。
左值 lvalue 是有标识符、可以取地址的表达式,最常见的情况有:
- 变量、函数或数据成员的名字
- 返回左值引用的表达式,如 `++x``x = 1``cout &lt;&lt; ' '`
- 字符串字面量如 `"hello world"`
在函数调用时,左值可以绑定到左值引用的参数,如 `T&amp;`。一个常量只能绑定到常左值引用,如 `const T&amp;`
反之,纯右值 prvalue 是没有标识符、不可以取地址的表达式,一般也称之为“临时对象”。最常见的情况有:
- 返回非引用类型的表达式,如 `x++``x + 1``make_shared&lt;int&gt;(42)`
- 除字符串字面量之外的字面量,如 `42``true`
在 C++11 之前右值可以绑定到常左值引用const lvalue reference的参数`const T&amp;`但不可以绑定到非常左值引用non-const lvalue reference`T&amp;`。从 C++11 开始C++ 语言里多了一种引用类型——右值引用。右值引用的形式是 `T&amp;&amp;`,比左值引用多一个 `&amp;` 符号。跟左值引用一样,我们可以使用 `const``volatile` 来进行修饰,但最常见的情况是,我们不会用 `const``volatile` 来修饰右值。本专栏就属于这种情况。
引入一种额外的引用类型当然增加了语言的复杂性,但也带来了很多优化的可能性。由于 C++ 有重载,我们就可以根据不同的引用类型,来选择不同的重载函数,来完成不同的行为。回想一下,在上一讲中,我们就利用了重载,让 `smart_ptr` 的构造函数可以有不同的行为:
```
template &lt;typename U&gt;
smart_ptr(const smart_ptr&lt;U&gt;&amp; other) noexcept
{
ptr_ = other.ptr_;
if (ptr_) {
other.shared_count_-&gt;add_count();
shared_count_ =
other.shared_count_;
}
}
template &lt;typename U&gt;
smart_ptr(smart_ptr&lt;U&gt;&amp;&amp; other) noexcept
{
ptr_ = other.ptr_;
if (ptr_) {
shared_count_ =
other.shared_count_;
other.ptr_ = nullptr;
}
}
```
你可能会好奇,使用右值引用的第二个重载函数中的变量 `other` 算是左值还是右值呢?根据定义,`other` 是个变量的名字,变量有标识符、有地址,所以它还是一个左值——虽然它的类型是右值引用。
尤其重要的是,拿这个 `other` 去调用函数时,它匹配的也会是左值引用。也就是说,**类型是右值引用的变量是一个左值!**这点可能有点反直觉,但跟 C++ 的其他方面是一致的。毕竟对于一个右值引用的变量,你是可以取地址的,这点上它和左值完全一致。稍后我们再回到这个话题上来。
再看一下下面的代码:
```
smart_ptr&lt;shape&gt; ptr1{new circle()};
smart_ptr&lt;shape&gt; ptr2 = std::move(ptr1);
```
第一个表达式里的 `new circle()` 就是一个纯右值;但对于指针,我们通常使用值传递,并不关心它是左值还是右值。
第二个表达式里的 `std::move(ptr)` 就有趣点了。它的作用是把一个左值引用强制转换成一个右值引用,而并不改变其内容。从实用的角度,在我们这儿 `std::move(ptr1)` 等价于 `static_cast&lt;smart_ptr&lt;shape&gt;&amp;&amp;&gt;(ptr1)`。因此,`std::move(ptr1)` 的结果是指向 `ptr1` 的一个右值引用,这样构造 `ptr2` 时就会选择上面第二个重载。
我们可以把 `std::move(ptr1)` 看作是一个有名字的右值。为了跟无名的纯右值 prvalue 相区别C++ 里目前就把这种表达式叫做 xvalue。跟左值 lvalue 不同xvalue 仍然是不能取地址的——这点上xvalue 和 prvalue 相同。所以xvalue 和 prvalue 都被归为右值 rvalue。我们用下面的图来表示会更清楚一点
<img src="https://static001.geekbang.org/resource/image/03/5a/036cc6865a9623a48918b504e408945a.png" alt="">
另外请注意“值类别”value category和“值类型”value type是两个看似相似、却毫不相干的术语。前者指的是上面这些左值、右值相关的概念后者则是与引用类型reference type相对而言表明一个变量是代表实际数值还是引用另外一个数值。在 C++ 里,所有的原生类型、枚举、结构、联合、类都代表值类型,只有引用(`&amp;`)和指针(`*`)才是引用类型。在 Java 里,数字等原生类型是值类型,类则属于引用类型。在 Python 里,一切类型都是引用类型。
## 生命周期和表达式类型
一个变量的生命周期在超出作用域时结束。如果一个变量代表一个对象当然这个对象的生命周期也在那时结束。那临时对象prvalue在这儿C++ 的规则是:一个临时对象会在包含这个临时对象的完整表达式估值完成后、按生成顺序的逆序被销毁,除非有生命周期延长发生。我们先看一个没有生命周期延长的基本情况:
```
process_shape(circle(), triangle());
```
在这儿,我们生成了临时对象,一个圆和一个三角形,它们会在 `process_shape` 执行完成并生成结果对象后被销毁。
我们插入一些实际的代码,就可以演示这一行为:
```
#include &lt;stdio.h&gt;
class shape {
public:
virtual ~shape() {}
};
class circle : public shape {
public:
circle() { puts("circle()"); }
~circle() { puts("~circle()"); }
};
class triangle : public shape {
public:
triangle() { puts("triangle()"); }
~triangle() { puts("~triangle()"); }
};
class result {
public:
result() { puts("result()"); }
~result() { puts("~result()"); }
};
result
process_shape(const shape&amp; shape1,
const shape&amp; shape2)
{
puts("process_shape()");
return result();
}
int main()
{
puts("main()");
process_shape(circle(), triangle());
puts("something else");
}
```
输出结果可能会是(`circle``triangle` 的顺序在标准中没有规定):
>
<p>`main()`<br>
`circle()`<br>
`triangle()`<br>
`process_shape()`<br>
`result()`<br>
`~result()`<br>
`~triangle()`<br>
`~circle()`<br>
`something else`</p>
目前我让 `process_shape` 也返回了一个结果,这是为了下一步演示的需要。你可以看到结果的临时对象最后生成、最先析构。
为了方便对临时对象的使用C++ 对临时对象有特殊的生命周期延长规则。这条规则是:
>
如果一个 prvalue 被绑定到一个引用上,它的生命周期则会延长到跟这个引用变量一样长。
我们对上面的代码只要改一行就能演示这个效果。把 `process_shape` 那行改成:
```
result&amp;&amp; r = process_shape(
circle(), triangle());
```
我们就能看到不同的结果了:
>
<p>`main()`<br>
`circle()`<br>
`triangle()`<br>
`process_shape()`<br>
`result()`<br>
`~triangle()`<br>
`~circle()`<br>
`something else`<br>
`~result()`</p>
现在 `result` 的生成还在原来的位置,但析构被延到了 `main` 的最后。
需要万分注意的是,这条生命期延长规则只对 prvalue 有效,而对 xvalue 无效。如果由于某种原因prvalue 在绑定到引用以前已经变成了 xvalue那生命期就不会延长。不注意这点的话代码就可能会产生隐秘的 bug。比如我们如果这样改一下代码结果就不对了
```
#include &lt;utility&gt; // std::move
result&amp;&amp; r = std::move(process_shape(
circle(), triangle()));
```
这时的代码输出就回到了前一种情况。虽然执行到 something else 那儿我们仍然有一个有效的变量 `r`,但它指向的对象已经不存在了,对 `r` 的解引用是一个未定义行为。由于 `r` 指向的是栈空间,通常不会立即导致程序崩溃,而会在某些复杂的组合条件下才会引致问题……
对 C++ 的这条生命期延长规则在后面讲到视图view的时候会十分有用。那时我们会看到有些 C++ 的用法实际上会隐式地利用这条规则。
此外,参考资料 [5] 中提到了一个有趣的事实:你可以把一个没有虚析构函数的子类对象绑定到基类的引用变量上,这个子类对象的析构仍然是完全正常的——这是因为这条规则只是延后了临时对象的析构而已,不是利用引用计数等复杂的方法,因而只要引用绑定成功,其类型并没有什么影响。
## 移动的意义
上面我们谈了一些语法知识。就跟学外语的语法一样,这些内容是比较枯燥的。虽然这些知识有时有用,但往往要回过头来看的时候才觉得。初学之时,更重要的是理解为什么,和熟练掌握基本的用法。
对于 `smart_ptr`,我们使用右值引用的目的是实现移动,而实现移动的意义是减少运行的开销——在引用计数指针的场景下,这个开销并不大。移动构造和拷贝构造的差异仅在于:
- 少了一次 `other.shared_count_-&gt;add_count()` 的调用
- 被移动的指针被清空,因而析构时也少了一次 `shared_count_-&gt;reduce_count()` 的调用
在使用容器类的情况下,移动更有意义。我们可以尝试分析一下下面这个假想的语句(假设 `name``string` 类型):
```
string result =
string("Hello, ") + name + ".";
```
在 C++11 之前的年代里,这种写法是绝对不推荐的。因为它会引入很多额外开销,执行流程大致如下:
1. 调用构造函数 `string(const char*)`,生成临时对象 1`"Hello, "` 复制 1 次。
1. 调用 `operator+(const string&amp;, const string&amp;)`,生成临时对象 2`"Hello, "` 复制 2 次,`name` 复制 1 次。
1. 调用 `operator+(const string&amp;, const char*)`,生成对象 3`"Hello, "` 复制 3 次,`name` 复制 2 次,`"."` 复制 1 次。
1. 假设返回值优化能够生效(最佳情况),对象 3 可以直接在 `result` 里构造完成。
1. 临时对象 2 析构,释放指向 `string("Hello, ") + name` 的内存。
1. 临时对象 1 析构,释放指向 `string("Hello, ")` 的内存。
既然 C++ 是一门追求性能的语言,一个合格的 C++ 程序员会写:
```
string result = "Hello, ";
result += name;
result += ".";
```
这样的话,只会调用构造函数一次和 `string::operator+=` 两次,没有任何临时对象需要生成和析构,所有的字符串都只复制了一次。但显然代码就啰嗦多了——尤其如果拼接的步骤比较多的话。从 C++11 开始,这不再是必须的。同样上面那个单行的语句,执行流程大致如下:
1. 调用构造函数 `string(const char*)`,生成临时对象 1`"Hello, "` 复制 1 次。
1. 调用 `operator+(string&amp;&amp;, const string&amp;)`,直接在临时对象 1 上面执行追加操作,并把结果移动到临时对象 2`name` 复制 1 次。
1. 调用 `operator+(string&amp;&amp;, const char*)`,直接在临时对象 2 上面执行追加操作,并把结果移动到 `result``"."` 复制 1 次。
1. 临时对象 2 析构,内容已经为空,不需要释放任何内存。
1. 临时对象 1 析构,内容已经为空,不需要释放任何内存。
性能上,所有的字符串只复制了一次;虽然比啰嗦的写法仍然要增加临时对象的构造和析构,但由于这些操作不牵涉到额外的内存分配和释放,是相当廉价的。程序员只需要牺牲一点点性能,就可以大大增加代码的可读性。而且,所谓的性能牺牲,也只是相对于优化得很好的 C 或 C++ 代码而言——这样的 C++ 代码的性能仍然完全可以超越 Python 类的语言的相应代码。
此外很关键的一点是C++ 里的对象缺省都是值语义。在下面这样的代码里:
```
class A {
B b_;
C c_;
};
```
从实际内存布局的角度,很多语言——如 Java 和 Python——会在 `A` 对象里放 `B``C` 的指针(虽然这些语言里本身没有指针的概念)。而 C++ 则会直接把 `B``C` 对象放在 `A` 的内存空间里。这种行为既是优点也是缺点。说它是优点,是因为它保证了内存访问的局域性,而局域性在现代处理器架构上是绝对具有性能优势的。说它是缺点,是因为复制对象的开销大大增加:在 Java 类语言里复制的是指针,在 C++ 里是完整的对象。这就是为什么 C++ 需要移动语义这一优化,而 Java 类语言里则根本不需要这个概念。
一句话总结,移动语义使得在 C++ 里返回大对象(如容器)的函数和运算符成为现实,因而可以提高代码的简洁性和可读性,提高程序员的生产率。
所有的现代 C++ 的标准容器都针对移动进行了优化。
## 如何实现移动?
要让你设计的对象支持移动的话,通常需要下面几步:
- 你的对象应该有分开的拷贝构造和移动构造函数(除非你只打算支持移动,不支持拷贝——如 `unique_ptr`)。
- 你的对象应该有 `swap` 成员函数,支持和另外一个对象快速交换成员。
- 在你的对象的名空间下,应当有一个全局的 `swap` 函数,调用成员函数 `swap` 来实现交换。支持这种用法会方便别人(包括你自己在将来)在其他对象里包含你的对象,并快速实现它们的 `swap` 函数。
- 实现通用的 `operator=`
- 上面各个函数如果不抛异常的话,应当标为 `noexcept`。这对移动构造函数尤为重要。
具体写法可以参考我们当前已经实现的 `smart_ptr`
- `smart_ptr` 有拷贝构造和移动构造函数(虽然此处我们的模板构造函数严格来说不算拷贝或移动构造函数)。移动构造函数应当从另一个对象获取资源,清空其资源,并将其置为一个可析构的状态。
```
smart_ptr(const smart_ptr&amp; other) noexcept
{
ptr_ = other.ptr_;
if (ptr_) {
other.shared_count_
-&gt;add_count();
shared_count_ =
other.shared_count_;
}
}
template &lt;typename U&gt;
smart_ptr(const smart_ptr&lt;U&gt;&amp; other) noexcept
{
ptr_ = other.ptr_;
if (ptr_) {
other.shared_count_
-&gt;add_count();
shared_count_ =
other.shared_count_;
}
}
template &lt;typename U&gt;
smart_ptr(smart_ptr&lt;U&gt;&amp;&amp; other) noexcept
{
ptr_ = other.ptr_;
if (ptr_) {
shared_count_ =
other.shared_count_;
other.ptr_ = nullptr;
}
}
```
- `smart_ptr``swap` 成员函数。
```
void swap(smart_ptr&amp; rhs) noexcept
{
using std::swap;
swap(ptr_, rhs.ptr_);
swap(shared_count_,
rhs.shared_count_);
}
```
- 有支持 `smart_ptr` 的全局 `swap` 函数。
```
template &lt;typename T&gt;
void swap(smart_ptr&lt;T&gt;&amp; lhs,
smart_ptr&lt;T&gt;&amp; rhs) noexcept
{
lhs.swap(rhs);
}
```
- `smart_ptr` 有通用的 `operator=` 成员函数。注意为了避免让人吃惊,通常我们需要将其实现成对 `a = a;` 这样的写法安全。下面的写法算是个小技巧,对传递左值和右值都有效,而且规避了 `if (&amp;rhs != this)` 这样的判断。
```
smart_ptr&amp;
operator=(smart_ptr rhs) noexcept
{
rhs.swap(*this);
return *this;
}
```
## 不要返回本地变量的引用
有一种常见的 C++ 编程错误,是在函数里返回一个本地对象的引用。由于在函数结束时本地对象即被销毁,返回一个指向本地对象的引用属于未定义行为。理论上来说,程序出任何奇怪的行为都是正常的。
在 C++11 之前返回一个本地对象意味着这个对象会被拷贝除非编译器发现可以做返回值优化named return value optimization或 NRVO能把对象直接构造到调用者的栈上。从 C++11 开始,返回值优化仍可以发生,但在没有返回值优化的情况下,编译器将试图把本地对象移动出去,而不是拷贝出去。这一行为不需要程序员手工用 `std::move` 进行干预——使用 `std::move` 对于移动行为没有帮助,反而会影响返回值优化。
下面是个例子:
```
#include &lt;iostream&gt; // std::cout/endl
#include &lt;utility&gt; // std::move
using namespace std;
class Obj {
public:
Obj()
{
cout &lt;&lt; "Obj()" &lt;&lt; endl;
}
Obj(const Obj&amp;)
{
cout &lt;&lt; "Obj(const Obj&amp;)"
&lt;&lt; endl;
}
Obj(Obj&amp;&amp;)
{
cout &lt;&lt; "Obj(Obj&amp;&amp;)" &lt;&lt; endl;
}
};
Obj simple()
{
Obj obj;
// 简单返回对象;一般有 NRVO
return obj;
}
Obj simple_with_move()
{
Obj obj;
// move 会禁止 NRVO
return std::move(obj);
}
Obj complicated(int n)
{
Obj obj1;
Obj obj2;
// 有分支,一般无 NRVO
if (n % 2 == 0) {
return obj1;
} else {
return obj2;
}
}
int main()
{
cout &lt;&lt; "*** 1 ***" &lt;&lt; endl;
auto obj1 = simple();
cout &lt;&lt; "*** 2 ***" &lt;&lt; endl;
auto obj2 = simple_with_move();
cout &lt;&lt; "*** 3 ***" &lt;&lt; endl;
auto obj3 = complicated(42);
}
```
输出通常为:
>
<p>`*** 1 ***`<br>
`Obj()`<br>
`*** 2 ***`<br>
`Obj()`<br>
`Obj(Obj&amp;&amp;)`<br>
`*** 3 ***`<br>
`Obj()`<br>
`Obj()`<br>
`Obj(Obj&amp;&amp;)`</p>
也就是,用了 `std::move` 反而妨碍了返回值优化。
## 引用坍缩和完美转发
最后讲一个略复杂、但又不得不讲的话题,引用坍缩(又称“引用折叠”)。这个概念在泛型编程中是一定会碰到的。我们今天既然讲了左值和右值引用,也需要一起讲一下。
我们已经讲了对于一个实际的类型 `T`,它的左值引用是 `T&amp;`,右值引用是 `T&amp;&amp;`。那么:
1. 是不是看到 `T&amp;`,就一定是个左值引用?
1. 是不是看到 `T&amp;&amp;`,就一定是个右值引用?
对于前者的回答是“是”,对于后者的回答为“否”。
关键在于,在有模板的代码里,对于类型参数的推导结果可能是引用。我们可以略过一些繁复的语法规则,要点是:
- 对于 `template &lt;typename T&gt; foo(T&amp;&amp;)` 这样的代码,如果传递过去的参数是左值,`T` 的推导结果是左值引用;如果传递过去的参数是右值,`T` 的推导结果是参数的类型本身。
- 如果 `T` 是左值引用,那 `T&amp;&amp;` 的结果仍然是左值引用——即 `type&amp; &amp;&amp;` 坍缩成了 `type&amp;`
- 如果 `T` 是一个实际类型,那 `T&amp;&amp;` 的结果自然就是一个右值引用。
我们之前提到过,右值引用变量仍然会匹配到左值引用上去。下面的代码会验证这一行为:
```
void foo(const shape&amp;)
{
puts("foo(const shape&amp;)");
}
void foo(shape&amp;&amp;)
{
puts("foo(shape&amp;&amp;)");
}
void bar(const shape&amp; s)
{
puts("bar(const shape&amp;)");
foo(s);
}
void bar(shape&amp;&amp; s)
{
puts("bar(shape&amp;&amp;)");
foo(s);
}
int main()
{
bar(circle());
}
```
输出为:
>
<p>`bar(shape&amp;&amp;)`<br>
`foo(const shape&amp;)`</p>
如果我们要让 `bar` 调用右值引用的那个 foo 的重载,我们必须写成:
```
foo(std::move(s));
```
或:
```
foo(static_cast&lt;shape&amp;&amp;&gt;(s));
```
可如果两个 `bar` 的重载除了调用 `foo` 的方式不一样,其他都差不多的话,我们为什么要提供两个不同的 `bar` 呢?
事实上,很多标准库里的函数,连目标的参数类型都不知道,但我们仍然需要能够保持参数的值类别:左值的仍然是左值,右值的仍然是右值。这个功能在 C++ 标准库中已经提供了,叫 `std::forward`。它和 `std::move` 一样都是利用引用坍缩机制来实现。此处,我们不介绍其实现细节,而是重点展示其用法。我们可以把我们的两个 `bar` 函数简化成:
```
template &lt;typename T&gt;
void bar(T&amp;&amp; s)
{
foo(std::forward&lt;T&gt;(s));
}
```
对于下面这样的代码:
```
circle temp;
bar(temp);
bar(circle());
```
现在的输出是:
>
<p>`foo(const shape&amp;)`<br>
`foo(shape&amp;&amp;)`</p>
一切如预期一样。
因为在 `T` 是模板参数时,`T&amp;&amp;` 的作用主要是保持值类别进行转发它有个名字就叫“转发引用”forwarding reference。因为既可以是左值引用也可以是右值引用它也曾经被叫做“万能引用”universal reference
## 内容小结
本讲介绍了 C++ 里的值类别,重点介绍了临时变量、右值引用、移动语义和实际的编程用法。由于这是 C++11 里的重点功能,你对于其基本用法需要牢牢掌握。
## 课后思考
留给你两道思考题:
1. 请查看一下标准函数模板 `make_shared` 的声明,然后想一想,这个函数应该是怎样实现的。
1. 为什么 `smart_ptr::operator=` 对左值和右值都有效,而且不需要对等号两边是否引用同一对象进行判断?
欢迎留言和我交流你的看法,尤其是对第二个问题。
## 参考资料
[1] cppreference.com, “Value categories”. [https://en.cppreference.com/w/cpp/language/value_category](https://en.cppreference.com/w/cpp/language/value_category)
[1a] cppreference.com, “值类别”. [https://zh.cppreference.com/w/cpp/language/value_category](https://zh.cppreference.com/w/cpp/language/value_category)
[2] Anders Schau Knatten, “lvalues, rvalues, glvalues, prvalues, xvalues, help!”. [https://blog.knatten.org/2018/03/09/lvalues-rvalues-glvalues-prvalues-xvalues-help/](https://blog.knatten.org/2018/03/09/lvalues-rvalues-glvalues-prvalues-xvalues-help/)
[3] Jeaye, “Value category cheat-sheet”. [https://blog.jeaye.com/2017/03/19/xvalues/](https://blog.jeaye.com/2017/03/19/xvalues/)
[4] Thomas Becker, “C++ rvalue references explained”. [http://thbecker.net/articles/rvalue_references/section_01.html](http://thbecker.net/articles/rvalue_references/section_01.html)
[5] Herb Sutter, “GotW #88: A candidate for the most important const”. [https://herbsutter.com/2008/01/01/gotw-88-a-candidate-for-the-most-important-const/](https://herbsutter.com/2008/01/01/gotw-88-a-candidate-for-the-most-important-const/)

View File

@@ -0,0 +1,446 @@
<audio id="audio" title="04 | 容器汇编 I比较简单的若干容器" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d8/ed/d8e06a7a49fccf72ff930414d7c23ced.mp3"></audio>
你好,我是吴咏炜。
上几讲我们学习了 C++ 的资源管理和值类别。今天我们换一个话题,来看一下 C++ 里的容器。
关于容器,已经存在不少的学习资料了。在 cppreference 上有很完备的参考资料([1])。今天我们采取一种非正规的讲解方式,尽量不重复已有的参考资料,而是让你加深对于重要容器的理解。
对于容器,学习上的一个麻烦点是你无法直接输出容器的内容——如果你定义了一个 `vector&lt;int&gt; v`,你是没法简单输出 `v` 的内容的。有人也许会说用 `copy(v.begin(), v.end(), ostream_iterator(…))`,可那既啰嗦,又对像 `map``vector&lt;vector&lt;…&gt;&gt;` 这样的复杂类型无效。因此,我们需要一个更好用的工具。在此,我向你大力推荐 xeus-cling [2]。它的便利性无与伦比——你可以直接在浏览器里以交互的方式运行代码不需要本机安装任何编译器点击“Trying it online”下面的 binder 链接)。下面是在线运行的一个截图:
<img src="https://static001.geekbang.org/resource/image/71/ca/7199bb5b1394fe1aa9f91b850c309eca.png" alt="">
xeus-cling 也可以在本地安装。对于使用 Linux 的同学,安装应当是相当便捷的。有兴趣的话,使用其他平台的同学也可以尝试一下。
如果你既没有本地运行的条件,也不方便远程使用互联网来运行代码,我个人还为本专栏写了一个小小的工具 [3]。在你的代码中包含这个头文件,也可以方便地得到类似于上面的输出。示例代码如下所示:
```
#include &lt;iostream&gt;
#include &lt;map&gt;
#include &lt;vector&gt;
#include "output_container.h"
using namespace std;
int main()
{
map&lt;int, int&gt; mp{
{1, 1}, {2, 4}, {3, 9}};
cout &lt;&lt; mp &lt;&lt; endl;
vector&lt;vector&lt;int&gt;&gt; vv{
{1, 1}, {2, 4}, {3, 9}};
cout &lt;&lt; vv &lt;&lt; endl;
}
```
我们会得到下面的输出:
>
<p>`{ 1 =&gt; 1, 2 =&gt; 4, 3 =&gt; 9 }`<br>
`{ { 1, 1 }, { 2, 4 }, { 3, 9 } }`</p>
这个代码中用到了很多我们目前专栏还没有讲的知识,所以你暂且不用关心它的实现原理。如果你能看得懂这个代码,那就太棒了。如果你看不懂,唔,不急,慢慢来,你会明白的。
工具在手,天下我有。下面我们正式开讲容器篇。
## string
`string` 一般并不被认为是一个 C++ 的容器。但鉴于其和容器有很多共同点,我们先拿 `string` 类来开说。
`string` 是模板 `basic_string` 对于 `char` 类型的特化,可以认为是一个只存放字符 `char` 类型数据的容器。“真正”的容器类与 `string` 的最大不同点是里面可以存放任意类型的对象。
跟其他大部分容器一样, `string` 具有下列成员函数:
- `begin` 可以得到对象起始点
- `end` 可以得到对象的结束点
- `empty` 可以得到容器是否为空
- `size` 可以得到容器的大小
- `swap` 可以和另外一个容器交换其内容
(对于不那么熟悉容器的人,需要知道 C++ 的 `begin``end` 是半开半闭区间:在容器非空时,`begin` 指向第一个元素,而 `end` 指向最后一个元素后面的位置;在容器为空时,`begin` 等于 `end`。在 `string` 的情况下,由于考虑到和 C 字符串的兼容,`end` 指向代表字符串结尾的 `\0` 字符。)
上面就几乎是所有容器的共同点了。也就是说:
- 容器都有开始和结束点
- 容器会记录其状态是否非空
- 容器有大小
- 容器支持交换
当然,这只是容器的“共同点”而已。每个容器都有其特殊的用途。
`string` 的内存布局大致如下图所示:
<img src="https://static001.geekbang.org/resource/image/ee/62/eec393f933220a9998b7235c8acc1862.png" alt="">
下面你会看到,不管是内存布局,还是成员函数,`string``vector` 是非常相似的。
`string` 当然是为了存放字符串。和简单的 C 字符串不同:
- `string` 负责自动维护字符串的生命周期
- `string` 支持字符串的拼接操作(如之前说过的 `+``+=`
- `string` 支持字符串的查找操作(如 `find``rfind`
- `string` 支持从 `istream` 安全地读入字符串(使用 `getline`
- `string` 支持给期待 `const char*` 的接口传递字符串内容(使用 `c_str`
- `string` 支持到数字的互转(`stoi` 系列函数和 `to_string`
- 等等
推荐你在代码中尽量使用 `string` 来管理字符串。不过,对于对外暴露的接口,情况有一点复杂。我一般不建议在接口中使用 `const string&amp;`,除非确知调用者已经持有 `string`:如果函数里不对字符串做复杂处理的话,使用 `const char*` 可以避免在调用者只有 C 字符串时编译器自动构造 `string`,这种额外的构造和析构代价并不低。反过来,如果实现较为复杂、希望使用 `string` 的成员函数的话,那就应该考虑下面的策略:
- 如果不修改字符串的内容,使用 `const string&amp;` 或 C++17 的 `string_view` 作为参数类型。后者是最理想的情况,因为即使在只有 C 字符串的情况,也不会引发不必要的内存复制。
- 如果需要在函数内修改字符串内容、但不影响调用者的该字符串,使用 `string` 作为参数类型(自动拷贝)。
- 如果需要改变调用者的字符串内容,使用 `string&amp;` 作为参数类型(通常不推荐)。
估计大部分同学对 `string` 已经很熟悉了。我们在此只给出一个非常简单的小例子:
```
string name;
cout &lt;&lt; "What's your name? ";
getline(cin, name);
cout &lt;&lt; "Nice to meet you, " &lt;&lt; name
&lt;&lt; "!\n";
```
## vector
`vector` 应该是最常用的容器了。它的名字“向量”来源于数学术语,但在实际应用中,我们把它当成动态数组更为合适。它基本相当于 Java 的 `ArrayList` 和 Python 的 `list`
`string` 相似,`vector` 的成员在内存里连续存放,同时 `begin``end``front``back` 成员函数指向的位置也和 `string` 一样,大致如下图所示:
<img src="https://static001.geekbang.org/resource/image/24/10/247951f886561c30ced2eb7700f9d510.png" alt="">
除了容器类的共同点,`vector` 允许下面的操作(不完全列表):
- 可以使用中括号的下标来访问其成员(同 `string`
- 可以使用 `data` 来获得指向其内容的裸指针(同 `string`
- 可以使用 `capacity` 来获得当前分配的存储空间的大小,以元素数量计(同 `string`
- 可以使用 `reserve` 来改变所需的存储空间的大小,成功后 `capacity()` 会改变(同 `string`
- 可以使用 `resize` 来改变其大小,成功后 `size()` 会改变(同 `string`
- 可以使用 `pop_back` 来删除最后一个元素(同 `string`
- 可以使用 `push_back` 在尾部插入一个元素(同 `string`
- 可以使用 `insert` 在指定位置前插入一个元素(同 `string`
- 可以使用 `erase` 在指定位置删除一个元素(同 `string`
- 可以使用 `emplace` 在指定位置构造一个元素
- 可以使用 `emplace_back` 在尾部新构造一个元素
大家可以留意一下 `push_…``pop_…` 成员函数。它们存在时,说明容器对指定位置的删除和插入性能较高。`vector` 适合在尾部操作,这是它的内存布局决定的。只有在尾部插入和删除时,其他元素才会不需要移动,除非内存空间不足导致需要重新分配内存空间。
`push_back``insert``reserve``resize` 等函数导致内存重分配时,或当 `insert``erase` 导致元素位置移动时,`vector` 会试图把元素“移动”到新的内存区域。`vector` 通常保证强异常安全性,如果元素类型没有提供一个**保证不抛异常的移动构造函数**`vector` 通常会使用拷贝构造函数。因此,对于拷贝代价较高的自定义元素类型,我们应当定义移动构造函数,并标其为 `noexcept`,或只在容器中放置对象的智能指针。这就是为什么我之前需要在 `smart_ptr` 的实现中标上 `noexcept` 的原因。
下面的代码可以演示这一行为:
```
#include &lt;iostream&gt;
#include &lt;vector&gt;
using namespace std;
class Obj1 {
public:
Obj1()
{
cout &lt;&lt; "Obj1()\n";
}
Obj1(const Obj1&amp;)
{
cout &lt;&lt; "Obj1(const Obj1&amp;)\n";
}
Obj1(Obj1&amp;&amp;)
{
cout &lt;&lt; "Obj1(Obj1&amp;&amp;)\n";
}
};
class Obj2 {
public:
Obj2()
{
cout &lt;&lt; "Obj2()\n";
}
Obj2(const Obj2&amp;)
{
cout &lt;&lt; "Obj2(const Obj2&amp;)\n";
}
Obj2(Obj2&amp;&amp;) noexcept
{
cout &lt;&lt; "Obj2(Obj2&amp;&amp;)\n";
}
};
int main()
{
vector&lt;Obj1&gt; v1;
v1.reserve(2);
v1.emplace_back();
v1.emplace_back();
v1.emplace_back();
vector&lt;Obj2&gt; v2;
v2.reserve(2);
v2.emplace_back();
v2.emplace_back();
v2.emplace_back();
}
```
我们可以立即得到下面的输出:
>
<p>`Obj1()`<br>
`Obj1()`<br>
`Obj1()`<br>
`Obj1(const Obj1&amp;)`<br>
`Obj1(const Obj1&amp;)`<br>
`Obj2()`<br>
`Obj2()`<br>
`Obj2()`<br>
`Obj2(Obj2&amp;&amp;)`<br>
`Obj2(Obj2&amp;&amp;)`</p>
`Obj1``Obj2` 的定义只差了一个 `noexcept`,但这个小小的差异就导致了 `vector` 是否会移动对象。这点非常重要。
C++11 开始提供的 `emplace…` 系列函数是为了提升容器的性能而设计的。你可以试试把 `v1.emplace_back()` 改成 `v1.push_back(Obj1())`。对于 `vector` 里的内容,结果是一样的;但使用 `push_back` 会额外生成临时对象,多一次(移动或拷贝)构造和析构。如果是移动的情况,那会有小幅性能损失;如果对象没有实现移动的话,那性能差异就可能比较大了。
现代处理器的体系架构使得对连续内存访问的速度比不连续的内存要快得多。因而,`vector` 的连续内存使用是它的一大优势所在。当你不知道该用什么容器时,缺省就使用 `vector` 吧。
`vector` 的一个主要缺陷是大小增长时导致的元素移动。如果可能,尽早使用 `reserve` 函数为 `vector` 保留所需的内存,这在 `vector` 预期会增长很大时能带来很大的性能提升。
## deque
`deque` 的意思是 double-ended queue双端队列。它主要是用来满足下面这个需求
- 容器不仅可以从尾部自由地添加和删除元素,也可以从头部自由地添加和删除。
`deque` 的接口和 `vector` 相比,有如下的区别:
- `deque` 提供 `push_front``emplace_front``pop_front` 成员函数。
- `deque` 不提供 `data``capacity``reserve` 成员函数。
`deque` 的内存布局一般是这样的:
<img src="https://static001.geekbang.org/resource/image/ae/a1/aea92e7e005d65fa56d6332bb7706fa1.png" alt="">
可以看到:
- 如果只从头、尾两个位置对 `deque` 进行增删操作的话,容器里的对象永远不需要移动。
- 容器里的元素只是部分连续的(因而没法提供 `data` 成员函数)。
- 由于元素的存储大部分仍然连续,它的遍历性能是比较高的。
- 由于每一段存储大小相等,`deque` 支持使用下标访问容器元素,大致相当于 `index[i / chunk_size][i % chunk_size]`,也保持高效。
如果你需要一个经常在头尾增删元素的容器,那 `deque` 会是个合适的选择。
## list
`list` 在 C++ 里代表双向链表。和 `vector` 相比,它优化了在容器中间的插入和删除:
- `list` 提供高效的、O(1) 复杂度的任意位置的插入和删除操作。
- `list` 不提供使用下标访问其元素。
- `list` 提供 `push_front``emplace_front``pop_front` 成员函数(和 `deque` 相同)。
- `list` 不提供 `data``capacity``reserve` 成员函数(和 `deque` 相同)。
它的内存布局一般是下图这个样子:
<img src="https://static001.geekbang.org/resource/image/ad/14/addb521e20de1a302206f4286eac3914.png" alt="">
需要指出的是,虽然 `list` 提供了任意位置插入新元素的灵活性,但由于每个元素的内存空间都是单独分配、不连续,它的遍历性能比 `vector``deque` 都要低。这在很大程度上抵消了它在插入和删除操作时不需要移动元素的理论性能优势。如果你不太需要遍历容器、又需要在中间频繁插入或删除元素,可以考虑使用 `list`
另外一个需要注意的地方是,因为某些标准算法在 `list` 上会导致问题list 提供了成员函数作为替代,包括下面几个:
- `merge`
- `remove`
- `remove_if`
- `reverse`
- `sort`
- `unique`
下面是一个示例(以 xeus-cling 的交互为例):
```
#include &lt;algorithm&gt;
#include &lt;list&gt;
#include &lt;vector&gt;
using namespace std;
```
```
list&lt;int&gt; lst{1, 7, 2, 8, 3};
vector&lt;int&gt; vec{1, 7, 2, 8, 3};
```
```
sort(vec.begin(), vec.end()); // 正常
// sort(lst.begin(), lst.end()); // 会出错
lst.sort(); // 正常
```
```
lst // 输出 { 1, 2, 3, 7, 8 }
```
```
vec // 输出 { 1, 2, 3, 7, 8 }
```
如果不用 xeus-cling 的话,我们需要做点转换:
-`using namespace std;` 后面的部分放到 `main` 函数里。
- 文件开头加上 `#include "output_container.h"``#include &lt;iostream&gt;`
- 把输出语句改写成 `cout &lt;&lt; … &lt;&lt; endl;`
这次我会给一下改造的示例(下次就请你自行改写了😉):
```
#include "output_container.h"
#include &lt;iostream&gt;
#include &lt;algorithm&gt;
#include &lt;list&gt;
#include &lt;vector&gt;
using namespace std;
int main()
{
list&lt;int&gt; lst{1, 7, 2, 8, 3};
vector&lt;int&gt; vec{1, 7, 2, 8, 3};
sort(vec.begin(), vec.end()); // 正常
// sort(lst.begin(), lst.end()); // 会出错
lst.sort(); // 正常
cout &lt;&lt; lst &lt;&lt; endl;
// 输出 { 1, 2, 3, 7, 8 }
cout &lt;&lt; vec &lt;&lt; endl;
// 输出 { 1, 2, 3, 7, 8 }
}
```
## forward_list
既然 `list` 是双向链表,那么 C++ 里有没有单向链表呢?答案是肯定的。从 C++11 开始,前向列表 `forward_list` 成了标准的一部分。
我们先看一下它的内存布局:
<img src="https://static001.geekbang.org/resource/image/ef/4e/ef23c4d60940c170629cf65771df084e.png" alt="">
大部分 C++ 容器都支持 `insert` 成员函数,语义是从指定的位置之前插入一个元素。对于 `forward_list`,这不是一件容易做到的事情(想一想,为什么?)。标准库提供了一个 `insert_after` 作为替代。此外,它跟 `list` 相比还缺了下面这些成员函数:
- `back`
- `size`
- `push_back`
- `emplace_back`
- `pop_back`
为什么会需要这么一个阉割版的 list 呢?原因是,在元素大小较小的情况下,`forward_list` 能节约的内存是非常可观的;在列表不长的情况下,不能反向查找也不是个大问题。提高内存利用率,往往就能提高程序性能,更不用说在内存可能不足时的情况了。
目前你只需要知道这个东西的存在就可以了。如果你觉得不需要用到它的话,也许你真的不需要它。
## queue
在结束本讲之前我们再快速讲两个类容器。它们的特别点在于它们都不是完整的实现而是依赖于某个现有的容器因而被称为容器适配器container adaptor
我们先看一下队列 `queue`先进先出FIFO的数据结构。
`queue` 缺省用 `deque` 来实现。它的接口跟 `deque` 比,有如下改变:
- 不能按下标访问元素
- 没有 `begin``end` 成员函数
-`emplace` 替代了 `emplace_back`,用 `push` 替代了 `push_back`,用 `pop` 替代了 `pop_front`;没有其他的 `push_…``pop_…``emplace…``insert``erase` 函数
它的实际内存布局当然是随底层的容器而定的。从概念上讲,它的结构可如下所示:
<img src="https://static001.geekbang.org/resource/image/09/ff/090f23e3b4cdd8d297e4b970cbbf6cff.png" alt="">
鉴于 `queue` 不提供 `begin``end` 方法,无法无损遍历,我们只能用下面的代码约略展示一下其接口:
```
#include &lt;iostream&gt;
#include &lt;queue&gt;
int main()
{
std::queue&lt;int&gt; q;
q.push(1);
q.push(2);
q.push(3);
while (!q.empty()) {
std::cout &lt;&lt; q.front()
&lt;&lt; std::endl;
q.pop();
}
}
```
这个代码的输出就不用解释了吧。哈哈。
## stack
类似地,栈 `stack` 是后进先出LIFO的数据结构。
`stack` 缺省也是用 `deque` 来实现,但它的概念和 `vector` 更相似。它的接口跟 `vector` 比,有如下改变:
- 不能按下标访问元素
- 没有 `begin``end` 成员函数
- `back` 成了 `top`,没有 `front`
-`emplace` 替代了 `emplace_back`,用 `push` 替代了 `push_back`,用 `pop` 替代了 `pop_back`;没有其他的 `push_…``pop_…``emplace…``insert``erase` 函数
一般图形表示法会把 `stack` 表示成一个竖起的 `vector`
<img src="https://static001.geekbang.org/resource/image/68/47/68026091ff5eacac00c49d75151fff47.png" alt="">
这里有一个小细节需要注意。`stack` 跟我们前面讨论内存管理时的栈有一个区别:在这里下面是低地址,向上则地址增大;而我们讨论内存管理时,高地址在下面,向上则地址减小,方向正好相反。提这一点,是希望你在有需要检查栈结构时不会因此而发生混淆;在使用 `stack` 时,这个区别通常无关紧要。
示例代码和上面的 `queue` 相似,但输出正好相反:
```
#include &lt;iostream&gt;
#include &lt;stack&gt;
int main()
{
std::stack&lt;int&gt; s;
s.push(1);
s.push(2);
s.push(3);
while (!s.empty()) {
std::cout &lt;&lt; s.top()
&lt;&lt; std::endl;
s.pop();
}
}
```
## 内容小结
本讲我们介绍了 C++ 里面的序列容器和两个容器适配器。通过本讲的介绍,你应该已经对容器有了一定的理解和认识。下一讲我们会讲完剩余的标准容器。
## 课后思考
留几个问题请你思考一下:
1. 今天讲的容器有哪些共同的特点?
1. 为什么 C++ 有这么多不同的序列容器类型?
1. 为什么 `stack`(或 `queue`)的 `pop` 函数返回类型为 `void`,而不是直接返回容器的 `top`(或 `front`)成员?
欢迎留言和我交流你的看法。
## 参考资料
[1] cppreference.com, “Containers library”. [https://en.cppreference.com/w/cpp/container](https://en.cppreference.com/w/cpp/container)
[1a] cppreference.com, “容器库”. [https://zh.cppreference.com/w/cpp/container](https://zh.cppreference.com/w/cpp/container)
[2] QuantStack, xeus-cling. [https://github.com/QuantStack/xeus-cling](https://github.com/QuantStack/xeus-cling)
[3] 吴咏炜, output_container. [https://github.com/adah1972/output_container/blob/master/output_container.h](https://github.com/adah1972/output_container/blob/master/output_container.h)

View File

@@ -0,0 +1,606 @@
<audio id="audio" title="05 | 容器汇编 II需要函数对象的容器" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c0/a8/c0be78d32a3c20c24679e93c0f151ba8.mp3"></audio>
你好,我是吴咏炜。
上一讲我们学习了 C++ 的序列容器和两个容器适配器,今天我们继续讲完剩下的标准容器([1])。
## 函数对象及其特化
在讲容器之前,我们需要首先来讨论一下两个重要的函数对象,`less``hash`
我们先看一下 `less`,小于关系。在标准库里,通用的 `less` 大致是这样定义的:
```
template &lt;class T&gt;
struct less
: binary_function&lt;T, T, bool&gt; {
bool operator()(const T&amp; x,
const T&amp; y) const
{
return x &lt; y;
}
};
```
也就是说,`less` 是一个函数对象,并且是个二元函数,执行对任意类型的值的比较,返回布尔类型。作为函数对象,它定义了函数调用运算符(`operator()`),并且缺省行为是对指定类型的对象进行 `&lt;` 的比较操作。
有点平淡无奇是吧原因是因为这个缺省实现在大部分情况下已经够用我们不太需要去碰它。在需要大小比较的场合C++ 通常默认会使用 `less`,包括我们今天会讲到的若干容器和排序算法 `sort`。如果我们需要产生相反的顺序的话,则可以使用 `greater`,大于关系。
计算哈希值的函数对象 `hash` 就不一样了。它的目的是把一个某种类型的值转换成一个无符号整数哈希值,类型为 `size_t`。它没有一个可用的默认实现。对于常用的类型,系统提供了需要的特化 [2],类似于:
```
template &lt;class T&gt; struct hash;
template &lt;&gt;
struct hash&lt;int&gt;
: public unary_function&lt;int, size_t&gt; {
size_t operator()(int v) const
noexcept
{
return static_cast&lt;size_t&gt;(v);
}
};
```
这当然是一个极其简单的例子。更复杂的类型,如指针或者 `string` 的特化,都会更复杂。要点是,对于每个类,类的作者都可以提供 `hash` 的特化,使得对于不同的对象值,函数调用运算符都能得到尽可能均匀分布的不同数值。
我们用下面这个例子来加深一下理解:
```
#include &lt;algorithm&gt; // std::sort
#include &lt;functional&gt; // std::less/greater/hash
#include &lt;iostream&gt; // std::cout/endl
#include &lt;string&gt; // std::string
#include &lt;vector&gt; // std::vector
#include "output_container.h"
using namespace std;
int main()
{
// 初始数组
vector&lt;int&gt; v{13, 6, 4, 11, 29};
cout &lt;&lt; v &lt;&lt; endl;
// 从小到大排序
sort(v.begin(), v.end());
cout &lt;&lt; v &lt;&lt; endl;
// 从大到小排序
sort(v.begin(), v.end(),
greater&lt;int&gt;());
cout &lt;&lt; v &lt;&lt; endl;
cout &lt;&lt; hex;
auto hp = hash&lt;int*&gt;();
cout &lt;&lt; "hash(nullptr) = "
&lt;&lt; hp(nullptr) &lt;&lt; endl;
cout &lt;&lt; "hash(v.data()) = "
&lt;&lt; hp(v.data()) &lt;&lt; endl;
cout &lt;&lt; "v.data() = "
&lt;&lt; static_cast&lt;void*&gt;(v.data())
&lt;&lt; endl;
auto hs = hash&lt;string&gt;();
cout &lt;&lt; "hash(\"hello\") = "
&lt;&lt; hs(string("hello")) &lt;&lt; endl;
cout &lt;&lt; "hash(\"hellp\") = "
&lt;&lt; hs(string("hellp")) &lt;&lt; endl;
}
```
在 MSVC 下的某次运行结果如下所示:
>
<p>`{ 13, 6, 4, 11, 29 }`<br>
`{ 4, 6, 11, 13, 29 }`<br>
`{ 29, 13, 11, 6, 4 }`<br>
`hash(nullptr) = a8c7f832281a39c5`<br>
`hash(v.data()) = 7a0bdfd7df0923d2`<br>
`v.data() = 000001EFFB10EAE0`<br>
`hash("hello") = a430d84680aabd0b`<br>
`hash("hellp") = a430e54680aad322`</p>
可以看到,在这个实现里,空指针的哈希值是一个非零的数值,指针的哈希值也和指针的数值不一样。要注意不同的实现处理的方式会不一样。事实上,我的测试结果是 GCC、Clang 和 MSVC 对常见类型的哈希方式都各有不同。
在上面的例子里,我们同时可以看到,这两个函数对象的值不重要。我们甚至可以认为,每个 `less`(或 `greater``hash`)对象都是等价的。关键在于其类型。以 `sort` 为例,第三个参数的类型确定了其排序行为。
对于容器也是如此,函数对象的类型确定了容器的行为。
## priority_queue
`priority_queue` 也是一个容器适配器。上一讲没有和其他容器适配器一起讲的原因就在于它用到了比较函数对象(默认是 `less`)。它和 `stack` 相似,支持 `push``pop``top` 等有限的操作,但容器内的顺序既不是后进先出,也不是先进先出,而是(部分)排序的结果。在使用缺省的 `less` 作为其 `Compare` 模板参数时,最大的数值会出现在容器的“顶部”。如果需要最小的数值出现在容器顶部,则可以传递 `greater` 作为其 `Compare` 模板参数。
下面的代码可以演示其功能:
```
#include &lt;functional&gt; // std::greater
#include &lt;iostream&gt; // std::cout/endl
#include &lt;memory&gt; // std::pair
#include &lt;queue&gt; // std::priority_queue
#include &lt;vector&gt; // std::vector
#include "output_container.h"
using namespace std;
int main()
{
priority_queue&lt;
pair&lt;int, int&gt;,
vector&lt;pair&lt;int, int&gt;&gt;,
greater&lt;pair&lt;int, int&gt;&gt;&gt;
q;
q.push({1, 1});
q.push({2, 2});
q.push({0, 3});
q.push({9, 4});
while (!q.empty()) {
cout &lt;&lt; q.top() &lt;&lt; endl;
q.pop();
}
}
```
输出为:
>
<p>`(0, 3)`<br>
`(1, 1)`<br>
`(2, 2)`<br>
`(9, 4)`</p>
## 关联容器
关联容器有 `set`(集合)、`map`(映射)、`multiset`(多重集)和 `multimap`(多重映射)。跳出 C++ 的语境,`map`(映射)的更常见的名字是关联数组和字典 [3],而在 JSON 里直接被称为对象object。在 C++ 外这些容器常常是无序的;在 C++ 里关联容器则被认为是有序的。
我们可以通过以下的 xeus-cling 交互来体会一下。
```
#include &lt;functional&gt;
#include &lt;map&gt;
#include &lt;set&gt;
#include &lt;string&gt;
using namespace std;
```
```
set&lt;int&gt; s{1, 1, 1, 2, 3, 4};
```
```
s
```
>
`{ 1, 2, 3, 4 }`
```
multiset&lt;int, greater&lt;int&gt;&gt; ms{1, 1, 1, 2, 3, 4};
```
```
ms
```
>
`{ 4, 3, 2, 1, 1, 1 }`
```
map&lt;string, int&gt; mp{
{"one", 1},
{"two", 2},
{"three", 3},
{"four", 4}
};
```
```
mp
```
>
`{ "four" =&gt; 4, "one" =&gt; 1, "three" =&gt; 3, "two" =&gt; 2 }`
```
mp.insert({"four", 4});
```
```
mp
```
>
`{ "four" =&gt; 4, "one" =&gt; 1, "three" =&gt; 3, "two" =&gt; 2 }`
```
mp.find("four") == mp.end()
```
>
`false`
```
mp.find("five") == mp.end()
```
>
`(bool) true`
```
mp["five"] = 5;
```
```
mp
```
>
`{ "five" =&gt; 5, "four" =&gt; 4, "one" =&gt; 1, "three" =&gt; 3, "two" =&gt; 2 }`
```
multimap&lt;string, int&gt; mmp{
{"one", 1},
{"two", 2},
{"three", 3},
{"four", 4}
};
```
```
mmp
```
>
`{ "four" =&gt; 4, "one" =&gt; 1, "three" =&gt; 3, "two" =&gt; 2 }`
```
mmp.insert({"four", -4});
```
```
mmp
```
>
`{ "four" =&gt; 4, "four" =&gt; -4, "one" =&gt; 1, "three" =&gt; 3, "two" =&gt; 2 }`
可以看到关联容器是一种有序的容器。名字带“multi”的允许键重复不带的不允许键重复。`set``multiset` 只能用来存放键,而 `map``multimap` 则存放一个个键值对。
与序列容器相比,关联容器没有前、后的概念及相关的成员函数,但同样提供 `insert``emplace` 等成员函数。此外,关联容器都有 `find``lower_bound``upper_bound` 等查找函数,结果是一个迭代器:
- `find(k)` 可以找到任何一个等价于查找键 k 的元素(`!(x &lt; k || k &lt; x)`
- `lower_bound(k)` 找到第一个不小于查找键 k 的元素(`!(x &lt; k)`
- `upper_bound(k)` 找到第一个大于查找键 k 的元素(`k &lt; x`
```
mp.find("four")-&gt;second
```
>
`4`
```
mp.lower_bound("four")-&gt;second
```
>
`4`
```
(--mp.upper_bound("four"))-&gt;second
```
>
`4`
```
mmp.lower_bound("four")-&gt;second
```
>
`4`
```
(--mmp.upper_bound("four"))-&gt;second
```
>
`-4`
如果你需要在 `multimap` 里精确查找满足某个键的区间的话,建议使用 `equal_range`,可以一次性取得上下界(半开半闭)。如下所示:
```
#include &lt;tuple&gt;
multimap&lt;string, int&gt;::iterator
lower, upper;
std::tie(lower, upper) =
mmp.equal_range("four");
```
```
(lower != upper) // 检测区间非空
```
>
`true`
```
lower-&gt;second
```
>
`4`
```
(--upper)-&gt;second
```
>
`-4`
如果在声明关联容器时没有提供比较类型的参数,缺省使用 `less` 来进行排序。如果键的类型提供了比较算符 `&lt;` 的重载,我们不需要做任何额外的工作。否则,我们就需要对键类型进行 `less` 的特化,或者提供一个其他的函数对象类型。
对于自定义类型,我推荐尽量使用标准的 `less` 实现,通过重载 `&lt;`及其他标准比较运算符对该类型的对象进行排序。存储在关联容器中的键一般应满足严格弱序关系strict weak ordering[4]),即:
- 对于任何该类型的对象 x`!(x &lt; x)`(非自反)
- 对于任何该类型的对象 x 和 y如果 `x &lt; y`,则 `!(y &lt; x)`(非对称)
- 对于任何该类型的对象 x、y 和 z如果 `x &lt; y` 并且 `y &lt; z`,则 `x &lt; z`(传递性)
- 对于任何该类型的对象 x、y 和 z如果 x 和 y 不可比(`!(x &lt; y)` 并且 `!(y &lt; x)`)并且 y 和 z 不可比,则 x 和 z 不可比(不可比的传递性)
大部分情况下,类型是可以满足这些条件的,不过:
- 如果类型没有一般意义上的大小关系(如复数),我们一定要别扭地定义一个大小关系吗?
- 通过比较来进行查找、插入和删除,复杂度为对数 O(log(n)),有没有达到更好的性能的方法?
## 无序关联容器
从 C++11 开始,每一个关联容器都有一个对应的无序关联容器,它们是:
- `unordered_set`
- `unordered_map`
- `unordered_multiset`
- `unordered_multimap`
这些容器和关联容器非常相似,主要的区别就在于它们是“无序”的。这些容器不要求提供一个排序的函数对象,而要求一个可以计算哈希值的函数对象。你当然可以在声明容器对象时手动提供这样一个函数对象类型,但更常见的情况是,我们使用标准的 `hash` 函数对象及其特化。
下面是一个示例(这次我们暂不使用 xeus-cling因为它在输出复数时有限制不能显示其数值
```
#include &lt;complex&gt; // std::complex
#include &lt;iostream&gt; // std::cout/endl
#include &lt;unordered_map&gt; // std::unordered_map
#include &lt;unordered_set&gt; // std::unordered_set
#include "output_container.h"
using namespace std;
namespace std {
template &lt;typename T&gt;
struct hash&lt;complex&lt;T&gt;&gt; {
size_t
operator()(const complex&lt;T&gt;&amp; v) const
noexcept
{
hash&lt;T&gt; h;
return h(v.real()) + h(v.imag());
}
};
} // namespace std
int main()
{
unordered_set&lt;int&gt; s{
1, 1, 2, 3, 5, 8, 13, 21
};
cout &lt;&lt; s &lt;&lt; endl;
unordered_map&lt;complex&lt;double&gt;,
double&gt;
umc{{{1.0, 1.0}, 1.4142},
{{3.0, 4.0}, 5.0}};
cout &lt;&lt; umc &lt;&lt; endl;
}
```
输出可能是(顺序不能保证):
>
<p>`{ 21, 5, 8, 3, 13, 2, 1 }`<br>
`{ (3,4) =&gt; 5, (1,1) =&gt; 1.4142 }`</p>
请注意我们在 `std` 名空间中添加了特化,这是少数用户可以向 `std` 名空间添加内容的情况之一。正常情况下,向 `std` 名空间添加声明或定义是禁止的,属于未定义行为。
从实际的工程角度,无序关联容器的主要优点在于其性能。关联容器和 `priority_queue` 的插入和删除操作,以及关联容器的查找操作,其复杂度都是 O(log(n)),而无序关联容器的实现使用哈希表 [5],可以达到平均 O(1)!但这取决于我们是否使用了一个好的哈希函数:在哈希函数选择不当的情况下,无序关联容器的插入、删除、查找性能可能成为最差情况的 O(n),那就比关联容器糟糕得多了。
## array
我们讲的最后一个容器是 C 数组的替代品。C 数组在 C++ 里继续存在,主要是为了保留和 C 的向后兼容性。C 数组本身和 C++ 的容器相差是非常大的:
- C 数组没有 `begin``end` 成员函数(虽然可以使用全局的 `begin``end` 函数)
- C 数组没有 `size` 成员函数(得用一些模板技巧来获取其长度)
- C 数组作为参数有退化行为,传递给另外一个函数后那个函数不再能获得 C 数组的长度和结束位置
在 C 的年代,大家有时候会定义这样一个宏来获得数组的长度:
```
#define ARRAY_LEN(a) \
(sizeof(a) / sizeof((a)[0]))
```
如果在一个函数内部对数组参数使用这个宏,结果肯定是错的。现在 GCC 会友好地发出警告:
```
void test(int a[8])
{
cout &lt;&lt; ARRAY_LEN(a) &lt;&lt; endl;
}
```
>
<p>warning: sizeof on array function parameter will return size of int * instead of int [8] [-Wsizeof-array-argument]<br>
&nbsp;&nbsp;&nbsp;&nbsp;cout &lt;&lt; ARRAY_LEN(a) &lt;&lt; endl;</p>
C++17 直接提供了一个 `size` 方法,可以用于提供数组长度,并且在数组退化成指针的情况下会直接失败:
```
#include &lt;iostream&gt; // std::cout/endl
#include &lt;iterator&gt; // std::size
void test(int arr[])
{
// 不能编译
// std::cout &lt;&lt; std::size(arr)
// &lt;&lt; std::endl;
}
int main()
{
int arr[] = {1, 2, 3, 4, 5};
std::cout &lt;&lt; "The array length is "
&lt;&lt; std::size(arr)
&lt;&lt; std::endl;
test(arr);
}
```
此外C 数组也没有良好的复制行为。你无法用 C 数组作为 `map``unordered_map` 的键类型。下面的代码演示了失败行为:
```
#include &lt;map&gt; // std::map
typedef char mykey_t[8];
int main()
{
std::map&lt;mykey_t, int&gt; mp;
mykey_t mykey{"hello"};
mp[mykey] = 5;
// 轰,大段的编译错误
}
```
如果不用 C 数组的话,我们该用什么来替代呢?
我们有三个可以考虑的选项:
- 如果数组较大的话,应该考虑 `vector``vector` 有最大的灵活性和不错的性能。
- 对于字符串数组,当然应该考虑 `string`
- 如果数组大小固定C 的数组在 C++ 里本来就是大小固定的)并且较小的话,应该考虑 `array``array` 保留了 C 数组在栈上分配的特点,同时,提供了 `begin``end``size` 等通用成员函数。
`array` 可以避免 C 数组的种种怪异行径。上面的失败代码,如果使用 `array` 的话,稍作改动就可以通过编译:
```
#include &lt;array&gt; // std::array
#include &lt;iostream&gt; // std::cout/endl
#include &lt;map&gt; // std::map
#include "output_container.h"
typedef std::array&lt;char, 8&gt; mykey_t;
int main()
{
std::map&lt;mykey_t, int&gt; mp;
mykey_t mykey{"hello"};
mp[mykey] = 5; // OK
std::cout &lt;&lt; mp &lt;&lt; std::endl;
}
```
输出则是意料之中的:
>
`{ hello =&gt; 5 }`
## 内容小结
本讲介绍了 C++ 的两个常用的函数对象,`less``hash`;然后介绍了用到这两个函数对象的容器适配器、关联容器和无序关联容器;最后,通过例子展示了为什么我们应当避免 C 数组而考虑使用 `array`。通过这两讲,我们已经完整地了解了 C++ 提供的标准容器。
## 课后思考
请思考一下:
1. 为什么大部分容器都提供了 `begin``end` 等方法?
1. 为什么容器没有继承一个公用的基类?
欢迎留言和我交流你的看法。
## 参考资料
[1] cppreference.com, “Containers library”. [https://en.cppreference.com/w/cpp/container](https://en.cppreference.com/w/cpp/container)
[1a] cppreference.com, “容器库”. [https://zh.cppreference.com/w/cpp/container](https://zh.cppreference.com/w/cpp/container)
[2] cppreference.com, “Explicit (full) template specialization”. [https://en.cppreference.com/w/cpp/language/template_specialization](https://en.cppreference.com/w/cpp/language/template_specialization)
[2a] cppreference.com, “显式(全)模板特化”. [https://zh.cppreference.com/w/cpp/language/template_specialization](https://zh.cppreference.com/w/cpp/language/template_specialization)
[3] Wikipedia, “Associative array”. [https://en.wikipedia.org/wiki/Associative_array](https://en.wikipedia.org/wiki/Associative_array)
[3a] 维基百科, “关联数组”. [https://zh.wikipedia.org/zh-cn/关联数组](https://zh.wikipedia.org/zh-cn/%E5%85%B3%E8%81%94%E6%95%B0%E7%BB%84)
[4] Wikipedia, “Weak ordering”. [https://en.wikipedia.org/wiki/Weak_ordering](https://en.wikipedia.org/wiki/Weak_ordering)
[5] Wikipedia, “Hash table”. [https://en.wikipedia.org/wiki/Hash_table](https://en.wikipedia.org/wiki/Hash_table)
[5a] 维基百科, “哈希表”. [https://zh.wikipedia.org/zh-cn/哈希表](https://zh.wikipedia.org/zh-cn/%E5%93%88%E5%B8%8C%E8%A1%A8)

View File

@@ -0,0 +1,357 @@
<audio id="audio" title="06 | 异常:用还是不用,这是个问题" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ab/4f/abea4ae1ab39cce76ac8e7aad4a1864f.mp3"></audio>
你好,我是吴咏炜。
到现在为止,我们已经有好多次都提到异常了。今天,我们就来彻底地聊一聊异常。
首先,开宗明义,如果你不知道到底该不该用异常的话,那答案就是该用。如果你需要避免使用异常,原因必须是你有明确的需要避免使用异常的理由。
下面我们就开始说说异常。
## 没有异常的世界
我们先来看看没有异常的世界是什么样子的。最典型的情况就是 C 了。
假设我们要做一些矩阵的操作,定义了下面这个矩阵的数据结构:
```
typedef struct {
float* data;
size_t nrows;
size_t ncols;
} matrix;
```
我们至少需要有初始化和清理的代码:
```
enum matrix_err_code {
MATRIX_SUCCESS,
MATRIX_ERR_MEMORY_INSUFFICIENT,
};
int matrix_alloc(matrix* ptr,
size_t nrows,
size_t ncols)
{
size_t size =
nrows * ncols * sizeof(float);
float* data = malloc(size);
if (data == NULL) {
return MATRIX_ERR_MEMORY_INSUFFICIENT;
}
ptr-&gt;data = data;
ptr-&gt;nrows = nrows;
ptr-&gt;ncols = ncols;
}
void matrix_dealloc(matrix* ptr)
{
if (ptr-&gt;data == NULL) {
return;
}
free(ptr-&gt;data);
ptr-&gt;data = NULL;
ptr-&gt;nrows = 0;
ptr-&gt;ncols = 0;
}
```
然后,我们做一下矩阵乘法吧。函数定义大概会是这个样子:
```
int matrix_multiply(matrix* result,
const matrix* lhs,
const matrix* rhs)
{
int errcode;
if (lhs-&gt;ncols != rhs-&gt;nrows) {
return MATRIX_ERR_MISMATCHED_MATRIX_SIZE;
// 呃,得把这个错误码添到 enum matrix_err_code 里
}
errcode = matrix_alloc(
result, lhs-&gt;nrows, rhs-&gt;ncols);
if (errcode != MATRIX_SUCCESS) {
return errcode;
}
// 进行矩阵乘法运算
return MATRIX_SUCCESS;
}
```
调用代码则大概是这个样子:
```
matrix c;
// 不清零的话,错误处理和资源清理会更复杂
memset(&amp;c, 0, sizeof(matrix));
errcode = matrix_multiply(c, a, b);
if (errcode != MATRIX_SUCCESS) {
goto error_exit;
}
// 使用乘法的结果做其他处理
error_exit:
matrix_dealloc(&amp;c);
return errcode;
```
可以看到,我们有大量需要判断错误的代码,零散分布在代码各处。
可这是 C 啊。我们用 C++、不用异常可以吗?
当然可以但你会发现结果好不了多少。毕竟C++ 的构造函数是不能返回错误码的,所以你根本不能用构造函数来做可能出错的事情。你不得不定义一个只能清零的构造函数,再使用一个 `init` 函数来做真正的构造操作。C++ 虽然支持运算符重载,可你也不能使用,因为你没法返回一个新矩阵……
我上面还只展示了单层的函数调用。事实上,如果出错位置离处理错误的位置相差很远的话,每一层的函数调用里都得有判断错误码的代码,这就既对写代码的人提出了严格要求,也对读代码的人造成了视觉上的干扰……
## 使用异常
如果使用异常的话,我们就可以在构造函数里做真正的初始化工作了。假设我们的矩阵类有下列的数据成员:
```
class matrix {
private:
float* data_;
size_t nrows_;
size_t ncols_;
}
```
构造函数我们可以这样写:
```
matrix::matrix(size_t nrows,
size_t ncols)
{
data_ = new float[nrows * ncols];
nrows_ = nrows;
ncols_ = ncols;
}
```
析构非常简单:
```
matrix::~matrix()
{
delete[] data_;
}
```
乘法函数可以这样写:
```
class matrix {
friend matrix
operator*(const matrix&amp;,
const matrix&amp;);
};
matrix operator*(const matrix&amp; lhs,
const matrix&amp; rhs)
{
if (lhs.ncols != rhs.nrows) {
throw std::runtime_error(
"matrix sizes mismatch");
}
matrix result(lhs.nrows, rhs.ncols);
// 进行矩阵乘法运算
return result;
}
```
使用乘法的代码则更是简单:
```
matrix c = a * b;
```
你可能已经非常疑惑了:错误处理在哪儿呢?只有一个 `throw`,跟前面的 C 代码能等价吗?
异常处理并不意味着需要写显式的 `try``catch`。**异常安全的代码,可以没有任何 `try``catch`。**
如果你不确定什么是“异常安全”,我们先来温习一下概念:异常安全是指当异常发生时,既不会发生资源泄漏,系统也不会处于一个不一致的状态。
我们看看可能会出现错误/异常的地方:
<li>
首先是内存分配。如果 `new` 出错,按照 C++ 的规则,一般会得到异常 `bad_alloc`,对象的构造也就失败了。这种情况下,在 `catch` 捕捉到这个异常之前,所有的栈上对象会全部被析构,资源全部被自动清理。
</li>
<li>
如果是矩阵的长宽不合适不能做乘法呢?我们同样会得到一个异常,这样,在使用乘法的地方,对象 `c` 根本不会被构造出来。
</li>
<li>
如果在乘法函数里内存分配失败呢?一样,`result` 对象根本没有构造出来,也就没有 `c` 对象了。还是一切正常。
</li>
<li>
如果 `a``b` 是本地变量,然后乘法失败了呢?析构函数会自动释放其空间,我们同样不会有任何资源泄漏。
</li>
总而言之,只要我们适当地组织好代码、利用好 RAII实现矩阵的代码和使用矩阵的代码都可以更短、更清晰。我们可以统一在外层某个地方处理异常——通常会记日志、或在界面上向用户报告错误了。
## 避免异常的风格指南?
但大名鼎鼎的 Google 的 C++ 风格指南不是说要避免异常吗 [1]?这又是怎么回事呢?
答案实际已经在 Google 的文档里了:
>
Given that Googles existing code is not exception-tolerant, the costs of using exceptions are somewhat greater than the costs in a new project. The conversion process would be slow and error-prone. We dont believe that the available alternatives to exceptions, such as error codes and assertions, introduce a significant burden.
Our advice against using exceptions is not predicated on philosophical or moral grounds, but practical ones. Because wed like to use our open-source projects at Google and its difficult to do so if those projects use exceptions, we need to advise against exceptions in Google open-source projects as well. Things would probably be different if we had to do it all over again from scratch.
我来翻译一下(我的加重):
>
鉴于 Google 的现有代码不能承受异常,**使用异常的代价要比在全新的项目中使用异常大一些**。转换[代码来使用异常的]过程会缓慢而容易出错。我们不认为可代替异常的方法,如错误码或断言,会带来明显的负担。
我们反对异常的建议并非出于哲学或道德的立场,而是出于实际考虑。因为我们希望在 Google 使用我们的开源项目,而如果这些项目使用异常的话就会对我们的使用带来困难,我们也需要反对在 Google 的开源项目中使用异常。**如果我们从头再来一次的话,事情可能就会不一样了。**
这个如果还比较官方、委婉的话Reddit 上还能找到一个更个人化的表述 [2]
>
I use [**sic**] to work at Google, and Craig Silverstein, who wrote the first draft of the style guideline, said that he regretted the ban on exceptions, but he had no choice; when he wrote it, it wasnt only that the compiler they had at the time did a very bad job on exceptions, but that they already had a huge volume of non-exception-safe code.
我的翻译(同样,我的加重):
>
我过去在 Google 工作,写了风格指南初稿的 Craig Silverstein 说过**他对禁用异常感到遗憾**,但他当时别无选择。在他写风格指南的时候,不仅**他们使用的编译器在异常上工作得很糟糕**,而且**他们已经有了一大堆异常不安全的代码了**。
当然除了历史原因以外也有出于性能等其他原因禁用异常的。美国国防部的联合攻击战斗机JSF项目的 C++ 编码规范就禁用异常,因为工具链不能保证抛出异常时的实时性能。不过在那种项目里,被禁用的 C++ 特性就多了,比如动态内存分配都不能使用。
一些游戏项目为了追求高性能,也禁用异常。这个实际上也有一定的历史原因,因为今天的主流 C++ 编译器在异常关闭和开启时应该已经能够产生性能差不多的代码在异常未抛出时。代价是产生的二进制文件大小的增加因为异常产生的位置决定了需要如何做栈展开这些数据需要存储在表里。典型情况使用异常和不使用异常比二进制文件大小会有约百分之十到二十的上升。LLVM 项目的编码规范里就明确指出这是不使用 RTTI 和异常的原因 [3]
>
In an effort to reduce code and executable size, LLVM does not use RTTI (e.g. `dynamic_cast&lt;&gt;;`) or exceptions.
我默默地瞅了眼我机器上 88MB 大小的单个 clang-9 可执行文件,对 Chris Lattner 的决定至少表示理解。但如果想跟这种项目比,你得想想是否值得这么去做。你的项目对二进制文件的大小和性能有这么渴求吗?需要这么去拼吗?
## 异常的问题
异常当然不是一个完美的特性,否则也不会招来这些批评和禁用了。对它的批评主要有两条:
- 异常违反了“你不用就不需要付出代价”的 C++ 原则。只要开启了异常,即使不使用异常你编译出的二进制代码通常也会膨胀。
- 异常比较隐蔽,不容易看出来哪些地方会发生异常和发生什么异常。
对于第一条,开发者没有什么可做的。事实上,这也算是 C++ 实现的一个折中了。目前的主流异常实现中都倾向于牺牲可执行文件大小、提高主流程happy path的性能。只要程序不抛异常C++ 代码的性能比起完全不做错误检查的代码,都只有几个百分点的性能损失 [4]。除了非常有限的一些场景,可执行文件大小通常不会是个问题。
第二条可以算作是一个真正有效的批评。和 Java 不同C++ 里不会对异常规约进行编译时的检查。从 C++17 开始C++ 甚至完全禁止了以往的动态异常规约,你不再能在函数声明里写你可能会抛出某某异常。你唯一能声明的,就是某函数不会抛出异常——`noexcept``noexcept(true)``throw()`。这也是 C++ 的运行时唯一会检查的东西了。如果一个函数声明了不会抛出异常、结果却抛出了异常C++ 运行时会调用 `std::terminate` 来终止应用程序。不管是程序员的声明,还是编译器的检查,都不会告诉你哪些函数会抛出哪些异常。
当然,不声明异常是有理由的。特别是在泛型编程的代码里,几乎不可能预知会发生些什么异常。我个人对避免异常带来的问题有几点建议:
1. 写异常安全的代码,尤其在模板里。可能的话,提供强异常安全保证 [5],在任何第三方代码发生异常的情况下,不改变对象的内容,也不产生任何资源泄漏。
1. 如果你的代码可能抛出异常的话,在文档里明确声明可能发生的异常类型和发生条件。确保使用你的代码的人,能在不检查你的实现的情况,了解需要准备处理哪些异常。
1. 对于肯定不会抛出异常的代码,将其标为 `noexcept`。注意类的特殊成员(构造函数、析构函数、赋值函数等)会自动成为 `noexcept`,如果它们调用的代码都是 `noexcept` 的话。所以,像 `swap` 这样的成员函数应当尽可能标成 `noexcept`
## 使用异常的理由
虽然后面我们会描述到一些不使用异常、也不使用错误返回码的错误处理方式,但异常是渗透在 C++ 中的标准错误处理方式。标准库的错误处理方式就是异常。其中不仅包括运行时错误,甚至包括一些逻辑错误。比如,在说容器的时候,有一个我没提的地方是,在能使用 `[]` 运算符的地方C++ 的标准容器也提供了 `at` 成员函数,能够在下标不存在的时候抛出异常,作为一种额外的帮助调试的手段。
```
#include &lt;iostream&gt; // std::cout/endl
#include &lt;stdexcept&gt; // std::out_of_range
#include &lt;vector&gt; // std::vector
using namespace std;
```
```
vector&lt;int&gt; v{1, 2, 3};
```
```
v[0]
```
>
`1`
```
v.at(0)
```
>
`1`
```
v[3]
```
>
`-1342175236`
```
try {
v.at(3);
}
catch (const out_of_range&amp; e) {
cerr &lt;&lt; e.what() &lt;&lt; endl;
}
```
>
`_M_range_check: __n (which is 3) &gt;= this-&gt;size() (which is 3)`
C++ 的标准容器在大部分情况下提供了强异常保证,即,一旦异常发生,现场会恢复到调用函数之前的状态,容器的内容不会发生改变,也没有任何资源泄漏。前面提到过,`vector` 会在元素类型没有提供保证不抛异常的移动构造函数的情况下,在移动元素时会使用拷贝构造函数。这是因为一旦某个操作发生了异常,被移动的元素已经被破坏,处于只能析构的状态,异常安全性就不能得到保证了。
只要你使用了标准容器,不管你自己用不用异常,你都得处理标准容器可能引发的异常——至少有 `bad_alloc`,除非你明确知道你的目标运行环境不会产生这个异常。这对普通配置的 Linux 环境而言,倒确实是对的……这也算是 Google 这么规定的一个底气吧。
虽然对于运行时错误,开发者并没有什么选择余地;但对于代码中的逻辑错误,开发者则是可以选择不同的处理方式的:你可以使用异常,也可以使用 `assert`,在调试环境中报告错误并中断程序运行。由于测试通常不能覆盖所有的代码和分支,`assert` 在发布模式下一般被禁用,两者并不是完全的替代关系。在允许异常的情况下,使用异常可以获得在调试和发布模式下都良好、一致的效果。
标准 C++ 可能会产生哪些异常,可以查看参考资料 [6]。
## 内容小结
今天我们讨论了使用异常的理由和不使用异常的理由。希望通过本讲,你能够充分理解为什么异常是 C++ 委员会和很多大拿推荐的错误处理方式,并在可以使用异常的地方正确地使用异常这一方便的错误处理机制。
如果你还想进一步深入了解异常的话,可以仔细阅读一下参考资料 [4]。
## 课后思考
你的 C++ 项目里使用异常吗?为什么?
欢迎留言和我交流你的看法。
## 参考资料
[1] Google, “Google C++ style guide”. [https://google.github.io/styleguide/cppguide.html#Exceptions](https://google.github.io/styleguide/cppguide.html#Exceptions)
[2] Reddit, Discussion on “Examples of C++ projects which embrace exceptions?”. [https://www.reddit.com/r/cpp/comments/4wkkge/examples_of_c_projects_which_embrace_exceptions/](https://www.reddit.com/r/cpp/comments/4wkkge/examples_of_c_projects_which_embrace_exceptions/)
[3] LLVM Project, “LLVM coding standards”. [https://llvm.org/docs/CodingStandards.html#do-not-use-rtti-or-exceptions](https://llvm.org/docs/CodingStandards.html#do-not-use-rtti-or-exceptions)
[4] Standard C++ Foundation, “FAQ—exceptions and error handling”. [https://isocpp.org/wiki/faq/exceptions](https://isocpp.org/wiki/faq/exceptions)
[5] cppreference.com, “Exceptions”. [https://en.cppreference.com/w/cpp/language/exceptions](https://en.cppreference.com/w/cpp/language/exceptions)
[5a] cppreference.com, “异常”. [https://zh.cppreference.com/w/cpp/language/exceptions](https://zh.cppreference.com/w/cpp/language/exceptions)
[6] cppreference.com, “std::exception”. [https://en.cppreference.com/w/cpp/error/exception](https://en.cppreference.com/w/cpp/error/exception)
[6a] cppreference.com, “std::exception”. [https://zh.cppreference.com/w/cpp/error/exception](https://zh.cppreference.com/w/cpp/error/exception)

View File

@@ -0,0 +1,299 @@
<audio id="audio" title="07 | 迭代器和好用的新for循环" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0a/32/0add1a86ea70e4b24c519ca734100c32.mp3"></audio>
你好,我是吴咏炜。
我们已经讲过了容器。在使用容器的过程中你也应该对迭代器iterator或多或少有了些了解。今天我们就来系统地讲一下迭代器。
## 什么是迭代器?
迭代器是一个很通用的概念,并不是一个特定的类型。它实际上是一组对类型的要求([1])。它的最基本要求就是从一个端点出发,下一步、下一步地到达另一个端点。按照一般的中文习惯,也许“遍历”是比“迭代”更好的用词。我们可以遍历一个字符串的字符,遍历一个文件的内容,遍历目录里的所有文件,等等。这些都可以用迭代器来表达。
我在用 output_container.h 输出容器内容的时候,实际上就对容器的 `begin``end` 成员函数返回的对象类型提出了要求。假设前者返回的类型是 I后者返回的类型是 S这些要求是
- I 对象支持 `*` 操作,解引用取得容器内的某个对象。
- I 对象支持 `++`,指向下一个对象。
- I 对象可以和 I 或 S 对象进行相等比较,判断是否遍历到了特定位置(在 S 的情况下是是否结束了遍历)。
注意在 C++17 之前,`begin``end` 返回的类型 I 和 S 必须是相同的。从 C++17 开始I 和 S 可以是不同的类型。这带来了更大的灵活性和更多的优化可能性。
上面的类型 I多多少少就是一个满足输入迭代器input iterator的类型了。不过output_container.h 只使用了前置 `++`,但输入迭代器要求前置和后置 `++` 都得到支持。
输入迭代器不要求对同一迭代器可以多次使用 `*` 运算符也不要求可以保存迭代器来重新遍历对象换句话说只要求可以单次访问。如果取消这些限制、允许多次访问的话那迭代器同时满足了前向迭代器forward iterator
一个前向迭代器的类型,如果同时支持 `--`前置及后置回到前一个对象那它就是个双向迭代器bidirectional iterator。也就是说可以正向遍历也可以反向遍历。
一个双向迭代器,如果额外支持在整数类型上的 `+``-``+=``-=`,跳跃式地移动迭代器;支持 `[]`数组式的下标访问支持迭代器的大小比较之前只要求相等比较那它就是个随机访问迭代器random-access iterator
一个随机访问迭代器 `i` 和一个整数 `n`,在 `*i` 可解引用且 `i + n` 是合法迭代器的前提下,如果额外还满足 `*(addressdof(*i) + n)` 等价于 `*(i + n)`,即保证迭代器指向的对象在内存里是连续存放的,那它(在 C++20 里就是个连续迭代器contiguous iterator
以上这些迭代器只考虑了读取。如果一个类型像输入迭代器,但 `*i` 只能作为左值来写而不能读那它就是个输出迭代器output iterator
而比输入迭代器和输出迭代器更底层的概念,就是迭代器了。基本要求是:
- 对象可以被拷贝构造、拷贝赋值和析构。
- 对象支持 `*` 运算符。
- 对象支持前置 `++` 运算符。
迭代器类型的关系可从下图中全部看到:
<img src="https://static001.geekbang.org/resource/image/dd/5a/dd25c3f074fe0b792dddecfd15f74e5a.png" alt="">
迭代器通常是对象。但需要注意的是,指针可以满足上面所有的迭代器要求,因而也是迭代器。这应该并不让人惊讶,因为本来迭代器就是根据指针的特性,对其进行抽象的结果。事实上,`vector` 的迭代器,在很多实现里就直接是使用指针的。
## 常用迭代器
最常用的迭代器就是容器的 `iterator` 类型了。以我们学过的顺序容器为例,它们都定义了嵌套的 `iterator` 类型和 `const_iterator` 类型。一般而言,`iterator` 可写入,`const_iterator` 类型不可写入,但这些迭代器都被定义为输入迭代器或其派生类型:
- `vector::iterator``array::iterator` 可以满足到连续迭代器。
- `deque::iterator` 可以满足到随机访问迭代器(记得它的内存只有部分连续)。
- `list::iterator` 可以满足到双向迭代器(链表不能快速跳转)。
- `forward_list::iterator` 可以满足到前向迭代器(单向链表不能反向遍历)。
很常见的一个输出迭代器是 `back_inserter` 返回的类型 `back_inserter_iterator` 了;用它我们可以很方便地在容器的尾部进行插入操作。另外一个常见的输出迭代器是 `ostream_iterator`,方便我们把容器内容“拷贝”到一个输出流。示例如下:
```
#include &lt;algorithm&gt; // std::copy
#include &lt;iterator&gt; // std::back_inserter
#include &lt;vector&gt; // std::vector
using namespace std;
```
```
vector&lt;int&gt; v1{1, 2, 3, 4, 5};
vector&lt;int&gt; v2;
copy(v1.begin(), v1.end(),
back_inserter(v2));
```
```
v2
```
>
`{ 1, 2, 3, 4, 5 }`
```
#include &lt;iostream&gt; // std::cout
copy(v2.begin(), v2.end(),
ostream_iterator&lt;int&gt;(cout, " "));
```
>
`1 2 3 4 5`
## 使用输入行迭代器
下面我们来看一下一个我写的输入迭代器。它的功能本身很简单,就是把一个输入流(`istream`)的内容一行行读进来。配上 C++11 引入的基于范围的 for 循环的语法,我们可以把遍历输入流的代码以一种自然、非过程式的方式写出来,如下所示:
```
for (const string&amp; line :
istream_line_reader(is)) {
// 示例循环体中仅进行简单输出
cout &lt;&lt; line &lt;&lt; endl;
}
```
我们可以对比一下以传统的方式写的 C++ 代码,其中需要照顾不少细节:
```
string line;
for (;;) {
getline(is, line);
if (!is) {
break;
}
cout &lt;&lt; line &lt;&lt; endl;
}
```
`is` 读入输入行的逻辑,在前面的代码里一个语句就全部搞定了,在这儿用了 5 个语句……
我们后面会分析一下这个输入迭代器。在此之前,我先解说一下基于范围的 for 循环这个语法。虽然这可以说是个语法糖,但它对提高代码的可读性真的非常重要。如果不用这个语法糖的话,简洁性上的优势就小多了。我们直接把这个循环改写成等价的普通 for 循环的样子。
```
{
auto&amp;&amp; r = istream_line_reader(is);
auto it = r.begin();
auto end = r.end();
for (; it != end; ++it) {
const string&amp; line = *it;
cout &lt;&lt; line &lt;&lt; endl;
}
}
```
可以看到,它做的事情也不复杂,就是:
- 获取冒号后边的范围表达式的结果,并隐式产生一个引用,在整个循环期间都有效。注意根据生命期延长规则,表达式结果如果是临时对象的话,这个对象要在循环结束后才被销毁。
- 自动生成遍历这个范围的迭代器。
- 循环内自动生成根据冒号左边的声明和 `*it` 来进行初始化的语句。
- 下面就是完全正常的循环体。
生成迭代器这一步有可能是——但不一定是——调用 `r``begin``end` 成员函数。具体规则是:
- 对于 C 数组(必须是没有退化为指针的情况),编译器会自动生成指向数组头尾的指针(相当于自动应用可用于数组的 `std::begin``std::end` 函数)。
- 对于有 `begin``end` 成员的对象,编译器会调用其 `begin``end` 成员函数(我们目前的情况)。
- 否则,编译器会尝试在 `r` 对象所在的名空间寻找可以用于 `r``begin``end` 函数,并调用 `begin(r)``end(r)`;找不到的话则失败报错。
## 定义输入行迭代器
下面我们看一下,要实现这个输入行迭代器,需要做些什么工作。
C++ 里有些固定的类型要求规范。对于一个迭代器,我们需要定义下面的类型:
```
class istream_line_reader {
public:
class iterator { // 实现 InputIterator
public:
typedef ptrdiff_t difference_type;
typedef string value_type;
typedef const value_type* pointer;
typedef const value_type&amp; reference;
typedef input_iterator_tag
iterator_category;
};
};
```
仿照一般的容器,我们把迭代器定义为 `istream_line_reader` 的嵌套类。它里面的这五个类型是必须定义的(其他泛型 C++ 代码可能会用到这五个类型;之前标准库定义了一个可以继承的类模板 `std::iterator` 来产生这些类型定义,但这个类目前已经被废弃 [2])。其中:
- `difference_type` 是代表迭代器之间距离的类型,定义为 `ptrdiff_t` 只是种标准做法(指针间差值的类型),对这个类型没什么特别作用。
- `value_type` 是迭代器指向的对象的值类型,我们使用 `string`,表示迭代器指向的是字符串。
- `pointer` 是迭代器指向的对象的指针类型,这儿就平淡无奇地定义为 `value_type` 的常指针了(我们可不希望别人来更改指针指向的内容)。
- 类似的,`reference``value_type` 的常引用。
- `iterator_category` 被定义为 `input_iterator_tag`,标识这个迭代器的类型是 input iterator输入迭代器
作为一个真的只能读一次的输入迭代器,有个特殊的麻烦(前向迭代器或其衍生类型没有):到底应该让 `*` 负责读取还是 `++` 负责读取。我们这儿采用常见、也较为简单的做法,让 `++` 负责读取,`*` 负责返回读取的内容(这个做法会有些副作用,但按我们目前的用法则没有问题)。这样的话,这个 `iterator` 类需要有一个数据成员指向输入流,一个数据成员来存放读取的结果。根据这个思路,我们定义这个类的基本成员函数和数据成员:
```
class istream_line_reader {
public:
class iterator {
iterator() noexcept
: stream_(nullptr) {}
explicit iterator(istream&amp; is)
: stream_(&amp;is)
{
++*this;
}
reference operator*() const noexcept
{
return line_;
}
pointer operator-&gt;() const noexcept
{
return &amp;line_;
}
iterator&amp; operator++()
{
getline(*stream_, line_);
if (!*stream_) {
stream_ = nullptr;
}
return *this;
}
iterator operator++(int)
{
iterator temp(*this);
++*this;
return temp;
}
private:
istream* stream_;
string line_;
};
};
```
我们定义了默认构造函数,将 `stream_` 清空;相应的,在带参数的构造函数里,我们根据传入的输入流来设置 `stream_`。我们也定义了 `*``-&gt;` 运算符来取得迭代器指向的文本行的引用和指针,并用 `++` 来读取输入流的内容(后置 `++` 则以惯常方式使用前置 `++` 和拷贝构造来实现)。唯一“特别”点的地方,是我们在构造函数里调用了 `++`,确保在构造后调用 `*` 运算符时可以读取内容,符合日常先使用 `*`、再使用 `++` 的习惯。一旦文件读取到尾部(或出错),则 `stream_` 被清空,回到默认构造的情况。
对于迭代器之间的比较,我们则主要考虑文件有没有读到尾部的情况,简单定义为:
```
bool operator==(const iterator&amp; rhs)
const noexcept
{
return stream_ == rhs.stream_;
}
bool operator!=(const iterator&amp; rhs)
const noexcept
{
return !operator==(rhs);
}
```
有了这个 `iterator` 的定义后,`istream_line_reader` 的定义就简单得很了:
```
class istream_line_reader {
public:
class iterator {…};
istream_line_reader() noexcept
: stream_(nullptr) {}
explicit istream_line_reader(
istream&amp; is) noexcept
: stream_(&amp;is) {}
iterator begin()
{
return iterator(*stream_);
}
iterator end() const noexcept
{
return iterator();
}
private:
istream* stream_;
};
```
也就是说,构造函数只是简单地把输入流的指针赋给 `stream_` 成员变量。`begin` 成员函数则负责构造一个真正有意义的迭代器;`end` 成员函数则只是返回一个默认构造的迭代器而已。
以上就是一个完整的基于输入流的行迭代器了。这个行输入模板的设计动机和性能测试结果可参见参考资料 [3] 和 [4];完整的工程可用代码,请参见参考资料 [5]。该项目中还提供了利用 C 文件接口的 file_line_reader 和基于内存映射文件的 mmap_line_reader。
## 内容小结
今天我们介绍了所有的迭代器类型,并介绍了基于范围的 for 循环。随后,我们介绍了一个实际的输入迭代器工具,并用它来简化从输入流中读入文本行这一常见操作。最后,我们展示了这个输入迭代器的定义。
## 课后思考
请思考一下:
1. 目前这个输入行迭代器的行为,在什么情况下可能导致意料之外的后果?
1. 请尝试一下改进这个输入行迭代器,看看能不能消除这种意外。如果可以,该怎么做?如果不可以,为什么?
欢迎留言和我交流你的看法。
## 参考资料
[1] cppreference.com, “Iterator library”. [https://en.cppreference.com/w/cpp/iterator](https://en.cppreference.com/w/cpp/iterator)
[1a] cppreference.com, “迭代器库”. [https://zh.cppreference.com/w/cpp/iterator](https://zh.cppreference.com/w/cpp/iterator)
[2] Jonathan Boccara, “std::iterator is deprecated: why, what it was, and what to use instead”. [https://www.fluentcpp.com/2018/05/08/std-iterator-deprecated/](https://www.fluentcpp.com/2018/05/08/std-iterator-deprecated/)
[3] 吴咏炜, “Python `yield` and C++ coroutines”. [https://yongweiwu.wordpress.com/2016/08/16/python-yield-and-cplusplus-coroutines/](https://yongweiwu.wordpress.com/2016/08/16/python-yield-and-cplusplus-coroutines/)
[4] 吴咏炜, “Performance of my line readers”. [https://yongweiwu.wordpress.com/2016/11/12/performance-of-my-line-readers/](https://yongweiwu.wordpress.com/2016/11/12/performance-of-my-line-readers/)
[5] 吴咏炜, nvwa. [https://github.com/adah1972/nvwa/](https://github.com/adah1972/nvwa/)

View File

@@ -0,0 +1,427 @@
<audio id="audio" title="08 | 易用性改进 I自动类型推断和初始化" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9b/f8/9b96c63e2ea13807ae9e7e56b40a0bf8.mp3"></audio>
你好,我是吴咏炜。
在之前的几讲里,我们已经多多少少接触到了一些 C++11 以来增加的新特性。下面的两讲,我会重点讲一下现代 C++C++11/14/17带来的易用性改进。
就像我们 [[开篇词]](https://time.geekbang.org/column/article/169177) 中说的,我们主要是介绍 C++ 里好用的特性,而非让你死记规则。因此,这里讲到的内容,有时是一种简化的说法。对于日常使用,本讲介绍的应该能满足大部分的需求。对于复杂用法和边角情况,你可能还是需要查阅参考资料里的明细规则。
## 自动类型推断
如果要挑选 C++11 带来的最重大改变的话,自动类型推断肯定排名前三。如果只看易用性或表达能力的改进的话,那它就是“舍我其谁”的第一了。
### auto
自动类型推断,顾名思义,就是编译器能够根据表达式的类型,自动决定变量的类型(从 C++14 开始,还有函数的返回类型),不再需要程序员手工声明([1])。但需要说明的是,`auto` 并没有改变 C++ 是静态类型语言这一事实——使用 `auto` 的变量(或函数返回值)的类型仍然是编译时就确定了,只不过编译器能自动帮你填充而已。
自动类型推断使得像下面这样累赘的表达式成为历史:
```
// vector&lt;int&gt; v;
for (vector&lt;int&gt;::iterator
it = v.begin(),
end = v.end();
it != end; ++it) {
// 循环体
}
```
现在我们可以直接写(当然,是不使用基于范围的 for 循环的情况):
```
for (auto it = v.begin(), end = v.end();
it != end; ++it) {
// 循环体
}
```
不使用自动类型推断时,如果容器类型未知的话,我们还需要加上 `typename`(注意此处 const 引用还要求我们写 `const_iterator` 作为迭代器的类型):
```
template &lt;typename T&gt;
void foo(const T&amp; container)
{
for (typename T::const_iterator
it = v.begin(),
}
```
如果 `begin` 返回的类型不是该类型的 `const_iterator` 嵌套类型的话,那实际上不用自动类型推断就没法表达了。这还真不是假设。比如,如果我们的遍历函数要求支持 C 数组的话,不用自动类型推断的话,就只能使用两个不同的重载:
```
template &lt;typename T, std::size_t N&gt;
void foo(const T (&amp;a)[N])
{
typedef const T* ptr_t;
for (ptr_t it = a, end = a + N;
it != end; ++it) {
// 循环体
}
}
template &lt;typename T&gt;
void foo(const T&amp; c)
{
for (typename T::const_iterator
it = c.begin(),
end = c.end();
it != end; ++it) {
// 循环体
}
}
```
如果使用自动类型推断的话,再加上 C++11 提供的全局 `begin``end` 函数,上面的代码可以统一成:
```
template &lt;typename T&gt;
void foo(const T&amp; c)
{
using std::begin;
using std::end;
// 使用依赖参数查找ADL见 [2]
for (auto it = begin(c),
ite = end(c);
it != ite; ++it) {
// 循环体
}
}
```
从这个例子可见,自动类型推断不仅降低了代码的啰嗦程度,也提高了代码的抽象性,使我们可以用更少的代码写出通用的功能。
`auto` 实际使用的规则类似于函数模板参数的推导规则([3])。当你写了一个含 `auto` 的表达式时,相当于把 `auto` 替换为模板参数的结果。举具体的例子:
- `auto a = expr;` 意味着用 `expr` 去匹配一个假想的 `template &lt;typename T&gt; f(T)` 函数模板,结果为值类型。
- `const auto&amp; a = expr;` 意味着用 `expr` 去匹配一个假想的 `template &lt;typename T&gt; f(const T&amp;)` 函数模板,结果为常左值引用类型。
- `auto&amp;&amp; a = expr;` 意味着用 `expr` 去匹配一个假想的 `template &lt;typename T&gt; f(T&amp;&amp;)` 函数模板,根据[[第 3 讲]](https://time.geekbang.org/column/article/169268) 中我们讨论过的转发引用和引用坍缩规则,结果是一个跟 `expr` 值类别相同的引用类型。
### decltype
`decltype` 的用途是获得一个表达式的类型,结果可以跟类型一样使用。它有两个基本用法:
- `decltype(变量名)` 可以获得变量的精确类型。
- `decltype(表达式)` (表达式不是变量名,但包括 `decltype((变量名))` 的情况可以获得表达式的引用类型除非表达式的结果是个纯右值prvalue此时结果仍然是值类型。
如果我们有 `int a;`,那么:
- `decltype(a)` 会获得 `int`(因为 `a``int`)。
- `decltype((a))` 会获得 `int&amp;`(因为 `a` 是 lvalue
- `decltype(a + a)` 会获得 `int`(因为 `a + a` 是 prvalue
### decltype(auto)
通常情况下,能写 `auto` 来声明变量肯定是件比较轻松的事。但这儿有个限制,你需要在写下 `auto` 时就决定你写下的是个引用类型还是值类型。根据类型推导规则,`auto` 是值类型,`auto&amp;` 是左值引用类型,`auto&amp;&amp;` 是转发引用(可以是左值引用,也可以是右值引用)。使用 `auto` 不能通用地根据表达式类型来决定返回值的类型。不过,`decltype(expr)` 既可以是值类型,也可以是引用类型。因此,我们可以这么写:
```
decltype(expr) a = expr;
```
这种写法明显不能让人满意特别是表达式很长的情况而且任何代码重复都是潜在的问题。为此C++14 引入了 `decltype(auto)` 语法。对于上面的情况,我们只需要像下面这样写就行了。
```
decltype(auto) a = expr;
```
这种代码主要用在通用的转发函数模板中:你可能根本不知道你调用的函数是不是会返回一个引用。这时使用这种语法就会方便很多。
### 函数返回值类型推断
从 C++14 开始,函数的返回值也可以用 `auto``decltype(auto)` 来声明了。同样的,用 `auto` 可以得到值类型,用 `auto&amp;``auto&amp;&amp;` 可以得到引用类型;而用 `decltype(auto)` 可以根据返回表达式通用地决定返回的是值类型还是引用类型。
和这个形式相关的有另外一个语法,后置返回值类型声明。严格来说,这不算“类型推断”,不过我们也放在一起讲吧。它的形式是这个样子:
```
auto foo(参数) -&gt; 返回值类型声明
{
// 函数体
}
```
通常,在返回类型比较复杂、特别是返回类型跟参数类型有某种推导关系时会使用这种语法。以后我们会讲到一些实例。今天暂时不多讲了。
### 类模板的模板参数推导
如果你用过 `pair` 的话,一般都不会使用下面这种形式:
```
pair&lt;int, int&gt; pr{1, 42};
```
使用 `make_pair` 显然更容易一些:
```
auto pr = make_pair(1, 42);
```
这是因为函数模板有模板参数推导,使得调用者不必手工指定参数类型;但 C++17 之前的类模板却没有这个功能,也因而催生了像 `make_pair` 这样的工具函数。
在进入了 C++17 的世界后,这类函数变得不必要了。现在我们可以直接写:
```
pair pr{1, 42};
```
生活一下子变得简单多了!
在初次见到 `array` 时,我觉得它的主要缺点就是不能像 C 数组一样自动从初始化列表来推断数组的大小了:
```
int a1[] = {1, 2, 3};
array&lt;int, 3&gt; a2{1, 2, 3}; // 啰嗦
// array&lt;int&gt; a3{1, 2, 3}; 不行
```
这个问题在 C++17 里也是基本不存在的。虽然不能只提供一个模板参数,但你可以两个参数全都不写 🤣:
```
array a{1, 2, 3};
// 得到 array&lt;int, 3&gt;
```
这种自动推导机制,可以是编译器根据构造函数来自动生成:
```
template &lt;typename T&gt;
struct MyObj {
MyObj(T value);
};
MyObj obj1{string("hello")};
// 得到 MyObj&lt;string&gt;
MyObj obj2{"hello"};
// 得到 MyObj&lt;const char*&gt;
```
也可以是手工提供一个推导向导,达到自己需要的效果:
```
template &lt;typename T&gt;
struct MyObj {
MyObj(T value);
};
MyObj(const char*) -&gt; MyObj&lt;string&gt;;
MyObj obj{"hello"};
// 得到 MyObj&lt;string&gt;
```
更多的技术细节请参见参考资料 [4]。
### 结构化绑定
在讲关联容器的时候我们有过这样一个例子:
```
multimap&lt;string, int&gt;::iterator
lower, upper;
std::tie(lower, upper) =
mmp.equal_range("four");
```
这个例子里,返回值是个 `pair`,我们希望用两个变量来接收数值,就不得不声明了两个变量,然后使用 `tie` 来接收结果。在 C++11/14 里,这里是没法使用 `auto` 的。好在 C++17 引入了一个新语法,解决了这个问题。目前,我们可以把上面的代码简化为:
```
auto [lower, upper] =
mmp.equal_range("four");
```
这个语法使得我们可以用 `auto` 声明变量来分别获取 `pair``tuple` 返回值里各个子项,可以让代码的可读性更好。
关于这个语法的更多技术说明,请参见参考资料 [5]。
## 列表初始化
在 C++98 里,标准容器比起 C 风格数组至少有一个明显的劣势:不能在代码里方便地初始化容器的内容。比如,对于数组你可以写:
```
int a[] = {1, 2, 3, 4, 5};
```
而对于 `vector` 你却得写:
```
vector&lt;int&gt; v;
v.push(1);
v.push(2);
v.push(3);
v.push(4);
v.push(5);
```
这样真是又啰嗦性能又差显然无法让人满意。于是C++ 标准委员会引入了列表初始化,允许以更简单的方式来初始化对象。现在我们初始化容器也可以和初始化数组一样简单了:
```
vector&lt;int&gt; v{1, 2, 3, 4, 5};
```
同样重要的是,这不是对标准库容器的特殊魔法,而是一个通用的、可以用于各种类的方法。从技术角度,编译器的魔法只是对 `{1, 2, 3}` 这样的表达式自动生成一个初始化列表,在这个例子里其类型是 `initializer_list&lt;int&gt;`。程序员只需要声明一个接受 `initializer_list` 的构造函数即可使用。从效率的角度,至少在动态对象的情况下,容器和数组也并无二致,都是通过拷贝(构造)进行初始化。
对于初始化列表在构造函数外的用法和更多的技术细节,请参见参考资料 [6]。
## 统一初始化
你可能已经注意到了,我在代码里使用了大括号 `{}` 来进行对象的初始化。这当然也是 C++11 引入的新语法,能够代替很多小括号 `()` 在变量初始化时使用。这被称为统一初始化uniform initialization
大括号对于构造一个对象而言,最大的好处是避免了 C++ 里“最令人恼火的语法分析”the most vexing parse。我也遇到过。假设你有一个类原型如下
```
class utf8_to_wstring {
public:
utf8_to_wstring(const char*);
operator wchar_t*();
};
```
然后你在 Windows 下想使用这个类来帮助转换文件名,打开文件:
```
ifstream ifs(
utf8_to_wstring(filename));
```
你随后就会发现,`ifs` 的行为无论如何都不正常。最后,要么你自己查到,要么有人告诉你,上面这个写法会被编译器认为是和下面的写法等价的:
```
ifstream ifs(
utf8_to_wstring filename);
```
换句话说,编译器认为你是声明了一个叫 `ifs` 的函数,而不是对象!
如果你把任何一对小括号替换成大括号(或者都替换,如下),则可以避免此类问题:
```
ifstream ifs{
utf8_to_wstring{filename}};
```
推而广之,你几乎可以在所有初始化对象的地方使用大括号而不是小括号。它还有一个附带的特点:当一个构造函数没有标成 `explicit` 时,你可以使用大括号不写类名来进行构造,如果调用上下文要求那类对象的话。如:
```
Obj getObj()
{
return {1.0};
}
```
如果 Obj 类可以使用浮点数进行构造的话,上面的写法就是合法的。如果有无参数、多参数的构造函数,也可以使用这个形式。除了形式上的区别,它跟 `Obj(1.0)` 的主要区别是,后者可以用来调用 `Obj(int)`,而使用大括号时编译器会拒绝“窄”转换,不接受以 `{1.0}``Obj{1.0}` 的形式调用构造函数 `Obj(int)`
这个语法主要的限制是,如果一个类既有使用初始化列表的构造函数,又有不使用初始化列表的构造函数,那编译器会**千方百计**地试图调用使用初始化列表的构造函数,导致各种意外。所以,如果给一个推荐的话,那就是:
- 如果一个类没有使用初始化列表的构造函数时,初始化该类对象可全部使用统一初始化语法。
- 如果一个类有使用初始化列表的构造函数时,则只应用在初始化列表构造的情况。
关于这个语法的更多详细用法讨论,请参见参考资料 [7]。
## 类数据成员的默认初始化
按照 C++98 的语法数据成员可以在构造函数里进行初始化。这本身不是问题但实践中如果数据成员比较多、构造函数又有多个的话逐个去初始化是个累赘并且很容易在增加数据成员时漏掉在某个构造函数中进行初始化。为此C++11 增加了一个语法,允许在声明数据成员时直接给予一个初始化表达式。这样,当且仅当构造函数的初始化列表中不包含该数据成员时,这个数据成员就会自动使用初始化表达式进行初始化。
这个句子有点长。我们看个例子:
```
class Complex {
public:
Complex()
: re_(0) , im_(0) {}
Complex(float re)
: re_(re), im_(0) {}
Complex(float re, float im)
: re_(re) , im_(im) {}
private:
float re_;
float im_;
};
```
假设由于某种原因,我们不能使用缺省参数来简化构造函数,我们可以用什么方式来优化上面这个代码呢?
使用数据成员的默认初始化的话,我们就可以这么写:
```
class Complex {
public:
Complex() {}
Complex(float re) : re_(re) {}
Complex(float re, float im)
: re_(re) , im_(im) {}
private:
float re_{0};
float im_{0};
};
```
第一个构造函数没有任何初始化列表,所以类数据成员的初始化全部由默认初始化完成,`re_``im_` 都是 0。第二个构造函数提供了 `re_` 的初始化,`im_` 仍由默认初始化完成。第三个构造函数则完全不使用默认初始化。
## 内容小结
在本讲中,我们介绍了现代 C++ 引入的几个易用性改进:自动类型推断,初始化列表,及类数据成员的默认初始化。使用这些特性非常简单,可以立即简化你的 C++ 代码,而不会引入额外的开销。唯一的要求只是你不要再使用那些上古时代的老掉牙编译器了……
## 课后思考
你使用过现代 C++ 的这些特性了吗?如果还没有的话,哪些特性你打算在下一个项目里开始使用?
欢迎留言来分享你的看法。
## 参考资料
[1] cppreference.com, “Placeholder type specifiers”. [https://en.cppreference.com/w/cpp/language/auto](https://en.cppreference.com/w/cpp/language/auto)
[1a] cppreference.com, “占位符类型说明符”. [https://zh.cppreference.com/w/cpp/language/auto](https://zh.cppreference.com/w/cpp/language/auto)
[2] Wikipedia, “Argument-dependent name lookup”. [https://en.wikipedia.org/wiki/Argument-dependent_name_lookup](https://en.wikipedia.org/wiki/Argument-dependent_name_lookup)
[2a] 维基百科, “依赖于实参的名字查找”. [https://zh.wikipedia.org/zh-cn/依赖于实参的名字查找](https://zh.wikipedia.org/zh-cn/%E4%BE%9D%E8%B5%96%E4%BA%8E%E5%AE%9E%E5%8F%82%E7%9A%84%E5%90%8D%E5%AD%97%E6%9F%A5%E6%89%BE)
[3] cppreference.com, “Template argument deduction”. [https://en.cppreference.com/w/cpp/language/template_argument_deduction](https://en.cppreference.com/w/cpp/language/template_argument_deduction)
[3a] cppreference.com, “模板实参推导”. [https://zh.cppreference.com/w/cpp/language/template_argument_deduction](https://zh.cppreference.com/w/cpp/language/template_argument_deduction)
[4] cppreference.com, “Class template argument deduction”. [https://en.cppreference.com/w/cpp/language/class_template_argument_deduction](https://en.cppreference.com/w/cpp/language/class_template_argument_deduction)
[4a] cppreference.com, “类模板实参推导”. [https://zh.cppreference.com/w/cpp/language/class_template_argument_deduction](https://zh.cppreference.com/w/cpp/language/class_template_argument_deduction)
[5] cppreference.com, “Structured binding declaration”. [https://en.cppreference.com/w/cpp/language/structured_binding](https://en.cppreference.com/w/cpp/language/structured_binding)
[5a] cppreference.com, “结构化绑定声明”. [https://zh.cppreference.com/w/cpp/language/structured_binding](https://zh.cppreference.com/w/cpp/language/structured_binding)
[6] cppreference.com, “std::initializer_list”. [https://en.cppreference.com/w/cpp/utility/initializer_list](https://en.cppreference.com/w/cpp/utility/initializer_list)
[6a] cppreference.com, “std::initializer_list”. [https://en.cppreference.com/w/cpp/utility/initializer_list](https://en.cppreference.com/w/cpp/utility/initializer_list)
[7] Scott Meyers, **Effective Modern C++**, item 7. OReilly Media, 2014. 有中文版高博译中国电力出版社2018 年)

View File

@@ -0,0 +1,345 @@
<audio id="audio" title="09 | 易用性改进 II字面量、静态断言和成员函数说明符" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/63/cb/63290510c554cb7ef0c7b3c9333b9fcb.mp3"></audio>
你好,我是吴咏炜。
本讲我们继续易用性的话题,看看现代 C++ 带来的其他易用性改进。
## 自定义字面量
字面量literal是指在源代码中写出的固定常量它们在 C++98 里只能是原生类型,如:
- `"hello"`,字符串字面量,类型是 `const char[6]`
- `1`,整数字面量,类型是 `int`
- `0.0`,浮点数字面量,类型是 `double`
- `3.14f`,浮点数字面量,类型是 `float`
- `123456789ul`,无符号长整数字面量,类型是 `unsigned long`
C++11 引入了自定义字面量,可以使用 `operator"" 后缀` 来将用户提供的字面量转换成实际的类型。C++14 则在标准库中加入了不少标准字面量。下面这个程序展示了它们的用法:
```
#include &lt;chrono&gt;
#include &lt;complex&gt;
#include &lt;iostream&gt;
#include &lt;string&gt;
#include &lt;thread&gt;
using namespace std;
int main()
{
cout &lt;&lt; "i * i = " &lt;&lt; 1i * 1i
&lt;&lt; endl;
cout &lt;&lt; "Waiting for 500ms"
&lt;&lt; endl;
this_thread::sleep_for(500ms);
cout &lt;&lt; "Hello world"s.substr(0, 5)
&lt;&lt; endl;
}
```
输出是:
>
<p>`i * i = (-1,0)`<br>
`Waiting for 500ms`<br>
`Hello`</p>
上面这个例子展示了 C++ 标准里提供的帮助生成虚数、时间和 `basic_string` 字面量的后缀。一个需要注意的地方是,我在上面使用了 `using namespace std`,这会同时引入 `std` 名空间和里面的内联名空间inline namespace包括了上面的字面量运算符所在的三个名空间
- `std::literals::complex_literals`
- `std::literals::chrono_literals`
- `std::literals::string_literals`
在产品项目中,一般不会(也不应该)全局使用 `using namespace std`(不过,为节约篇幅起见,专栏里的很多例子,特别是不完整的例子,还是默认使用了 `using namespace std`)。这种情况下,应当在使用到这些字面量的作用域里导入需要的名空间,以免发生冲突。在类似上面的例子里,就是在函数体的开头写:
```
using namespace std::literals::
chrono_literals;
```
等等。
要在自己的类里支持字面量也相当容易,唯一的限制是非标准的字面量后缀必须以下划线 `_` 打头。比如,假如我们有下面的长度类:
```
struct length {
double value;
enum unit {
metre,
kilometre,
millimetre,
centimetre,
inch,
foot,
yard,
mile,
};
static constexpr double factors[] =
{1.0, 1000.0, 1e-3,
1e-2, 0.0254, 0.3048,
0.9144, 1609.344};
explicit length(double v,
unit u = metre)
{
value = v * factors[u];
}
};
length operator+(length lhs,
length rhs)
{
return length(lhs.value +
rhs.value);
}
// 可能有其他运算符
```
我们可以手写 `length(1.0, length::metre)` 这样的表达式,但估计大部分开发人员都不愿意这么做吧。反过来,如果我们让开发人员这么写,大家应该还是基本乐意的:
```
1.0_m + 10.0_cm
```
要允许上面这个表达式,我们只需要提供下面的运算符即可:
```
length operator"" _m(long double v)
{
return length(v, length::metre);
}
length operator"" _cm(long double v)
{
return length(v, length::centimetre);
}
```
如果美国国家航空航天局采用了类似的系统的话,火星气候探测者号的事故也许就不会发生了 [1]。当然,历史无法重来,而且 C++ 引入这样的语法已经是在事故发生之后十多年了……
关于自定义字面量的进一步技术细节,请参阅参考资料 [2]。
## 二进制字面量
你一定知道 C++ 里有 `0x` 前缀,可以让开发人员直接写出像 `0xFF` 这样的十六进制字面量。另外一个目前使用得稍少的前缀就是 `0` 后面直接跟 07 的数字,表示八进制的字面量,在跟文件系统打交道的时候还会经常用到:有经验的 Unix 程序员可能会觉得 `chmod(path, S_IRUSR|S_IWUSR|S_IRGRP|S_IROTH)` 并不比 `chmod(path, 0644)` 更为直观。从 C++14 开始,我们对于二进制也有了直接的字面量:
```
unsigned mask = 0b111000000;
```
这在需要比特级操作等场合还是非常有用的。
不过,遗憾的是, I/O streams 里只有 `dec``hex``oct` 三个操纵器manipulator而没有 `bin`,因而输出一个二进制数不能像十进制、十六进制、八进制那么直接。一个间接方式是使用 `bitset`,但调用者需要手工指定二进制位数:
```
#include &lt;bitset&gt;
cout &lt;&lt; bitset&lt;9&gt;(mask) &lt;&lt; endl;
```
>
`111000000`
## 数字分隔符
数字长了之后看清位数就变得麻烦了。有了二进制字面量这个问题变得分外明显。C++14 开始,允许在数字型字面量中任意添加 `'` 来使其更可读。具体怎么添加,完全由程序员根据实际情况进行约定。某些常见的情况可能会是:
- 十进制数字使用三位的分隔,对应英文习惯的 thousand、million 等单位。
- 十进制数字使用四位的分隔,对应中文习惯的万、亿等单位。
- 十六进制数字使用两位或四位的分隔,对应字节或双字节。
- 二进制数字使用三位的分隔,对应文件系统的权限分组。
- 等等。
一些实际例子如下:
```
unsigned mask = 0b111'000'000;
long r_earth_equatorial = 6'378'137;
double pi = 3.14159'26535'89793;
const unsigned magic = 0x44'42'47'4E;
```
## 静态断言
C++98 的 `assert` 允许在运行时检查一个函数的前置条件是否成立。没有一种方法允许开发人员在编译的时候检查假设是否成立。比如,如果模板有个参数 `alignment`,表示对齐,那我们最好在编译时就检查 `alignment` 是不是二的整数次幂。之前人们用了一些模板技巧来达到这个目的,但输出的信息并不那么友善。比如,我之前使用的方法,会产生类似下面这样的输出:
<img src="https://static001.geekbang.org/resource/image/59/a5/59fa97e766d6c363ae23546e211774a5.png" alt="">
能起作用但不够直观。C++11 直接从语言层面提供了静态断言机制,不仅能输出更好的信息,而且适用性也更好,可以直接放在类的定义中,而不像之前用的特殊技巧只能放在函数体里。对于类似上面的情况,现在的输出是:
<img src="https://static001.geekbang.org/resource/image/37/48/37c7f1703b6c2d09137ccf3a5065be48.png" alt="">
静态断言语法上非常简单,就是:
```
static_assert(编译期条件表达式,
可选输出信息);
```
产生上面的示例错误信息的代码是:
```
static_assert((alignment &amp; (alignment - 1)) == 0,
"Alignment must be power of two");
```
## default 和 delete 成员函数
在类的定义时C++ 有一些规则决定是否生成默认的特殊成员函数。这些特殊成员函数可能包括:
- 默认构造函数
- 析构函数
- 拷贝构造函数
- 拷贝赋值函数
- 移动构造函数
- 移动赋值函数
生成这些特殊成员函数(或不生成)的规则比较复杂,感兴趣的话你可以查看参考资料 [3]。每个特殊成员函数有几种不同的状态:
- 隐式声明还是用户声明
- 默认提供还是用户提供
- 正常状态还是删除状态
这三个状态是可组合的,虽然不是所有的组合都有效。隐式声明的必然是默认提供的;默认提供的才可能被删除;用户提供的也必然是用户声明的。
如果成员和父类没有特殊原因导致对象不可拷贝或移动,在用户不声明这些成员函数的情况下,编译器会自动产生这些成员函数,即隐式声明、默认提供、正常状态。有特殊成员、用户声明的话,情况就非常复杂了:
- 没有初始化的非静态 const 数据成员和引用类型数据成员会导致默认提供的默认构造函数被删除。
- 非静态的 const 数据成员和引用类型数据成员会导致默认提供的拷贝构造函数、拷贝赋值函数、移动构造函数和移动赋值函数被删除。
- 用户如果没有自己提供一个拷贝构造函数(必须形如 `Obj(Obj&amp;)``Obj(const Obj&amp;)`;不是模板),编译器会隐式声明一个。
- 用户如果没有自己提供一个拷贝赋值函数(必须形如 `Obj&amp; operator=(Obj&amp;)``Obj&amp; operator=(const Obj&amp;)`;不是模板),编译器会隐式声明一个。
- 用户如果自己声明了一个移动构造函数或移动赋值函数,则默认提供的拷贝构造函数和拷贝赋值函数被删除。
- 用户如果没有自己声明拷贝构造函数、拷贝赋值函数、移动赋值函数和析构函数,编译器会隐式声明一个移动构造函数。
- 用户如果没有自己声明拷贝构造函数、拷贝赋值函数、移动构造函数和析构函数,编译器会隐式声明一个移动赋值函数。
- ……
我不鼓励你去死记硬背这些规则,而是希望你在项目和测试中体会其缘由。我认为这些规则还相当合理,虽然有略偏保守之嫌。尤其是关于移动构造和赋值:只要用户声明了另外的特殊成员函数中的任何一个,编译器就不默认提供了。不过嘛,缺省慢点总比缺省不安全要好……
我们这儿主要要说的是,我们可以改变缺省行为,在编译器能默认提供特殊成员函数时将其删除,或在编译器不默认提供特殊成员函数时明确声明其需要默认提供(不过,要注意,即使用户要求默认提供,编译器也可能根据其他规则将特殊成员函数标为删除)。
还是举例子来说明一下。对于下面这样的类,编译器看到有用户提供的构造函数,就会不默认提供默认构造函数:
```
template &lt;typename T&gt;
class my_array {
public:
my_array(size_t size);
private:
T* data_{nullptr};
size_t size_{0};
};
```
在没有默认初始化时,我们如果需要默认构造函数,就需要手工写一个,如:
```
my_array()
: data_(nullptr)
, size_(0) {}
```
可有了默认初始化之后,这个构造函数显然就不必要了,所以我们现在可以写:
```
my_array() = default;
```
再来一个反向的例子。我们[[第 1 讲]](https://time.geekbang.org/column/article/169225) 里的 `shape_wrapper`,它的复制行为是不安全的。我们可以像[[第 2 讲]](https://time.geekbang.org/column/article/169263) 里一样去改进它,但如果正常情况不需要复制行为、只是想防止其他开发人员误操作时,我们可以简单地在类的定义中加入:
```
class shape_wrapper {
shape_wrapper(
const shape_wrapper&amp;) = delete;
shape_wrapper&amp; operator=(
const shape_wrapper&amp;) = delete;
};
```
在 C++11 之前,我们可能会用在 `private` 段里声明这些成员函数的方法,来达到相似的目的。但目前这个语法效果更好,可以产生更明确的错误信息。另外,你可以注意一下,用户声明成删除也是一种声明,因此编译器不会提供默认版本的移动构造和移动赋值函数。
## override 和 final 说明符
`override``final` 是两个 C++11 引入的新说明符。它们不是关键词,仅在出现在函数声明尾部时起作用,不影响我们使用这两个词作变量名等其他用途。这两个说明符可以单个或组合使用,都是加在类成员函数声明的尾部。
`override` 显式声明了成员函数是一个虚函数且覆盖了基类中的该函数。如果有 `override` 声明的函数不是虚函数,或基类中不存在这个虚函数,编译器会报告错误。这个说明符的主要作用有两个:
- 给开发人员更明确的提示,这个函数覆写了基类的成员函数;
- 让编译器进行额外的检查,防止程序员由于拼写错误或代码改动没有让基类和派生类中的成员函数名称完全一致。
`final` 则声明了成员函数是一个虚函数,且该虚函数不可在派生类中被覆盖。如果有一点没有得到满足的话,编译器就会报错。
`final` 还有一个作用是标志某个类或结构不可被派生。同样,这时应将其放在被定义的类或结构名后面。
用法示意如下:
```
class A {
public:
virtual void foo();
virtual void bar();
void foobar();
};
class B : public A {
public:
void foo() override; // OK
void bar() override final; // OK
//void foobar() override;
// 非虚函数不能 override
};
class C final : public B {
public:
void foo() override; // OK
//void bar() override;
// final 函数不可 override
};
class D : public C {
// 错误final 类不可派生
};
```
## 内容小结
今天我们介绍了现代 C++ 引入的另外几个易用性改进自定义字面量二进制字面量数字分隔符静态断言default 和 delete 成员函数,及 override 和 final。同上一讲介绍的易用性改进一样这些新功能可以改进代码的可读性同时也不会带来额外的开销。在任何有条件使用满足新 C++ 标准的编译器的项目中,都应该考虑使用这些新特性。
## 课后思考
你最喜欢的 C++ 易用性改进是什么?为什么?
欢迎留言和我分享你的看法!
## 参考资料
[1] Wikipedia, “**Mars Climate Orbiter**”. [https://en.wikipedia.org/wiki/Mars_Climate_Orbiter](https://en.wikipedia.org/wiki/Mars_Climate_Orbiter)
[1a] 维基百科, “火星气候探测者号”. [https://zh.wikipedia.org/zh-cn/火星氣候探測者號](https://zh.wikipedia.org/zh-cn/%E7%81%AB%E6%98%9F%E6%B0%A3%E5%80%99%E6%8E%A2%E6%B8%AC%E8%80%85%E8%99%9F)
[2] cppreference.com, “User-defined literals”. [https://en.cppreference.com/w/cpp/language/user_literal](https://en.cppreference.com/w/cpp/language/user_literal)
[2a] cppreference.com, “用户定义字面量”. [https://zh.cppreference.com/w/cpp/language/user_literal](https://zh.cppreference.com/w/cpp/language/user_literal)
[3] cppreference.com, “Non-static member functions”, section “Special member functions”. [https://en.cppreference.com/w/cpp/language/member_functions](https://en.cppreference.com/w/cpp/language/member_functions)
[3a] cppreference.com, “非静态成员函数”, “特殊成员函数”部分. [https://zh.cppreference.com/w/cpp/language/member_functions](https://zh.cppreference.com/w/cpp/language/member_functions)

View File

@@ -0,0 +1,328 @@
<audio id="audio" title="21 | 工具漫谈:编译、格式化、代码检查、排错各显身手" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8a/79/8a343e94eeadf237b0e1158281769a79.mp3"></audio>
你好,我是吴咏炜。
现代 C++ 语言,我们讲到这里就告一段落了。今天我们正式开启了实战篇,先讲一个轻松些的话题——工具。
## 编译器
当然,轻松不等于不重要。毕竟,工欲善其事,必先利其器。我们做 C++ 开发最基本的工具就是编译器对其有些了解显然也是必要的。我们就先来看看我在专栏开头就提到的三种编译器MSVC [1]、GCC [2] 和 Clang [3]。
### MSVC
三种编译器里最老资格的就是 MSVC 了。据微软员工在 2015 年的一篇博客,在 MSVC 的代码里还能找到 1982 年写下的注释 [4]。这意味着 MSVC 是最历史悠久、最成熟,但也是最有历史包袱的编译器。
微软的编译器在传统代码的优化方面做得一直不错,但对模板的支持则是它的软肋,在 Visual Studio 2015 之前尤其不行——之前模板问题数量巨大,之后就好多了。而 2018 年 11 月 MSVC 宣布终于能够编译 range-v3 库,也成了一件值得庆贺的事 [5]。当然,这件事情是值得高兴的,但考虑我在 2016 年的演讲里就已经用到了 range-v3不能不觉得还是有点晚了。此外我已经提过微软对代码的“容忍度”一直有点太高缺省情况下不使用 `/Za` 选项),能接受 C++ 标准认为非法的代码,这至少对写跨平台的代码而言,绝不是一件好事。
MSVC 当然也有领先的地方。它对标准库的实现一直不算慢,较早就提供了比较健壮的线程([[第 19 讲]](https://time.geekbang.org/column/article/186689)、[[第 20 讲]](https://time.geekbang.org/column/article/186708))、正则表达式([6])等标准库。在并发 [7] 方面,微软也是比较领先的,并主导了协程的技术规格书 [8]。微软一开始支持 C++ 标准的速度比较慢,但慢慢地,微软已经把全面支持 C++ 标准当作了目标,并在 2018 年宣布已全面支持 C++17 标准;虽然同时也承认仍有一些重大问题影响了其编译一些重要的开源 C++ 项目 [9]。
MSVC 有一个地方我一直比较喜欢,就是代码里可以写出要求链接具体什么库,而链接什么库的命令,可以是使用的第三方代码里直接给出的。这就使得在命令行上编译使用到第三方库(如 Boost的代码变得非常容易。在使用 GCC 和 Clang 时,用到什么库,就必须在命令行上写出来,这就迫使程序员使用更规范、也更麻烦的管理方式了。具体而言,对于下面的这个最小的单元测试程序:
```
#define BOOST_TEST_MAIN
#include &lt;boost/test/unit_test.hpp&gt;
BOOST_AUTO_TEST_CASE(minimal_test)
{
BOOST_CHECK(1 + 1 == 2);
}
```
使用 GCC 或 Clang 时你需要输入类似下面这样的命令:
>
`g++ -DBOOST_TEST_DYN_LINK test.cpp -lboost_unit_test_framework`
而 Windows 下使用 MSVC 你只需要输入:
>
`cl /DBOOST_TEST_DYN_LINK /EHsc /MD test.cpp`
一下子就简单多了。
另外,在免费的 C++ 集成开发环境里Visual Studio Community Edition 恐怕可以算是最好的了,至少在 Windows 上是这样。在自动完成功能和调试功能上 Visual Studio 做得特别好,为其他的免费工具所不及。如果你开发的 C++ 程序主要在 Windows 上运行,那 MSVC 就应该是首选了。
### Clang
相反,在三个编译器里,最新的就是 Clang。作为 LLVM 项目的一部分,它的最早发布是在 2007 年,然后流行程度一路飙升,到现在成了一个通用的跨平台编译器。其中有不少苹果的支持——因为苹果对 GCC 的许可要求不满意,苹果把 LLVM 开发者 Chris Lattner 招致麾下2005—2017期间他除了为苹果设计开发了全新的语言 SwiftClang 的 C++ 支持也得到了飞速的发展。
作为后来者Clang 在错误信息易用性上做出了极大的改善。Clang 虽然一直在模拟 GCC 的功能和命令行但错误信息的友好性是它的最大亮点。在语言层面Clang 对 C++ 标准的支持也是飞速,正如下面这张图所展示的那样([10]
<img src="https://static001.geekbang.org/resource/image/a6/71/a6432b0cbdc5ad6965402800f2057971.png" alt="">
可以看到Clang 在 2011 异军突起,对 C++11 的支持程度在短时间甚至还超过了原先的领跑者 GCC。由于 Clang/LLVM 的模块化设计,在 Clang 上扩展新功能相当容易;而且动态库 libclang 直接向开发者暴露了分析 C++ 代码的接口,这也是 Clang 流行的一个主要原因。
即使在我主要使用 Windows 工作的时候,我在机器上也装了 Clang。我主要不是用它编译而是利用它对 C++ 的理解,做代码的格式化(本讲下面会讲)和自动完成——对于文件数不多的项目,我还是喜欢使用 Vim [11],那机器上能不能用 clang_complete [12] 区别就很大了。有了 clang_complete那 Vim 里也就有个不算太笨的 C++ 自动完成引擎了。顾名思义clang_complete 主要依赖的就是 Clang 了,更精确地说,是 libclang。
另外,当我写出在 MSVC 下编译不过的代码时,我也会看看代码能不能在 Clang 下通过。如果能过,那我就比较有信心,我写出的代码是正确的,只不过是 MSVC 处理不了而已😈。
Clang 目前在 macOS 下是默认的 C/C++ 编译器。在 Linux 和 Windows 下当然也都能安装这种情况下Clang 会使用平台上的主流 C++ 库,也就是在 Linux 上使用 libstdc++,在 Windows 上使用 MSVC 的 C++ 运行时。只有在 macOS 上Clang 才会使用其原生 C++ 库libc++ [13]。顺便说一句,如果你想阅读一下现代 C++ 标准库的参考实现的话libc++ 是可读性最好的——不过任何一个软件产品的源代码都不是以可读性为第一考量比起教科书、专栏里的代码例子libc++ 肯定是要复杂多了。
最后一个关于版本号的说明:苹果开发工具里带的 Clang 的是苹果自己维护的一个分支,版本号和苹果的 Xcode 开发工具版本号一致,和开源项目 Clang 的版本号没有关系,显得比较乱。目前 Apple Clang 的最新版本是 11 了,但功能上落后于官方的 LLVM Clang 9.0 [14]。要想使用最新版本的 Clang最方便的方式是使用 Homebrew [15] 安装 llvm
>
`brew install llvm`
安装完之后,新的 clang 和 clang++ 工具在 /usr/local/opt/llvm/bin 目录下和系统原有的命令不会发生冲突。你如果需要使用新的工具的话需要改变路径的顺序或者自己创建命令的别名alias
### GCC
GCC 的第一个版本发布于 1987 年,是由自由软件运动的发起人 Richard Stallman常常被缩写为 RMS亲自写的。因而从诞生伊始GCC 就带着很强的意识形态,承担着振兴自由软件的任务。在 GNU/Linux 平台上GCC 自然是首选的编译器。自由软件的开发者,大部分也选择了 GCC。由于 GCC 是用 GPL 发布的,任何对 GCC 的修改都必须以 GPL 协议发布。这就迫使想修改 GCC 的人要为 GCC 做出贡献。这对自由软件当然是件好事,但对一家公司来讲就未必了。此外,你想拆出 GCC 的一部分来做其他事情,比如对代码进行分析,也绝不是件容易的事。这些问题,实际上就是迫使苹果公司在 LLVM/Clang 上投资的动机了。
作为应用最广的自由软件之一GCC 无疑是非常成熟的软件。某些实验性的功能,比如对概念的支持,也是最早在 GCC 上面出现的。对 C++ 标准的支持GCC 一直跟得非常紧,但是,由于自由软件依靠志愿者的工作,而非项目经理或产品经理的管理,对不同功能的优先级跟商业产品往往不同,也造就了 GCC 和 MSVC 上各有不同的着重点,优化编译结果哪个性能更高也会依赖于具体的程序。当然 GCC 是跨平台的,这点上肯定是 MSVC 不及的。根据 GCC 的方式写出的代码,跨平台性就会更好。目前我已知的最主要例外是终端上的多语言支持:由于 GCC 在 Windows 上使用了 MSVC 的一个过时的运行库 MSVCRT.DLL到现在为止 GCC 要在终端上显示中文经常会出现问题 [16]。
初期 GCC 在出错信息的友好程度上一直做得不太好。但 Clang 的出现刺激出了一种和 GCC 之间的良性竞争到今天GCC 的错误信息反而是最友好的了。我如果遇到程序编译出错在 Clang 里看不明白的话,我会试着用 GCC 再编译看看,在某些情况下,可能 GCC 的出错信息会更让人明白一些。
在可预见的将来,在自由/开源软件的开发上GCC 一直会是编译器的标准。
## 格式化工具
### Clang-Format
我上面提到了 Clang 有着非常模块化的设计容易被其他工具复用其代码分析功能。LLVM 团队自己也提供一些工具,其中我个人最常用的就是 Clang-Format [17]。
在使用 Clang-Format 之前,我也使用过一些其他的格式化工具。它们和 Clang-Format 的最大区别是,它们不理解 C++ 代码,在对付简单的 C 代码时还行,遇到复杂的 C++ 代码时就很容易出问题。此外Clang-Format 还很智能,可以像人一样,根据具体情况和剩余空间来格式化,比如:
```
void func(int arg1, int arg2,
int arg3);
void long_func_name(int arg1,
int arg2,
int arg3);
void a_very_long_func_name(
int arg1, int arg2, int arg3);
```
此外,它也提供了完善的配置项,你可以根据自己的需要来进行配置,如这是我的一个项目使用的格式化选项:
[https://github.com/adah1972/nvwa/blob/master/.clang-format](https://github.com/adah1972/nvwa/blob/master/.clang-format)
C++ 项目里放上这样一个文件,代码的格式化问题大家就不用瞎争了——大家确定这个文件的内容就行。
目前这个专栏的代码格式化选项也和上面的类似最主要的区别就是行长限制ColumnLimit设成了 36缩进宽度IndentWidth等选项基本减半来适配手机的小显示屏。如果没有 Clang-Format做代码的小屏适配就会累多了。
## 代码检查工具
### Clang-Tidy
Clang 项目也提供了其他一些工具,包括代码的静态检查工具 Clang-Tidy [18]。这是一个比较全面的工具它除了会提示你危险的用法也会告诉你如何去现代化你的代码。默认情况下Clang-Tidy 只做基本的分析。你也可以告诉它你想现代化你的代码和提高代码的可读性:
>
`clang-tidy --checks='clang-analyzer-*,modernize-*,readability-*' test.cpp`
以下面简单程序为例:
```
#include &lt;iostream&gt;
#include &lt;stddef.h&gt;
using namespace std;
int sqr(int x) { return x * x; }
int main()
{
int a[5] = {1, 2, 3, 4, 5};
int b[5];
for (int i = 0; i &lt; 5; ++i) {
b[i] = sqr(a[i]);
}
for (int i : b) {
cout &lt;&lt; i &lt;&lt; endl;
}
char* ptr = NULL;
*ptr = '\0';
}
```
Clang-Tidy 会报告下列问题:
- &lt;stddef.h&gt; 应当替换成 &lt;cstddef&gt;
- 函数形式 `int func(…)` 应当修改成 `auto func(…) -&gt; int`
- 不要使用 C 数组,应当改成 `std::array`
- `5` 是魔术数,应当改成具名常数
- `NULL` 应当改成 `nullptr`
前两条我不想听。这种情况下,使用配置文件来定制行为就必要了。配置文件叫 .clang-tidy应当放在你的代码目录下或者代码的一个父目录下。Clang-Tidy 会使用最“近”的那个配置文件。下面的配置文件反映了我的偏好:
```
Checks: 'clang-diagnostic-*,clang-analyzer-*,modernize-*,readability-*,-modernize-deprecated-headers,-modernize-use-trailing-return-type'
```
世界清静多了:我不想听到的唐僧式的啰唣就消失了。
使用 Clang-Tidy 还需要注意的地方是,额外的命令行参数应当跟在命令行最后的 `--` 后面。比如,如果我们要扫描一个 C++ 头文件 foo.h我们就需要明确告诉 Clang-Tidy 这是 C++ 文件(默认 .h 是 C 文件)。然后,如果我们需要包含父目录下的 common 目录,语言标准使用了 C++17命令行就应该是下面这个样子
>
`clang-tidy foo.h -- -x c++ -std=c++17 -I../common`
你有没有注意到,上面 Clang-Tidy 实际上漏报告了些问题:它报告了一些不重要的问题,却漏过了真正严重的问题。这似乎是个实现相关的特殊问题,因为如果把前面那些行删掉的话,后面两行有问题的代码也还是会产生告警的。
### Cppcheck
Clang-Tidy 还是一个比较“重”的工具。它需要有一定的配置,需要能看到文件用到的头文件,运行的时间也会较长。而 Cppcheck [19] 就是一个非常轻量的工具了。它运行速度飞快,看不到头文件、不需要配置就能使用。它跟 Clang-Tidy 的重点也不太一样:它强调的是发现代码可能出问题的地方,而不太着重代码风格问题,两者功能并不完全重叠。有条件的情况下,这两个工具可以一起使用。
以上面的例子来为例Cppcheck 会干脆地报告代码中最严重的问题——空指针的解引用。它的开销很低,却能发现潜在的安全性问题,因而我觉得这是个性价比很高的工具。
## 排错工具
排错工具当然也有很多种我们今天介绍其中两个Valgrind 和 nvwa::debug_new。
### Valgrind
Valgrind [20] 算是一个老牌工具了。它是一个非侵入式的排错工具。根据 Valgrind 的文档,它会导致可执行文件的速度减慢 20 至 30 倍。但它可以在不改变可执行文件的情况下,只要求你在编译时增加产生调试信息的命令行参数(`-g`),即可查出内存相关的错误。
以下面的简单程序为例:
```
int main()
{
char* ptr = new char[20];
}
```
在 Linux 上使用 `g++ -g test.cpp` 编译之后,然后使用 `valgrind --leak-check=full ./a.out` 检查运行结果,我们得到的输出会如下所示:
<img src="https://static001.geekbang.org/resource/image/5c/50/5cb2060de012f04c4b30741c6e0deb50.png" alt="">
即其中包含了内存泄漏的信息,包括内存是从什么地方泄漏的。
Valgrind 的功能并不只是内存查错,也包含了多线程问题分析等其他功能。要进一步了解相关信息,请查阅其文档。
### nvwa::debug_new
在 nvwa [21] 项目里,我也包含了一个很小的内存泄漏检查工具。它的最大优点是小巧,并且对程序运行性能影响极小;缺点主要是不及 Valgrind 易用和强大,只能检查 `new` 导致的内存泄漏,并需要侵入式地对项目做修改。
需要检测内存泄漏时,你需要把 debug_new.cpp 加入到项目里。比如,可以简单地在命令行上加入这个文件:
>
<p>`c++ test.cpp \`<br>
`../nvwa/nvwa/debug_new.cpp`</p>
下面是可能的运行时报错:
>
<p>`Leaked object at 0x100302760 (size 20, 0x1000018a4)`<br>
`*** 1 leaks found`</p>
在使用 GCC 和 Clang 时,可以让它自动帮你找出内存泄漏点的位置。在命令行上需要加入可执行文件的名称,并产生调试信息:
>
<p>`c++ -D_DEBUG_NEW_PROGNAME=\"a.out\" \`<br>
`-g test.cpp \`<br>
`../nvwa/nvwa/debug_new.cpp`</p>
这样,我们就可以在运行时看到一个更明确的错误:
>
<p>`Leaked object at 0x100302760 (size 20, main (in a.out) (test.cpp:3))`<br>
`*** 1 leaks found`</p>
这个工具的其他用法可以参见文档。
## 网页工具
### Compiler Explorer
编译器都有输出汇编代码的功能:在 MSVC 上可使用 `/Fa`,在 GCC 和 Clang 上可使用 `-S`。不过要把源代码和汇编对应起来就需要一定的功力了。在这点上godbolt.org [22] 可以提供很大的帮助。它配置了多个不同的编译器,可以过滤掉编译器产生的汇编中开发者一般不关心的部分,并能够使用颜色和提示来帮助你关联源代码和产生的汇编。使用这个网站,你不仅可以快速查看你的代码在不同编译器里的优化结果,还能快速分享结果。比如,下面这个链接,就可以展示我们之前讲过的一个模板元编程代码的编译结果:
[https://godbolt.org/z/zPNEJ4](https://godbolt.org/z/zPNEJ4)
网页截图示意如下:
<img src="https://static001.geekbang.org/resource/image/e1/a2/e1f3f1f2125c6b0679fbf0752d30cda2.jpg" alt="">
当然作为一个网站godbolt.org 对代码的复杂度有一定的限制,也不能任意使用你在代码里用到的第三方库(不过,它已经装了不少主流的 C++ 库,如我们后面会讲到的 Boost、Catch2、range-v3 和 cppcoro。要解决这个问题你可以在你自己的机器上本地安装它背后的引擎compiler-explorer [23]。如果你的代码较复杂,或者有安全、隐私方面的顾虑的话,可以考虑这个方案。
### C++ Insights
如果你在上面的链接里点击了“CppInsights”按钮的话你就会跳转到 C++ Insights [24] 网站,并且你贴在 godbolt.org 的代码也会一起被带过去。这个网站提供了另外一个编译器目前没有提供、但十分有用的功能:展示模板的展开过程。
回想我们在模板编程时的痛苦之一来自于我们需要在脑子中想象模板是如何展开的,而这个过程非常容易出错。当编译器出错时,我们得通过冗长的错误信息来寻找出错原因的蛛丝马迹;当编译器成功编译了一段我们不那么理解的模板代码时,我们在感到庆幸的同时,也往往会仍然很困惑——而使用这个网站,你就可以看到一个正确工作的模板是如何展开的。以[[第 18 讲]](https://time.geekbang.org/column/article/185899) 讨论的 `make_index_sequence` 为例,如果你把代码完整输入到网站上去、然后尝试展开 `make_index_sequence&lt;5&gt;`,你就会看到 `index_sequence_helper` 是这样展开的:
>
<p>`index_sequence_helper&lt;5&gt;`<br>
`index_sequence_helper&lt;4, 4&gt;`<br>
`index_sequence_helper&lt;3, 3, 4&gt;`<br>
`index_sequence_helper&lt;2, 2, 3, 4&gt;`<br>
`index_sequence_helper&lt;1, 1, 2, 3, 4&gt;`<br>
`index_sequence_helper&lt;0, 0, 1, 2, 3, 4&gt;`</p>
如果我更早一点知道这个工具的话,我就会在讲编译期编程的时候直接建议大家用了,应该会更有助于模板的理解……
## 内容小结
在今天这一讲中,我们对各个编译器和一些常用的工具作了简单的介绍。用好工具,可以大大提升你的开发效率。
## 课后思考
哪些工具你觉得比较有用?哪些工具你已经在用了(除了编译器)?你个人还会推荐哪些工具?
欢迎留言和我分享。
## 参考资料
[1] Visual Studio. [https://visualstudio.microsoft.com/](https://visualstudio.microsoft.com/)
[2] GCC, the GNU Compiler Collection. [https://gcc.gnu.org/](https://gcc.gnu.org/)
[3] Clang: a C language family frontend for LLVM. [https://clang.llvm.org/](https://clang.llvm.org/)
[4] Jim Springfield, “Rejuvenating the Microsoft C/C++ compiler”. [https://devblogs.microsoft.com/cppblog/rejuvenating-the-microsoft-cc-compiler/](https://devblogs.microsoft.com/cppblog/rejuvenating-the-microsoft-cc-compiler/)
[5] Casey Carter, “Use the official range-v3 with MSVC 2017 version 15.9”. [https://devblogs.microsoft.com/cppblog/use-the-official-range-v3-with-msvc-2017-version-15-9/](https://devblogs.microsoft.com/cppblog/use-the-official-range-v3-with-msvc-2017-version-15-9/)
[6] cppreference.com, “std::regex”. [https://en.cppreference.com/w/cpp/regex/basic_regex](https://en.cppreference.com/w/cpp/regex/basic_regex)
[7] Microsoft, “Concurrency Runtime”. [https://docs.microsoft.com/en-us/cpp/parallel/concrt/concurrency-runtime](https://docs.microsoft.com/en-us/cpp/parallel/concrt/concurrency-runtime)
[8] ISO/IEC JTC1 SC22 WG21, “Programming languages—C++extensions for coroutines”. [http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/n4680.pdf](http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/n4680.pdf)
[9] Ulzii Luvsanbat, “Announcing: MSVC conforms to the C++ standard”. [https://devblogs.microsoft.com/cppblog/announcing-msvc-conforms-to-the-c-standard/](https://devblogs.microsoft.com/cppblog/announcing-msvc-conforms-to-the-c-standard/)
[10] Jonathan Adamczewski, “The growth of modern C++ support”. [http://brnz.org/hbr/?p=1404](http://brnz.org/hbr/?p=1404)
[11] Vim Online. [https://www.vim.org/](https://www.vim.org/)
[12] Xavier Deguillard, clang_complete. [https://github.com/xavierd/clang_complete](https://github.com/xavierd/clang_complete)
[13] “libc++” C++ Standard Library . [https://libcxx.llvm.org/](https://libcxx.llvm.org/)
[14] cppreference.com, “C++ compiler support”. [https://en.cppreference.com/w/cpp/compiler_support](https://en.cppreference.com/w/cpp/compiler_support)
[15] Homebrew. [https://brew.sh/](https://brew.sh/)
[16] 吴咏炜, “MSVCRT.DLL console I/O bug”. [https://yongweiwu.wordpress.com/2016/05/27/msvcrt-dll-console-io-bug/](https://yongweiwu.wordpress.com/2016/05/27/msvcrt-dll-console-io-bug/)
[17] ClangFormat. [https://clang.llvm.org/docs/ClangFormat.html](https://clang.llvm.org/docs/ClangFormat.html)
[18] Clang-Tidy. [https://clang.llvm.org/extra/clang-tidy/](https://clang.llvm.org/extra/clang-tidy/)
[19] Daniel Marjamäki, Cppcheck. [https://github.com/danmar/cppcheck](https://github.com/danmar/cppcheck)
[20] Valgrind Home. [https://valgrind.org/](https://valgrind.org/)
[21] 吴咏炜, nvwa. [https://github.com/adah1972/nvwa/](https://github.com/adah1972/nvwa/)
[22] Matt Godbolt, “Compiler Explorer”. [https://godbolt.org/](https://godbolt.org/)
[23] Matt Godbolt, compiler-explorer. [https://github.com/mattgodbolt/compiler-explorer](https://github.com/mattgodbolt/compiler-explorer)
[24] Andreas Fertig, “C++ Insights”. [https://cppinsights.io/](https://cppinsights.io/)

View File

@@ -0,0 +1,472 @@
<audio id="audio" title="22 | 处理数据类型变化和错误optional、variant、expected和Herbception" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a4/d1/a41f07d67a4fb7f363494d1b4875bbd1.mp3"></audio>
你好,我是吴咏炜。
我们之前已经讨论了异常是推荐的 C++ 错误处理方式。不过C++ 里有另外一些结构也很适合进行错误处理,今天我们就来讨论一下。
## optional
在面向对象(引用语义)的语言里,我们有时候会使用空值 null 表示没有找到需要的对象。也有人推荐使用一个特殊的空对象,来避免空值带来的一些问题 [1]。可不管是空值,还是空对象,对于一个返回普通对象(值语义)的 C++ 函数都是不适用的——空值和空对象只能用在返回引用/指针的场合,一般情况下需要堆内存分配,在 C++ 里会引致额外的开销。
C++17 引入的 `optional` 模板 [2] 可以(部分)解决这个问题。语义上来说,`optional` 代表一个“也许有效”“可选”的对象。语法上来说,一个 `optional` 对象有点像一个指针,但它所管理的对象是直接放在 `optional` 里的,没有额外的内存分配。
构造一个 `optional&lt;T&gt;` 对象有以下几种方法:
1. 不传递任何参数,或者使用特殊参数 `std::nullopt`(可以和 `nullptr` 类比),可以构造一个“空”的 `optional` 对象,里面不包含有效值。
1. 第一个参数是 `std::in_place`,后面跟构造 `T` 所需的参数,可以在 `optional` 对象上直接构造出 `T` 的有效值。
1. 如果 `T` 类型支持拷贝构造或者移动构造的话,那在构造 `optional&lt;T&gt;` 时也可以传递一个 `T` 的左值或右值来将 `T` 对象拷贝或移动到 `optional` 中。
对于上面的第 1 种情况,`optional` 对象里是没有值的,在布尔值上下文里,会得到 `false`(类似于空指针的行为)。对于上面的第 2、3 两种情况,`optional` 对象里是有值的,在布尔值上下文里,会得到 `true`(类似于有效指针的行为)。类似的,在 `optional` 对象有值的情况下,你可以用 `*``-&gt;` 运算符去解引用(没值的情况下,结果是未定义行为)。
虽然 `optional` 是 C++17 才标准化的,但实际上这个用法更早就通行了。因为 `optional` 的实现不算复杂,有些库里就自己实现了一个版本。比如 cpptoml [3] 就给出了下面这样的示例(进行了翻译和重排版),用法跟标准的 `optional` 完全吻合:
```
auto val = config-&gt;
get_as&lt;int64_t&gt;("my-int");
// val 是 cpptoml::option&lt;int64_t&gt;
if (val) {
// *val 是 "my-int" 键下的整数值
} else {
// "my-int" 不存在或不是整数
}
```
cpptoml 里只是个缩微版的 `optional`,实现只有几十行,也不支持我们上面说的所有构造方式。标准库的 `optional` 为了方便程序员使用,除了我目前描述的功能,还支持下面的操作:
- 安全的析构行为
- 显式的 `has_value` 成员函数,判断 `optional` 是否有值
- `value` 成员函数,行为类似于 `*`,但在 `optional` 对象无值时会抛出异常 `std::bad_optional_access`
- `value_or` 成员函数,在 `optional` 对象无值时返回传入的参数
- `swap` 成员函数,和另外一个 `optional` 对象进行交换
- `reset` 成员函数,清除 `optional` 对象包含的值
- `emplace` 成员函数,在 `optional` 对象上构造一个新的值(不管成功与否,原值会被丢弃)
- `make_optional` 全局函数,产生一个 `optional` 对象(类似 `make_pair``make_unique` 等)
- 全局比较操作
- 等等
如果我们认为无值就是数据无效,应当跳过剩下的处理,我们可以写出下面这样的高阶函数:
```
template &lt;typename T&gt;
constexpr bool has_value(
const optional&lt;T&gt;&amp; x) noexcept
{
return x.has_value();
}
template &lt;typename T,
typename... Args&gt;
constexpr bool has_value(
const optional&lt;T&gt;&amp; first,
const optional&lt;
Args&gt;&amp;... other) noexcept
{
return first.has_value() &amp;&amp;
has_value(other...);
}
template &lt;typename F&gt;
auto lift_optional(F&amp;&amp; f)
{
return [f = forward&lt;F&gt;(f)](
auto&amp;&amp;... args) {
typedef decay_t&lt;decltype(f(
forward&lt;decltype(args)&gt;(args)
.value()...))&gt;
result_type;
if (has_value(args...)) {
return optional&lt;result_type&gt;(
f(forward&lt;decltype(args)&gt;(
args)
.value()...));
} else {
return optional&lt;
result_type&gt;();
}
};
}
```
`has_value` 比较简单,它可以有一个或多个 `optional` 参数,并在所有参数都有值时返回真,否则返回假。`lift_optional` 稍复杂些,它接受一个函数,返回另外一个函数。在返回的函数里,参数是一个或多个 `optional` 类型,`result_type` 是用参数的值(`value()`)去调用原先函数时的返回值类型,最后返回的则是 `result_type``optional` 封装。函数内部会检查所有的参数是否都有值(通过调用 `has_value`):有值时会去拿参数的值去调用原先的函数,否则返回一个空的 `optional` 对象。
这个函数能把一个原本要求参数全部有效的函数抬升lift成一个接受和返回 `optional` 参数的函数,并且,只在参数全部有效时去调用原来的函数。这是一种非常函数式的编程方式。使用上面函数的示例代码如下:
```
#include &lt;iostream&gt;
#include &lt;functional&gt;
#include &lt;optional&gt;
#include &lt;type_traits&gt;
#include &lt;utility&gt;
using namespace std;
// 需包含 lift_optional 的定义
constexpr int increase(int n)
{
return n + 1;
}
// 标准库没有提供 optional 的输出
ostream&amp;
operator&lt;&lt;(ostream&amp; os,
optional&lt;int&gt;(x))
{
if (x) {
os &lt;&lt; '(' &lt;&lt; *x &lt;&lt; ')';
} else {
os &lt;&lt; "(Nothing)";
}
return os;
}
int main()
{
auto inc_opt =
lift_optional(increase);
auto plus_opt =
lift_optional(plus&lt;int&gt;());
cout &lt;&lt; inc_opt(optional&lt;int&gt;())
&lt;&lt; endl;
cout &lt;&lt; inc_opt(make_optional(41))
&lt;&lt; endl;
cout &lt;&lt; plus_opt(
make_optional(41),
optional&lt;int&gt;())
&lt;&lt; endl;
cout &lt;&lt; plus_opt(
make_optional(41),
make_optional(1))
&lt;&lt; endl;
}
```
输出结果是:
>
<p>`(Nothing)`<br>
`(42)`<br>
`(Nothing)`<br>
`(42)`</p>
## variant
`optional` 是一个非常简单而又好用的模板,很多情况下,使用它就足够解决问题了。在某种意义上,可以把它看作是允许有两种数值的对象:要么是你想放进去的对象,要么是 `nullopt`(再次提醒,联想 `nullptr`)。如果我们希望除了我们想放进去的对象,还可以是 `nullopt` 之外的对象怎么办呢(比如,某种出错的状态)?又比如,如果我希望有三种或更多不同的类型呢?这种情况下,`variant` [4] 可能就是一个合适的解决方案。
在没有 `variant` 类型之前你要达到类似的目的恐怕会使用一种叫做带标签的联合tagged union的数据结构。比如下面就是一个可能的数据结构定义
```
struct FloatIntChar {
enum {
Float,
Int,
Char
} type;
union {
float float_value;
int int_value;
char char_value;
};
};
```
这个数据结构的最大问题,就是它实际上有很多复杂情况需要特殊处理。对于我们上面例子里的 POD 类型,这么写就可以了(但我们仍需小心保证我们设置的 `type` 和实际使用的类型一致)。如果我们把其中一个类型换成非 POD 类型,就会有复杂问题出现。比如,下面的代码是不能工作的:
```
struct StringIntChar {
enum {
String,
Int,
Char
} type;
union {
string string_value;
int int_value;
char char_value;
};
};
```
编译器会很合理地看到在 union 里使用 `string` 类型会带来构造和析构上的问题,所以会拒绝工作。要让这个代码工作,我们得手工加上析构函数,并且,在析构函数里得小心地判断存储的是什么数值,来决定是否应该析构(否则,默认不调用任何 union 里的析构函数,从而可能导致资源泄漏):
```
~StringIntChar()
{
if (type == String) {
string_value.~string();
}
}
```
这样,我们才能安全地使用它(还是很麻烦):
```
StringIntChar obj{
.type = StringIntChar::String,
.string_value = "Hello world"};
cout &lt;&lt; obj.string_value &lt;&lt; endl;
```
这里用到了按成员初始化的语法,把类型设置成了字符串,同时设置了字符串的值。不用说,这是件麻烦、容易出错的事情。同时,细查之后我发现,这个语法虽然在 C99 里有,但在 C++ 里要在 C++20 才会被标准化,因此实际是有兼容性问题的——老版本的 MSVC或最新版本的 MSVC 在没有开启 C++20 支持时,就不支持这个语法。
所以,目前的主流建议是,应该避免使用“裸” union 了。替换方式,就是这一节要说的 `variant`。上面的例子,如果用 `variant` 的话,会非常的干净利落:
```
variant&lt;string, int, char&gt; obj{
"Hello world"};
cout &lt;&lt; get&lt;string&gt;(obj) &lt;&lt; endl;
```
可以注意到我上面构造时使用的是 `const char*`,但构造函数仍然能够正确地选择 `string` 类型,这是因为标准要求实现在没有一个完全匹配的类型的情况下,会选择成员类型中能够以传入的类型来构造的那个类型进行初始化(有且只有一个时)。`string` 类存在形式为 `string(const char*)` 的构造函数(不精确地说),所以上面的构造能够正确进行。
`tuple` 相似,`variant` 上可以使用 `get` 函数模板,其模板参数可以是代表序号的数字,也可以是类型。如果编译时可以确定序号或类型不合法,我们在编译时就会出错。如果序号或类型合法,但运行时发现 `variant` 里存储的并不是该类对象,我们则会得到一个异常 `bad_variant_access`
`variant` 上还有一个重要的成员函数是 `index`,通过它我们能获得当前的数值的序号。就我们上面的例子而言,`obj.index()` 即为 `1`。正常情况下,`variant` 里总有一个有效的数值(缺省为第一个类型的默认构造结果),但如果 `emplace` 等修改操作中发生了异常,`variant` 里也可能没有任何有效数值,此时 `index()` 将会得到 `variant_npos`
从基本概念来讲,`variant` 就是一个安全的 union相当简单我就不多做其他介绍了。你可以自己看文档来了解进一步的信息。其中比较有趣的一个非成员函数是 `visit` [5],文档里展示了一个非常简洁的、可根据当前包含的变量类型进行函数分发的方法。
**平台细节:**在老于 Mojave 的 macOS 上编译含有 `optional``variant` 的代码,需要在文件开头加上:
```
#if defined(__clang__) &amp;&amp; defined(__APPLE__)
#include &lt;__config&gt;
#undef _LIBCPP_AVAILABILITY_BAD_OPTIONAL_ACCESS
#undef _LIBCPP_AVAILABILITY_BAD_VARIANT_ACCESS
#define _LIBCPP_AVAILABILITY_BAD_OPTIONAL_ACCESS
#define _LIBCPP_AVAILABILITY_BAD_VARIANT_ACCESS
#endif
```
原因是苹果在头文件里把 `optional``variant` 在早期版本的 macOS 上禁掉了,而上面的代码去掉了这几个宏里对使用 `bad_optional_access``bad_variant_access` 的平台限制。我真看不出使用这两个头文件跟 macOS 的版本有啥关系。😞
## expected
和前面介绍的两个模板不同,`expected` 不是 C++ 标准里的类型。但概念上这三者有相关性,因此我们也放在一起讲一下。
我前面已经提到,`optional` 可以作为一种代替异常的方式:在原本该抛异常的地方,我们可以改而返回一个空的 `optional` 对象。当然,此时我们就只知道没有返回一个合法的对象,而不知道为什么没有返回合法对象了。我们可以考虑改用一个 `variant`,但我们此时需要给错误类型一个独特的类型才行,因为这是 `variant` 模板的要求。比如:
```
enum class error_code {
success,
operation_failure,
object_not_found,
};
variant&lt;Obj, error_code&gt;
get_object(…);
```
这当然是一种可行的错误处理方式:我们可以判断返回值的 `index()`来决定是否发生了错误。但这种方式不那么直截了当也要求实现对允许的错误类型作出规定。Andrei Alexandrescu 在 2012 年首先提出的 Expected 模板 [6],提供了另外一种错误处理方式。他的方法的要点在于,把完整的异常信息放在返回值,并在必要的时候,可以“重放”出来,或者手工检查是不是某种类型的异常。
他的概念并没有被广泛推广,最主要的原因可能是性能。异常最被人诟病的地方是性能,而他的方式对性能完全没有帮助。不过,后面的类似模板都汲取了他的部分思想,至少会用一种显式的方式来明确说明当前是异常情况还是正常情况。在目前的 expected 的标准提案 [7] 里,用法有点是 `optional``variant` 的某种混合:模板的声明形式像 `variant`,使用正常返回值像 `optional`
下面的代码展示了一个 expected 实现 [8] 的基本用法。
```
#include &lt;climits&gt;
#include &lt;iostream&gt;
#include &lt;string&gt;
#include &lt;tl/expected.hpp&gt;
using namespace std;
using tl::expected;
using tl::unexpected;
// 返回 expected 的安全除法
expected&lt;int, string&gt;
safe_divide(int i, int j)
{
if (j == 0)
return unexpected(
"divide by zero"s);
if (i == INT_MIN &amp;&amp; j == -1)
return unexpected(
"integer divide overflows"s);
if (i % j != 0)
return unexpected(
"not integer division"s);
else
return i / j;
}
// 一个测试函数
expected&lt;int, string&gt;
caller(int i, int j, int k)
{
auto q = safe_divide(j, k);
if (q)
return i + *q;
else
return q;
}
// 支持 expected 的输出函数
template &lt;typename T, typename E&gt;
ostream&amp; operator&lt;&lt;(
ostream&amp; os,
const expected&lt;T, E&gt;&amp; exp)
{
if (exp) {
os &lt;&lt; exp.value();
} else {
os &lt;&lt; "unexpected: "
&lt;&lt; exp.error();
}
return os;
}
// 调试使用的检查宏
#define CHECK(expr) \
{ \
auto result = (expr); \
cout &lt;&lt; result; \
if (result == \
unexpected( \
"divide by zero"s)) { \
cout \
&lt;&lt; ": Are you serious?"; \
} else if (result == 42) { \
cout &lt;&lt; ": Ha, I got you!"; \
} \
cout &lt;&lt; endl; \
}
int main()
{
CHECK(caller(2, 1, 0));
CHECK(caller(37, 20, 7));
CHECK(caller(39, 21, 7));
}
```
输出是:
>
<p>`unexpected: divide by zero: Are you serious?`<br>
`unexpected: not integer division`<br>
`42: Ha, I got you!`</p>
一个 `expected&lt;T, E&gt;` 差不多可以看作是 `T``unexpected&lt;E&gt;``variant`。在学过上面的 `variant` 之后,我们应该很容易看明白上面的程序了。下面是几个需要注意一下的地方:
- 如果一个函数要正常返回数据,代码无需任何特殊写法;如果它要表示出现了异常,则可以返回一个 `unexpected` 对象。
- 这个返回值可以用来和一个正常值或 unexpected 对象比较,可以在布尔值上下文里检查是否有正常值,也可以用 `*` 运算符来取得其中的正常值——与 `optional` 类似,在没有正常值的情况下使用 `*` 是未定义行为。
- 可以用 `value` 成员函数来取得其中的正常值,或使用 `error` 成员函数来取得其中的错误值——与 `variant` 类似,在 `expected` 中没有对应的值时产生异常 `bad_expected_access`
- 返回错误跟抛出异常比较相似,但检查是否发生错误的代码还是要比异常处理啰嗦。
## Herbception
上面的用法初看还行,但真正用起来,你会发现仍然没有使用异常方便。这只是为了解决异常在错误处理性能问题上的无奈之举。大部分试图替换 C++ 异常的方法都是牺牲编程方便性,来换取性能。只有 Herb Sutter 提出了一个基本兼容当前 C++ 异常处理方式的错误处理方式 [9],被戏称为 Herbception。
上面使用 expected 的示例代码,如果改用 Herbception 的话,可以大致如下改造(示意,尚无法编译):
```
int safe_divide(int i, int j) throws
{
if (j == 0)
throw arithmetic_errc::
divide_by_zero;
if (i == INT_MIN &amp;&amp; j == -1)
throw arithmetic_errc::
integer_divide_overflows;
if (i % j != 0)
throw arithmetic_errc::
not_integer_division;
else
return i / j;
}
int caller(int i, int j,
int k) throws
{
return i + safe_divide(j, k);
}
#define CHECK(expr) \
try { \
int result = (expr); \
cout &lt;&lt; result; \
if (result == 42) { \
cout &lt;&lt; ": Ha, I got you!"; \
} \
} \
catch (error e) { \
if (e == arithmetic_errc:: \
divide_by_zero) { \
cout \
&lt;&lt; "Are you serious? "; \
} \
cout &lt;&lt; "An error occurred"; \
} \
cout &lt;&lt; endl
int main()
{
CHECK(caller(2, 1, 0));
CHECK(caller(37, 20, 7));
CHECK(caller(39, 21, 7));
}
```
我们可以看到,上面的代码和普通使用异常的代码非常相似,区别有以下几点:
- 函数需要使用 `throws`(注意不是 `throw`)进行声明。
- 抛出异常的语法和一般异常语法相同,但抛出的是一个 `std::error` 值 [10]。
- 捕捉异常时不需要使用引用(因为 `std::error` 是个“小”对象),且使用一般的比较操作来检查异常“类型”,不再使用开销大的 RTTI。
虽然语法上基本是使用异常的样子,但 Herb 的方案却没有异常的不确定开销,性能和使用 expected 相仿。他牺牲了异常类型的丰富,但从实际编程经验来看,越是体现出异常优越性的地方——异常处理点和异常发生点距离较远的时候——越不需要异常有丰富的类型。因此,总体上看,这是一个非常吸引人的方案。不过,由于提案时间较晚,争议颇多,这个方案要进入标准至少要 C++23 了。我们目前稍稍了解一下就行。
更多技术细节,请查看参考资料。
## 内容小结
本讲我们讨论了两个 C++ 标准库的模板 `optional``variant`,然后讨论了两个标准提案 expected 和 Herbception。这些结构都可以使用在错误处理过程中——前三者当前可用但和异常相比有不同的取舍Herbception 当前还不可用,但有希望在错误处理上达到最佳的权衡点。
## 课后思考
错误处理是一个非常复杂的问题,在 C++ 诞生之后这么多年仍然没有该如何处理的定论。如何对易用性和性能进行取舍,一直是一个有矛盾的老大难问题。你的实际项目中是如何选择的?你觉得应该如何选择?
欢迎留言和我分享你的看法。
## 参考资料
[1] Wikipedia, “Null object pattern”. [https://en.wikipedia.org/wiki/Null_object_pattern](https://en.wikipedia.org/wiki/Null_object_pattern)
[2] cppreference.com, “std::optional”. [https://en.cppreference.com/w/cpp/utility/optional](https://en.cppreference.com/w/cpp/utility/optional)
[2a] cppreference.com, “std::optional”. [https://zh.cppreference.com/w/cpp/utility/optional](https://zh.cppreference.com/w/cpp/utility/optional)
[3] Chase Geigle, cpptoml. [https://github.com/skystrife/cpptoml](https://github.com/skystrife/cpptoml)
[4] cppreference.com, “std::optional”. [https://en.cppreference.com/w/cpp/utility/variant](https://en.cppreference.com/w/cpp/utility/variant)
[4a] cppreference.com, “std::optional”. [https://zh.cppreference.com/w/cpp/utility/variant](https://zh.cppreference.com/w/cpp/utility/variant)
[5] cppreference.com, “std::visit”. [https://en.cppreference.com/w/cpp/utility/variant/visit](https://en.cppreference.com/w/cpp/utility/variant/visit)
[5a] cppreference.com, “std::visit”. [https://zh.cppreference.com/w/cpp/utility/variant/visit](https://zh.cppreference.com/w/cpp/utility/variant/visit)
[6] Andrei Alexandrescu, “Systematic error handling in C++”. [https://channel9.msdn.com/Shows/Going+Deep/C-and-Beyond-2012-Andrei-Alexandrescu-Systematic-Error-Handling-in-C](https://channel9.msdn.com/Shows/Going+Deep/C-and-Beyond-2012-Andrei-Alexandrescu-Systematic-Error-Handling-in-C)
[7] Vicente J. Botet Escribá and JF Bastien, “Utility class to represent expected object”. [http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/p0323r3.pdf](http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/p0323r3.pdf)
[8] Simon Brand, expected. [https://github.com/TartanLlama/expected](https://github.com/TartanLlama/expected)
[9] Herb Sutter, “P0709R0: Zero-overhead deterministic exceptions: Throwing values”. [http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2018/p0709r0.pdf](http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2018/p0709r0.pdf)
[10] Niall Douglas, “P1028R0: SG14 `status_code` and standard `error object` for P0709 Zero-overhead deterministic exceptions”. [http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2018/p1028r0.pdf](http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2018/p1028r0.pdf)

View File

@@ -0,0 +1,385 @@
<audio id="audio" title="23 | 数字计算:介绍线性代数和数值计算库" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8b/b1/8bcc899b1dfec29c3d6b90f47a522db1.mp3"></audio>
你好,我是吴咏炜。
科学计算在今天已经完全可以使用 C++ 了。我不是从事科学计算这一领域的工作的,不过,在工作中也多多少少接触到了一些计算相关的库。今天,我就给你介绍几个有用的计算库。
## Armadillo
说到计算,你可能首先会想到矩阵、矢量这些东西吧?这些计算,确实就是科学计算中的常见内容了。这些领域的标准,即是一些 Fortran 库定下的,如:
- BLAS [1]
- LAPACK [2]
- ARPACK [3]
它们的实现倒不一定用 Fortran尤其是 BLAS
- OpenBLAS [4] 是用汇编和 C 语言写的
- Intel MKL [5] 有针对 Intel 的特定 CPU 指令集进行优化的汇编代码
- Mir GLAS [6] 是用 D 语言写的
不管实现的方法是哪一种,暴露出来的函数名字是这个样子的:
- `ddot`
- `dgemv`
- `dsyrk`
- `sgemm`
- ……
这个接口的唯一好处,应该就是,它是跨语言并且跨实现的😅。所以,使用这些函数时,你可以切换不同的实现,而不需要更改代码。唯一需要修改的,通常就是链接库的名字或位置而已。
假设我们需要做一个简单的矩阵运算,对一个矢量进行旋转:
$$<br>
\begin{aligned}<br>
\mathbf{P} &amp;= \begin{bmatrix} 1 \\\ 0 \end{bmatrix}\\\<br>
\mathbf{R} &amp;= \begin{bmatrix}<br>
\cos(\theta) &amp; -\sin(\theta) \\\<br>
\sin(\theta) &amp; \cos(\theta)\end{bmatrix}\\\<br>
\mathbf{P^\prime} &amp;= \mathbf{R} \cdot \mathbf{P}<br>
\end{aligned}<br>
$$
这么一个简单的操作,用纯 C 接口的 BLAS 来表达,有点痛苦:你需要使用的大概是 `dgemv_` 函数,而这个函数需要 11 个参数!我查阅了一下资料之后,也就放弃了给你展示一下如何调用 `dgemv_` 的企图,我们还是老老实实地看一下在现代 C++ 里的写法吧:
```
#include &lt;armadillo&gt;
#include &lt;cmath&gt;
#include &lt;iostream&gt;
using namespace std;
int main()
{
// 代表位置的向量
arma::vec pos{1.0, 0.0};
// 旋转矩阵
auto&amp; pi = arma::datum::pi;
double angle = pi / 2;
arma::mat rot = {
{cos(angle), -sin(angle)},
{sin(angle), cos(angle)}};
cout &lt;&lt; "Current position:\n"
&lt;&lt; pos;
cout &lt;&lt; "Rotating "
&lt;&lt; angle * 180 / pi
&lt;&lt; " deg\n";
arma::vec new_pos = rot * pos;
cout &lt;&lt; "New position:\n"
&lt;&lt; new_pos;
}
```
这就是使用 Armadillo [7] 库来实现矢量旋转的代码。这个代码,基本就是上面的数学公式的一一对应了。代码相当直白,我只需要稍稍说明一下:
- 所有的 Armadillo 的类型和函数都定义在 `arma` 名空间下。
- Armadillo 在 `arma::datum` 下定义了包括 pi 和 e 在内的一些数学常量。
- `vec` 是矢量类型,`mat` 是矩阵类型,这两个类型实际上是 `Col&lt;double&gt;``Mat&lt;double&gt;` 的缩写别名。
- Armadillo 支持使用 C++11 的列表初始化语法来初始化对象。
- Armadillo 支持使用流来输出对象。
上面代码的输出为:
>
<p>`Current position:`<br>
`1.0000`<br>
`0`<br>
`Rotating 90 deg`<br>
`New position:`<br>
`6.1232e-17`<br>
`1.0000e+00`</p>
输出里面的 `6.1232e-17` 是浮点数表示不精确的后果,把它理解成 0 就对了。
我们上面已经提到了 `vec` 实际上是 `Col&lt;double&gt;`双精度浮点数类型的列矢量。自然Armadillo 也有行矢量 `rowvec`(即 `Row&lt;double&gt;`),也可以使用其他的数字类型,如 `int``float``complex&lt;float&gt;`。此外除了大小不确定的线性代数对象之外Armadillo 也提供了固定大小的子类型,如 `vec::fixed&lt;2&gt;``mat::fixed&lt;2, 2&gt;`;为方便使用,还提供了不少别名,如 `imat22` 代表 `Mat&lt;int&gt;::fixed&lt;2, 2&gt;` 等。固定大小的对象不需要动态内存分配,使用上有一定的性能优势。
Armadillo 是一个非常复杂的库,它的头文件数量超过了 500 个。我们今天不可能、也不必要描述它的所有功能,只能稍稍部分列举一下:
- 除了目前提到的列矢量、行矢量和矩阵外Armadillo 也支持三维的数据立方体,`Cube` 模板。
- Armadillo 支持稀疏矩阵,`SpMat` 模板。
- 除了数学上的加、减、乘运算Armadillo 支持按元素的乘法、除法、相等、不等、小于比较等(使用 `%``/``==``!=``&lt;` 等)运算,结果的大小跟参数相同,每个元素是相应运算的结果。某些运算符可能不太直观,尤其是 `%`(不是取模)和 `==`(返回不是单个布尔值,而是矩阵)。
- Armadillo 支持对非固定大小的矢量、矩阵和立方体,改变其大小(`.reshape()``resize()`)。
- Armadillo 可以方便地按行(`.col()`)、列(`.row()`)、对角线(`.diag()`)读写矩阵的内容,包括用一个矢量去改写矩阵的对角线。
- Armadillo 可以方便地对矩阵进行转置(`.t()`)、求反(`.inv()`)。
- Armadillo 可以对矩阵进行特征分解(`eigen_sym()``eigen_gen()` 等)。
- Armadillo 支持傅立叶变换(`fft()``fft2()` 等)。
- Armadillo 支持常见的统计计算,如平均值、中位值、标准偏差等(`mean()``median()``stddev()` 等)。
- Armadillo 支持多项式方程求根(`roots`)。
- Armadillo 支持 k平均聚类**k**-means clustering算法`kmeans`)。
- 等等。
如果你需要用到这些功能,你可以自己去查看一下具体的细节,我们这儿只提几个与编程有关的细节。
### 对象的输出
我们上面已经展示了直接把对象输出到一个流。我们的写法是:
```
cout &lt;&lt; "Current position:\n"
&lt;&lt; pos;
```
实际上基本等价于调用 `print` 成员函数:
```
pos.print("Current position:");
```
这个写法可能会更简单些。此外,在这两种情况,输出的格式都是 Armadillo 自动控制的。如果你希望自己控制的话,可以使用 `raw_print` 成员函数。比如,对于上面代码里对 `new_pos` 的输出,我们可以写成(需要包含 &lt;iomanip&gt;
```
cout &lt;&lt; fixed &lt;&lt; setw(9)
&lt;&lt; setprecision(4);
new_pos.raw_print(
cout, "New position:");
```
这种情况下,你可以有效地对格式、宽度和精度进行设置,能得到:
>
<p>`New position:`<br>
`0.0000`<br>
`1.0000`</p>
记得我们说过 `vec``Col&lt;double&gt;` 的别名因此输出是多行的。我们要输出成单行的话转置transpose一下就可以了
```
cout &lt;&lt; fixed &lt;&lt; setw(9)
&lt;&lt; setprecision(4);
new_pos.t().raw_print(
cout, "New position:");
```
输出为:
>
<p>`New position:`<br>
`0.0000 1.0000`</p>
### 表达式模板
如果你奇怪前面 `dgemv_` 为什么有 11 个参数,这里有个我没有提的细节是,它执行的实际上是个复合操作:
$$<br>
\mathbf{y} \gets \alpha\mathbf{A}\cdot\mathbf{x} + \beta\mathbf{y}<br>
$$
如果你只是简单地做乘法的话,就相当于 $\alpha$ 为 1、$\beta$ 为 0 的特殊情况。那么问题来了,如果你真的写了类似于上面这样的公式的话,编译器和线性代数库能不能转成合适的调用、而没有额外的开销呢?
答案是至少在某些情况下是可以的。秘诀就是表达式模板expression template[8]。
那什么是表达式模板呢?我们先回过去看我上面的例子。有没有注意到我写的是:
```
arma::vec new_pos = rot * pos;
```
而没有使用 `auto` 来声明?
其中部分的原因是,`rot * pos` 的类型并不是 `vec`,而是:
```
const Glue&lt;Mat&lt;double&gt;, Col&lt;double&gt;, glue_times&gt;
```
换句话说,结果是一个表达式,而并没有实际进行计算。如果我用 `auto` 的话,行为上似乎一切都正常,但我每次输出这个结果时,都会重新进行一次矩阵的乘法!而我用 `arma::vec` 接收的话,构造时就直接进行了计算,存储了表达式的结果。
上面的简单例子不能实际触发对 `dgemv_` 的调用,我用下面的代码实际验证出了表达式模板产生的优化(`fill::randu` 表示对矢量和矩阵的内容进行随机填充):
```
#include &lt;armadillo&gt;
#include &lt;iostream&gt;
using namespace std;
using namespace arma;
int main()
{
vec x(8, fill::randu);
mat r(8, 8, fill::randu);
vec result = 2.5 * r * x;
cout &lt;&lt; result;
}
```
赋值语句右边的类型是:
```
const Glue&lt;eOp&lt;Mat&lt;double&gt;,
eop_scalar_times&gt;,
Col&lt;double&gt;, glue_times&gt;
```
当使用这个表达式构造 `vec` 时,就会实际发生对 `dgemv_` 的调用。我也确实跟踪到了,在将要调用 `dgemv_` 时,标量值 2.5 确实在参数 `alpha` 指向的位置上(这个接口的参数都是指针)。
从上面的描述可以看到,表达式模板是把双刃剑:既可以提高代码的性能,又能增加代码被误用的可能性。在可能用到表达式模板的地方,你需要注意这些问题。
### 平台细节
Armadillo 的文档里说明了如何从源代码进行安装,但在 Linux 和 macOS 下通过包管理器安装可能是更快的方式。在 CentOS 下可使用 `sudo yum install armadillo-devel`,在 macOS 下可使用 `brew install armadillo`。使用包管理器一般也会同时安装常见的依赖软件,如 ARPACK 和 OpenBLAS。
在 Windows 上Armadillo 的安装包里自带了一个基本版本的 64 位 BLAS 和 LAPACK 库。如果需要更高性能或 32 位版本的话,就需要自己另外去安装了。除非你只是做一些非常简单的线性代数计算(就像我今天的例子),那直接告诉 Armadillo 不要使用第三方库也行。
>
`cl /EHsc /DARMA_DONT_USE_BLAS /DARMA_DONT_USE_LAPACK …`
## Boost.Multiprecision
众所周知C 和 C++(甚至推而广之到大部分的常用编程语言)里的数值类型是有精度限制的。比如,上一讲的代码里我们就用到了 `INT_MIN`,最小的整数。很多情况下,使用目前这些类型是够用的(最高一般是 64 位整数和 80 位浮点数)。但也有很多情况,这些标准的类型远远不能满足需要。这时你就需要一个高精度的数值类型了。
有一次我需要找一个高精度整数类型和计算库,最后找到的就是 Boost.Multiprecision [9]。它基本满足我的需求,以及一般意义上对库的期望:
- 正确实现我需要的功能
- 接口符合直觉、易用
- 有良好的性能
正确实现功能这点我就不多讲了。这是一个基本出发点,没有太多可讨论的地方。在我上次的需求里,对性能其实也没有很高的要求。让我对 Boost.Multiprecision 满意的主要原因,就是它的接口了。
### 接口易用性
我在[[第 12 讲]](https://time.geekbang.org/column/article/179363) 提到了 CLN。它对我来讲就是个反面教材。它的整数类型不仅不提供 `%` 运算符,居然还不提供 `/` 运算符!它强迫用户在下面两个方案中做出选择:
- 使用 `truncate2` 函数,得到一个商数和余数
- 使用 `exquo` 函数,当且仅当可以整除的时候
不管作者的设计原则是什么,这简直就是易用性方面的灾难了——不仅这些函数要查文档才能知晓,而且有的地方我真的只需要简单的除法呀……
哦,对了,它在 Windows 编译还很不方便,而我那时用的正是 Windows。
Boost.Multiprecision 的情况则恰恰相反,让我当即大为满意:
- 使用基本的 `cpp_int` 对象不需要预先编译库,只需要 Boost 的头文件和一个好的编译器。
- 常用运算符 `+``-``*``/``%` 一个不缺,全部都有。
- 可以自然地通过整数和字符串来进行构造。
- 提供了用户自定义字面量来高效地进行初始化。
- 在使用 IO 流时,输入输出既可以使用十进制,也可以通过 `hex` 来切换到十六进制。
下面的代码展示了它的基本功能:
```
#include &lt;iomanip&gt;
#include &lt;iostream&gt;
#include &lt;boost/multiprecision/cpp_int.hpp&gt;
using namespace std;
int main()
{
using namespace boost::
multiprecision::literals;
using boost::multiprecision::
cpp_int;
cpp_int a =
0x123456789abcdef0_cppi;
cpp_int b = 16;
cpp_int c{"0400"};
cpp_int result = a * b / c;
cout &lt;&lt; hex &lt;&lt; result &lt;&lt; endl;
cout &lt;&lt; dec &lt;&lt; result &lt;&lt; endl;
}
```
输出是:
>
<p>`123456789abcdef`<br>
`81985529216486895`</p>
我们可以看到,`cpp_int` 可以通过自定义字面量(后缀 `_cppi`;只能十六进制)来初始化,可以通过一个普通整数来初始化,也可以通过字符串来初始化(并可以使用 `0x``0` 前缀来选择十六进制和八进制)。拿它可以正常地进行加减乘除操作,也可以通过 IO 流来输入输出。
### 性能
Boost.Multiprecision 使用了表达式模板和 C++11 的移动来避免不必要的拷贝。后者当然是件好事,而前者曾经坑了我一下——我第一次使用 Boost.Multiprecision 时非常困惑为什么我使用 `half(n - 1)` 调用下面的简单函数居然会编译不过:
```
template &lt;typename N&gt;
inline N half(N n)
{
return n / 2;
}
```
我的意图当然是 `N` 应当被推导为 `cpp_int``half` 的结果也是 `cpp_int`。可实际上,`n - 1` 的结果跟上面的 Armadillo 展示的情况类似,是另外一个单独的类型。我需要把 `half(n - 1)` 改写成 `half(N(n - 1))` 才能得到期望的结果。
我做的计算挺简单,并不觉得表达式模板对我的计算有啥帮助,所以我最后是禁用了表达式模板:
```
typedef boost::multiprecision::
number&lt;
boost::multiprecision::
cpp_int_backend&lt;&gt;,
boost::multiprecision::et_off&gt;
int_type;
```
类似于 Armadillo 可以换不同的 BLAS 和 LAPACK 实现Boost.Multiprecision 也可以改换不同的后端。比如,如果我们打算使用 GMP [10] 的话,我们需要包含利用 GMP 的头文件,并把上面的 `int_type` 的定义修正一下:
```
#include &lt;boost/multiprecision/gmp.hpp&gt;
typedef boost::multiprecision::
number&lt;
boost::multiprecision::gmp_int,
boost::multiprecision::et_off&gt;
int_type;
```
注意,我并不是推荐你换用 GMP。如果你真的对性能非常渴求的话应当进行测试来选择合适的后端。否则缺省的后端易用性最好——比如使用 GMP 后端就不能使用自定义字面量了。
我当时寻找高精度算术库是为了做 RSA 加解密。计算本身不复杂,属于编程几小时、运行几毫秒的情况。如果你有兴趣的话,可以看一下我那时的挑选过程和最终代码 [11]。
Boost 里好东西很多,远远不止这一样。下一讲我们就来专门聊聊 Boost。
## 内容小结
本讲我们讨论了两个进行计算的模板库Armadillo 和 Boost.Multiprecision并讨论了它们用到的表达式模板技巧和相关的计算库如 BLAS、LAPACK 和 GMP。可以看到使用 C++ 你可以站到巨人肩上,轻松写出高性能的计算代码。
## 课后思考
性能和易用性往往是有矛盾的。你对性能和易用性有什么样的偏好呢?欢迎留言与我分享。
## 参考资料
[1] Wikipedia, “Basic Linear Algebra Subprograms”. [https://en.wikipedia.org/wiki/Basic_Linear_Algebra_Subprograms](https://en.wikipedia.org/wiki/Basic_Linear_Algebra_Subprograms)
[2] Wikipedia, “LAPACK”. [https://en.wikipedia.org/wiki/LAPACK](https://en.wikipedia.org/wiki/LAPACK)
[3] Wikipedia, “ARPACK”. [https://en.wikipedia.org/wiki/ARPACK](https://en.wikipedia.org/wiki/ARPACK)
[4] Zhang Xianyi et al., OpenBLAS. [https://github.com/xianyi/OpenBLAS](https://github.com/xianyi/OpenBLAS)
[5] Intel, Math Kernel Library. [https://software.intel.com/mkl](https://software.intel.com/mkl)
[6] Ilya Yaroshenko, mir-glas. [https://github.com/libmir/mir-glas](https://github.com/libmir/mir-glas)
[7] Conrad Sanderson and Ryan Curtin, “Armadillo: C++ library for linear algebra &amp; scientific computing”. [http://arma.sourceforge.net/](http://arma.sourceforge.net/)
[8] Wikipedia, “Expression templates”. [https://en.wikipedia.org/wiki/Expression_templates](https://en.wikipedia.org/wiki/Expression_templates)
[9] John Maddock, Boost.Multiprecision. [https://www.boost.org/doc/libs/release/libs/multiprecision/doc/html/index.html](https://www.boost.org/doc/libs/release/libs/multiprecision/doc/html/index.html)
[10] The GNU MP bignum library. [https://gmplib.org/](https://gmplib.org/)
[11] 吴咏炜, “Choosing a multi-precision library for C++—a critique”. [https://yongweiwu.wordpress.com/2016/06/04/choosing-a-multi-precision-library-for-c-a-critique/](https://yongweiwu.wordpress.com/2016/06/04/choosing-a-multi-precision-library-for-c-a-critique/)

View File

@@ -0,0 +1,609 @@
<audio id="audio" title="24 | Boost你需要的“瑞士军刀”" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f1/95/f14698612ac9a15d134cee6431ce9b95.mp3"></audio>
你好,我是吴咏炜。
我们已经零零碎碎提到过几次 Boost 了。作为 C++ 世界里标准库之外最知名的开放源码程序库,我们值得专门用一讲来讨论一下 Boost。
## Boost 概览
Boost 的网站把 Boost 描述成为经过同行评审的、可移植的 C++ 源码库peer-reviewed portable C++ source libraries[1]。换句话说它跟很多个人开源库不一样的地方在于它的代码是经过评审的。事实上Boost 项目的背后有很多 C++ 专家,比如发起人之一的 Dave Abarahams 是 C++ 标准委员会的成员也是《C++ 模板元编程》一书 [2] 的作者。这也就使得 Boost 有了很不一样的特殊地位:它既是 C++ 标准库的灵感来源之一,也是 C++ 标准库的试验田。下面这些 C++ 标准库就源自 Boost
- 智能指针
- thread
- regex
- random
- array
- bind
- tuple
- optional
- variant
- any
- string_view
- filesystem
- 等等
当然,将来还会有新的库从 Boost 进入 C++ 标准,如网络库的标准化就是基于 Boost.Asio 进行的。因此,即使相关的功能没有被标准化,我们也可能可以从 Boost 里看到某个功能可能会被标准化的样子——当然,最终标准化之后的样子还是经常有所变化的。
我们也可以在我们的编译器落后于标准、不能提供标准库的某个功能时使用 Boost 里的替代品。比如,我之前提到过老版本的 macOS 上苹果的编译器不支持 optional 和 variant。除了我描述的不正规做法改用 Boost 也是方法之一。比如,对于 variant所需的改动只是
- 把包含 &lt;variant&gt; 改成包含 &lt;boost/variant.hpp&gt;
- 把代码中的 `std::variant` 改成 `boost::variant`
这样,就基本大功告成了。
作为一个准标准的库,很多环境里缺省会提供 Boost。这种情况下在程序里使用 Boost 不会额外增加编译或运行时的依赖,减少了可能的麻烦。如果我需要某个功能,在标准库里没有,在 Boost 里有,我会很乐意直接使用 Boost 里的方案,而非另外去查找。如果我要使用非 Boost 的第三方库的话,那一般要么是 Boost 里没有,要么就是那个库比 Boost 里的要好用很多了。
鉴于 Boost 是一个库集合当前版本1.72)有 160 个独立库,即使写本书也不可能完整地讨论所有的库。这一讲里,我们也就管中窥豹式地浏览几个 Boost 库。具体你需要什么,还是得你自己回头去细细品味。
### Boost 的安装
在主要的开发平台上,现在你都可以直接安装 Boost而不需要自己从源代码编译了
- 在 Windows 下使用 MSVC我们可以使用 NuGet 安装(按需逐个安装)
- 在 Linux 下,我们可以使用系统的包管理器(如 apt 和 yum安装按需逐个安装或一次性安装所有的开发需要的包
- 在 macOS 下,我们可以使用 Homebrew 安装(一次性安装完整的 Boost
如果你在某个平台上使用非缺省的编译器,如在 Windows 上或 macOS 上使用 GCC一般就需要自己编译了具体步骤请参见 Boost 的文档。不过,很多 Boost 库是完全不需要编译的,只需要把头文件加到编译器能找到的路径里就可以——如我们上一讲讨论的 Boost.Multiprecision 就是这样。我们讨论 Boost 库的时候,也会提一下使用这个库是否需要链接某个 Boost 库——需要的话,也就意味着需要编译和安装这个 Boost 库。
## Boost.TypeIndex
TypeIndex 是一个很轻量级的库,它不需要链接,解决的也是使用模板时的一个常见问题,如何精确地知道一个表达式或变量的类型。我们还是看一个例子:
```
#include &lt;iostream&gt;
#include &lt;typeinfo&gt;
#include &lt;utility&gt;
#include &lt;vector&gt;
#include &lt;boost/type_index.hpp&gt;
using namespace std;
using boost::typeindex::type_id;
using boost::typeindex::
type_id_with_cvr;
int main()
{
vector&lt;int&gt; v;
auto it = v.cbegin();
cout &lt;&lt; "*** Using typeid\n";
cout &lt;&lt; typeid(const int).name()
&lt;&lt; endl;
cout &lt;&lt; typeid(v).name() &lt;&lt; endl;
cout &lt;&lt; typeid(it).name() &lt;&lt; endl;
cout &lt;&lt; "*** Using type_id\n";
cout &lt;&lt; type_id&lt;const int&gt;() &lt;&lt; endl;
cout &lt;&lt; type_id&lt;decltype(v)&gt;()
&lt;&lt; endl;
cout &lt;&lt; type_id&lt;decltype(it)&gt;()
&lt;&lt; endl;
cout &lt;&lt; "*** Using "
"type_id_with_cvr\n";
cout
&lt;&lt; type_id_with_cvr&lt;const int&gt;()
&lt;&lt; endl;
cout &lt;&lt; type_id_with_cvr&lt;decltype(
(v))&gt;()
&lt;&lt; endl;
cout &lt;&lt; type_id_with_cvr&lt;decltype(
move((v)))&gt;()
&lt;&lt; endl;
cout &lt;&lt; type_id_with_cvr&lt;decltype(
(it))&gt;()
&lt;&lt; endl;
}
```
上面的代码里,展示了标准的 `typeid` 和 Boost 的 `type_id``type_id_with_cvr` 的使用。它们的区别是:
- `typeid` 是标准 C++ 的关键字,可以应用到变量或类型上,返回一个 `std::type_info`。我们可以用它的 `name` 成员函数把结果转换成一个字符串,但标准不保证这个字符串的可读性和唯一性。
- `type_id` 是 Boost 提供的函数模板,必须提供类型作为模板参数——所以对于表达式和变量我们需要使用 `decltype`。结果可以直接输出到 IO 流上。
- `type_id_with_cvr``type_id` 相似,但它获得的结果会包含 const/volatile 状态及引用类型。
上面程序在 MSVC 下的输出为:
>
<p>`*** Using typeid`<br>
`int`<br>
`class std::vector&lt;int,class std::allocator&lt;int&gt; &gt;`<br>
`class std::_Vector_const_iterator&lt;class std::_Vector_val&lt;struct std::_Simple_types&lt;int&gt; &gt; &gt;`<br>
`*** Using type_id`<br>
`int`<br>
`class std::vector&lt;int,class std::allocator&lt;int&gt; &gt;`<br>
`class std::_Vector_const_iterator&lt;class std::_Vector_val&lt;struct std::_Simple_types&lt;int&gt; &gt; &gt;`<br>
`*** Using type_id_with_cvr`<br>
`int const`<br>
`class std::vector&lt;int,class std::allocator&lt;int&gt; &gt; &amp;`<br>
`class std::vector&lt;int,class std::allocator&lt;int&gt; &gt; &amp;&amp;`<br>
`class std::_Vector_const_iterator&lt;class std::_Vector_val&lt;struct std::_Simple_types&lt;int&gt; &gt; &gt; &amp;`</p>
在 GCC 下的输出为:
>
<p>`*** Using typeid`<br>
`i`<br>
`St6vectorIiSaIiEE`<br>
`N9__gnu_cxx17__normal_iteratorIPKiSt6vectorIiSaIiEEEE`<br>
`*** Using type_id`<br>
`int`<br>
`std::vector&lt;int, std::allocator&lt;int&gt; &gt;`<br>
`__gnu_cxx::__normal_iterator&lt;int const*, std::vector&lt;int, std::allocator&lt;int&gt; &gt; &gt;`<br>
`*** Using type_id_with_cvr`<br>
`int const`<br>
`std::vector&lt;int, std::allocator&lt;int&gt; &gt;&amp;`<br>
`std::vector&lt;int, std::allocator&lt;int&gt; &gt;&amp;&amp;`<br>
`__gnu_cxx::__normal_iterator&lt;int const*, std::vector&lt;int, std::allocator&lt;int&gt; &gt; &gt;&amp;`</p>
我们可以看到 MSVC 下 `typeid` 直接输出了比较友好的类型名称,但 GCC 下没有。此外,我们可以注意到:
- `typeid` 的输出忽略了 const 修饰,也不能输出变量的引用类型。
- `type_id` 保证可以输出友好的类型名称,输出时也不需要调用成员函数,但例子里它忽略了 `int` 的 const 修饰,也和 `typeid` 一样不能输出表达式的引用类型。
- `type_id_with_cvr` 可以输出 const/volatile 状态和引用类型,注意这种情况下模板参数必须包含引用类型,所以我用了 `decltype((v))` 这种写法,而不是 `decltype(v)`。如果你忘了这两者的区别,请复习一下[[第 8 讲]](https://time.geekbang.org/column/article/176850) 的 `decltype`
显然,除非你正在使用 MSVC否则调试期 `typeid` 的用法完全应该用 Boost 的 `type_id` 来替代。另外,如果你的开发环境要求禁用 RTTI运行时类型识别`typeid` 在 Clang 和 GCC 下根本不能使用,而使用 Boost.TypeIndex 库仍然没有问题。
当然,上面说的前提都是你在调试中试图获得变量的类型,而不是要获得一个多态对象的运行时类型。后者还是离不开 RTTI 的——虽然你也可以用一些其他方式来模拟 RTTI但我个人觉得一般的项目不太有必要这样做。下面的代码展示了 `typeid``type_id` 在获取对象类型上的差异:
```
#include &lt;iostream&gt;
#include &lt;typeinfo&gt;
#include &lt;boost/type_index.hpp&gt;
using namespace std;
using boost::typeindex::type_id;
class shape {
public:
virtual ~shape() {}
};
class circle : public shape {};
#define CHECK_TYPEID(object, type) \
cout &lt;&lt; "typeid(" #object &lt;&lt; ")" \
&lt;&lt; (typeid(object) == \
typeid(type) \
? " is " \
: " is NOT ") \
&lt;&lt; #type &lt;&lt; endl
#define CHECK_TYPE_ID(object, \
type) \
cout &lt;&lt; "type_id(" #object \
&lt;&lt; ")" \
&lt;&lt; (type_id&lt;decltype( \
object)&gt;() == \
type_id&lt;type&gt;() \
? " is " \
: " is NOT ") \
&lt;&lt; #type &lt;&lt; endl
int main()
{
shape* ptr = new circle();
CHECK_TYPEID(*ptr, shape);
CHECK_TYPEID(*ptr, circle);
CHECK_TYPE_ID(*ptr, shape);
CHECK_TYPE_ID(*ptr, circle);
delete ptr;
}
```
输出为:
>
<p>`typeid(*ptr) is NOT shape`<br>
`typeid(*ptr) is circle`<br>
`type_id(*ptr) is shape`<br>
`type_id(*ptr) is NOT circle`</p>
## Boost.Core
Core 里面提供了一些通用的工具,这些工具常常被 Boost 的其他库用到,而我们也可以使用,不需要链接任何库。在这些工具里,有些已经(可能经过一些变化后)进入了 C++ 标准,如:
- `addressof`,在即使用户定义了 `operator&amp;` 时也能获得对象的地址
- `enable_if`,这个我们已经深入讨论过了([[第 14 讲]](https://time.geekbang.org/column/article/181636)
- `is_same`判断两个类型是否相同C++11 开始在 &lt;type_traits&gt; 中定义
- `ref`,和标准库的相同,我们在[[第 19 讲]](https://time.geekbang.org/column/article/186689) 讨论线程时用过
我们在剩下的里面来挑几个讲讲。
### boost::core::demangle
`boost::core::demangle` 能够用来把 `typeid` 返回的内部名称“反粉碎”demangle成可读的形式看代码和输出应该就非常清楚了
```
#include &lt;iostream&gt;
#include &lt;typeinfo&gt;
#include &lt;vector&gt;
#include &lt;boost/core/demangle.hpp&gt;
using namespace std;
using boost::core::demangle;
int main()
{
vector&lt;int&gt; v;
auto it = v.cbegin();
cout &lt;&lt; "*** Using typeid\n";
cout &lt;&lt; typeid(const int).name()
&lt;&lt; endl;
cout &lt;&lt; typeid(v).name() &lt;&lt; endl;
cout &lt;&lt; typeid(it).name() &lt;&lt; endl;
cout &lt;&lt; "*** Demangled\n";
cout &lt;&lt; demangle(typeid(const int)
.name())
&lt;&lt; endl;
cout &lt;&lt; demangle(typeid(v).name())
&lt;&lt; endl;
cout &lt;&lt; demangle(
typeid(it).name())
&lt;&lt; endl;
}
```
GCC 下的输出为:
>
<p>`*** Using typeid`<br>
`i`<br>
`St6vectorIiSaIiEE`<br>
`N9__gnu_cxx17__normal_iteratorIPKiSt6vectorIiSaIiEEEE`<br>
`*** Demangled`<br>
`int`<br>
`std::vector&lt;int, std::allocator&lt;int&gt; &gt;`<br>
`__gnu_cxx::__normal_iterator&lt;int const*, std::vector&lt;int, std::allocator&lt;int&gt; &gt; &gt;`</p>
如果你不使用 RTTI 的话,那直接使用 TypeIndex 应该就可以。如果你需要使用 RTTI、又不是使用 MSVC 的话,`demangle` 就会给你不少帮助。
### boost::noncopyable
`boost::noncopyable` 提供了一种非常简单也很直白的把类声明成不可拷贝的方式。比如,我们[[第 1 讲]](https://time.geekbang.org/column/article/169225) 里的 `shape_wrapper`,用下面的写法就明确表示了它不允许被拷贝:
```
#include &lt;boost/core/noncopyable.hpp&gt;
class shape_wrapper
: private boost::noncopyable {
};
```
你当然也可以自己把拷贝构造和拷贝赋值函数声明成 `= delete`,不过,上面的写法是不是可读性更佳?
### boost::swap
你有没有印象在通用的代码如何对一个不知道类型的对象执行交换操作?不记得的话,标准做法是这样的:
```
{
using std::swap;
swap(lhs, rhs);
}
```
即,我们需要(在某个小作用域里)引入 `std::swap`,然后让编译器在“看得到” `std::swap` 的情况下去编译 `swap` 指令。根据 ADL如果在被交换的对象所属类型的名空间下有 `swap` 函数,那个函数会被优先使用,否则,编译器会选择通用的 `std::swap`
似乎有点小啰嗦。使用 Boost 的话,你可以一行搞定:
```
boost::swap(lhs, rhs);
```
当然,你需要包含头文件 &lt;boost/core/swap.hpp&gt;
## Boost.Conversion
Conversion 同样是一个不需要链接的轻量级的库。它解决了标准 C++ 里的另一个问题,标准类型之间的转换不够方便。在 C++11 之前,这个问题尤为严重。在 C++11 里,标准引入了一系列的函数,已经可以满足常用类型之间的转换。但使用 Boost.Conversion 里的 `lexical_cast` 更不需要去查阅方法名称或动脑子去努力记忆。
下面是一个例子:
```
#include &lt;iostream&gt;
#include &lt;stdexcept&gt;
#include &lt;string&gt;
#include &lt;boost/lexical_cast.hpp&gt;
using namespace std;
using boost::bad_lexical_cast;
using boost::lexical_cast;
int main()
{
// 整数到字符串的转换
int d = 42;
auto d_str =
lexical_cast&lt;string&gt;(d);
cout &lt;&lt; d_str &lt;&lt; endl;
// 字符串到浮点数的转换
auto f =
lexical_cast&lt;float&gt;(d_str) /
4.0;
cout &lt;&lt; f &lt;&lt; endl;
// 测试 lexical_cast 的转换异常
try {
int t = lexical_cast&lt;int&gt;("x");
cout &lt;&lt; t &lt;&lt; endl;
}
catch (bad_lexical_cast&amp; e) {
cout &lt;&lt; e.what() &lt;&lt; endl;
}
// 测试标准库 stoi 的转换异常
try {
int t = std::stoi("x");
cout &lt;&lt; t &lt;&lt; endl;
}
catch (invalid_argument&amp; e) {
cout &lt;&lt; e.what() &lt;&lt; endl;
}
}
```
GCC 下的输出为:
>
<p>`42`<br>
`10.5`<br>
`bad lexical cast: source type value could not be interpreted as target`<br>
`stoi`</p>
我觉得 GCC 里 `stoi` 的异常输出有点太言简意赅了……而 `lexical_cast` 的异常输出在不同的平台上有很好的一致性。
## Boost.ScopeExit
我们说过 RAII 是推荐的 C++ 里管理资源的方式。不过,作为 C++ 程序员,跟 C 函数打交道也很正常。每次都写个新的 RAII 封装也有点浪费。Boost 里提供了一个简单的封装,你可以从下面的示例代码里看到它是如何使用的:
```
#include &lt;stdio.h&gt;
#include &lt;boost/scope_exit.hpp&gt;
void test()
{
FILE* fp = fopen("test.cpp", "r");
if (fp == NULL) {
perror("Cannot open file");
}
BOOST_SCOPE_EXIT(&amp;fp) {
if (fp) {
fclose(fp);
puts("File is closed");
}
} BOOST_SCOPE_EXIT_END
puts("Faking an exception");
throw 42;
}
int main()
{
try {
test();
}
catch (int) {
puts("Exception received");
}
}
```
唯一需要说明的可能就是 `BOOST_SCOPE_EXIT` 里的那个 `&amp;` 符号了——把它理解成 lambda 表达式的按引用捕获就对了(虽然 `BOOST_SCOPE_EXIT` 可以支持 C++98 的代码)。如果不需要捕获任何变量,`BOOST_SCOPE_EXIT` 的参数必须填为 `void`
输出为(假设 test.cpp 存在):
>
<p>`Faking an exception`<br>
`File is closed`<br>
`Exception received`</p>
使用这个库也只需要头文件。注意实现类似的功能在 C++11 里相当容易,但由于 ScopeExit 可以支持 C++98 的代码,因而它的实现还是相当复杂的。
## Boost.Program_options
传统上 C 代码里处理命令行参数会使用 `getopt`。我也用过,比如在下面的代码中:
[https://github.com/adah1972/breaktext/blob/master/breaktext.c](https://github.com/adah1972/breaktext/blob/master/breaktext.c)
这种方式有不少缺陷:
- 一个选项通常要在三个地方重复:说明文本里,`getopt` 的参数里,以及对 `getopt` 的返回结果进行处理时。不知道你觉得怎样,我反正发生过改了一处、漏改其他的错误。
- 对选项的附加参数需要手工写代码处理因而常常不够严格C 的类型转换不够方便,尤其是检查错误)。
Program_options 正是解决这个问题的。这个代码有点老了,不过还挺实用;懒得去找特别的处理库时,至少这个伸手可用。使用这个库需要链接 boost_program_options 库。
下面的代码展示了代替上面的 `getopt` 用法的代码:
```
#include &lt;iostream&gt;
#include &lt;string&gt;
#include &lt;stdlib.h&gt;
#include &lt;boost/program_options.hpp&gt;
namespace po = boost::program_options;
using std::cout;
using std::endl;
using std::string;
string locale;
string lang;
int width = 72;
bool keep_indent = false;
bool verbose = false;
int main(int argc, char* argv[])
{
po::options_description desc(
"Usage: breaktext [OPTION]... "
"&lt;Input File&gt; [Output File]\n"
"\n"
"Available options");
desc.add_options()
("locale,L",
po::value&lt;string&gt;(&amp;locale),
"Locale of the console (system locale by default)")
("lang,l",
po::value&lt;string&gt;(&amp;lang),
"Language of input (asssume no language by default)")
("width,w",
po::value&lt;int&gt;(&amp;width),
"Width of output text (72 by default)")
("help,h", "Show this help message and exit")
(",i",
po::bool_switch(&amp;keep_indent),
"Keep space indentation")
(",v",
po::bool_switch(&amp;verbose),
"Be verbose");
po::variables_map vm;
try {
po::store(
po::parse_command_line(
argc, argv, desc),
vm);
}
catch (po::error&amp; e) {
cout &lt;&lt; e.what() &lt;&lt; endl;
exit(1);
}
vm.notify();
if (vm.count("help")) {
cout &lt;&lt; desc &lt;&lt; "\n";
exit(1);
}
}
```
略加说明一下:
- `options_description` 是基本的选项描述对象的类型,构造时我们给出对选项的基本描述。
- `options_description` 对象的 `add_options` 成员函数会返回一个函数对象,然后我们直接用括号就可以添加一系列的选项。
- 每个选项初始化时可以有两个或三个参数,第一项是选项的形式,使用长短选项用逗号隔开的字符串(可以只提供一种),最后一项是选项的文字描述,中间如果还有一项的话,就是选项的值描述。
- 选项的值描述可以用 `value``bool_switch` 等方法,参数是输出变量的指针。
- `variables_map`,变量映射表,用来存储对命令行的扫描结果;它继承了标准的 `std::map`
- `notify` 成员函数用来把变量映射表的内容实际传送到选项值描述里提供的那些变量里去。
- `count` 成员函数继承自 `std::map`,只能得到 0 或 1 的结果。
这样,我们的程序就能处理上面的那些选项了。如果运行时在命令行加上 `-h``--help` 选项,程序就会输出跟原来类似的帮助输出——额外的好处是选项的描述信息较长时还能自动帮你折行,不需要手工排版了。建议你自己尝试一下,提供各种正确或错误的选项,来检查一下运行的结果。
当然现在有些更新的选项处理库,但它们应该都和 Program_options 更接近,而不是和 `getopt` 更接近。如果你感觉 Program_options 功能不足了,换一个其他库不会是件麻烦事。
## Boost.Hana
Boost 里自然也有模板元编程相关的东西。但我不打算介绍 MPL、Fusion 和 Phoenix 那些,因为有些技巧,在 C++11 和 Lambda 表达式到来之后已经略显得有点过时了。Hana 则不同,它是一个使用了 C++11/14 实现技巧和惯用法的新库,也和一般的模板库一样,只要有头文件就能使用。
Hana 里定义了一整套供**编译期**使用的数据类型和函数。我们现在看一下它提供的部分类型:
- `type`:把类型转化成对象(我们在[[第 13 讲]](https://time.geekbang.org/column/article/181608) 曾经示例过相反的动作,把数值转化成对象),来方便后续处理。
- `integral_constant`:跟 `std::integral_constant` 相似,但定义了更多的运算符和语法糖。特别的,你可以用字面量来生成一个 `long long` 类型的 `integral_constant`,如 `1_c`
- `string`:一个编译期使用的字符串类型。
- `tuple`:跟 `std::tuple` 类似,意图是当作编译期的 `vector` 来使用。
- `map`:编译期使用的关联数组。
- `set`:编译期使用的集合。
Hana 里的算法的名称跟标准库的类似,我就不一一列举了。下面的例子展示了一个基本用法:
```
#include &lt;boost/hana.hpp&gt;
namespace hana = boost::hana;
class shape {};
class circle {};
class triangle {};
int main()
{
using namespace hana::literals;
constexpr auto tup =
hana::make_tuple(
hana::type_c&lt;shape*&gt;,
hana::type_c&lt;circle&gt;,
hana::type_c&lt;triangle&gt;);
constexpr auto no_pointers =
hana::remove_if(
tup, [](auto a) {
return hana::traits::
is_pointer(a);
});
static_assert(
no_pointers ==
hana::make_tuple(
hana::type_c&lt;circle&gt;,
hana::type_c&lt;triangle&gt;));
static_assert(
hana::reverse(no_pointers) ==
hana::make_tuple(
hana::type_c&lt;triangle&gt;,
hana::type_c&lt;circle&gt;));
static_assert(
tup[1_c] == hana::type_c&lt;circle&gt;);
}
```
这个程序可以编译,但没有任何运行输出。在这个程序里,我们做了下面这几件事:
- 使用 `type_c` 把类型转化成 `type` 对象,并构造了类型对象的 `tuple`
- 使用 `remove_if` 算法移除了 `tup` 中的指针类型
- 使用静态断言确认了结果是我们想要的
- 使用静态断言确认了可以用 `reverse``tup` 反转一下
- 使用静态断言确认了可以用方括号运算符来获取 `tup` 中的某一项
可以看到Hana 本质上以类似普通的运行期编程的写法,来做编译期的计算。上面展示的只是一些最基本的用法,而 Hana 的文档里展示了很多有趣的用法。尤其值得一看的是,文档中展示了如何利用 Hana 提供的机制,来自己定义 `switch_``case_``default_`,使得下面的代码可以通过编译:
```
boost::any a = 'x';
std::string r =
switch_(a)(
case_&lt;int&gt;([](auto i) {
return "int: "s +
std::to_string(i);
}),
case_&lt;char&gt;([](auto c) {
return "char: "s +
std::string{c};
}),
default_(
[] { return "unknown"s; }));
assert(r == "char: x"s);
```
我个人认为很有意思。
## 内容小结
本讲我们对 Boost 的意义做了概要介绍,并蜻蜓点水地简单描述了若干 Boost 库的功能。如果你想进一步了解 Boost 的细节的话,就得自行查看文档了。
## 课后思考
请你考虑一下,我今天描述的 Boost 库里的功能是如何实现的。然后自己去看一下源代码(开源真是件大好事!),检查一下跟自己想象的是不是有出入。
## 参考资料
[1] Boost C++ Libraries. [https://www.boost.org/](https://www.boost.org/)
[2] David Abarahams and Aleksey Gurtovoy, **C++ Template Metaprogramming**. Addison-Wesley, 2004. 有中文版荣耀译机械工业出版社2010 年)

View File

@@ -0,0 +1,351 @@
<audio id="audio" title="25 | 两个单元测试库C++里如何进行单元测试?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/49/95/49b641c0bc262c646457a7e02ce89495.mp3"></audio>
你好,我是吴咏炜。
单元测试已经越来越成为程序员工作密不可分的一部分了。在 C++ 里,我们当然也是可以很方便地进行单元测试的。今天,我就来介绍两个单元测试库:一个是 Boost.Test [1],一个是 Catch2 [2]。
## Boost.Test
单元测试库有很多,我选择 Boost 的原因我在上一讲已经说过:“如果我需要某个功能,在标准库里没有,在 Boost 里有,我会很乐意直接使用 Boost 里的方案而非另外去查找。”再说Boost.Test 提供的功能还挺齐全的,我需要的都有了。作为开胃小菜,我们先看一个单元测试的小例子:
```
#define BOOST_TEST_MAIN
#include &lt;boost/test/unit_test.hpp&gt;
#include &lt;stdexcept&gt;
void test(int n)
{
if (n == 42) {
return;
}
throw std::runtime_error(
"Not the answer");
}
BOOST_AUTO_TEST_CASE(my_test)
{
BOOST_TEST_MESSAGE("Testing");
BOOST_TEST(1 + 1 == 2);
BOOST_CHECK_THROW(
test(41), std::runtime_error);
BOOST_CHECK_NO_THROW(test(42));
int expected = 5;
BOOST_TEST(2 + 2 == expected);
BOOST_CHECK(2 + 2 == expected);
}
BOOST_AUTO_TEST_CASE(null_test)
{
}
```
我们从代码里可以看到:
- 我们在包含单元测试的头文件之前定义了 `BOOST_TEST_MAIN`。如果编译时用到了多个源文件,只有一个应该定义该宏。多文件测试的时候,我一般会考虑把这个定义这个宏加包含放在一个单独的文件里(只有两行)。
- 我们用 `BOOST_AUTO_TEST_CASE` 来定义一个测试用例。一个测试用例里应当有多个测试语句(如 `BOOST_CHECK`)。
- 我们用 `BOOST_CHECK``BOOST_TEST` 来检查一个应当成立的布尔表达式(区别下面会讲)。
- 我们用 `BOOST_CHECK_THROW` 来检查一个应当抛出异常的语句。
- 我们用 `BOOST_CHECK_NO_THROW` 来检查一个不应当抛出异常的语句。
如[[第 21 讲]](https://time.geekbang.org/column/article/187980) 所述,我们可以用下面的命令行来进行编译:
- MSVC`cl /DBOOST_TEST_DYN_LINK /EHsc /MD test.cpp`
- GCC`g++ -DBOOST_TEST_DYN_LINK test.cpp -lboost_unit_test_framework`
- Clang`clang++ -DBOOST_TEST_DYN_LINK test.cpp -lboost_unit_test_framework`
运行结果如下图所示:
<img src="https://static001.geekbang.org/resource/image/2e/fc/2e71b25e154d6609bd5cd3f4bf4911fc.png" alt="">
我们现在能看到 `BOOST_CHECK``BOOST_TEST` 的区别了。后者是一个较新加入 Boost.Test 的宏,能利用模板技巧来输出表达式的具体内容。但在某些情况下,`BOOST_TEST` 试图输出表达式的内容会导致编译出错,这时可以改用更简单的 `BOOST_CHECK`
不管是 `BOOST_CHECK` 还是 `BOOST_TEST`,在测试失败时,执行仍然会继续。在某些情况下,一个测试失败后继续执行后面的测试已经没有意义,这时,我们就可以考虑使用 `BOOST_REQUIRE``BOOST_TEST_REQUIRE`——表达式一旦失败,整个测试用例会停止执行(但其他测试用例仍会正常执行)。
缺省情况下单元测试的输出只包含错误信息和结果摘要,但输出的详细程度是可以通过命令行选项来进行控制的。如果我们在运行测试程序时加上命令行参数 `--log_level=all`(或 `-l all`),我们就可以得到下面这样更详尽的输出:
<img src="https://static001.geekbang.org/resource/image/4e/73/4ead9a603e0f5c4703637c905a2faf73.png" alt="">
我们现在额外可以看到:
- 在进入、退出测试模块和用例时的提示
- `BOOST_TEST_MESSAGE` 的输出
- 正常通过的测试的输出
- 用例里无测试断言的警告
使用 Windows 的同学如果运行了测试程序的话,多半会惊恐地发现终端上的文字颜色已经发生了变化。这似乎是 Boost.Test 在 Windows 上特有的一个问题:建议你把单元测试的色彩显示关掉。你可以在系统高级设置里添加下面这个环境变量,也可以直接在命令行上输入:
```
set BOOST_TEST_COLOR_OUTPUT=0
```
下面我们看一个更真实的例子。
假设我们有一个 `split` 函数,定义如下:
```
template &lt;typename String,
typename Delimiter&gt;
class split_view {
public:
typedef
typename String::value_type
char_type;
class iterator { … };
split_view(const String&amp; str,
Delimiter delimiter);
iterator begin() const;
iterator end() const;
vector&lt;basic_string&lt;char_type&gt;&gt;
to_vector() const;
vector&lt;basic_string_view&lt;char_type&gt;&gt;
to_vector_sv() const;
};
template &lt;typename String,
typename Delimiter&gt;
split_view&lt;String, Delimiter&gt;
split(const String&amp; str,
Delimiter delimiter);
```
这个函数的意图是把类似于字符串的类型(`string``string_view`)分割开,并允许对分割的结果进行遍历。为了方便使用,结果也可以直接转化成字符串的数组(`to_vector`)或字符串视图的数组(`to_vector_sv`)。我们不用关心这个函数是如何实现的,我们就需要测试一下,该如何写呢?
首先,当然是写出一个测试用例的框架,把试验的待分割字符串写进去:
```
BOOST_AUTO_TEST_CASE(split_test)
{
string_view str{
"&amp;grant_type=client_credential"
"&amp;appid="
"&amp;secret=APPSECRET"};
}
```
最简单直白的测试,显然就是用 `to_vector``to_vector_sv` 来查看结果是否匹配了。这个非常容易加进去:
```
vector&lt;string&gt;
split_result_expected{
"",
"grant_type=client_"
"credential",
"appid=",
"secret=APPSECRET"};
auto result = split(str, '&amp;');
auto result_s =
result.to_vector();
BOOST_TEST(result_s ==
split_result_expected);
```
如果 `to_vector` 实现正确的话,我们现在运行程序就能在终端输出上看到:
>
`*** No errors detected`
下面,我们进一步检查 `to_vector``to_vector_sv` 的结果是否一致:
```
auto result_sv =
result.to_vector_sv();
BOOST_TEST_REQUIRE(
result_s.size() ==
result_sv.size());
{
auto it = result_sv.begin();
for (auto&amp; s : result_s) {
BOOST_TEST(s == *it);
++it;
}
}
```
最后我们再测试可以遍历 `result`,并且结果和之前的相同:
```
size_t i = 0;
auto it = result.begin();
auto end = result.end();
for (; it != end &amp;&amp;
i &lt; result_s.size();
++it) {
BOOST_TEST(*it == result_s[i]);
++i;
}
BOOST_CHECK(it == end);
```
而这,差不多就接近我实际的 `split` 测试代码了。完整代码可参见:
[https://github.com/adah1972/nvwa/blob/master/test/boosttest_split.cpp](https://github.com/adah1972/nvwa/blob/master/test/boosttest_split.cpp)
Boost.Test 产生的可执行代码支持很多命令行参数,可以用 `--help` 命令行选项来查看。常用的有:
- `build_info` 可用来展示构建信息
- `color_output` 可用来打开或关闭输出中的色彩
- `log_format` 可用来指定日志输出的格式包括纯文本、XML、JUnit 等
- `log_level` 可指定日志输出的级别,有 all、test_suite、error、fatal_error、nothing 等一共 11 个级别
- `run_test` 可选择只运行指定的测试用例
- `show_progress` 可在测试时显示进度,在测试数量较大时比较有用(见下图)
<img src="https://static001.geekbang.org/resource/image/c2/e5/c21168d96cd55836575a7e5b44e3a7e5.png" alt="">
我这儿只是个简单的介绍。完整的 Boost.Test 的功能介绍还是请你自行参看文档。
## Catch2
说完了 Boost.Test我们再来看一下另外一个单元测试库Catch2。仍然是和上一讲里说的一样我要选择 Boost 之外的库一定有一个比较强的理由。Catch2 有着它自己独有的优点:
- 只需要单个头文件即可使用,不需要安装和链接,简单方便
- 可选使用 BDDBehavior-Driven Development风格的分节形式
- 测试失败可选直接进入调试器Windows 和 macOS 上)
我们拿前面 Boost.Test 的示例直接改造一下:
```
#define CATCH_CONFIG_MAIN
#include "catch.hpp"
#include &lt;stdexcept&gt;
void test(int n)
{
if (n == 42) {
return;
}
throw std::runtime_error(
"Not the answer");
}
TEST_CASE("My first test", "[my]")
{
INFO("Testing");
CHECK(1 + 1 == 2);
CHECK_THROWS_AS(
test(41), std::runtime_error);
CHECK_NOTHROW(test(42));
int expected = 5;
CHECK(2 + 2 == expected);
}
TEST_CASE("A null test", "[null]")
{
}
```
可以看到,两者之间的相似性非常多,基本只是宏的名称变了一下。唯一值得一提的,是测试用例的参数:第一项是名字,第二项是标签,可以一个或多个。你除了可以直接在命令行上写测试的名字(不需要选项)来选择运行哪个测试外,也可以写测试的标签来选择运行哪些测试。
这是它在 Windows 下用 MSVC 编译的输出:
<img src="https://static001.geekbang.org/resource/image/6f/c7/6faf4113a57d9c4b65d203de52c8bfc7.png" alt="">
终端的色彩不会被搞乱。缺省的输出清晰程度相当不错。至少在 Windows 下,它看起来可能是个比 Boost.Test 更好的选择。但反过来在浅色的终端里Catch2 的色彩不太友好。Boost.Test 在 Linux 和 macOS 下则不管终端的色彩设定,都有比较友好的输出。
和 Boost.Test 类似Catch2 的测试结果输出格式也是可以修改的。默认格式是纯文本,但你可以通过使用 `-r junit` 来设成跟 JUnit 兼容的格式,或使用 `-r xml` 输出成 Catch2 自己的 XML 格式。这方面,它比 Boost.Test 明显易用的一个地方是格式参数大小写不敏感,而在 Boost.Test 里你必须用全大写的形式,如 `-f JUNIT`,麻烦!
下面我们通过另外一个例子来展示一下所谓的 BDD [3] 风格的测试。
BDD 风格的测试一般采用这样的结构:
- Scenario场景我要做某某事
- Given给定已有的条件
- When某个事件发生时
- Then那样就应该发生什么
如果我们要测试一个容器,那代码就应该是这个样子的:
```
SCENARIO("Int container can be accessed and modified",
"[container]")
{
GIVEN("A container with initialized items")
{
IntContainer c{1, 2, 3, 4, 5};
REQUIRE(c.size() == 5);
WHEN("I access existing items")
{
THEN("The items can be retrieved intact")
{
CHECK(c[0] == 1);
CHECK(c[1] == 2);
CHECK(c[2] == 3);
CHECK(c[3] == 4);
CHECK(c[4] == 5);
}
}
WHEN("I modify items")
{
c[1] = -2;
c[3] = -4;
THEN("Only modified items are changed")
{
CHECK(c[0] == 1);
CHECK(c[1] == -2);
CHECK(c[2] == 3);
CHECK(c[3] == -4);
CHECK(c[4] == 5);
}
}
}
}
```
你可以在程序前面加上类型定义来测试你自己的容器类或标准容器(如 `vector&lt;int&gt;`)。这是一种非常直观的写测试的方式。正常情况下,你当然应该看到:
>
`All tests passed (12 assertions in 1 test case)`
如果你没有留意到的话,在 GIVEN 里 WHEN 之前的代码是在每次 WHEN 之前都会执行一遍的。这也是 BDD 方式的一个非常方便的地方。
如果测试失败,我们就能看到类似下面这样的信息输出了(我存心制造了一个错误):
<img src="https://static001.geekbang.org/resource/image/fe/8b/fe7e7efbbc6e69418b378ea27701998b.png" alt="">
如果没有失败的情况下,想看到具体的测试内容,可以传递参数 `--success`(或 `-s`)。
如果你发现 Catch2 的编译速度有点慢的话,那我得告诉你,那是非常正常的。在你沮丧之前,我还应该马上告诉你,这在实际项目中完全不是一个问题。因为慢的原因通常主要是构建 Catch2 的主程序部分,而这部份在项目中只需要做一次,以后不会再有变动。你需要的是分离下面这部分代码在主程序里:
```
#define CATCH_CONFIG_MAIN
#include "catch.hpp"
```
只要这两行,来单独编译 Catch2 的主程序部分。你的实际测试代码里,则不要再定义 `CATCH_CONFIG_MAIN` 了。你会发现,这样一分离后,编译速度会大大加快。事实上,如果 Catch2 的主程序部分不需要编译的话Catch2 的测试用例的编译速度在我的机器上比 Boost.Test 的还要快。
我觉得 Catch2 是一个很现代、很好用的测试框架。它的宏更简单,一个 `CHECK` 可以替代 Boost.Test 中的 `BOOST_TEST``BOOST_CHECK`,也没有 `BOOST_TEST` 在某些情况下不能用、必须换用 `BOOST_CHECK` 的问题。对于一个新项目,使用 Catch2 应该是件更简单、更容易上手的事——尤其如果你在 Windows 上开发的话。
目前,在 GitHub 上Catch2 的收藏数超过一万复刻fork数达到一千七也已经足以证明它的流行程度。
## 内容小结
今天我们介绍了两个单元测试库Boost.Test 和 Catch2。整体上来看这两个都是很优秀的单元测试框架可以满足日常开发的需要。
## 课后思考
请你自己试验一下本讲中的例子,来制造一些成功和失败的情况。使用一下,才能更容易确定哪一个更适合你的需求。
## 参考资料
[1] Gennadiy Rozental and Raffi Enficiaud, Boost.Test. [https://www.boost.org/doc/libs/release/libs/test/doc/html/index.html](https://www.boost.org/doc/libs/release/libs/test/doc/html/index.html)
[2] Two Blue Cubes Ltd., Catch2. [https://github.com/catchorg/Catch2](https://github.com/catchorg/Catch2)
[3] Wikipedia, “Behavior-driven development”. [https://en.wikipedia.org/wiki/Behavior-driven_development](https://en.wikipedia.org/wiki/Behavior-driven_development)

View File

@@ -0,0 +1,513 @@
<audio id="audio" title="26 | Easylogging++和spdlog两个好用的日志库" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/74/bd/74f7118683d10140fec8c96f72f851bd.mp3"></audio>
你好,我是吴咏炜。
上一讲正文我介绍了两个可以在 C++ 中进行单元测试的库。今天,类似的,我介绍两个实用的日志库,分别是 Easylogging++ [1] 和 spdlog [2]。
## Easylogging++
事实上,我本来想只介绍 Easylogging++ 的。但在检查其 GitHub 页面时,我发现了一个问题:它在 2019 年基本没有更新,且目前上报的问题也没有人处理。这是个潜在问题,除非你觉得这个库好到愿意自己动手修问题(话说回来,这个库还是不错的,我在这个项目贡献了 8 个被合并的 pull request。不管怎样原先说了要介绍这个库所以我也还是介绍一下。
### 概述
Easylogging++ 一共只有两个文件,一个是头文件,一个是普通 C++ 源文件。事实上,它的一个较早版本只有一个文件。正如 Catch2 里一旦定义了 `CATCH_CONFIG_MAIN` 编译速度会大大减慢一样,把什么东西都放一起最终证明对编译速度还是相当不利的,因此,有人提交了一个补丁,把代码拆成了两个文件。使用 Easylogging++ 也只需要这两个文件——除此之外,就只有对标准和系统头文件的依赖了。
要使用 Easylogging++推荐直接把这两个文件放到你的项目里。Easylogging++ 有很多的配置项会影响编译结果,我们先大致查看一下常用的可配置项:
- `ELPP_UNICODE`:启用 Unicode 支持,为在 Windows 上输出混合语言所必需
- `ELPP_THREAD_SAFE`:启用多线程支持
- `ELPP_DISABLE_LOGS`:全局禁用日志输出
- `ELPP_DEFAULT_LOG_FILE`:定义缺省日志文件名称
- `ELPP_NO_DEFAULT_LOG_FILE`:不使用缺省的日志输出文件
- `ELPP_UTC_DATETIME`:在日志里使用协调世界时而非本地时间
- `ELPP_FEATURE_PERFORMANCE_TRACKING`:开启性能跟踪功能
- `ELPP_FEATURE_CRASH_LOG`:启用 GCC 专有的崩溃日志功能
- `ELPP_SYSLOG`允许使用系统日志Unix 世界的 syslog来记录日志
- `ELPP_STL_LOGGING`:允许在日志里输出常用的标准容器对象(`std::vector` 等)
- `ELPP_QT_LOGGING`:允许在日志里输出 Qt 的核心对象(`QVector` 等)
- `ELPP_BOOST_LOGGING`:允许在日志里输出某些 Boost 的容器(`boost::container::vector` 等)
- `ELPP_WXWIDGETS_LOGGING`:允许在日志里输出某些 wxWidgets 的模板对象(`wxVector` 等)
可以看到Easylogging++ 的功能还是很丰富很全面的。
### 开始使用 Easylogging++
虽说 Easylogging++ 的功能非常多,但开始使用它毫不困难。我们从一个简单的例子开始看一下:
```
#include "easylogging++.h"
INITIALIZE_EASYLOGGINGPP
int main()
{
LOG(INFO) &lt;&lt; "My first info log";
}
```
编译链接的时候要把 easylogging++.cc 放进去。比如,使用 GCC 的话,命令行会像:
>
`g++ -std=c++17 test.cpp easylogging++.cc`
运行生成的可执行程序,你就可以看到结果输出到终端和 myeasylog.log 文件里,包含了日期、时间、级别、日志名称和日志信息,形如:
>
`2020-01-25 20:47:50,990 INFO [default] My first info log`
如果你对上面用到的宏感到好奇的话, `INITIALIZE_EASYLOGGINGPP` 展开后(可以用编译器的 `-E` 参数查看宏展开后的结果)是定义了 Easylogging++ 使用到的全局对象,而 `LOG(INFO)` 则是 Info 级别的日志记录器,同时传递了文件名、行号、函数名等日志需要的信息。
### 使用 Unicode
如果你在 Windows 上那有一个复杂性就是是否使用“Unicode”的问题[[第 11 讲]](https://time.geekbang.org/column/article/179357) 中讨论了)。就我们日志输出而言,启用 Unicode 支持的好处是:
- 可以使用宽字符来输出
- 日志文件的格式是 UTF-8而不是传统的字符集只能支持一种文字
要启用 Unicode 支持,你需要定义宏 `ELPP_UNICODE`,并确保程序中有对标准输出进行区域或格式设置(如[[第 11 讲]](https://time.geekbang.org/column/article/179357) 中所述,需要进行设置才能输出含非 ASCII 字符的宽字符串)。下面的程序给出了一个简单的示例:
```
#ifdef _WIN32
#include &lt;fcntl.h&gt;
#include &lt;io.h&gt;
#else
#include &lt;locale&gt;
#endif
#include "easylogging++.h"
INITIALIZE_EASYLOGGINGPP
int main()
{
#ifdef _WIN32
_setmode(_fileno(stdout),
_O_WTEXT);
#else
using namespace std;
locale::global(locale(""));
wcout.imbue(locale());
#endif
LOG(INFO) &lt;&lt; L"测试 test";
LOG(INFO)
&lt;&lt; "Narrow ASCII always OK";
}
```
编译使用的命令行是:
>
`cl /EHsc /DELPP_UNICODE test.cpp easylogging++.cc`
### 改变输出文件名
Easylogging++ 的缺省输出日志名为 myeasylog.log这在大部分情况下都是不适用的。我们可以直接在命令行上使用宏定义来修改当然稍大点的项目就应该放在项目的编译配置文件里了如 Makefile。比如要把输出文件名改成 test.log我们只需要在命令行上加入下面的选项就可以
>
`-DELPP_DEFAULT_LOG_FILE=\"test.log\"`
### 使用配置文件设置日志选项
不过对于日志文件名称这样的设置使用配置文件是一个更好的办法。Easylogging++ 库自己支持配置文件,我也推荐使用一个专门的配置文件,并让 Easylogging++ 自己来加载配置文件。我自己使用的配置文件是这个样子的:
```
* GLOBAL:
FORMAT = &quot;%datetime{%Y-%M-%d %H:%m:%s.%g} %levshort %msg&quot;
FILENAME = &quot;test.log&quot;
ENABLED = true
TO_FILE = true ## 输出到文件
TO_STANDARD_OUTPUT = true ## 输出到标准输出
SUBSECOND_PRECISION = 6 ## 秒后面保留 6 位
MAX_LOG_FILE_SIZE = 2097152 ## 最大日志文件大小设为 2MB
LOG_FLUSH_THRESHOLD = 10 ## 写 10 条日志刷新一次缓存
* DEBUG:
FORMAT = &quot;%datetime{%Y-%M-%d %H:%m:%s.%g} %levshort [%fbase:%line] %msg&quot;
TO_FILE = true
TO_STANDARD_OUTPUT = false ## 调试日志不输出到标准输出
```
这个配置文件里有两节第一节是全局global配置配置了适用于所有级别的日志选项第二节是专门用于调试debug级别的配置你当然也可以自己配置 fatal、error、warning 等其他级别)。
假设这个配置文件的名字是 log.conf我们在代码中可以这样使用
```
#include "easylogging++.h"
INITIALIZE_EASYLOGGINGPP
int main()
{
el::Configurations conf{
"log.conf"};
el::Loggers::
reconfigureAllLoggers(conf);
LOG(DEBUG) &lt;&lt; "A debug message";
LOG(INFO) &lt;&lt; "An info message";
}
```
注意编译命令行上应当加上 `-DELPP_NO_DEFAULT_LOG_FILE`,否则 Easylogging++ 仍然会生成缺省的日志文件。
运行生成的可执行程序,我们会在终端上看到一条信息,但在日志文件里则可以看到两条信息。如下所示:
>
<p>`2020-01-26 12:54:58.986739 D [test.cpp:11] A debug message`<br>
`2020-01-26 12:54:58.987444 I An info message`</p>
我们也可以明确看到我们在配置文件中定义的日志格式生效了,包括:
- 日期时间的格式使用“.”分隔秒的整数和小数部分,并且小数部分使用 6 位
- 日志级别使用单个大写字母
- 对于普通的日志,后面直接跟日志的信息;对于调试日志,则会输出文件名和行号
我们现在只需要修改配置文件,就能调整日志格式、决定输出和不输出哪些日志了。此外,我也推荐在编译时定义宏 `ELPP_DEBUG_ASSERT_FAILURE`,这样能在找不到配置文件时直接终止程序,而不是继续往下执行、在终端上以缺省的方式输出日志了。
### 性能跟踪
Easylogging++ 可以用来在日志中记录程序执行的性能数据。这个功能还是很方便的。下面的代码展示了用于性能跟踪的三个宏的用法:
```
#include &lt;chrono&gt;
#include &lt;thread&gt;
#include "easylogging++.h"
INITIALIZE_EASYLOGGINGPP
void foo()
{
TIMED_FUNC(timer);
LOG(WARNING) &lt;&lt; "A warning message";
}
void bar()
{
using namespace std::literals;
TIMED_SCOPE(timer1, "void bar()");
foo();
foo();
TIMED_BLOCK(timer2, "a block") {
foo();
std::this_thread::sleep_for(100us);
}
}
int main()
{
el::Configurations conf{
"log.conf"};
el::Loggers::
reconfigureAllLoggers(conf);
bar();
}
```
简单说明一下:
- `TIMED_FUNC` 接受一个参数,是用于性能跟踪的对象的名字。它能自动产生函数的名称。示例中的 `TIMED_FUNC``TIMED_SCOPE` 的作用是完全相同的。
- `TIMED_SCOPE` 接受两个参数,分别是用于性能跟踪的对象的名字,以及用于记录的名字。如果你不喜欢 `TIMED_FUNC` 生成的函数名字,可以用 `TIMED_SCOPE` 来代替。
- `TIMED_BLOCK` 用于对下面的代码块进行性能跟踪,参数形式和 `TIMED_SCOPE` 相同。
在编译含有上面三个宏的代码时,需要定义宏 `ELPP_FEATURE_PERFORMANCE_TRACKING`。你一般也应该定义 `ELPP_PERFORMANCE_MICROSECONDS`,来获取微秒级的精度。下面是定义了上面两个宏编译的程序的某次执行的结果:
>
<p>`2020-01-26 15:00:11.99736 W A warning message`<br>
`2020-01-26 15:00:11.99748 I Executed [void foo()] in [110 us]`<br>
`2020-01-26 15:00:11.99749 W A warning message`<br>
`2020-01-26 15:00:11.99750 I Executed [void foo()] in [5 us]`<br>
`2020-01-26 15:00:11.99750 W A warning message`<br>
`2020-01-26 15:00:11.99751 I Executed [void foo()] in [4 us]`<br>
`2020-01-26 15:00:11.99774 I Executed [a block] in [232 us]`<br>
`2020-01-26 15:00:11.99776 I Executed [void bar()] in [398 us]`</p>
不过需要注意,由于 Easylogging++ 本身有一定开销,且开销有一定的不确定性,这种方式只适合颗粒度要求比较粗的性能跟踪。
性能跟踪产生的日志级别固定为 Info。性能跟踪本身可以在配置文件里的 GLOBAL 节下用 `PERFORMANCE_TRACKING = false` 来关闭。当然,关闭所有 Info 级别的输出也能达到关闭性能跟踪的效果。
### 记录崩溃日志
在 GCC 和 Clang 下,通过定义宏 `ELPP_FEATURE_CRASH_LOG` 我们可以启用崩溃日志。此时当程序崩溃时Easylogging++ 会自动在日志中记录程序的调用栈信息。通过记录下的信息,再利用 `addr2line` 这样的工具,我们就能知道是程序的哪一行引发了崩溃。下面的代码可以演示这一行为:
```
#include "easylogging++.h"
INITIALIZE_EASYLOGGINGPP
void boom()
{
char* ptr = nullptr;
*ptr = '\0';
}
int main()
{
el::Configurations conf{
"log.conf"};
el::Loggers::
reconfigureAllLoggers(conf);
boom();
}
```
你可以自己尝试编译运行一下,就会在终端和日志文件中看到崩溃的信息了。
使用 macOS 的需要特别注意一下:由于缺省方式产生的可执行文件是位置独立的,系统每次加载程序会在不同的地址,导致无法通过地址定位到程序行。在编译命令行尾部加上 `-Wl,-no_pie` 可以解决这个问题。
### 其他
Easylogging++ 还有很多其他功能,我就不再一一讲解了。有些你简单试一下就可以用起来的。对于 `ELPP_STL_LOGGING`,你也可以在包含 easylogging++.h 之前包含我的 output_container.h可以达到类似的效果。
此外Easylogging++ 的 samples 目录下有不少例子,可以用作参考。比如常见的日志文件切换功能,在 Easylogging++ 里实现是需要稍微写一点代码的Easylogging++ 会在文件满的时候调用你之前注册的回调函数而你需要在回调函数里对老的日志文件进行重命名、备份之类的工作samples/STL/roll-out.cpp 则提供了最简单的实现参考。
注意我使用的都是全局的日志记录器,但 Easylogging++ 允许你使用多个不同的日志记录器,用于(比如)不同的模块或功能。你如果需要这样的功能的话,也请你自行查阅文档了。
## spdlog
跟 Easylogging++ 比起来spdlog 要新得多了:前者是 2012 年开始的项目,而后者是 2014 年开始的。我在 2016 年末开始在项目中使用 Easylogging++ 时Easylogging++ 的版本是 9.85 左右,而 spdlog 大概是 0.11,成熟度和热度都不那么高。
整体上spdlog 也确实感觉要新很多。项目自己提到的功能点是:
- 非常快(性能是其主要目标)
- 只需要头文件即可使用
- 没有其他依赖
- 跨平台
- 有单线程和多线程的日志记录器
- 日志文件旋转切换
- 每日日志文件
- 终端日志输出
- 可选异步日志
- 多个日志级别
- 通过用户自定义式样来定制输出格式
### 开始使用 spdlog
跟 Easylogging++ 的例子相对应,我们以最简单的日志输出开头:
```
#include "spdlog/spdlog.h"
int main()
{
spdlog::info("My first info log");
}
```
代码里看不到的是输出结果中的“info”字样是彩色的方便快速识别日志的级别。这个功能在 Windows、Linux 和 macOS 上都能正常工作,对用户还是相当友好的。不过,和 Easylogging++ 缺省就会输出到文件中不同spdlog 缺省只是输出到终端而已。
你也许从代码中已经注意到spdlog 不是使用 IO 流风格的输出了。它采用跟 Python 里的 `str.format` 一样的方式,使用大括号——可选使用序号和格式化要求——来对参数进行格式化。下面是一个很简单的例子:
```
spdlog::warn(
"Message with arg {}", 42);
spdlog::error(
"{0:d}, {0:x}, {0:o}, {0:b}",
42);
```
输出会像下面这样:
>
<p>`[2020-01-26 17:20:08.355] [warning] Message with arg 42`<br>
`[2020-01-26 17:20:08.355] [error] 42, 2a, 52, 101010`</p>
事实上,这就是 C++20 的 `format` 的风格了——spdlog 就是使用了一个 `format` 的库实现 fmt [3]。
### 设置输出文件
在 spdlog 里,要输出文件得打开专门的文件日志记录器,下面的例子展示了最简单的用法:
```
#include "spdlog/spdlog.h"
#include "spdlog/sinks/basic_file_sink.h"
int main()
{
auto file_logger =
spdlog::basic_logger_mt(
"basic_logger",
"test.log");
spdlog::set_default_logger(
file_logger);
spdlog::info("Into file: {1} {0}",
"world", "hello");
}
```
执行之后,终端上没有任何输出,但 test.log 文件里就会增加如下的内容:
>
`[2020-01-26 17:47:37.864] [basic_logger] [info] Into file: hello world`
估计你立即会想问,那我想同时输出到终端和文件,该怎么办呢?
答案是你可以设立一个日志记录器让它有两个或更多个日志槽sink即可。示例代码如下
```
#include &lt;memory&gt;
#include "spdlog/spdlog.h"
#include "spdlog/sinks/basic_file_sink.h"
#include "spdlog/sinks/stdout_color_sinks.h"
using namespace std;
using namespace spdlog::sinks;
void set_multi_sink()
{
auto console_sink = make_shared&lt;
stdout_color_sink_mt&gt;();
console_sink-&gt;set_level(
spdlog::level::warn);
console_sink-&gt;set_pattern(
"%H:%M:%S.%e %^%L%$ %v");
auto file_sink =
make_shared&lt;basic_file_sink_mt&gt;(
"test.log");
file_sink-&gt;set_level(
spdlog::level::trace);
file_sink-&gt;set_pattern(
"%Y-%m-%d %H:%M:%S.%f %L %v");
auto logger =
shared_ptr&lt;spdlog::logger&gt;(
new spdlog::logger(
"multi_sink",
{console_sink, file_sink}));
logger-&gt;set_level(
spdlog::level::debug);
spdlog::set_default_logger(
logger);
}
int main()
{
set_multi_sink();
spdlog::warn(
"this should appear in both "
"console and file");
spdlog::info(
"this message should not "
"appear in the console, only "
"in the file");
}
```
大致说明一下:
- `console_sink` 是一个指向 `stdout_color_sink_mt` 的智能指针,我们设定让它只显示警告级别及以上的日志信息,并把输出式样调整成带毫秒的时间、有颜色的短级别以及信息本身。
- `file_sink` 是一个指向 `basic_file_sink_mt` 的智能指针,我们设定让它显示跟踪级别及以上(也就是所有级别了)的日志信息,并把输出式样调整成带微秒的日期时间、短级别以及信息本身。
- 然后我们创建了日志记录器让它具有上面的两个日志槽。注意这儿的两个细节1. 这儿的接口普遍使用 `shared_ptr`2. 由于 `make_shared` 在处理 `initializer_list` 上的缺陷,对 `spdlog::logger` 的构造只能直接调用 `shared_ptr` 的构造函数,而不能使用 `make_shared`,否则编译会出错。
- 最后我们调用了 `spdlog::set_default_logger` 把缺省的日志记录器设置成刚创建的对象。这样,之后的日志缺省就会记录到这个新的日志记录器了(我们当然也可以手工调用这个日志记录器的 `critical``error``warn` 等日志记录方法)。
在某次运行之后,我的终端上出现了:
>
`20:44:45.086 W this should appear in both console and file`
而 test.log 文件中则增加了:
>
<p>`2020-01-26 20:44:45.086524 W this should appear in both console and file`<br>
`2020-01-26 20:44:45.087174 I this message should not appear in the console, only in the file`</p>
跟 Easylogging++ 相比,我们现在看到了 spdlog 也有复杂的一面。两者在输出式样的灵活性上也有不同的选择Easylogging++ 对不同级别的日志可采用不同的式样,而 spdlog 对不同的日志槽可采用不同的式样。
### 日志文件切换
在 Easylogging++ 里实现日志文件切换是需要写代码的,而且完善的多文件切换代码需要写上几十行代码才能实现。这项工作在 spdlog 则是超级简单的,因为 spdlog 直接提供了一个实现该功能的日志槽。把上面的例子改造成带日志文件切换我们只需要修改两处:
```
#include "spdlog/sinks/rotating_file_sink.h"
// 替换 basic_file_sink.h
auto file_sink = make_shared&lt;
rotating_file_sink_mt&gt;(
"test.log", 1048576 * 5, 3);
// 替换 basic_file_sink_mt文件大
// 小为 5MB一共保留 3 个日志文件
```
这就非常简单好用了。
### 适配用户定义的流输出
虽然 spdlog 缺省不支持容器的输出,但是,它是可以和用户提供的流 `&lt;&lt;` 运算符协同工作的。如果我们要输出普通容器的话,我们只需要在代码开头加入:
```
#include "output_container.h"
#include "spdlog/fmt/ostr.h"
```
前一行包含了我们用于容器输出的代码,后一行包含了 spdlog 使用 ostream 来输出对象的能力。注意此处包含的顺序是重要的spdlog 必须能看到用户的 `&lt;&lt;` 的定义。在有了这两行之后,我们就可以像下面这样写代码了:
```
vector&lt;int&gt; v;
// …
spdlog::info(
"Content of vector: {}", v);
```
### 只用头文件吗?
使用 spdlog 可以使用只用头文件的方式也可以使用预编译的方式。只用头文件的编译速度较慢我的机器上使用预编译方式构建第一个例子需要一秒多而只用头文件的方式需要五秒多Clang 的情况GCC 耗时要更长)。因此正式使用的话,我还是推荐你使用预编译、安装的方式。
在安装了库后,编译时需额外定义一个宏,在命令行上要添加库名。以 GCC 为例,命令行会像下面这个样子:
>
`g++ -std=c++17 -DSPDLOG_COMPILED_LIB test.cpp -lspdlog`
### 其他
刚才介绍的还只是 spdlog 的部分功能。你如果对使用这个库感兴趣的话,应该查阅文档来获得进一步的信息。我这儿觉得下面这些功能点值得提一下:
- 可以使用多个不同的日志记录器,用于不同的模块或功能。
- 可以使用异步日志,减少记日志时阻塞的可能性。
- 通过 `spdlog::to_hex` 可以方便地在日志里输出二进制信息。
- 可用的日志槽还有 syslog、systemd、Android、Windows 调试输出等;扩展新的日志槽较为容易。
## 内容小结
今天我们介绍了两个不同的日志库Easylogging++ 和 spdlog。它们在功能和实现方式上有很大的不同建议你根据自己的实际需要来进行选择。
我目前对新项目的推荐是优先选择 spdlog仅在你需要某个 Easylogging++ 提供、而 spdlog 不提供的功能时才选择 Easylogging++。
当然C++ 的日志库远远不止这两个:我挑选的是我觉得比较好的和有实际使用经验的。其他可选择的日志库至少还有 Boost.Log [4]、g3log [5]、NanoLog [6] 等Log for C++ 接口有着 Java 式的啰嗦,且感觉有点“年久失修”,我明确不推荐)。在严肃的项目里,选择哪个日志库是值得认真比较和评估一下的。
## 课后思考
请对比一下 Easylogging++ 和 spdlog考虑以下两个问题
1. Easylogging++ 更多地使用了编译时的行为定制,而 spdlog 主要通过面向对象的方式在运行时修改日志的行为。你觉得哪种更好?为什么?
1. Easylogging++ 使用了 IO 流的方式,而 spdlog 使用了 `std::format` 的方式。你更喜欢哪种?为什么?
## 参考资料
[1] Amrayn Web Services, easyloggingpp. [https://github.com/amrayn/easyloggingpp](https://github.com/amrayn/easyloggingpp)
[2] Gabi Melman, spdlog. [https://github.com/gabime/spdlog](https://github.com/gabime/spdlog)
[3] Victor Zverovich, fmt. [https://github.com/fmtlib/fmt](https://github.com/fmtlib/fmt)
[4] Andrey Semashev, Boost.Log v2. [https://www.boost.org/doc/libs/release/libs/log/doc/html/index.html](https://www.boost.org/doc/libs/release/libs/log/doc/html/index.html)
[5] Kjell Hedström, g3log. [https://github.com/KjellKod/g3log](https://github.com/KjellKod/g3log)
[6] Stanford University, NanoLog. [https://github.com/PlatformLab/NanoLog](https://github.com/PlatformLab/NanoLog)

View File

@@ -0,0 +1,680 @@
<audio id="audio" title="27 | C++ REST SDK使用现代C++开发网络应用" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/44/78/44d5594c569500dc14cd9b55554a8078.mp3"></audio>
你好,我是吴咏炜。
在实战篇,我们最后要讲解的一个库是 C++ REST SDK也写作 cpprestsdk[1],一个支持 HTTP 协议 [2]、主要用于 RESTful [3] 接口开发的 C++ 库。
## 初识 C++ REST SDK
向你提一个问题,你认为用多少行代码可以写出一个类似于 curl [4] 的 HTTP 客户端?
使用 C++ REST SDK 的话,答案是,只需要五十多行有效代码(即使是适配到我们目前的窄小的手机屏幕上)。请看:
```
#include &lt;iostream&gt;
#ifdef _WIN32
#include &lt;fcntl.h&gt;
#include &lt;io.h&gt;
#endif
#include &lt;cpprest/http_client.h&gt;
using namespace utility;
using namespace web::http;
using namespace web::http::client;
using std::cerr;
using std::endl;
#ifdef _WIN32
#define tcout std::wcout
#else
#define tcout std::cout
#endif
auto get_headers(http_response resp)
{
auto headers = resp.to_string();
auto end =
headers.find(U("\r\n\r\n"));
if (end != string_t::npos) {
headers.resize(end + 4);
};
return headers;
}
auto get_request(string_t uri)
{
http_client client{uri};
// 用 GET 方式发起一个客户端请求
auto request =
client.request(methods::GET)
.then([](http_response resp) {
if (resp.status_code() !=
status_codes::OK) {
// 不 OK显示当前响应信息
auto headers =
get_headers(resp);
tcout &lt;&lt; headers;
}
// 进一步取出完整响应
return resp
.extract_string();
})
.then([](string_t str) {
// 输出到终端
tcout &lt;&lt; str;
});
return request;
}
#ifdef _WIN32
int wmain(int argc, wchar_t* argv[])
#else
int main(int argc, char* argv[])
#endif
{
#ifdef _WIN32
_setmode(_fileno(stdout),
_O_WTEXT);
#endif
if (argc != 2) {
cerr &lt;&lt; "A URL is needed\n";
return 1;
}
// 等待请求及其关联处理全部完成
try {
auto request =
get_request(argv[1]);
request.wait();
}
// 处理请求过程中产生的异常
catch (const std::exception&amp; e) {
cerr &lt;&lt; "Error exception: "
&lt;&lt; e.what() &lt;&lt; endl;
return 1;
}
}
```
这个代码有点复杂,需要讲解一下:
- 第 1418 行,我们根据平台来定义 `tcout`,确保多语言的文字能够正确输出。
- 第 2029 行,我们定义了 `get_headers`,来从 `http_response` 中取出头部的字符串表示。
- 第 36 行,构造了一个客户端请求,并使用 `then` 方法串联了两个下一步的动作。`http_client::request` 的返回值是 `pplx::task&lt;http_response&gt;``then``pplx::task` 类模板的成员函数,参数是能接受其类型参数对象的函数对象。除了最后一个 `then` 块,其他每个 `then` 里都应该返回一个 `pplx::task`,而 `task` 的内部类型就是下一个 `then` 块里函数对象接受的参数的类型。
- 第 37 行开始,是第一段异步处理代码。参数类型是 `http_response`——因为`http_client::request` 的返回值是 `pplx::task&lt;http_response&gt;`。代码中判断如果响应的 HTTP 状态码不是 200 OK就会显示响应头来帮助调试。然后进一步取出所有的响应内容可能需要进一步的异步处理等待后续的 HTTP 响应到达)。
- 第 49 行开始,是第二段异步处理代码。参数类型是 `string_t`——因为上一段 `then` 块的返回值是 `pplx::task&lt;string_t&gt;`。代码中就是简单地把需要输出的内容输出到终端。
- 第 5660 行,我们根据平台来定义合适的程序入口,确保命令行参数的正确处理。
- 第 6265 行,在 Windows 上我们把标准输出设置成宽字符模式,来确保宽字符(串)能正确输出(参考[[第 11 讲]](https://time.geekbang.org/column/article/179357) )。注意 `string_t` 在 Windows 上是 `wstring`,在其他平台上是 `string`
- 第 7283 行,如注释所言,产生 HTTP 请求、等待 HTTP 请求完成,并处理相关的异常。
整体而言这个代码还是很简单的虽然这种代码风格对于之前没有接触过这种函数式编程风格的人来讲会有点奇怪——这被称作持续传递风格continuation-passing style显式地把上一段处理的结果传递到下一个函数中。这个代码已经处理了 Windows 环境和 Unix 环境的差异,底下是相当复杂的。
另外提醒一下,在 Windows 上如果你把源代码存成 UTF-8 的话,需要确保文件以 BOM 字符打头。Windows 的编辑器通常缺省就会做到;在 Vim 里,可以通过 `set bomb` 命令做到这一点。
## 安装和编译
上面的代码本身虽然简单但要把它编译成可执行文件比我们之前讲的代码都要复杂——C++ REST SDK 有外部依赖,在 Windows 上和 Unix 上还不太一样。它的编译和安装也略复杂,如果你没有这方面的经验的话,建议尽量使用平台推荐的二进制包的安装方式。
由于其依赖较多,使用它的编译命令行也较为复杂。正式项目中绝对是需要使用项目管理软件的(如 cmake。此处我给出手工编译的典型命令行仅供你尝试编译上面的例子作参考。
Windows MSVC
>
`cl /EHsc /std:c++17 test.cpp cpprest.lib zlib.lib libeay32.lib ssleay32.lib winhttp.lib httpapi.lib bcrypt.lib crypt32.lib advapi32.lib gdi32.lib user32.lib`
Linux GCC
>
`g++ -std=c++17 -pthread test.cpp -lcpprest -lcrypto -lssl -lboost_thread -lboost_chrono -lboost_system`
macOS Clang
>
`clang++ -std=c++17 test.cpp -lcpprest -lcrypto -lssl -lboost_thread-mt -lboost_chrono-mt`
## 概述
有了初步印象之后,现在我们可以回过头看看 C++ REST SDK 到底是什么了。它是一套用来开发 HTTP 客户端和服务器的现代异步 C++ 代码库,支持以下特性(随平台不同会有所区别):
- HTTP 客户端
- HTTP 服务器
- 任务
- JSON
- URI
- 异步流
- WebSocket 客户端
- OAuth 客户端
上面的例子里用到了 HTTP 客户端、任务和 URI实际上是由 `string_t` 隐式构造了 `uri`我们下面再介绍一下异步流、JSON 和 HTTP 服务器。
## 异步流
C++ REST SDK 里实现了一套异步流,能够实现对文件的异步读写。下面的例子展示了我们如何把网络请求的响应异步地存储到文件 results.html 中:
```
#include &lt;iostream&gt;
#include &lt;utility&gt;
#ifdef _WIN32
#include &lt;fcntl.h&gt;
#include &lt;io.h&gt;
#endif
#include &lt;stddef.h&gt;
#include &lt;cpprest/http_client.h&gt;
#include &lt;cpprest/filestream.h&gt;
using namespace utility;
using namespace web::http;
using namespace web::http::client;
using namespace concurrency::streams;
using std::cerr;
using std::endl;
#ifdef _WIN32
#define tcout std::wcout
#else
#define tcout std::cout
#endif
auto get_headers(http_response resp)
{
auto headers = resp.to_string();
auto end =
headers.find(U("\r\n\r\n"));
if (end != string_t::npos) {
headers.resize(end + 4);
};
return headers;
}
auto get_request(string_t uri)
{
http_client client{uri};
// 用 GET 方式发起一个客户端请求
auto request =
client.request(methods::GET)
.then([](http_response resp) {
if (resp.status_code() ==
status_codes::OK) {
// 正常的话
tcout &lt;&lt; U("Saving...\n");
ostream fs;
fstream::open_ostream(
U("results.html"),
std::ios_base::out |
std::ios_base::trunc)
.then(
[&amp;fs,
resp](ostream os) {
fs = os;
// 读取网页内容到流
return resp.body()
.read_to_end(
fs.streambuf());
})
.then(
[&amp;fs](size_t size) {
// 然后关闭流
fs.close();
tcout
&lt;&lt; size
&lt;&lt; U(" bytes "
"saved\n");
})
.wait();
} else {
// 否则显示当前响应信息
auto headers =
get_headers(resp);
tcout &lt;&lt; headers;
tcout
&lt;&lt; resp.extract_string()
.get();
}
});
return request;
}
#ifdef _WIN32
int wmain(int argc, wchar_t* argv[])
#else
int main(int argc, char* argv[])
#endif
{
#ifdef _WIN32
_setmode(_fileno(stdout),
_O_WTEXT);
#endif
if (argc != 2) {
cerr &lt;&lt; "A URL is needed\n";
return 1;
}
// 等待请求及其关联处理全部完成
try {
auto request =
get_request(argv[1]);
request.wait();
}
// 处理请求过程中产生的异常
catch (const std::exception&amp; e) {
cerr &lt;&lt; "Error exception: "
&lt;&lt; e.what() &lt;&lt; endl;
}
}
```
跟上一个例子比,我们去掉了原先的第二段处理统一输出的异步处理代码,但加入了一段嵌套的异步代码。有几个地方需要注意一下:
- C++ REST SDK 的对象基本都是基于 `shared_ptr` 用引用计数实现的,因而可以轻松大胆地进行复制。
- 虽然 `string_t` 在 Windows 上是 `wstring`,但文件流无论在哪个平台上都是以 UTF-8 的方式写入,符合目前的主流处理方式(`wofstream` 的行为跟平台和环境相关)。
- `extract_string` 的结果这次没有传递到下一段,而是直接用 `get` 获得了最终结果(类似于[[第 19 讲]](https://time.geekbang.org/column/article/186689) 中的 `future`)。
这个例子的代码是基于 [cpprestsdk 官方的例子](https://github.com/Microsoft/cpprestsdk/wiki/Getting-Started-Tutorial)改编的。但我做的下面这些更动值得提一下:
- 去除了不必要的 `shared_ptr` 的使用。
- `fstream::open_ostream` 缺省的文件打开方式是 `std::ios_base::out`,官方例子没有用 `std::ios_base::trunc`,导致不能清除文件中的原有内容。此处 C++ REST SDK 的 `file_stream` 行为跟标准 C++ 的 `ofstream` 是不一样的:后者缺省打开方式也是 `std::ios_base::out`,但此时文件内容**会**被自动清除。
- 沿用我的前一个例子,先进行请求再打开文件流,而不是先打开文件流再发送网络请求,符合实际流程。
- 这样做的一个结果就是 `then` 不完全是顺序的了,有嵌套,增加了复杂度,但展示了实际可能的情况。
## JSON 支持
在基于网页的开发中JSON [5] 早已取代 XML 成了最主流的数据交换方式。REST 接口本身就是基于 JSON 的自然C++ REST SDK 需要对 JSON 有很好的支持。
JSON 本身可以在网上找到很多介绍的文章,我这儿就不多讲了。有几个 C++ 相关的关键点需要提一下:
- JSON 的基本类型是空值类型、布尔类型、数字类型和字符串类型。其中空值类型和数字类型在 C++ 里是没有直接对应物的。数字类型在 C++ 里可能映射到 `double`,也可能是 `int32_t``int64_t`
- JSON 的复合类型是数组array和对象object。JSON 数组像 C++ 的 `vector`,但每个成员的类型可以是任意 JSON 类型,而不像 `vector` 通常是同质的——所有成员属于同一类型。JSON 对象像 C++ 的 `map`,键类型为 JSON 字符串,值类型则为任意 JSON 类型。JSON 标准不要求对象的各项之间有顺序,不过,从实际项目的角度,我个人觉得保持顺序还是非常有用的。
如果你去搜索“c++ json”的话还是可以找到一些不同的 JSON 实现的。功能最完整、名声最响的目前似乎是 nlohmann/json [6],而腾讯释出的 RapidJSON [7] 则以性能闻名 [8]。需要注意一下各个实现之间的区别:
- nlohmann/json 不支持对 JSON 的对象object保持赋值顺序RapidJSON 保持赋值顺序C++ REST SDK 可选保持赋值顺序(通过 `web::json::keep_object_element_order``web::json::value::object` 的参数)。
- nlohmann/json 支持最友好的初始化语法,可以使用初始化列表和 JSON 字面量C++ REST SDK 只能逐项初始化,并且一般应显式调用 `web::json::value` 的构造函数(接受布尔类型和字符串类型的构造函数有 `explicit` 标注RapidJSON 介于中间,不支持初始化列表和字面量,但赋值可以直接进行。
- nlohmann/json 和 C++ REST SDK 支持直接在用方括号 `[]` 访问不存在的 JSON 数组array成员时改变数组的大小RapidJSON 的接口不支持这种用法,要向 JSON 数组里添加成员要麻烦得多。
- 作为性能的代价RapidJSON 里在初始化字符串值时,只会传递指针值;用户需要保证字符串在 JSON 值使用过程中的有效性。要复制字符串的话,接口要麻烦得多。
- RapidJSON 的 JSON 对象没有 `begin``end` 方法,因而无法使用标准的基于范围的 for 循环。总体而言RapidJSON 的接口显得最特别、不通用。
如果你使用 C++ REST SDK 的其他功能,你当然也没有什么选择;否则,你可以考虑一下其他的 JSON 实现。下面,我们就只讨论 C++ REST SDK 里的 JSON 了。
在 C++ REST SDK 里,核心的类型是 `web::json::value`,这就对应到我前面说的“任意 JSON 类型”了。还是拿例子说话(改编自 RapidJSON 的例子):
```
#include &lt;iostream&gt;
#include &lt;string&gt;
#include &lt;utility&gt;
#include &lt;assert.h&gt;
#ifdef _WIN32
#include &lt;fcntl.h&gt;
#include &lt;io.h&gt;
#endif
#include &lt;cpprest/json.h&gt;
using namespace std;
using namespace utility;
using namespace web;
#ifdef _WIN32
#define tcout std::wcout
#else
#define tcout std::cout
#endif
int main()
{
#ifdef _WIN32
_setmode(_fileno(stdout),
_O_WTEXT);
#endif
// 测试的 JSON 字符串
string_t json_str = U(R"(
{
"s": "你好,世界",
"t": true,
"f": false,
"n": null,
"i": 123,
"d": 3.1416,
"a": [1, 2, 3]
})");
tcout &lt;&lt; "Original JSON:"
&lt;&lt; json_str &lt;&lt; endl;
// 保持元素顺序并分析 JSON 字符串
json::keep_object_element_order(
true);
auto document =
json::value::parse(json_str);
// 遍历对象成员并输出类型
static const char* type_names[] =
{
"Number", "Boolean", "String",
"Object", "Array", "Null",
};
for (auto&amp;&amp; value :
document.as_object()) {
tcout &lt;&lt; "Type of member "
&lt;&lt; value.first &lt;&lt; " is "
&lt;&lt; type_names[value.second
.type()]
&lt;&lt; endl;
}
// 检查 document 是对象
assert(document.is_object());
// 检查 document["s"] 是字符串
assert(document.has_field(U("s")));
assert(
document[U("s")].is_string());
tcout &lt;&lt; "s = "
&lt;&lt; document[U("s")] &lt;&lt; endl;
// 检查 document["t"] 是字符串
assert(
document[U("t")].is_boolean());
tcout
&lt;&lt; "t = "
&lt;&lt; (document[U("t")].as_bool()
? "true"
: "false")
&lt;&lt; endl;
// 检查 document["f"] 是字符串
assert(
document[U("f")].is_boolean());
tcout
&lt;&lt; "f = "
&lt;&lt; (document[U("f")].as_bool()
? "true"
: "false")
&lt;&lt; endl;
// 检查 document["f"] 是空值
tcout
&lt;&lt; "n = "
&lt;&lt; (document[U("n")].is_null()
? "null"
: "?")
&lt;&lt; endl;
// 检查 document["i"] 是整数
assert(
document[U("i")].is_number());
assert(
document[U("i")].is_integer());
tcout &lt;&lt; "i = "
&lt;&lt; document[U("i")] &lt;&lt; endl;
// 检查 document["d"] 是浮点数
assert(
document[U("d")].is_number());
assert(
document[U("d")].is_double());
tcout &lt;&lt; "d = "
&lt;&lt; document[U("d")] &lt;&lt; endl;
{
// 检查 document["a"] 是数组
auto&amp; a = document[U("a")];
assert(a.is_array());
// 测试读取数组元素并转换成整数
int y = a[0].as_integer();
(void)y;
// 遍历数组成员并输出
tcout &lt;&lt; "a = ";
for (auto&amp;&amp; value :
a.as_array()) {
tcout &lt;&lt; value &lt;&lt; ' ';
}
tcout &lt;&lt; endl;
}
// 修改 document["i"] 为长整数
{
uint64_t bignum = 65000;
bignum *= bignum;
bignum *= bignum;
document[U("i")] = bignum;
assert(!document[U("i")]
.as_number()
.is_int32());
assert(document[U("i")]
.as_number()
.to_uint64() ==
bignum);
tcout &lt;&lt; "i is changed to "
&lt;&lt; document[U("i")]
&lt;&lt; endl;
}
// 在数组里添加数值
{
auto&amp; a = document[U("a")];
a[3] = 4;
a[4] = 5;
tcout &lt;&lt; "a is changed to "
&lt;&lt; document[U("a")]
&lt;&lt; endl;
}
// 在 JSON 文档里添加布尔值:等号
// 右侧 json::value 不能省
document[U("b")] =
json::value(true);
// 构造新对象,保持多个值的顺序
auto temp =
json::value::object(true);
// 在新对象里添加字符串:等号右侧
// json::value 不能省
temp[U("from")] =
json::value(U("rapidjson"));
temp[U("changed for")] =
json::value(U("geekbang"));
// 把对象赋到文档里json::value
// 内部使用 unique_ptr因而使用
// move 可以减少拷贝
document[U("adapted")] =
std::move(temp);
// 完整输出目前的 JSON 对象
tcout &lt;&lt; document &lt;&lt; endl;
}
```
例子里我加了不少注释,应当可以帮助你看清 JSON 对象的基本用法了。唯一遗憾的是宏 `U`(类似于[[第 11 讲]](https://time.geekbang.org/column/article/179357) 里提到过的 `_T`)的使用有点碍眼:要确保代码在 Windows 下和 Unix 下都能工作,目前这还是必要的。
建议你测试一下这个例子。查看一下结果。
C++ REST SDK 里的 `http_request``http_response` 都对 JSON 有原生支持,如可以使用 `extract_json` 成员函数来异步提取 HTTP 请求或响应体中的 JSON 内容。
## HTTP 服务器
前面我们提到了如何使用 C++ REST SDK 来快速搭建一个 HTTP 客户端。同样,我们也可以使用 C++ REST SDK 来快速搭建一个 HTTP 服务器。在三种主流的操作系统上C++ REST SDK 的 `http_listener` 会通过调用 Boost.Asio [9] 和操作系统的底层接口IOCP、epoll 或 kqueue来完成功能向使用者隐藏这些细节、提供一个简单的编程接口。
我们将搭建一个最小的 REST 服务器,只能处理一个 sayHi 请求。客户端应当向服务器发送一个 HTTP 请求URI 是:
>
`/sayHi?name=…`
“…”部分代表一个名字,而服务器应当返回一个 JSON 的回复,形如:
```
{"msg": "Hi, …!"}
```
这个服务器的有效代码行同样只有六十多行,如下所示:
```
#include &lt;exception&gt;
#include &lt;iostream&gt;
#include &lt;map&gt;
#include &lt;string&gt;
#ifdef _WIN32
#include &lt;fcntl.h&gt;
#include &lt;io.h&gt;
#endif
#include &lt;cpprest/http_listener.h&gt;
#include &lt;cpprest/json.h&gt;
using namespace std;
using namespace utility;
using namespace web;
using namespace web::http;
using namespace web::http::
experimental::listener;
#ifdef _WIN32
#define tcout std::wcout
#else
#define tcout std::cout
#endif
void handle_get(http_request req)
{
auto&amp; uri = req.request_uri();
if (uri.path() != U("/sayHi")) {
req.reply(
status_codes::NotFound);
return;
}
tcout &lt;&lt; uri::decode(uri.query())
&lt;&lt; endl;
auto query =
uri::split_query(uri.query());
auto it = query.find(U("name"));
if (it == query.end()) {
req.reply(
status_codes::BadRequest,
U("Missing query info"));
return;
}
auto answer =
json::value::object(true);
answer[U("msg")] = json::value(
string_t(U("Hi, ")) +
uri::decode(it-&gt;second) +
U("!"));
req.reply(status_codes::OK,
answer);
}
int main()
{
#ifdef _WIN32
_setmode(_fileno(stdout),
_O_WTEXT);
#endif
http_listener listener(
U("http://127.0.0.1:8008/"));
listener.support(methods::GET,
handle_get);
try {
listener.open().wait();
tcout &lt;&lt; "Listening. Press "
"ENTER to exit.\n";
string line;
getline(cin, line);
listener.close().wait();
}
catch (const exception&amp; e) {
cerr &lt;&lt; e.what() &lt;&lt; endl;
return 1;
}
}
```
如果你熟悉 HTTP 协议的话,上面的代码应当是相当直白的。只有少数几个细节我需要说明一下:
- 我们调用 `http_request::reply` 的第二个参数是 `json::value` 类型,这会让 HTTP 的内容类型Content-Type自动置成“application/json”。
- `http_request::request_uri` 函数返回的是 `uri` 的引用,因此我用 `auto&amp;` 来接收。`uri::split_query` 函数返回的是一个普通的 `std::map`,因此我用 `auto` 来接收。
- `http_listener::open``http_listener::close` 返回的是 `pplx::task&lt;void&gt;`;当这个任务完成时(`wait` 调用返回),表示 HTTP 监听器上的对应操作(打开或关闭)真正完成了。
运行程序,然后在另外一个终端里使用我们的第一个例子生成的可执行文件(或 curl
>
`curl "http://127.0.0.1:8008/sayHi?name=Peter"`
我们就应该会得到正确的结果:
>
`{"msg":"Hi, Peter!"}`
你也可以尝试把路径和参数写错,查看一下程序对出错的处理。
## 关于线程的细节
C++ REST SDK 使用异步的编程模式,使得写不阻塞的代码变得相当容易。不过,底层它是使用一个线程池来实现的——在 C++20 的协程能被使用之前,并没有什么更理想的跨平台方式可用。
C++ REST SDK 缺省会开启 40 个线程。在目前的实现里,如果这些线程全部被用完了,会导致系统整体阻塞。反过来,如果你只是用 C++ REST SDK 的 HTTP 客户端,你就不需要这么多线程。这个线程数量目前在代码里是可以控制的。比如,下面的代码会把线程池的大小设为 10
```
#include &lt;pplx/threadpool.h&gt;
crossplat::threadpool::
initialize_with_threads(10);
```
如果你使用 C++ REST SDK 开发一个服务器,则不仅应当增加线程池的大小,还应当对并发数量进行统计,在并发数接近线程数时主动拒绝新的连接——一般可返回 `status_codes::ServiceUnavailable`——以免造成整个系统的阻塞。
## 内容小结
今天我们对 C++ REST SDK 的主要功能作了一下概要的讲解和演示,让你了解了它的主要功能和这种异步的编程方式。还有很多功能没有讲,但你应该可以通过查文档了解如何使用了。
这只能算是我们旅程中的一站——因为随着 C++20 的到来,我相信一定会有更多好用的网络开发库出现的。
## 课后思考
作为实战篇的最后一讲,内容还是略有点复杂的。如果你一下子消化不了,可以复习前面的相关内容。
如果对这讲的内容本身没有问题,则可以考虑一下,你觉得 C++ REST SDK 的接口好用吗?如果好用,原因是什么?如果不好用,你有什么样的改进意见?
## 参考资料
[1] Microsoft, cpprestsdk. [https://github.com/microsoft/cpprestsdk](https://github.com/microsoft/cpprestsdk)
[2] Wikipedia, “Hypertext Transfer Protocol”. [https://en.wikipedia.org/wiki/Hypertext_Transfer_Protocol](https://en.wikipedia.org/wiki/Hypertext_Transfer_Protocol)
[2a] 维基百科, “超文本传输协议”. [https://zh.m.wikipedia.org/zh-hans/超文本传输协议](https://zh.m.wikipedia.org/zh-hans/%E8%B6%85%E6%96%87%E6%9C%AC%E4%BC%A0%E8%BE%93%E5%8D%8F%E8%AE%AE)
[3] RESTful. [https://restfulapi.net/](https://restfulapi.net/)
[4] curl. [https://curl.haxx.se/](https://curl.haxx.se/)
[5] JSON. [https://www.json.org/](https://www.json.org/)
[6] Niels Lohmann, json. [https://github.com/nlohmann/json](https://github.com/nlohmann/json)
[7] Tencent, rapidjson. [https://github.com/Tencent/rapidjson](https://github.com/Tencent/rapidjson)
[8] Milo Yip, nativejson-benchmark. [https://github.com/miloyip/nativejson-benchmark](https://github.com/miloyip/nativejson-benchmark)
[9] Christopher Kohlhoff, Boost.Asio. [https://www.boost.org/doc/libs/release/doc/html/boost_asio.html](https://www.boost.org/doc/libs/release/doc/html/boost_asio.html)

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)

View File

@@ -0,0 +1,117 @@
<audio id="audio" title="新春寄语 | 35年码龄程序员人生漫长走点弯路在所难免" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/aa/53/aa5089c532ef83a529d3bf453efeb953.mp3"></audio>
你好,我是吴咏炜。
当你读到这一讲的时候,新年已经要来临了。今天,我们就轻松点,不讲硬核的话题了。我们就一起闲聊一下如何学习编程。
## 我的编程生涯
我学习编程在我的那一代人里算是比较早的。那时候已经有了“学电脑要从娃娃抓起”的说法,所以初一时老师就给我们介绍了什么是电脑:我第一次见到的是一台 Z80 芯片的 Laser 200连接在一台单色显示器上有着集成的键盘ROM 里装载着 BASIC 语言解释器,有 4 KB 内存(你没有看错单位),没有磁盘、鼠标这些我们熟悉的外设,但可以连磁带机,还可以做比计算器更复杂的计算工作!
我还记得我尝试的头几个程序里有一个是出十道计算题,用随机数生成题目,让用户输入答案,并在最后计算得分。记得一开始我对 `For``Next` 循环的语义理解还不正确,找了计算机老师帮忙才得到正确的结果。但得到想要的结果后,真是有成就感啊!
于是,我就被这个按键会滴滴响的破烂玩意儿迷上了,还央求爸爸妈妈花了足足 500 元大洋(那时我爸妈的月收入才 60 几元,我妈因此放弃了一条金项链……)给我买了台更先进的 Laser 310有 18 KB 内存哎!),连在家里的老式电视机上,这直接或间接导致了我的视力在初中急剧下降。不过,我的学习成绩完全没有受到负面影响,还各科全部稳定上升,直到考进了理想的高中和大学。
当然,没有磁盘的 Laser 310 实在是太弱了。还好,我参加了少科站的计算机班,逐渐接触到了 Apple II 计算机,后来又接触到最早的 IBM PC并一路学习了
<li>
<p>BASIC当然BASIC 还是少不了,在我们那个年代,它一直是很主流的,竞赛什么的还是常用 BASIC。Apple BASIC 和 DOS 3.3 就是我高中玩得最多的系统环境了。记得因为没有递归,在 BASIC 里写快速排序还是一件非常痛苦的事……<br>
<img src="https://static001.geekbang.org/resource/image/4f/90/4f493489d877b49b6efc90979fe1cd90.png" alt="" title="Apple DOS 3.3 的 HELLO 程序列表"></p>
</li>
<li>
<p>Logo当时曾经热过一阵但除了提高了我对编程的热情似乎没记得它对我有多大的帮助。当时就只是玩玩作图而已没有领会到它背后的编程理念。<br>
<img src="https://static001.geekbang.org/resource/image/ba/c2/bae4aeefadfdacb0edf4b99eddc574c2.png" alt="" title="Apple Logo 的界面:有没有注意到圆不圆,方也不方?因为硬件像素点就不是正方的……"></p>
</li>
<li>
<p>Pascal比起 BASIC绝对是一阵新鲜空气啊。从 Pascal 里,我最早领会到了什么是结构化编程。记得最初需要理解指针就是在 Pascal 里。麻烦的地方是,在 Apple II 上跑 Pascal 没有 80 列卡是十分痛苦的,而少科站只有一台机器有 80 列卡……<br>
<img src="https://static001.geekbang.org/resource/image/14/4b/146c7e73f4ee36ea8ff796dd11650a4b.png" alt="" title="UCSD Pascal 的程序编辑界面:这里用上了 80 列卡"></p>
</li>
<li>
Prolog最初在纸面上学了点但对我个人没有产生深远的影响。整个编程生涯里实际运行 Prolog 程序的机会都没有很多次。
</li>
<li>
Lisp学 Prolog 的时候也一起了解了。但当时只是了解了语法,而没有理解它的编程范式,也完全没有实践环境,因而没什么用。真正对 Lisp 有所领会要到多年之后捡起 Scheme 和 Racket 的时候。
</li>
<li>
Forth有段时间我很迷 Forth但同样因为没有实践的机会实际上影响可以忽略不计。
</li>
<li>
C少科站计算机老师大力推荐的语言但跟 Pascal 一样,跑起来不容易——在 Apple II 上需要插 Z80 卡跑 CP/M 操作系统才行。在 Turbo C/C++ 到来之前,没有太多机会使用。
</li>
<li>
<p>C++:接触到 Turbo C++ 之后,使用 C++ 就是件顺理成章的事了。具体哪一年开始用,我已经记不清了。印象比较深刻的,是 94—95 年写毕业论文时,一开始用的是没有数学协处理器的 386作图的时候可以看到点是一个个出来的。后来换了有协处理器的 486 后,点就是一串串地流淌出来的。<br>
<img src="https://static001.geekbang.org/resource/image/21/0d/21dc3488debd0706a2e9e427b1ea310d.jpg" alt="" title="网上找来的 Turbo C++ 集成开发环境界面"></p>
</li>
在我学习计算机的年代,一个高性能的编程语言属于实际应用的刚需,所以 C++ 成了后面非玩具项目的不二选择。大学期间肯定是花了不少时间把玩 C++ 的(依稀记得有个实验室老师希望我们有人接手他的一个 Fortran 计算代码,但没人肯上)。不过,那段时间对 C 和 C++ 的理解还是有缺陷的,再加上 DOS 下没有保护模式也不那么容易发现错误C++ 的路不太好走。记得曾经遇到个程序发生了空指针错误,我一直没找到原因……
大学之后,又陆续接触了一些其他的开发语言,如 VBScript 和 JavaScript。而我对 C++ 的理解有真正长足的进步,是在 2000—2005 那段时间。那时候,我在一家网络安全公司工作,对网络上的实时报文做高性能协议分析和内容检测,需要对网络协议、面向对象编程、系统架构都有比较好的把握。这又恰恰是互联网越来越成熟、走进大众生活的年代,网上的开发资源也愈见增多。我那时在 Windows 上开发 Linux 应用程序,为了方便起见,在本机也安装了 GCC因而有段时间我在 MinGW GCC 的邮件列表还是相当活跃的。因为有切实的工作需要,也因为有好的交流场所,那是我真正在开发技能上突飞猛进的时间。
再后面,我加入了 Intel并在那里工作了十年多。Intel 是一家了不起的公司,我在那里也学到了很多东西。不过,纯粹从软件开发技能上来说,那段时间进步不多。如果我坚持一直待下去的话,应该会有机会成为 PowerPoint Engineer 的。😂
离开 Intel 后,我的主要开发语言从 C++ 变成了 Python。这也算是一种全新的体验吧。不同的开发语言有着不同的功能取舍而 Python 放弃了性能,来换取表达能力上的提高,对开发人员来说真是轻松之极。我在好几年的 C++ 大会上,讲 C++ 时都会拿 Python 代码来对标。一开始还有点不服气的意思(我也就比你多写了那么几行嘛),后来成了由衷的佩服和学习。我觉得 C++ 的发展目标就应该是达到 Python 的表达能力,同时还具有高出几个数量级的性能,那样就完美了……
## 一万小时定律
说了半天,你有没有觉得我学得非常的杂啊?我现在自己想想,都觉得似乎有点太杂了。不过,人生在世,不走点弯路是不可能的。我也没觉得我花在那些小众语言或不重要的特性上的时间完全是浪费,虽说也许有更好的学习方式。学习编程绝对是一场比马拉松更长的长跑,是需要长期的努力和付出的。
你很可能已经听说过“一万小时定律”了。这个说法的核心意思是,在大部分的领域里,从普通人变为专家需要投入至少约一万小时的时间。如果按每天工作八个小时、一周工作五天计算,那么成为一个领域的专家至少需要五年。如果投入时间不那么多,或者问题领域更复杂,“十年磨一剑”也是件很自然的事。
注意这是个约数,也只是必要条件,而非充分条件。时间少了,肯定完全没有希望;大于等于一万小时,也不能保证你一定成为专家。即使天才也需要勤学苦练。而如果天资真的不足,那估计投入再多也不会有啥效果。
如果你看得更仔细一点,你在了解一万小时定律时,应该会注意到不是随便练习一万小时就有用的。你需要的是刻意的练习。换句话说,练习是为了磨练你的思维(或肌肉,或其他需要训练的部分),而不能只是枯燥的练习而已。
打另外一个比方,如果你工作了五年,任务是数据库的查询和报表。那估计对绝大多数人,只有第一年可能是有能力提高的,后面四年则对能力提高没有什么大帮助。在求职市场上,这样的五年经验,多半不比两年工作经验的更有竞争力。考虑到五年经验的的薪水比两年的要高,很多公司会更偏好经验更少的呢。这就是为什么纯编程的职位(像 Java 开发工程师、Python 开发工程师之类基本上见不到要求十年以上经验的。C++ 这方面相对还略好些,因为大家都知道 C++ 上手需要的时间比较长,所以经验很少的 C++ 程序员意义不大,除非公司里能分配比较有经验的程序员指导。但总体而言,经验要求多的,一般更会是架构、管理方面的职位,大家更认同这些方面的能力更需要长时间的积累。
你有没有注意我说的是“大家更认同”,而不是“我认为”?事实上,我个人的观点恰恰相反,纯粹的程序员能力,不论何种语言,也是可以积累的。只不过,这种积累的有效性因人而异,因而在求职市场上参考意义偏弱些。程序员如果希望积累个人能力的话,很重要的一点,是不要把重复的事情一直重复下去。如果你做一件事感觉以前做过了,你就得想想这里面是否有共性了;如果你做一件事已经是第三第四遍了,那绝对是某个地方出了点问题。你应当做的事情,是退一步,看看如何可以用更好的抽象来解决问题,而不是一遍遍地去重复——否则,就真成了码农,潜在可以被其他人,甚至机器,在某一天替代了。
这点上来说C++ 是很适合爱好抽象的程序员的语言。C++ 允许高效的抽象,可以用来构造新的领域专用语言。我个人觉得 C++ 挺适合分层开发的——一部分有经验的老手开发系统的框架和底层抽象,并暴露出一个简单易用的接口给上层开发者;上层开发者可以相对经验较少,在老手的指导下完成整个系统的构建。
估计我说了这么多,你还是会想,那你学了这么多乱七八糟的语言,有用吗?
我的个人意见是,既没用,也有用。
如果我有一个好的老师具有针对性地来指导我,指出我哪方面有欠缺,哪方面需要加强训练,那当然会是件极好的事。可那样的事似乎只在音乐界和体育界发生了。在编程这样的开放领域,我还真不知道哪个大师是好老师教出来的。我不是说老师没有用(我遇到过好几个对我编程生涯有影响的好老师),但存在一个一对一的、同领域的好老师的概率真的太低了,尤其在你成长到一定程度之后。编程绝对是一个需要自学的领域。我在编程上“荒废”的时间,应该属于这一万小时中比较低效的部分,但不是无效的部分。说低效,是因为我并不是针对我的编程能力方面的缺陷,去刻意提高自己在某方面的能力。但是,因为我也不是无聊地重复编写同样的程序,而是根据自己的兴趣爱好钻研自己不理解、感兴趣的方面,这种“泛读”也应该部分起到了塑造我的思维的作用。
我还很喜欢 Larry Wall 认为程序员该有的三大美德懒惰急切傲慢laziness, impatience, hubris初次阐释于 **Programming Perl** 第二版)。我翻译出完整的原文与你妙文共赏:
>
**懒惰**
使得你花费极大努力来减少总体能量开销的品质。懒惰使你去写能让别人觉得有用、并减少繁杂工作的程序;你也会用文档描述你的程序,免得你不得不去回答别人的问题。因此,这是程序员的第一大美德。
**急切**
当计算机不能满足你的需求时你所感到的愤怒。这使得你写的程序不仅满足自己的需求,还能预期其他需求。至少努力去这么做。因此,这是程序员的第二大美德。
**傲慢**
老天都受不了你的极度骄傲。这种品质使得你写程序(和维护程序)时不允许别人有机会来说三道四。因此这是程序员的第三大美德。
搞软件开发的,我相信没有很笨的。智力上的差距通过努力、态度和技巧是完全可以填补的。希望你也能拥有这三个懒惰、急切、傲慢的美德。
## 英语学习
学编程应该学好英语。学编程应该学好英语。学编程应该学好英语。(重要的事情说三遍。)
不管出于什么原因,中文的编程资源没有英文的好,这是不争的事实。前些天,我在帮朋友出些 C++ 的考题,也在网上找了一下,结果看到了很多错误的题目和答案。有还在使用 `void main` 的(应该是 `int main`),有对结构的对齐规则理解不正确的(部分是因为 MSVC 和 GCC 规则有些细微的小区别),还有对 C++ 对象的生命周期规则理解有偏差的,等等。
事实上,我对 C++ 有疑惑时,解答大部分来自于 Stack Overflow——而国内完全没有同等级的编程问答网站。形式上有点相似的是百度问答但内容的质量——不管是问题还是回答——完全不可同日而语。百度问答上的很多问题如果有人胆敢贴到 Stack Overflow 上去,问题立即会变成负分……
怎么学好英语,这个问题是被讨论过无数次了。对于我来讲,非常简单:我喜欢英语。喜欢永远是最好的学习动力。那如果你就是喜欢不起来呢?那我觉得,你需要告诉你自己:
1. 学好英语是必要的
1. 学好英语比学好编程容易
1. 还有任何疑问,请参见第 1 条
你需要逼自己一下,定下小小的目标和计划,并每天**坚持**。比如:
- 每天阅读一篇英语的编程文章
- 每天看一条英文的 C++ Core Guideline
- 每天看 5 页 C++ 之父的 **The C++ Programming Language**(或其他的英文编程书籍)
- 每天在 Stack Overflow 上看 3 个问答
- 等等
你很快会发现,克服心理障碍之后,学习英语比起编程来是不费吹灰之力的事,不需要动什么脑筋。把自己沉浸到一个英文环境里,英语能力的提高是件自然而然的事。听说读写,其他方面缺乏训练还情有可原,读是应该最容易被解决的。为了能和世界各地的其他优秀程序员网上交流,写也需要多加练习——不要怕有错误,用简单的词把意思表达清楚,就是很好的第一步。多读多写,基本的网上阅读和交流就不会是一个难题了。
## 新春快乐
听我唠叨了这么多,希望你没有觉得厌烦。哈哈,就在此祝你新春快乐,在新的一年里学习顺利、工作成功。轻松的生活不是成长的人生所需要的,但希望你遇到的困难都能找到解决的方法,进而有所提高。
在这里顺便告个假请容我和编辑稍稍歇上一天1 月 27 日也就是大年初三我们停更一次。大家过年期间除了和亲朋好友团聚外也可以抽时间把前面的内容再复习下。初五我会为大家提供一个加餐C++ 编程推荐书目希望能够为你在这个课程之外进一步提升自己的能力提供参考方向。1 月 31 日起我们恢复正文更新,再上征途,完成最后五讲的学习。

View File

@@ -0,0 +1,233 @@
<audio id="audio" title="新春福利 | C++好书荐读" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/96/92/9690d4fef875e0a7e992307ccb89a092.mp3"></audio>
## 写在前面
你好,我是吴咏炜。
今天我会介绍一些我觉得好并且值得推荐的书,但我不会提供任何购买或下载链接。前者没有必要,大家应该都知道怎么搜索;后者我个人认为违反道义。这些书没有哪本是程序员买不起的。如果书作者没有提供免费下载,而市面上又买不到某本书的话,那自己偷偷找个下载渠道也情有可原——但也请你不要分享出来、告诉我或者其他人。即使你认为以后别人复制你的作品是完全没有问题的(事实上我很怀疑这点,除非你是个硬核的自由软件贡献者),也不等于你有权利复制别人的作品。
## 入门介绍
Bjarne Stroustrup, **A Tour of C++**, 2nd ed. Addison-Wesley, 2018
中文版王刚译《C++ 语言导学》(第二版。机械工业出版社2019
推荐指数:★★★★★
(也有第一版的影印版,那就不推荐了。)
这是唯一一本较为浅显的全面介绍现代 C++ 的入门书。书虽然较薄,但 C++ 之父的功力在那里(这不是废话么😂),时有精妙之论。书的覆盖面很广,介绍了 C++ 的基本功能和惯用法。这本书的讲授方式,也体现了他的透过高层抽象来教授 C++ 的理念。
Michael Wong 和 IBM XL 编译器中国开发团队,《深入理解 C++11C++11 新特性解析与应用》。机械工业出版社2013
推荐指数:★★★☆
这本书我犹豫了好久是否应该推荐。Michael Wong 是 C++ 标准委员会委员内容的权威性没有问题。但这本书从电子书版本Kindle 和微信读书上都有此书)看,排印错误不少——校对工作没有做好。我觉得,如果你已经熟悉 C++98想很系统地检视一下 C++11 的新特性,这本书可能还比较适合。(我只讲了一些重点的现代 C++ 特性,完整性相差很远。)
## 最佳实践
Scott Meyers, **Effective C++: 55 Specific Ways to Improve Your Programs and Designs**, 3rd ed. Addison-Wesley, 2005
中文版侯捷译《Effective C++ 中文版》第三版。电子工业出版社2011
推荐指数:★★★★
Scott Meyers, **Effective STL: 50 Specific Ways to Improve Your Use of the Standard Template Library**. Addison-Wesley, 2001
中文版潘爱民、陈铭、邹开红译《Effective STL 中文版》。清华大学出版社2006
推荐指数:★★★★
Scott Meyers, **Effective Modern C++: 42 Specific Ways to Improve Your Use of C++11 and C++14**. OReilly, 2014
中文版高博译《Effective Modern C++ 中文版》。中国电力出版社2018
推荐指数:★★★★★
C++ 的大牛中有三人尤其让我觉得高山仰止Scott Meyers 就是其中之一——Bjarne 让人感觉是睿智,而 Scott Meyers、Andrei Alexandrescu 和 Herb Sutter 则会让人感觉智商被碾压。Scott 对 C++ 语言的理解无疑是非常深入的,并以良好的文笔写出了好几代的 C++ 最佳实践。我读过他整个 Effective 系列四本书,每一本都是从头看到尾,收获巨大。(之所以不推荐第二本 **More Effective C++**是因为那本没有出过新版1996 年的内容有点太老了。)
这几本书讨论的都是最佳实践,因此,如果你没有实际做过 C++ 项目,感触可能不会那么深。做过实际项目的一定会看到,哦,原来我也犯了这个错误啊……如果你不想一下子看三本,至少最后一本属于必修课。
值得一提的是,这三本的译者在国内都是响当当的名家,翻译质量有保证。因此,这几本看看中文版就挺好。
## 深入学习
Herb Sutter, **Exceptional C++: 47 Engineering Puzzles, Programming Problems, and Solutions**. Addison-Wesley, 1999
中文版卓小涛译《Exceptional C++ 中文版》。中国电力出版社2003
推荐指数:★★★★
我已经说过,我认为 Herb Sutter 是 C++ 界最聪明的三人之一。在这本书里Herb 用提问、回答的形式讨论了很多 C++ 里的难点问题。虽然这本书有点老了,但我认为你一定可以从中学到很多东西。
Herb Sutter and Andrei Alexandrescu, **C++ Coding Standards: 101 Rules, Guidelines, and Best Practices**. Addison-Wesley, 2004
中文版刘基诚译《C++编程规范101条规则准则与最佳实践》。人民邮电出版社2006
推荐指数:★★★★
两个牛人制定的 C++ 编码规范。与其盲目追随网上的编码规范比如Google 的),不如仔细看看这两位大牛是怎么看待编码规范方面的问题的。
侯捷《STL 源码剖析》。华中科技大学出版社2002
推荐指数:★★★★☆
这本是我推荐的唯二的直接以中文出版的 C++ 技术书。侯捷以庖丁解牛的方式,仔细剖析了早期 STL 的源码——对于不熟悉 STL 代码的人来说,绝对可以学到许多。我当年从这本书中学到了不少知识。虽说那里面的 STL 有点老了,但同时也更简单些,比今天主流的 STL 容易学习。
同时仍需注意,这本书有点老,也有些错误(比如,有人提到它对 `std::copy``memmove` 的说明是错的,但我已经不再有这本书了,没法确认),阅读时需要自己鉴别。但瑕不掩瑜,我还是认为这是本好书。
## 高级专题
Alexander A. Stepanov and Daniel E. Rose, **From Mathematics to Generic Programming**. Addison-Wesley, 2014
中文版爱飞翔译《数学与泛型编程高效编程的奥秘》。机械工业出版社2017
推荐指数:★★★★★
Alexander Stepanov 是 STL 之父这本书写的却不是具体的编程技巧或某个库而是把泛型编程和抽象代数放在一起讨论了。说来惭愧我是读了这本书之后才对群论稍稍有了点理解之前看到的介绍材料都过于抽象没能理解。事实上Alexander 之前还写了一本同一题材、但使用公理推导风格的 **Elements of Programming**(裘宗燕译《编程原本》),那本就比较抽象艰深,从受欢迎程度上看远远不及这一本。我也只是买了放在书架上没看多少页😝。​
回到这本书本身。这本书用的编程语言是 C++,并引入了“概念”——虽然作者写这本书时并没有真正的 C++ 概念可用。书中讨论了数学、编程,还介绍了很多大数学家的生平。相对来说(尤其跟《编程原本》比),通过这本书是可以比较轻松地学习到泛型的威力的。哦,对了,我之前提到使用高精度整数算 RSA 就是拿这本书里描述的内容做练习。计算 RSA从抽象的角度只不过就是求幂和最大公约数而已……
除非抽象代数和模板编程你都已经了然于胸,否则这本书绝对会让你对编程的理解再上一个层次。相信我!
Andrei Alexandrescu, **Modern C++ Design: Generic Programming and Design Patterns Applied**. Addison-Wesley, 2001
中文版侯捷、於春景译《C++ 设计新思维》。华中科技大学出版社2003
推荐指数:★★★★☆
这本书算是 Andrei 的成名作了,一出版就艳惊四座。书中讨论了大量模板相关的技巧,尤其是基于策略的设计。记得在这本书出版时,大量书中的代码是不能被编译器接受的。当然,错的基本上都是编译器,而不是 Andrei。
对了,注意到英文书名中的 Modern C++ 了吗?现代 C++ 这一提法就是从他开始的,虽然那是在 C++11 发布之前十年了。可以说他倡导了新的潮流。在今天,这本书的内容当然是略老了,但它仍然是不可替代的经典作品。书里的技巧有些已经过时了(我也不推荐大家今天去使用 Loki 库),但理念没有过时,对思维的训练也仍然有意义。
Anthony Williams, **C++ Concurrency in Action**, 2nd ed. Manning, 2019
中文译本只有第一版,且有人评论“机器翻译的都比这个好”。因而不推荐中文版。
推荐指数:★★★★☆
C++ 在并发上出名的书似乎只此一本。这也不算奇怪:作者是 Boost.Thread 的主要作者之一,并且也直接参与了 C++ 跟线程相关的很多标准化工作;同时,这本书也非常全面,内容覆盖并发编程相关的所有主要内容,甚至包括在 Concurrency TS 里讨论的,尚未进入 C++17 标准(但应当会进入 C++20的若干重要特性barrier、latch 和 continuation。
除非你为一些老式的嵌入式系统开发 C++ 程序,完全不需要接触并发,否则我推荐你阅读这本书。
Ivan Čukić, **Functional Programming in C++**. Manning, 2019
中文版程继洪、孙玉梅、娄山佑译《C++ 函数式编程》。机械工业出版社2020
推荐指数:★★★★
推荐这本书我是有点私心的,毕竟我为这本书作了点小贡献,你也能在这本书里找到我的名字。因为这个,也因为这本书太新、评价太少,我也很犹豫该不该推荐。不过,鉴于在这个领域这是唯一的一本,如果你想用 C++ 语言做函数式编程的话,也没有更好的书可选了。
如果你对函数式编程有兴趣,可以读一读这本书。如果你对函数式编程不感冒,可以跳过这一本。
## 参考书
Bjarne Stroustrup, **The C++ Programming Language**, 4th ed. Addison-Wesley, 2013
中文版王刚、杨巨峰译《C++ 程序设计语言》。机械工业出版社, 2016
推荐指数:★★★★☆
没什么可多说的C++ 之父亲自执笔写的 C++ 语言。主要遗憾是没有覆盖 C++14/17 的内容。中文版分为两卷出版,内容实在是有点多了。不过,如果你没有看过之前的版本,并且对 C++ 已经有一定经验的话,这个新版还是会让你觉得,姜还是老的辣!
Nicolai M. Josuttis, **The C++ Standard Library: A Tutorial and Reference**, 2nd ed. Addison-Wesley, 2012
中文版侯捷译《C++ 标准库》。电子工业出版社2015
推荐指数:★★★★☆
Nicolai 写的这本经典书被人称为既完备又通俗易懂,也是殊为不易。从 C++11 的角度,这本书堪称完美。当然,超过一千页的大部头,要看完也是颇为不容易了。如果你之前没有阅读过第一版,那我也会非常推荐这一本。
## C++ 的设计哲学
Bjarne Stroustrup, **The Design and Evolution of C++**. Addison-Wesley, 1994
中文版裘宗燕译《C++ 语言的设计与演化》。科学出版社, 2002
推荐指数:★★★☆
这本书不是给所有的 C++ 开发者准备的。它讨论的是为什么 C++ 会成为今天1994 年)这个样子。如果你对 C++ 的设计思想感兴趣,那这本书会比较有用些。如果你对历史不感兴趣,那这本书不看也不会有很大问题。
Bruce Eckel, **Thinking in C++, Vol. 1: Introduction to Standard C++**, 2nd ed. Prentice-Hall, 2000
Bruce Eckel and Chuck Allison, **Thinking in C++, Vol. 2: Practical Programming**. Pearson, 2003
中文版刘宗田等译《C++ 编程思想》。机械工业出版社2011
推荐指数:★★★
据说这套书翻译不怎么样,我没看过,不好评价。如果你英文没问题,还是看英文版吧——作者释出了英文的免费版本。这套书适合有一点编程经验的人,讲的是编程思想。推荐星级略低的原因是,书有点老,且据说存在一些错误。但 Bruce Eckel 对编程的理解非常深入,即使在 C++ 的细节上他有错误,通读此书肯定还是会对你大有益处的。
## 非 C++ 的经典书目
W. Richard Stevens, **TCP/IP Illustrated Volume 1: The Protocols**. Addison-Wesley, 1994
Gary R. Wright and W. Richard Stevens, **TCP/IP Illustrated Volume 2: The Implementation**. Addison-Wesley, 1995
W. Richard Stevens, **TCP/IP Illustrated Volume 3: TCP for Transactions, HTTP, NNTP and the Unix Domain Protocols**. Addison-Wesley 1996
中文版翻译不佳,不推荐。
推荐指数:★★★★☆
不是所有的书都是越新越好《TCP/IP 详解》就是其中一例。W. Richard Stevens 写的卷一比后人补写的卷一第二版评价更高,就是其中一例。关于 TCP/IP 的编程,这恐怕是难以超越的经典了。不管你使用什么语言开发,如果你的工作牵涉到网络协议的话,这套书恐怕都值得一读——尤其是卷一。
W. Richard Stevens and Stephen A. Rago, **Advanced Programming in the UNIX Environment**, 3rd, ed.Addison-Wesley, 2013
中文版: 戚正伟、张亚英、尤晋元译《UNIX环境高级编程》。人民邮电出版社2014
推荐指数:★★★★
从事 C/C++ 编程应当对操作系统有深入的了解,而这本书就是讨论 Unix 环境下的编程的。鉴于 Windows 下都有了 Unix 的编程环境Unix 恐怕是开发人员必学的一课了。这本书是经典,而它的第三版至少没有损坏前两版的名声。
Erich Gamma, Richard Helm, Ralph Johson, John Vlissides, and Grady Booch, **Design Patterns: Elements of Reusable Object-Oriented Software**. Addison-Wesley, 1994
中文版李英军、马晓星、蔡敏、刘建中等译《设计模式》。机械工业出版社2000
推荐指数:★★★★☆
经典就是经典,没什么可多说的。提示:如果你感觉这本书很枯燥、没用,那就等你有了更多的项目经验再回过头来看一下,也许就有了不同的体验。
Eric S. Raymond, **The Art of UNIX Programming**. Addison-Wesley, 2003
中文版姜宏、何源、蔡晓骏译《UNIX 编程艺术》。电子工业出版社2006
推荐指数:★★★★
抱歉,这仍然是一本 Unix 相关的经典。如果你对 Unix 设计哲学有兴趣的话,那这本书仍然无可替代。如果你愿意看英文的话,这本书的英文一直是有在线免费版本的。
Pete McBreen, **Software Craftsmanship: The New Imperative**. Addison-Wesley, 2001
中文版熊节译《软件工艺》。人民邮电出版社2004
推荐指数:★★★★
这本书讲的是软件开发的过程,强调的是软件开发中人的作用。相比其他的推荐书,这本要“软”不少。但不等于这本书不重要。如果你之前只关注纯技术问题的话,那现在是时间关注一下软件开发中人的问题了。
Paul Graham, **Hackers &amp; Painters: Big Ideas From The Computer Age**. OReilly, 2008
中文版阮一峰译《黑客与画家》。人民邮电出版社2011
推荐指数:★★★★
这本讲的是一个更玄的问题:黑客是如何工作的。作者 Paul Graham 也是一名计算机界的大神了,用 Lisp 写出了被 Yahoo! 收购的网上商店,然后又从事风险投资,创办了著名的孵化器公司 Y Combinator。这本书是他的一本文集讨论了黑客——即优秀程序员——的爱好、动机、工作方法等等。你可以从中学习一下一个优秀的程序员是如何工作的包括为什么脚本语言比静态类型语言受欢迎😁……
Robert C. Martin, **Clean Code: A Handbook of Agile Software Craftsmanship**. Prentice Hall, 2008
中文版韩磊译《代码整洁之道》。人民邮电出版社2010
推荐指数:★★★★☆
Bob 大叔的书如果你之前没看过的话,这本是必看的。这本也是语言无关的,讲述的是如何写出干净的代码。有些建议初看也许有点出乎意料,但细想之下又符合常理。推荐。
## 其他
别忘了下面这两个重要的免费网站:
C++ Reference. [https://en.cppreference.com](https://en.cppreference.com)
C++ Core Guidelines. [https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines](https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines)
这两个网站绝对是优秀的免费资源。大力推荐!
希望今天的推荐能给你提供多一些的参考方向,从而进一步提升自己。

View File

@@ -0,0 +1,8 @@
你好,我是吴咏炜。
《现代C++实战30讲》这个专栏已经完结有段时间了很庆幸啊依然能收到很多留言与我交流技术。为认真学习的你点赞也很感谢你的支持
为了让你更好地检测自己的学习成果我特意做了一套期末测试题。题目共有20道满分为100分快来检测一下吧
[<img src="https://static001.geekbang.org/resource/image/28/a4/28d1be62669b4f3cc01c36466bf811a4.png" alt="">](http://time.geekbang.org/quiz/intro?act_id=136&amp;exam_id=293)

View File

@@ -0,0 +1,497 @@
<audio id="audio" title="28 | Concepts如何对模板进行约束?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/74/7d/74c10f01ecaa217624ce93a2747f387d.mp3"></audio>
你好,我是吴咏炜。
从这一讲开始,我们进入了未来篇,展望一下即将在 C++20 出现的新功能。我们第一个要讨论的,是 concepts概念——一个难产了很多年才终于进入 C++ 的新功能。
## 一个小例子
老规矩,要讲“概念”,我们先看例子。
我们知道 C++ 里有重载,可以根据参数的类型来选择合适的函数。比如,我们可以定义 `half` 对于 `int``string` 有不同的作用:
```
int half(int n)
{
return n / 2;
}
string half(string s)
{
s.resize(s.size() / 2);
return s;
}
```
初看,似乎重载可以解决问题,但细想,不对啊:除了 `int`,我们还有差不多的 `short``long` 等类型,甚至还有 `boost::multiprecision::cpp_int`;除了 `string`,我们也还有 `wstring``u16string``u32string` 等等。上面的每个函数,实际上都适用于一族类型,而不是单个类型。重载在这方面并帮不了什么忙。
也许你现在已经反应过来了,我们有 SFINAE 啊!回答部分正确。可是,你告诉我你有没有想到一种很简单的方式能让 SFINAE 对整数类型可以工作Type traits嗯嗯总是可以解决的是吧但这会不会是一条把初学者劝退的道路呢……
C++ 的概念就是用来解决这个问题的。对于上面的例子,我们只需要事先定义了 `Integer``String` 的概念(如何定义一个概念我们后面会说),我们就可以写出下面这样的代码:
```
template &lt;Integer N&gt;
N half(N n)
{
return n / 2;
}
template &lt;String S&gt;
S half(S s)
{
s.resize(s.size() / 2);
return s;
}
```
我们应当了解一下,从概念上讲,上面这种形式的含义和下面的代码实质相同(以上面的第一个函数为例):
```
template &lt;typename N&gt;
requires Integer&lt;N&gt;
N half(N n)
{
return n / 2;
}
```
即,这个 `half` 是一个函数模板,有一个模板参数,启用这个模板的前提条件是这个参数满足 `Integer` 这个约束。
## Concepts 简史
2019 年 11 月,上海,当在 C++ 峰会上被问起他最喜欢的 C++ 特性有哪些时Bjarne 的回答里就有 concepts。这丝毫不让我感到惊讶。虽然 C++ 的“概念”看起来是个挺简单的概念但它的历史并不短——Bjarne 想把它加入 C++ 已经有好多年了 [1]。
从基本概念上来讲,“概念”就是一组对模板参数的约束条件。我们讨论过模板就是 C++ 里的鸭子类型但我们没有提过Bjarne 对模板的接口实际上是相当不满意的:他自己的用词直接就是 lousy并认为这一糟糕的接口设计是后面导致了恐怖的模板编译错误信息的根源。
从另一方面讲Alex Stepanov 设计的 STL 一开始就包含了“概念”的概念,如我们在[[第 7 讲]](https://time.geekbang.org/column/article/176842) 中提到的各种不同类型的迭代器:
- Output Iterator
- Input Iterator
- Forward Iterator
- Bidirectional Iterator
- Random Access Iterator
-
这些概念出现在了 STL 的文档中,有详细的定义;但它们只是落在纸面上,而没有在 C++ 语言中有真正的体现。后来,他还进一步把很多概念的形式描述写进了他于 2009 年(和 Paul McJones 一起)出版的“神作” **Elements of Programming** [2] 中,并给出了假想的实现代码——其中就有关键字 `requires`——即使那时没有任何编译器能够编译这样的代码。
在 C++ 第一次标准化1998之后Bjarne 多次试图把“概念”引入 C++(根据我看到的文献,他在 03 到 09 年直接有至少九篇单独或合著的论文跟“概念”有关),但一直没有成功——魔鬼在细节,一旦进入细节,人们对一个看起来很美的点子的分歧就非常大了。一直到 C++11 标准化,“概念” 还是因为草案复杂、争议多、无成熟实现而没有进入 C++ 标准。
目前 C++20 里的“概念”的基础是 2009 年重新启动的 Concepts Lite并在 2015 年出版成为技术规格书 Concepts TS正式的 TS 文档需要花钱购买,我们需要进一步了解可以查看正式出版前的草案 [3])。很多人参与了相关工作,其中就包括了 Andrew Sutton、Bjarne Stroustrup 和 Alex Stepanov。这回实现简化了有了一个实现GCC争议也少多了。然而“概念”还是没有进入 C++17主要由于下面这些原因
- 从 Concepts TS 出版到标准定稿只有不到四个⽉的时间C++20 的内容也同样是在 2019 年就全部冻结了,到正式出版前的时间留个修正小问题和走批准流程)
- “概念”只有一个实现GCC
- Concepts TS 规格书的作者和 GCC 中的概念实现者是同⼀个⼈,没有⼈独⽴地从规格书出发实现概念
- Concepts TS ⾥没有实际定义概念,标准库也没有把概念用起来
当然,大家还是认可“概念”是个好功能,到了 2017 年 7 月,“概念”就正式并入 C++20 草案了。之后,小修订还是不少的(所以“概念”没有进入 C++20 也不完全是件坏事)。从用户的角度,最大的一个改变是“概念”的名字:目前,所有标准“概念”从全部由大写字母打头改成了“标准大小写”——即全部小写字母加下划线 [4]。比如,允许相等比较这个概念,原先写作 `EqualityComparable`,现在要写成 `equality_comparable`
## 基本的 Concepts
下图中给出了 C++ 里对象相关的部分标准概念(不完整):
<img src="https://static001.geekbang.org/resource/image/fc/21/fc99fa3b010ab1e84741eea004933f21.png" alt="">
我们从下往上快速看一下:
- `move_constructible`:可移动构造
- `swappable`:可交换
- `movable`:可移动构造、可交换,合在一起就是可移动了
- `copy_constructible`:可拷贝构造
- `copyable`:可拷贝构造、可移动,合在一起就是可复制了(注:这儿“拷贝”和“复制”只是我在翻译中做的一点小区分,英文中没有区别)
- `default_initializable`:可默认初始化(名字不叫 `default_constructible` 是因为目前的 type traits 中有 `is_default_constructible`,且意义和 `default_initializable` 有点微妙的区别;详见[问题报告 3338](https://timsong-cpp.github.io/lwg-issues/3338)
- `semiregular`:可复制、可默认初始化,合在一起就是半正则了
- `equality_comparable`:可相等比较,即对象之间可以使用 `==` 运算符
- `regular`:半正则、可相等比较,合在一起就是正则了
这些“概念”现在不只是文字描述,绝大部分是可以真正在代码中定义的。现在,准标准的定义已经可以在 cppreference.com 上找到 [5]。从实际的角度,下面我们列举部分概念在 CMCSTL2 [6]——一个 Ranges我们下一讲讨论的参考实现——中的定义。
从简单性的角度,我们自上往下看,首先是 `regular`
```
template &lt;class T&gt;
concept regular =
semiregular&lt;T&gt; &amp;&amp;
equality_comparable&lt;T&gt;;
```
很简单吧,定义一个 concept 此处只是一些针对类型的条件而已。可以看出,每个概念测试表达式(如 `semiregular&lt;T&gt;`)的结果是一个布尔值(编译期常量)。
然后是 `semiregular`
```
template &lt;class T&gt;
concept semiregular =
copyable&lt;T&gt; &amp;&amp;
default_initializable&lt;T&gt;;
```
再看一眼 `equality_comparable`
```
template &lt;class T, class U&gt;
concept WeaklyEqualityComparable =
requires(
const remove_reference_t&lt;T&gt;&amp; t,
const remove_reference_t&lt;U&gt;&amp; u) {
{ t == u } -&gt; boolean;
{ t != u } -&gt; boolean;
{ u == t } -&gt; boolean;
{ u != t } -&gt; boolean;
};
template &lt;class T&gt;
concept equality_comparable =
WeaklyEqualityComparable&lt;T, T&gt;;
```
这个稍复杂点,用到了 `requires` [7],但不需要我讲解,你也能看出来 `equality_comparable` 的要求就是类型的常左值引用之间允许进行 `==``!=` 的比较,且返回类型为布尔类型吧。
注意上面的定义里写的是 `boolean` 而不是 `bool`。这个概念定义不要求比较运算符的结果类型是 `bool`,而是可以用在需要布尔值的上下文中。自然,`boolean` 也是有定义的,但这个定义可能比你想象的复杂,我这儿就不写出来了😜。
我们之前已经讲过了各种迭代器,每个迭代器也自然地满足一个“概念”——概念名称基本上就是之前给的,只是大小写要变化一下而已。最底下的 `iterator` 是个例外:因为这个名字在标准里已经被占用啦。所以现在它的名字是 `input_or_output_iterator`
迭代器本身需要满足哪些概念呢?我们看下图:
<img src="https://static001.geekbang.org/resource/image/6a/0c/6ade3581f8f2da22c92987e81974210c.png" alt="">
注意这张跟上面那张图不一样,概念之间不是简单的“合取”关系,而是一种“继承”关系:上面的概念比它指向的下面的概念有更多的要求。具体到代码:
```
template &lt;class I&gt;
concept weakly_incrementable =
semiregular&lt;I&gt; &amp;&amp; requires(I i) {
typename iter_difference_t&lt;I&gt;;
requires signed_integral&lt;
iter_difference_t&lt;I&gt;&gt;;
{ ++i } -&gt; same_as&lt;I&amp;&gt;;
i++;
};
```
也就是说,`weakly_incrementable``semiregular` 再加一些额外的要求:
- `iter_difference_t&lt;I&gt;` 是一个类型
- `iter_difference_t&lt;I&gt;` 是一个有符号的整数类型
- `++i` 的结果跟 `I&amp;` 是完全相同的类型
- 能够执行 `i++` 操作(不检查结果的类型)
`input_or_output_iterator` 也很简单:
```
template &lt;class I&gt;
concept input_or_output_iterator =
__dereferenceable&lt;I&amp;&gt; &amp;&amp;
weakly_incrementable&lt;I&gt;;
```
就是要求可以解引用、可以执行 `++`、可以使用 `iter_difference_t` 提取迭代器的 `difference_type` 而已。
剩下的概念的定义也不复杂,我这儿就不一一讲解了。感兴趣的话你可以自己去看 CMCSTL2 的源代码。
### 简单的概念测试
为了让你再简单感受一下标准的概念,我写了下面这个简单的测试程序,展示一些标准概念的测试结果:
```
#include &lt;armadillo&gt;
#include &lt;iostream&gt;
#include &lt;memory&gt;
#include &lt;type_traits&gt;
using namespace std;
#if defined(__cpp_concepts)
#if __cpp_concepts &lt; 201811
#include &lt;experimental/ranges/concepts&gt;
using namespace experimental::ranges;
#else
#include &lt;concepts&gt;
#endif
#else // defined(__cpp_concepts)
#error "No support for concepts!"
#endif
#define TEST_CONCEPT(Concept, \
Type) \
cout &lt;&lt; #Concept &lt;&lt; '&lt;' &lt;&lt; #Type \
&lt;&lt; "&gt;: " \
&lt;&lt; Concept&lt;Type&gt; &lt;&lt; endl
#define TEST_CONCEPT2( \
Concept, Type1, Type2) \
cout &lt;&lt; #Concept &lt;&lt; '&lt;' \
&lt;&lt; #Type1 &lt;&lt; ", " &lt;&lt; #Type2 \
&lt;&lt; "&gt;: " \
&lt;&lt; Concept&lt;Type1, \
Type2&gt; &lt;&lt; endl
int main()
{
cout &lt;&lt; boolalpha;
cout &lt;&lt; "__cpp_concepts is "
&lt;&lt; __cpp_concepts &lt;&lt; endl;
TEST_CONCEPT(regular, int);
TEST_CONCEPT(regular, char);
TEST_CONCEPT(integral, int);
TEST_CONCEPT(integral, char);
TEST_CONCEPT(readable, int);
TEST_CONCEPT(readable,
unique_ptr&lt;int&gt;);
TEST_CONCEPT2(
writable, unique_ptr&lt;int&gt;, int);
TEST_CONCEPT2(writable,
unique_ptr&lt;int&gt;,
double);
TEST_CONCEPT2(writable,
unique_ptr&lt;int&gt;,
int*);
TEST_CONCEPT(semiregular,
unique_ptr&lt;int&gt;);
TEST_CONCEPT(semiregular,
shared_ptr&lt;int&gt;);
TEST_CONCEPT(equality_comparable,
unique_ptr&lt;int&gt;);
TEST_CONCEPT(semiregular,
arma::imat);
TEST_CONCEPT2(assignable_from,
arma::imat&amp;,
arma::imat&amp;);
TEST_CONCEPT(semiregular,
arma::imat22);
TEST_CONCEPT2(assignable_from,
arma::imat22&amp;,
arma::imat22&amp;);
}
```
代码照顾了两种可能的环境:
- 最新的 MSVC需要使用 `/std:c++latest`;我用的是 Visual Studio 2019 16.4.4
- GCC需要使用 `-fconcepts`;我测试了 7、8、9 三个版本都可以)和 CMCSTL2需要将其 include 目录用 `-I` 选项加到命令行上)
程序在 MSVC 下的结果如下所示:
>
<p>`__cpp_concepts is 201811`<br>
`regular&lt;int&gt;: true`<br>
`regular&lt;char&gt;: true`<br>
`integral&lt;int&gt;: true`<br>
`integral&lt;char&gt;: true`<br>
`readable&lt;int&gt;: false`<br>
`readable&lt;unique_ptr&lt;int&gt;&gt;: true`<br>
`writable&lt;unique_ptr&lt;int&gt;, int&gt;: true`<br>
`writable&lt;unique_ptr&lt;int&gt;, double&gt;: true`<br>
`writable&lt;unique_ptr&lt;int&gt;, int*&gt;: false`<br>
`semiregular&lt;unique_ptr&lt;int&gt;&gt;: false`<br>
`semiregular&lt;shared_ptr&lt;int&gt;&gt;: true`<br>
`equality_comparable&lt;unique_ptr&lt;int&gt;&gt;: true`<br>
`semiregular&lt;arma::imat&gt;: true`<br>
`assignable_from&lt;arma::imat&amp;, arma::imat&amp;&gt;: true`<br>
`semiregular&lt;arma::imat22&gt;: false`<br>
`assignable_from&lt;arma::imat22&amp;, arma::imat22&amp;&gt;: false`</p>
除了第一行 `__cpp_concepts` 的输出GCC 的结果也是完全一致的。大部分的结果应当没有意外,但也需要注意,某些用起来没问题的类(如 `arma::imat22`),却因为一些实现上的特殊技术,不能满足 `semiregular`。——概念要比鸭子类型更为严格。
## 概念、出错信息和 SFINAE
显然,对于上面出现的这个例子:
```
template &lt;Integer N&gt;
N half(N n)
{
return n / 2;
}
```
我们用 `enable_if` 也是能写出来的:
```
template &lt;typename N&gt;
enable_if_t&lt;Integer&lt;N&gt;, N&gt;
half(N n)
{
return n / 2;
}
```
不过,你不会觉得这种方式更好吧?而且,对于没有返回值的情况,要用对 `enable_if` 还是非常麻烦的(参见 [8] 里的 Notes /注解部分)。
更重要的是,“概念”可以提供更为友好可读的代码,以及潜在更为友好的出错信息。拿 Andrew Sutton 的一个例子 [9](根据我们上节说的编译环境做了改编):
```
#include &lt;string&gt;
#include &lt;vector&gt;
using namespace std;
#if defined(__cpp_concepts)
#if __cpp_concepts &lt; 201811
#include &lt;experimental/ranges/concepts&gt;
using namespace experimental::ranges;
#else
#include &lt;concepts&gt;
#include &lt;ranges&gt;
using namespace ranges;
#endif
#define REQUIRES(x) requires x
#else // defined(__cpp_concepts)
#define REQUIRES(x)
#endif
template &lt;typename R, typename T&gt;
REQUIRES(
(range&lt;R&gt; &amp;&amp;
equality_comparable_with&lt;
T, typename R::value_type&gt;))
bool in(R const&amp; r, T const&amp; value)
{
for (auto const&amp; x : r)
if (x == value)
return true;
return false;
}
int main()
{
vector&lt;string&gt; v{"Hello",
"World"};
in(v, "Hello");
in(v, 0);
}
```
以 GCC 8 为例,如果不使用概念约束,`in(v, 0)` 这行会产生 166 行出错信息;而启用了概念约束后,出错信息缩减到了 8 行。MSVC 上对于这个例子不使用概念错误信息也较短,但启用了概念后仍然能产生更短、更明确的出错信息:
>
<p>`test.cpp(47): error C2672: 'in': no matching overloaded function found`<br>
`test.cpp(47): error C7602: 'in': the associated constraints are not satisfied`<br>
`test.cpp(34): note: see declaration of 'in'`</p>
随着编译器的改进,概念在出错信息上的优势在消减,但在代码表达上的优势仍然是实实在在的。记得[[第 14 讲]](https://time.geekbang.org/column/article/181636) 里我们费了好大的劲、用了几种不同的方法来定义 `has_reserve` 吗?在概念面前,那些就成了“回”字有几种写法了。我们可以飞快地定义下面的概念:
```
template &lt;typename T&gt;
concept has_reserve =
requires(T&amp; dest) {
dest.reserve(1U);
};
```
这个概念用在编译期条件语句里,效果和之前的完全相同……哦,错了,不用再写 `::value``{}` 了😂。
在[[第 13 讲]](https://time.geekbang.org/column/article/181608) 我给出过的 `fmap`,在实际代码中我也是用了 SFINAE 来进行约束的(略简化):
```
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)
-&gt; decltype(
begin(inputs),
end(inputs),
OutContainer&lt;decay_t&lt;
decltype(f(*begin(
inputs)))&gt;&gt;());
```
我费了老大的劲,要把返回值写出来,实际上就是为了利用 SFINAE 而已。如果使用“概念”,那代码可以简化成:
```
template &lt;
template &lt;typename, typename&gt;
class OutContainer = vector,
typename F, class R&gt;
requires requires(R&amp;&amp; r) {
begin(r);
end(r);
}
auto fmap(F&amp;&amp; f, R&amp;&amp; inputs);
```
上面的 `requires requires` 不是错误,正如 `noexcept(noexcept(…))` 不是错误一样。第一个 `requires` 开始一个 **requires 子句**,后面跟一个常量表达式,结果的真假表示是否满足了模板的约束条件。第二个 `requires` 则开始了一个 **requires 表达式**:如果类型 `R` 满足约束——可以使用 `begin``end``R&amp;&amp;` 类型的变量进行调用——则返回真,否则返回假。
不过,在 C++20 里,上面这个条件我是不需要这么写出来的。有一个现成的概念可用,这么写就行了:
```
template &lt;
template &lt;typename, typename&gt;
class OutContainer = vector,
typename F, class R&gt;
requires range&lt;R&gt;
auto fmap(F&amp;&amp; f, R&amp;&amp; inputs);
```
如你所见,我今天第二次用了 `range` 这个概念。究竟什么是 range我们留到下一讲再说。
## 内容小结
今天我们讨论了 C++20 里可以说是最重要的新功能——概念。概念可以用来对模板参数进行约束,能取代 SFINAE产生更好、更可读的代码。
注意本讲的内容并非一个形式化的描述,请你在阅读了本讲的内容之后,再对照参考资料 [6] 的内容看一下更严格的描述,然后再回过头来读一下例子,来加深你对本讲内容的理解。
## 课后思考
请结合自己的 C++ 项目,考虑一下,“概念”可以为开发具体带来哪些好处?反过来,负面的影响又可能会是什么?
## 参考资料
[1] Bjarne Stroustrup, “Concepts: the future of generic programming, or how to design good concepts and use them well”. [http://www.stroustrup.com/good_concepts.pdf](http://www.stroustrup.com/good_concepts.pdf)
[2] Alexander Stepanov and Paul McJones, **Elements of Programming**. Addison-Wesley, 2009. 有中文版裘宗燕译《编程原本》人民邮电出版社2019 年)
[3] ISO/IEC JTC1 SC22 WG21, N4549, “Programming languages — C++ extensions for concepts”. [http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2015/n4549.pdf](http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2015/n4549.pdf)
[4] Herb Sutter et al., “Rename concepts to standard_case for C++20, while we still can”. [http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p1754r1.pdf](http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p1754r1.pdf)
[5] cppreference.com, “Standard library header &lt;concepts&gt;”. [https://en.cppreference.com/w/cpp/header/concepts](https://en.cppreference.com/w/cpp/header/concepts).
[5a] cppreference.com, “标准库头文件 &lt;concepts&gt;”. [https://zh.cppreference.com/w/cpp/header/concepts](https://zh.cppreference.com/w/cpp/header/concepts).
[6] Casey Carter et al., cmcstl2. [https://github.com/CaseyCarter/cmcstl2](https://github.com/CaseyCarter/cmcstl2)
[7] cppreference.com, “Constraints and concepts”. [https://en.cppreference.com/w/cpp/language/constraints](https://en.cppreference.com/w/cpp/language/constraints)
[7a] cppreference.com, “约束与概念”. [https://zh.cppreference.com/w/cpp/language/constraints](https://zh.cppreference.com/w/cpp/language/constraints)
[8] cppreference.com, “std::enable_if”. [https://en.cppreference.com/w/cpp/types/enable_if](https://en.cppreference.com/w/cpp/types/enable_if)
[8a] cppreference.com, “std::enable_if”. [https://zh.cppreference.com/w/cpp/types/enable_if](https://zh.cppreference.com/w/cpp/types/enable_if)
[9] Andrew Sutton, “Introducing concepts”. [https://accu.org/index.php/journals/2157](https://accu.org/index.php/journals/2157)

View File

@@ -0,0 +1,494 @@
<audio id="audio" title="29 | Ranges无迭代器的迭代和更方便的组合" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/79/15/797ba11446ee824f9da51c53914f8715.mp3"></audio>
你好,我是吴咏炜。
今天,我们继续上一讲开始的话题,讨论 ranges范围
## Ranges 简介
像下面这样的代码:
```
#include &lt;algorithm&gt;
#include &lt;iostream&gt;
#include &lt;iterator&gt;
int main()
{
using namespace std;
int a[] = {1, 7, 3, 6,
5, 2, 4, 8};
copy(begin(a), end(a),
ostream_iterator&lt;int&gt;(
std::cout, " "));
std::cout &lt;&lt; std::endl;
sort(begin(a), end(a));
copy(begin(a), end(a),
ostream_iterator&lt;int&gt;(
std::cout, " "));
std::cout &lt;&lt; std::endl;
}
```
你应该已经见到过好多次了。有没有觉得这个代码有点重复、有点无聊呢?尤其是里面的 `begin``end`
很多人都留意到了迭代器虽然灵活,但不是一个足够高级的抽象——尤其是我们已经对 C 数组都可以进行基于“范围”的循环之后。如果我们把数组看作一个抽象的“范围”,我们就可以得到下面的代码:
```
#include &lt;experimental/ranges/algorithm&gt;
#include &lt;experimental/ranges/iterator&gt;
#include &lt;iostream&gt;
int main()
{
using namespace std::
experimental::ranges;
int a[] = {1, 7, 3, 6,
5, 2, 4, 8};
copy(a, ostream_iterator&lt;int&gt;(
std::cout, " "));
std::cout &lt;&lt; std::endl;
sort(a);
copy(a, ostream_iterator&lt;int&gt;(
std::cout, " "));
std::cout &lt;&lt; std::endl;
}
```
这是真正可以编译的代码,用我们上一讲讲过的环境——最新版的 MSVC编译命令行上需要额外加 `/permissive-` 选项)或 GCC 7+——都可以。不过,这一次即使最新版的 MSVC 也不能靠编译器本身支持 ranges 库的所有特性了:在两种环境下我们都必须使用 CMCSTL2 [1],也只能(在 C++20 之前临时)使用 `std::experimental::ranges` 而不是 `std::ranges`。注意我只引入了 `ranges` 名空间,而没有引入 `std` 名空间,这是因为 `copy``sort` 等名称同时出现在了这两个名空间里,同时引入两个名空间会在使用 `sort` 等名字时导致冲突。
这个程序的输出,当然是毫不意外的:
>
<p>`1 7 3 6 5 2 4 8`<br>
`1 2 3 4 5 6 7 8`</p>
下面我们看“视图”。比如下面的代码展示了一个反转的视图:
```
#include &lt;experimental/ranges/algorithm&gt;
#include &lt;experimental/ranges/iterator&gt;
#include &lt;experimental/ranges/ranges&gt;
#include &lt;iostream&gt;
int main()
{
using namespace std::
experimental::ranges;
int a[] = {1, 7, 3, 6,
5, 2, 4, 8};
copy(a, ostream_iterator&lt;int&gt;(
std::cout, " "));
std::cout &lt;&lt; std::endl;
auto r = reverse_view(a);
copy(r, ostream_iterator&lt;int&gt;(
std::cout, " "));
std::cout &lt;&lt; std::endl;
}
```
这个程序的输出是:
>
<p>`1 7 3 6 5 2 4 8`<br>
`8 4 2 5 6 3 7 1`</p>
为什么 `r` 是视图,而不是反向复制出的内容?我们可以在输出 `r` 之前15行之后16行之前插入下面这行
```
a[0] = 9;
```
我们可以看到最后那行输出变成了:
>
`8 4 2 5 6 3 7 9`
这就证明了,`r` 没有复制 `a` 的内容。
视图的大小也不一定跟原先的“范围”一样。下面是我们在[[第 17 讲]](https://time.geekbang.org/column/article/185189) 讨论过的过滤视图在 ranges 里的实现的用法:
```
auto r =
filter_view(a, [](int i) {
return i % 2 == 0;
});
```
拿这个来替换上面用到 `reverse_view` 的那行,我们就能得到:
>
`6 2 4 8`
这些视图还能进行组合:我们可以写 `reverse_view(filter_view(…))`。不过,在组合的情况下,下面这样的写法(使用 `|` 和视图适配器)可能更清晰些:
```
auto r = a |
views::filter([](int i) {
return i % 2 == 0;
}) |
views::reverse;
```
这个程序的执行结果是:
>
`8 4 2 6`
如果你用过 Unix 的管道符,你一定会觉得这种写法非常自然、容易组合吧……
## 范围相关的概念
整个 ranges 库是基于概念来定义的。下面这张图展示了 range 相关的概念:
<img src="https://static001.geekbang.org/resource/image/e5/f9/e5a943a0f87d8c796fe3c78dabf524f9.png" alt="">
从图的右下角,我们可以看到上一讲讨论过的几个概念,包括 copyable 和 semiregular。再往上我们看到了 view——视图——也看到了视图是一个 range。现在我们就先来看一下 range 和 view 的定义。
在 CMCSTL2 里range 是这样定义的:
```
template &lt;class T&gt;
concept _RangeImpl =
requires(T&amp;&amp; t) {
begin(static_cast&lt;T&amp;&amp;&gt;(t));
end(static_cast&lt;T&amp;&amp;&gt;(t));
};
template&lt;class T&gt;
concept range = _RangeImpl&lt;T&amp;&gt;;
```
换句话说,一个 range 允许执行 `begin``end` 操作(注意这是在 `ranges` 名空间下的 `begin``end`,和 `std` 下的有些小区别)。所以,一个数组,一个容器,通常也能当作一个 range。
我们已经提到了视图,我们接下来就看一下 view 的定义:
```
template &lt;class T&gt;
concept view =
range&lt;T&gt; &amp;&amp;
semiregular&lt;T&gt; &amp;&amp;
enable_view&lt;__uncvref&lt;T&gt;&gt;;
```
可以看到view 首先是一个 range其次它是 semiregular也就是可以被移动和复制对 range 没有这个要求)。然后 `enable_view` 是个实现提供的概念,它的实际要求就是,视图应该不是一个容器,可以在 O(1) 复杂度完成拷贝或移动操作。我们常用的 `string` 满足 range不满足 view`string_view` 则同时满足 range 和 view。
下面,我们看 common_range它的意思是这是个普通的 range对其应用 `begin()``end()`,结果是同一类型:
```
template &lt;class T&gt;
concept common_range =
range&lt;T&gt; &amp;&amp;
same_as&lt;iterator_t&lt;T&gt;,
sentinel_t&lt;T&gt;&gt;;
```
然后sized_range 的意思就是这个 range 是有大小的,可以取出其大小(注意我们刚才的 `filter_view` 就是没有大小的):
```
template &lt;class T&gt;
concept sized_range =
range&lt;T&gt; &amp;&amp;
requires(T&amp; r) { size(r); };
```
自然output_range 的意思是这个 range 的迭代器满足输出迭代器的条件:
```
template &lt;class R, class T&gt;
concept output_range =
range&lt;R&gt; &amp;&amp;
output_iterator&lt;iterator_t&lt;R&gt;, T&gt;;
```
当然input_range 的意思是这个 range 的迭代器满足输入迭代器的条件:
```
template &lt;class T&gt;
concept input_range =
range&lt;T&gt; &amp;&amp;
input_iterator&lt;iterator_t&lt;T&gt;&gt;;
```
再往上的这些概念,我想我就不用再啰嗦了……
### Sentinel
我估计其他概念你理解起来应该问题不大,但 common_range 也许会让有些人迷糊:什么样的 range 会**不**是 common_range 呢?
答案是,有些 range 的结束点,不是固定的位置,而是某个条件:如遇到 0或者某个谓词满足了 10 次之后……从 C++17 开始,基于范围的 for 循环也接受 `begin``end` 的结果不是同一类型了——我们把前者返回的结果类型叫 iterator迭代器而把后者返回的结果类型叫 sentinel标记
下面展示了一个实际的例子:
```
#include &lt;experimental/ranges/algorithm&gt;
#include &lt;experimental/ranges/iterator&gt;
#include &lt;iostream&gt;
using namespace std::experimental::
ranges;
struct null_sentinel {};
template &lt;input_iterator I&gt;
bool operator==(I i, null_sentinel)
{
return *i == 0;
}
template &lt;input_iterator I&gt;
bool operator==(null_sentinel, I i)
{
return *i == 0;
}
template &lt;input_iterator I&gt;
bool operator!=(I i, null_sentinel)
{
return *i != 0;
}
template &lt;input_iterator I&gt;
bool operator!=(null_sentinel, I i)
{
return *i != 0;
}
int main(int argc, char* argv[])
{
if (argc != 2) {
std::cout &lt;&lt; "Please provide "
"an argument!"
&lt;&lt; std::endl;
return 1;
}
for_each(argv[1], null_sentinel(),
[](char ch) {
std::cout &lt;&lt; ch;
});
std::cout &lt;&lt; std::endl;
}
```
在这个程序里,`null_sentinel` 就是一个“空值标记”。这个类型存在的唯一意义,就是允许 `==``!=` 根据重载规则做一些特殊的事情:在这里,就是判断当前迭代器指向的位置是否为 0。上面程序的执行结果是把命令行上传入的第一个参数输出到终端上。
## 概念测试
我们现在对概念来做一下检查,看看常用的一些容器和视图满足哪些 ranges 里的概念。
<img src="https://static001.geekbang.org/resource/image/36/5b/3628cbde0fa893b5d9df888db085c65b.png" alt="">
这张表里没有什么意外的东西。除了 view`vector&lt;int&gt;` 满足所有的 range 概念。另外,`const vector&lt;int&gt;` 不能满足 output_range不能往里写内容也一切正常。
<img src="https://static001.geekbang.org/resource/image/93/a5/930d8b0e7d11be467eed5e12b98f0aa5.png" alt="">
这张表,同样表达了我们已知的事实:`list` 不满足 random_access_range 和 contiguous_range。
<img src="https://static001.geekbang.org/resource/image/1a/f4/1a575d6630dcbf2efdb5d41d229577f4.png" alt="">
这张表,说明了从 range 的角度C 数组和 `vector` 是没啥区别的。
<img src="https://static001.geekbang.org/resource/image/64/e7/64c011b79225c8c4b37353ec374321e7.png" alt="">
这张就有点意思了,展示了反转视图的特点。我们可以看到它几乎和原始容器可满足的概念一样,就多了 view少了 contiguous_range。应该没有让你感到意外的内容吧。
<img src="https://static001.geekbang.org/resource/image/84/71/8447ab67eefb08e389a8fabfcbeca371.png" alt="">
但过滤视图就不一样了:我们不能预知元素的数量,所以它不能满足 sized_range。
<img src="https://static001.geekbang.org/resource/image/0f/94/0f7b3cededc2309d97e146e5cd566294.png" alt="">
我们前面说过istream_line_reader 的迭代器是输入迭代器,所以它也只能是个 input_range。我们在设计上对 `begin()``end` 的返回值采用了相同的类型,因此它仍是个 common_range。用 take_view 可以取一个范围的前若干项,它就不是一个 commom_range 了。因为输入可能在到达预定项数之前结束,所以它也不是 sized_range。
<img src="https://static001.geekbang.org/resource/image/9e/08/9e77c25703ecfb51783ebbf604930708.png" alt="">
我们再来介绍一个新的视图,`iota_view`。它代表一个从某个数开始的递增序列。单参数的 `iota_view` 是无穷序列,双参数的是有限序列,从它们能满足的概念上就能看出来。这儿比较有趣的事实是,虽然 `iota_view(0, 5)``iota_view(0) | take(5)` 的结果相同,都是序列 {0, 1, 2, 3, 4},但编译器看起来,前者比后者要多满足两个概念。这应该也不难理解。
## 抽象和性能
说了这么多,你可能还是有点好奇,那 ranges 的用途是什么呢?为了少写 `begin()``end()`?为了方便函数式编程?
当然,上面的说法都对,但最基本的目的,还是为了抽象和表达能力。我们可以看一眼下面的 Python 代码:
```
reduce(lambda x, y: x + y,
map(lambda x: x * x, range(1, 101)))
```
你应该不难看出,这个表达式做的是 $1^2+2^2+3^2+\dots+100^2$。C++ 里我们该怎么做呢?
当然,手工循环是可以的:
```
auto square = [](int x) {
return x * x;
};
int sum = 0;
for (int i = 1; i &lt; 101; ++i) {
sum += square(i);
}
```
比起 Python 的代码来,似乎上面这个写法有点啰嗦?我们试试使用 ranges
```
int sum = nvwa::reduce(
std::plus&lt;int&gt;(),
views::iota(1, 101) |
views::transform(
[](int x) { return x * x; }));
```
我不知道你喜不喜欢上面这个表达方式,但它至少能在单个表达式里完成同样的功能。唯一遗憾的是,标准算法 `accumulate``reduce` 在上面不可用(没有针对 ranges 的改造),我只好拿我的非标 `reduce` [2] 来凑凑数了。
同样重要的是,上面的代码性能很高……多高呢?看下面这行汇编输出的代码就知道了:
```
movl $338350, -4(%rbp)
```
## ranges 名空间
我们现在再来看一下 ranges 名空间(我们目前代码里的 `std::experimental::ranges`C++20 的 `std::ranges`)。这个名空间有 ranges 特有的内容:
- 视图(如 `reverse_view`)和视图适配器(如 `views::reverse`
- ranges 相关的概念(如 `range``view` 等)
但也有些名称是从 `std` 名空间“复制”过来的,包括:
- 标准算法(如 `copy``transform``sort``all_of``for_each` 等;但是,如前面所说,没有 `accumulate``reduce`
- `begin``end`
`std::copy` 接受的是迭代器,而 `ranges::copy` 接受的是范围,似乎还有点道理。那 `begin``end` 呢?本来接受的参数就是一个范围啊……
Eric NieblerRanges TS 的作者)引入 `ranges::begin` 的目的是解决下面的代码可能产生的问题(他的例子 [3]
```
extern std::vector&lt;int&gt; get_data();
auto it = std::begin(get_data());
int i = *it; // BOOM
```
注意在读取 `*it` 的时候,`get_data()` 返回的 `vector` 已经被销毁了——所以这个读取操作是未定义行为undefined behavior
Eric Niebler 和 Casey CarterCMCSTL2 的主要作者)使用了一个特殊的技巧,把 `begin``end` 实现成了有特殊约束的函数对象,使得下面这样的代码无法通过编译:
```
extern std::vector&lt;int&gt; get_data();
auto it = ranges::begin(get_data());
int i = *it; // BOOM
```
如果你对此有兴趣的话,可以看一下 CMCSTL2 里的 include/stl2/detail/range/access.hpp。
对一般的用户而言,记住 `ranges::begin``ranges::end` 是将来 `std::begin``std::end` 的更好的替代品就行了。
## 一点历史
对于标准算法里的迭代器的问题早就有人看到了,并且有不少人提出了改进的方案。最早在 2003 年Boost.Range 就已经出现但影响似乎不大。Andrei Alexandresu 在 2009 年发了一篇很有影响力的文章“Iterators must go” [4],讨论迭代器的问题,及他在 D 语言里实现 ranges 的经验,但在 C++ 界没有开花结果。Eric Niebler 在 2013 年开始了 range-v3 [5] 的工作,这才是目前的 ranges 的基础。他把 ranges 写成了一个标准提案 [6],并在 2017 年被 ISO 出版成为正式的 Ranges TS。2018 年末好消息传来C++ 委员会通过了决议Ranges 正式被并入了 C++20 的草案!
<img src="https://static001.geekbang.org/resource/image/04/b4/04dfc0486f87f25871c5fc873d631eb4.png" alt="" title="图片背景来自网络">
谁说程序员都是无趣的?这篇内容申请把 Ranges 并入 C++ 标准草案的纯技术文档 The One Ranges Proposal [7],开头绝对是激情四射啊。
## 批评和未来
如果我只说好的方面、问题一点不说,对于学习道路上的你,也不是件好事。最有名的对 C++ Ranges 的批评,就是 Unity 开发者 Aras Pranckevičius 发表的一篇文章 [8]。我不完全认同文中的观点,但我觉得读一下反面的意见也很重要。
此外C++20 里的 ranges 不是一个概念的终点。即便在 range-v3 库里,也有很多东西仍然没有进入 C++ 标准。比如,看一眼下面的代码:
```
#include &lt;iostream&gt;
#include &lt;string&gt;
#include &lt;vector&gt;
#include &lt;range/v3/all.hpp&gt;
int main()
{
std::vector&lt;int&gt; vd{1, 7, 3, 6,
5, 2, 4, 8};
std::vector&lt;std::string&gt; vs{
"one", "seven", "three",
"six", "five", "two",
"four", "eight"};
auto v =
ranges::views::zip(vd, vs);
ranges::sort(v);
for (auto i : vs) {
std::cout &lt;&lt; i &lt;&lt; std::endl;
}
}
```
上面的代码展示了标准 ranges 中还没有的 zip 视图并且zip 视图的结果还可以被排序,结果将使得原始的两个 `vector` 都重新排序。上述程序的运行结果是:
>
<p>`one`<br>
`two`<br>
`three`<br>
`four`<br>
`five`<br>
`six`<br>
`seven`<br>
`eight`</p>
这个非标的 range-v3 库的另外一个好处是,它不依赖于概念的支持,因而可以用在更多的环境中,包括目前还不支持概念的 Clang。
如果你希望自己尝试一下这个代码的话,需要在命令行上使用 `-I` 选项来包含 range-v3 的 include 目录,此外 MSVC 还需要几个特殊选项:
>
`cl /EHsc /std:c++latest /permissive- /experimental:preprocessor …`
## 内容小结
本讲讨论了 C++20 的又一重要特性 ranges。虽然这一特性比起 concepts 来争议要多,但无疑它展示了 C++ 语言的一些新的可能性,并可以产生非常紧凑的高性能代码。
## 课后思考
你怎么看待 ranges 和对它的批评?你会想用 ranges 吗?欢迎留言与我交流。
## 参考资料
[1] Casey Carter et al., cmcstl2. [https://github.com/CaseyCarter/cmcstl2](https://github.com/CaseyCarter/cmcstl2)
[2] 吴咏炜, nvwa/functional.h. [https://github.com/adah1972/nvwa/blob/master/nvwa/functional.h](https://github.com/adah1972/nvwa/blob/master/nvwa/functional.h)
[3] Eric Niebler, “Standard ranges”. [http://ericniebler.com/2018/12/05/standard-ranges/](http://ericniebler.com/2018/12/05/standard-ranges/)
[4] Andrei Alexandrescu, “Iterators must go”, [http://accu.org/content/conf2009/AndreiAlexandrescu_iterators-must-go.pdf](http://accu.org/content/conf2009/AndreiAlexandrescu_iterators-must-go.pdf)
[5] Eric Niebler, range-v3. [https://github.com/ericniebler/range-v3](https://github.com/ericniebler/range-v3)
[6] Eric Niebler and Casey Carter, “Working draft, C++ extensions for ranges”. [http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2015/n4560.pdf](http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2015/n4560.pdf)
[7] Eric Niebler, Casey Carter, and Christopher Di Bella, “The one ranges proposal”. [http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2018/p0896r4.pdf](http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2018/p0896r4.pdf)
[8] Aras Pranckevičius, “Modern C++ lamentations”. [https://aras-p.info/blog/2018/12/28/Modern-C-Lamentations/](https://aras-p.info/blog/2018/12/28/Modern-C-Lamentations/) CSDN 的翻译见 [https://blog.csdn.net/csdnnews/article/details/86386281](https://blog.csdn.net/csdnnews/article/details/86386281)

View File

@@ -0,0 +1,617 @@
<audio id="audio" title="30 | Coroutines协作式的交叉调度执行" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b2/ee/b233b80aa52a63b9b45756145ea910ee.mp3"></audio>
你好,我是吴咏炜。
今天是我们未来篇的最后一讲,也是这个专栏正文内容的最后一篇了。我们讨论 C++20 里的又一个非常重要的新功能——协程 Coroutines。
## 什么是协程?
协程是一个很早就被提出的编程概念。根据高德纳的描述,协程的概念在 1958 年就被提出了。不过,它在主流编程语言中得到的支持不那么好,因而你很可能对它并不熟悉吧。
如果查阅维基百科,你可以看到下面这样的定义 [1]
>
协程是计算机程序的⼀类组件,推⼴了协作式多任务的⼦程序,允许执⾏被挂起与被恢复。相对⼦例程⽽⾔,协程更为⼀般和灵活……
等学完了这一讲,也许你可以明白这段话的意思。但对不了解协程的人来说,估计只能吐槽一句了,这是什么鬼?
<img src="https://static001.geekbang.org/resource/image/4d/f9/4d4fb4a1c16edb1087d934cd1bb7eef9.png" alt="" title="图片源自网络">
很遗憾,在 C++ 里的标准协程有点小复杂。我们还是从……Python 开始。
```
def fibonacci():
a = 0
b = 1
while True:
yield b
a, b = b, a + b
```
即使你没学过 Python上面这个生成斐波那契数列的代码应该也不难理解。唯一看起来让人会觉得有点奇怪的应该就是那个 `yield` 了。这种写法在 Python 里叫做“生成器”generator返回的是一个可迭代的对象每次迭代就能得到一个 yield 出来的结果。这就是一种很常见的协程形式了。
如何使用这个生成器,请看下面的代码:
```
# 打印头 20 项
for i in islice(fibonacci(), 20):
print(i)
# 打印小于 10000 的数列项
for i in takewhile(
lambda x: x &lt; 10000,
fibonacci()):
print(i)
```
这些代码很容易理解:`islice` 相当于[[第 29 讲]](https://time.geekbang.org/column/article/195553) 中的 `take`,取一个范围的头若干项;`takewhile` 则在范围中逐项取出内容,直到第一个参数的条件不能被满足。两个函数的结果都可以被看作是 C++ 中的视图。
我们唯一需要提的是,在代码的执行过程中,`fibonacci` 和它的调用代码是交叉执行的。下面我们用代码行加注释的方式标一下:
```
a = 0 # fibonacci()
b = 0 # fibonacci()
yield b # fibonacci()
print(i) # 调用者
a, b = 1, 0 + 1 # fibonacci()
yield b # fibonacci()
print(i) # 调用者
a, b = 1, 1 + 1 # fibonacci()
yield b # fibonacci()
print(i) # 调用者
a, b = 2, 1 + 2 # fibonacci()
yield b # fibonacci()
print(i) # 调用者
```
学到这儿的同学应该都知道我们在 C++ 里怎么完成类似的功能吧?我就不讲解了,直接给出可工作的代码。这是对应的 `fibonacci` 的定义:
```
#include &lt;iterator&gt;
#include &lt;stddef.h&gt;
#include &lt;stdint.h&gt;
class fibonacci {
public:
class sentinel;
class iterator;
iterator begin() noexcept;
sentinel end() noexcept;
};
class fibonacci::sentinel {};
class fibonacci::iterator {
public:
// Required to satisfy iterator
// concept
typedef ptrdiff_t difference_type;
typedef uint64_t value_type;
typedef const uint64_t* pointer;
typedef const uint64_t&amp; reference;
typedef std::input_iterator_tag
iterator_category;
value_type operator*() const
{
return b_;
}
pointer operator-&gt;() const
{
return &amp;b_;
}
iterator&amp; operator++()
{
auto tmp = a_;
a_ = b_;
b_ += tmp;
return *this;
}
iterator operator++(int)
{
auto tmp = *this;
++*this;
return tmp;
}
bool
operator==(const sentinel&amp;) const
{
return false;
}
bool
operator!=(const sentinel&amp;) const
{
return true;
}
private:
uint64_t a_{0};
uint64_t b_{1};
};
// sentinel needs to be
// equality_comparable_with iterator
bool operator==(
const fibonacci::sentinel&amp; lhs,
const fibonacci::iterator&amp; rhs)
{
return rhs == lhs;
}
bool operator!=(
const fibonacci::sentinel&amp; lhs,
const fibonacci::iterator&amp; rhs)
{
return rhs != lhs;
}
inline fibonacci::iterator
fibonacci::begin() noexcept
{
return iterator();
}
inline fibonacci::sentinel
fibonacci::end() noexcept
{
return sentinel();
}
```
调用代码跟 Python 的相似:
```
// 打印头 20 项
for (auto i :
fibonacci() | take(20)) {
cout &lt;&lt; i &lt;&lt; endl;
}
// 打印小于 10000 的数列项
for (auto i :
fibonacci() |
take_while([](uint64_t x) {
return x &lt; 10000;
})) {
cout &lt;&lt; i &lt;&lt; endl;
}
```
这似乎还行。但 `fibonacci` 的定义差异就大了:在 Python 里是 6 行有效代码,在 C++ 里是 53 行。C++ 的生产率似乎有点低啊……
## C++20 协程
C++20 协程的基础是微软提出的 Coroutines TS可查看工作草案 [2]),它在 2019 年 7 月被批准加入到 C++20 草案中。目前MSVC 和 Clang 已经支持协程。不过,需要提一下的是,目前被标准化的只是协程的底层语言支持,而不是上层的高级封装;稍后,我们会回到这个话题。
协程可以有很多不同的用途,下面列举了几种常见情况:
- 生成器
- 异步 I/O
- 惰性求值
- 事件驱动应用
这一讲中,我们主要还是沿用生成器的例子,向你展示协程的基本用法。异步 I/O 应当在协程得到广泛采用之后,成为最能有明显收益的使用场景;但目前,就我看到的,只有 Windows 平台上有较好的支持——微软目前还是做了很多努力的。
回到 Coroutines。我们今天采用 Coroutines TS 中的写法,包括 `std::experimental` 名空间,以确保你可以在 MSVC 和 Clang 下编译代码。首先,我们看一下协程相关的新关键字,有下面三个:
- `co_await`
- `co_yield`
- `co_return`
这三个关键字最初是没有 `co_` 前缀的,但考虑到 `await``yield` 已经在很多代码里出现,就改成了目前这个样子。同时,`return``co_return` 也作出了明确的区分:一个协程里只能使用 `co_return`,不能使用 `return`。这三个关键字只要有一个出现在函数中,这个函数就是一个协程了——从外部则看不出来,没有用其他语言常用的 `async` 关键字来标记(`async` 也已经有其他用途了,见[[第 19 讲]](https://time.geekbang.org/column/article/186689)。C++ 认为一个函数是否是一个协程是一个实现细节,不是对外接口的一部分。
我们看一下用协程实现的 `fibonacci` 长什么样子:
```
uint64_resumable fibonacci()
{
uint64_t a = 0;
uint64_t b = 1;
while (true) {
co_yield b;
auto tmp = a;
a = b;
b += tmp;
}
}
```
这个形式跟 Python 的非常相似了吧,也非常简洁。我们稍后再讨论 `uint64_resumable` 的定义,先看一下调用代码的样子:
```
auto res = fibonacci();
while (res.resume()) {
auto i = res.get();
if (i &gt;= 10000) {
break;
}
cout &lt;&lt; i &lt;&lt; endl;
}
```
这个代码也非常简单,但我们需要留意 `resume``get` 两个函数调用——这就是我们的 `uint64_resumable` 类型需要提供的接口了。
### co_await、co_yield、co_return 和协程控制
在讨论该如何定义 `uint64_resumable` 之前,我们需要先讨论一下协程的这三个新关键字。
首先是 `co_await`。对于下面这样一个表达式:
```
auto result = co_await 表达式;
```
编译器会把它理解为:
```
auto&amp;&amp; __a = 表达式;
if (!__a.await_ready()) {
__a.await_suspend(协程句柄);
// 挂起/恢复点
}
auto result = __a.await_resume();
```
也就是说,“表达式”需要支持 `await_ready``await_suspend``await_resume` 三个接口。如果 `await_ready()` 返回真,就代表不需要真正挂起,直接返回后面的结果就可以;否则,执行 `await_suspend` 之后即挂起协程,等待协程被唤醒之后再返回 `await_resume()` 的结果。这样一个表达式被称作是个 awaitable。
标准里定义了两个 awaitable如下所示
```
struct suspend_always {
bool await_ready() const noexcept
{
return false;
}
void await_suspend(
coroutine_handle&lt;&gt;)
const noexcept {}
void await_resume()
const noexcept {}
};
struct suspend_never {
bool await_ready() const noexcept
{
return true;
}
void await_suspend(
coroutine_handle&lt;&gt;)
const noexcept {}
void await_resume()
const noexcept {}
};
```
也就是说,`suspend_always` 永远告诉调用者需要挂起,而 `suspend_never` 则永远告诉调用者不需要挂起。两者的 `await_suspend``await_resume` 都是平凡实现,不做任何实际的事情。一个 awaitable 可以自行实现这些接口,以定制挂起之前和恢复之后需要执行的操作。
上面的 `coroutine_handle` 是 C++ 标准库提供的类模板。这个类是用户代码跟系统协程调度真正交互的地方,有下面这些成员函数我们等会就会用到:
- `destroy`:销毁协程
- `done`:判断协程是否已经执行完成
- `resume`:让协程恢复执行
- `promise`:获得协程相关的 promise 对象(和[[第 19 讲]](https://time.geekbang.org/column/article/186689) 中的“承诺量”有点相似,是协程和调用者的主要交互对象;一般类型名称为 `promise_type`
- `from_promise`(静态):通过 promise 对象的引用来生成一个协程句柄
协程的执行过程大致是这个样子的:
1. 为协程调用分配一个协程帧含协程调用的参数、变量、状态、promise 对象等所需的空间。
1. 调用 `promise.get_return_object()`,返回值会在协程第一次挂起时返回给协程的调用者。
1. 执行 `co_await promise.initial_suspsend()`;根据上面对 `co_await` 语义的描述,协程可能在此第一次挂起(但也可能此时不挂起,在后面的协程体执行过程中挂起)。
1. 执行协程体中的语句,中间可能有挂起和恢复;如果期间发生异常没有在协程体中处理,则调用 `promise.unhandled_exception()`
1. 当协程执行到底,或者执行到 `co_return` 语句时,会根据是否有非 void 的返回值,调用 `promise.return_value(…)``promise.return_void()`,然后执行 `co_await promise.final_suspsend()`
用代码可以大致表示如下:
```
frame = operator new(…);
promise_type&amp; promise =
frame-&gt;promise;
// 在初次挂起时返回给调用者
auto return_value =
promise.get_return_object();
co_await promise
.initial_suspsend();
try {
执行协程体;
可能被 co_wait、co_yield 挂起;
恢复后继续执行,直到 co_return;
}
catch (...) {
promise.unhandled_exception();
}
final_suspend:
co_await promise.final_suspsend();
```
上面描述了 `co_await``co_return`,那 `co_yield` 呢?也很简单,`co_yield 表达式` 等价于:
```
co_await promise.yield_value(表达式);
```
### 定义 `uint64_resumable`
了解了上述知识之后,我们就可以展示一下 `uint64_resumable` 的定义了:
```
class uint64_resumable {
public:
struct promise_type {…};
using coro_handle =
coroutine_handle&lt;promise_type&gt;;
explicit uint64_resumable(
coro_handle handle)
: handle_(handle)
{
}
~uint64_resumable()
{
handle_.destroy();
}
uint64_resumable(
const uint64_resumable&amp;) =
delete;
uint64_resumable(
uint64_resumable&amp;&amp;) = default;
bool resume();
uint64_t get();
private:
coro_handle handle_;
};
```
这个代码相当简单,我们的结构内部有个 `promise_type`(下面会定义),而私有成员只有一个协程句柄。协程构造需要一个协程句柄,析构时将使用协程句柄来销毁协程;为简单起见,我们允许结构被移动,但不可复制(以免重复调用 `handle_.destroy()`)。除此之外,我们这个结构只提供了调用者需要的 `resume``get` 成员函数,分别定义如下:
```
bool uint64_resumable::resume()
{
if (!handle_.done()) {
handle_.resume();
}
return !handle_.done();
}
uint64_t uint64_resumable::get()
{
return handle_.promise().value_;
}
```
也就是说,`resume` 会判断协程是否已经结束,没结束就恢复协程的执行;当协程再次挂起时(调用者恢复执行),返回协程是否仍在执行中的状态。而 `get` 简单地返回存储在 promise 对象中的数值。
现在我们需要看一下 promise 类型了,它里面有很多协程的定制点,可以修改协程的行为:
```
struct promise_type {
uint64_t value_;
using coro_handle =
coroutine_handle&lt;promise_type&gt;;
auto get_return_object()
{
return uint64_resumable{
coro_handle::from_promise(
*this)};
}
constexpr auto initial_suspend()
{
return suspend_always();
}
constexpr auto final_suspend()
{
return suspend_always();
}
auto yield_value(uint64_t value)
{
value_ = value;
return suspend_always();
}
void return_void() {}
void unhandled_exception()
{
std::terminate();
}
};
```
简单解说一下:
- 结构里面只有一个数据成员 `value_`,存放供 `uint64_resumable::get` 取用的数值。
- `get_return_object` 是第一个定制点。我们前面提到过,调用协程的返回值就是 `get_return_object()` 的结果。我们这儿就是使用 promise 对象来构造一个 `uint64_resumable`
- `initial_suspend` 是第二个定制点。我们此处返回 `suspend_always()`,即协程立即挂起,调用者马上得到 `get_return_object()` 的结果。
- `final_suspend` 是第三个定制点。我们此处返回 `suspend_always()`,即使执行到了 `co_return` 语句,协程仍处于挂起状态。如果我们返回 `suspend_never()` 的话,那一旦执行了 `co_return` 或执行到协程结束,协程就会被销毁,连同已初始化的本地变量和 promise并释放协程帧内存。
- `yield_value` 是第四个定制点。我们这儿仅对 `value_` 进行赋值,然后让协程挂起(执行控制回到调用者)。
- `return_void` 是第五个定制点。我们的代码永不返回,这儿无事可做。
- `unhandled_exception` 是第六个定制点。我们这儿也不应该发生任何异常,所以我们简单地调用 `terminate` 来终结程序的执行。
好了,这样,我们就完成了协程相关的所有定义。有没有觉得轻松点?
没有那就对了。正如我在这一节开头说的C++20 标准化的只是协程的底层语言支持(我上面还并不是一个非常完整的描述)。要用这些底层直接写应用代码,那是非常痛苦的事。这些接口的目标用户实际上也不是普通开发者,而是库的作者。
幸好,我们并不是没有任何高层抽象,虽然这些实现不“标准”。
## C++20 协程的高层抽象
### cppcoro
我们首先看一下跨平台的 cppcoro 库 [3],它提供的高层接口就包含了 `generator`。如果使用 cppcoro我们的 `fibonacci` 协程可以这样实现:
```
#include &lt;cppcoro/generator.hpp&gt;
using cppcoro::generator;
generator&lt;uint64_t&gt; fibonacci()
{
uint64_t a = 0;
uint64_t b = 1;
while (true) {
co_yield b;
auto tmp = a;
a = b;
b += tmp;
}
}
```
使用 `fibonacci` 也比刚才的代码要方便:
```
for (auto i : fibonacci()) {
if (i &gt;= 10000) {
break;
}
cout &lt;&lt; i &lt;&lt; endl;
}
```
除了生成器cppcoro 还支持异步任务和异步 I/O——遗憾的是异步 I/O 目前只有 Windows 平台上有,还没人实现 Linux 或 macOS 上的支持。
### MSVC
作为协程的先行者和 Coroutines TS 的提出者,微软在协程上做了很多工作。生成器当然也在其中:
```
#include &lt;experimental/generator&gt;
using std::experimental::generator;
generator&lt;uint64_t&gt; fibonacci()
{
uint64_t a = 0;
uint64_t b = 1;
while (true) {
co_yield b;
auto tmp = a;
a = b;
b += tmp;
}
}
```
微软还有一些有趣的私有扩展。比如MSVC 把标准 C++ 的 `future` 改造成了 awaitable。下面的代码在 MSVC 下可以编译通过,简单地展示了基本用法:
```
future&lt;int&gt; compute_value()
{
int result = co_await async([] {
this_thread::sleep_for(1s);
return 42;
});
co_return result;
}
int main()
{
auto value = compute_value();
cout &lt;&lt; value.get() &lt;&lt; endl;
}
```
代码中有一个地方我需要提醒一下:虽然上面 `async` 返回的是 `future&lt;int&gt;`,但 `compute_value` 的调用者得到的并不是这个 `future`——它得到的是另外一个独立的 `future`,并最终由 `co_return` 把结果数值填充了进去。
## 有栈协程和无栈协程
我们最后需要说一下有栈stackful协程和无栈stackless协程的区别。C++ 里很早就有了有栈的协程概念上来讲有栈的协程跟纤程、goroutines 基本是一个概念,都是由用户自行调度的、操作系统之外的运行单元。每个这样的运行单元都有自己独立的栈空间,缺点当然就是栈的空间占用和切换栈的开销了。而无栈的协程自己没有独立的栈空间,每个协程只需要一个很小的栈帧,空间占用小,也没有栈的切换开销。
C++20 的协程是无栈的。部分原因是有栈的协程可以使用纯库方式实现,而无栈的协程需要一点编译器魔法帮忙。毕竟,协程里面的变量都是要放到堆上而不是栈上的。
一个简单的无栈协程调用的内存布局如下图所示:
<img src="https://static001.geekbang.org/resource/image/e3/66/e35d2b262c741acf40d69eedc6a5ad66.png" alt="">
可以看到,协程 C 本身的本地变量不占用栈,但当它调用其他函数时,它会使用线程原先的栈空间。在上面的函数 D 的执行过程中,协程是不可以挂起的——如果控制回到 B 继续B 可能会使用目前已经被 D 使用的栈空间!
因此,无栈的协程牺牲了一定的灵活性,换来了空间的节省和性能。有栈的协程你可能起几千个就占用不少内存空间,而无栈的协程可以轻轻松松起到亿级——毕竟,维持基本状态的开销我实测下来只有一百字节左右。
反过来,如果无栈的协程不满足需要——比如,你的协程里需要有递归调用,并在深层挂起——你就不得不寻找一个有栈的协程的解决方案。目前已经有一些成熟的方案,比如 Boost.Coroutine2 [4]。下面的代码展示如何在 Boost.Coroutine2 里实现 `fibonacci`,让你感受一点点小区别:
```
#include &lt;iostream&gt;
#include &lt;stdint.h&gt;
#include &lt;boost/coroutine2/all.hpp&gt;
typedef boost::coroutines2::
coroutine&lt;const uint64_t&gt;
coro_t;
void fibonacci(
coro_t::push_type&amp; yield)
{
uint64_t a = 0;
uint64_t b = 1;
while (true) {
yield(b);
auto tmp = a;
a = b;
b += tmp;
}
}
int main()
{
for (auto i : coro_t::pull_type(
boost::coroutines2::
fixedsize_stack(),
fibonacci)) {
if (i &gt;= 10000) {
break;
}
std::cout &lt;&lt; i &lt;&lt; std::endl;
}
}
```
## 编译器支持
前面提到了MSVC 和 Clang 目前支持协程。不过,它们都需要特殊的命令行选项来开启协程支持:
- MSVC 需要 `/await` 命令行选项
- Clang 需要 `-fcoroutines-ts` 命令行选项
为了满足使用 CMake 的同学的要求,也为了方便大家编译,我把示例代码放到了 GitHub 上:[https://github.com/adah1972/geek_time_cpp](https://github.com/adah1972/geek_time_cpp)
## 内容小结
本讲讨论了 C++20 里的第三个重要特性:协程。协程仍然很新,但它的重要性是毋庸置疑的——尤其在生成器和异步 I/O 上。
## 课后思考
请仔细比较第一个 `fibonacci` 的 C++ 实现和最后使用 `generator``fibonacci` 的实现,体会协程代码如果自行用状态机的方式来实现,是一件多麻烦的事情。
如果你对协程有兴趣,可以查看参考资料 [5],里面提供了一些较为深入的原理介绍。
## 参考资料
[1] 维基百科, “协程”. [https://zh.wikipedia.org/zh-cn/协程](https://zh.wikipedia.org/zh-cn/%E5%8D%8F%E7%A8%8B)
[2] Gor Nishanov, “Working draft, C++ extensions for coroutines”. [http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2018/n4775.pdf](http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2018/n4775.pdf)
[3] Lewis Baker, CppCoro. [https://github.com/lewissbaker/cppcoro](https://github.com/lewissbaker/cppcoro)
[4] Oliver Kowalke, Boost.Coroutine2. [https://www.boost.org/doc/libs/release/libs/coroutine2/doc/html/index.html](https://www.boost.org/doc/libs/release/libs/coroutine2/doc/html/index.html)
[5] Dawid Pilarski, “Coroutines introduction”. [https://blog.panicsoftware.com/coroutines-introduction/](https://blog.panicsoftware.com/coroutines-introduction/)

View File

@@ -0,0 +1,31 @@
你好,我是吴咏炜。
今年 2 月,我们这门 C++ 课程结课。5个月后我给你带来了一个全新的课程Vim 实用技巧必知必会。
在咱 C++ 课程里,我分享过 Larry Wall拉里 · 沃尔所说的程序员的三大美德懒惰急切傲慢laziness, impatience, hubris。正是这些美德驱动我们不断地追求效率和极致而 Vim 就恰恰是这样一个效率利器。我想通过这门新课,和你一起享受 Vim 带来的技术乐趣。
那么 Vim 真的这么厉害吗它到底好在哪里呢根据我自己的使用经验除了高效之外Vim 还有以下三大优势:
- Vim 是一个完全跨平台的编辑器
- Vim 是一个高度可定制、可扩展的编辑器
- Vim 有着良好的生态环境
不过比起很多“开箱即用”的编辑器Vim 是有一定的学习曲线的。这一点你不用担心,我会尽量多讲原理,而不是枯燥地讲解命令。不得不讲命令的时候,我会使用图片和动画,让你能对相关内容有一个直观的理解。
对于很多 Vim 的命令,我们是需要形成“肌肉记忆”的;我们不需要死记硬背,但需要多看、多练,熟能生巧,在学习过程中自然而然就掌握了使用 Vim 的技巧。
在这个课程中,我会基于目前最新的 Vim 8.2 来讲解 Vim 的功能。学完后,你将获得:
- 即学即用的 Vim 高频命令
- 定制自己专属的 Vim 环境
- 利用 Vim 脚本和插件实现常用功能
- Vim 高阶用法和技巧
如果你正在使用Vim期待和你在课程里探讨更多Vim的可能性。如果你还没有用过Vim希望这门课能为你打开一个全新的效率世界。
现在,课程已经上线了。我为你申请了老用户福利,一张 15 元专属优惠券,可与限时优惠叠加使用,到手仅需 ¥64建议尽早使用。
点击下方图片即可进入新课程试读期待与你在Vim课程里继续切磋
[<img src="https://static001.geekbang.org/resource/image/b9/d6/b90c9c03f317ec3f7e07fe82defbc9d6.jpg" alt="">](https://time.geekbang.org/column/intro/324?utm_term=zeusE5AJR&amp;utm_source=app&amp;utm_medium=geektime&amp;utm_campaign=diyiji&amp;utm_content=0720)

View File

@@ -0,0 +1,38 @@
<audio id="audio" title="结束语 | 终点已达,行程还要继续" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/96/ab/9651a20560c8fba096a4e9b75fa46dab.mp3"></audio>
你好,我是吴咏炜。
从去年八月底极客时间第一次找我到现在,一眨眼居然差不多半年了,时间过得也真是飞快。终于结束了这段旅程,感到轻松,感到欣然,也感到有丝丝遗憾。
轻松,是因为终于结束了被人“追债”的日子😝。记得最初我们的计划是一周拿出十几小时的时间来写专栏,但从实际结果来看,这更接近我写一篇文章所需要的时间。“后果”就是我的业余时间近乎全部搭进去了,即使我的家庭领导(你懂的),已经赦免了我的家务工作,但到了专栏更新后期,熬夜突击也在所难免。
欣然,是因为我发现写作这个过程不仅对他人有益,对我自己的知识体系也是一个极好的梳理。写作的原初目的当然是分享,看到有人能觉得我写的东西可以带来帮助,这就是一件很快乐的事。同时,写作也是写给自己,可以将知识形式化、系统化、具象化。在这个过程中,很容易发现自己的问题:我也修正了自己的一些错误的见解,部分靠自己的再学习,部分靠他人的反馈。你也应该试试这个方法,多输出、多分享,在交流中共同进步。
遗憾,是因为这个专栏我再努力,也只能覆盖 C++ 的部分重要特性。C++ 的功能要写全,恐怕得用上至少 100 讲如果要做到对初学者友好200 讲够不够都是个问题。但就目前的专栏定位来讲,也只能是这种以点带面的方式,引出一些最重要的 C++ 学习方向了……
综合来说,对于最后的成品,我还是满意的。它可以称得上我 2019-2020 的跨年心血之作——里面的内容,从文字到代码到插图,都凝结着我的辛勤和汗水。
虽然不能保证我讲述的内容完全没有错误,但我能担保内容中没有任何我已知的错误。虽然不能担保我提供的代码完全没有 bug但我至少可以保证我写代码和写正文是一样的正式和规范甚至更认真并且但凡略长的代码我都已经仔仔细细地测试过。事实上在查阅资料的过程中我还看到了一些 C++ 之父的代码错误——显然,由于这种或那种的原因,他没有测试他的所有代码——而即使 C++ 之父,也做不到直接写下的代码没有 bug😈。
虽然之前已经说过,但在结束之前,我还是要再重复强调一下,如果学习专栏时遇到问题,该怎么办。
由于定位和篇幅,你不可能通过这个专栏从无到有地学会 C++,也不可能通过这个专栏从了解 C++ 变成精通 C++——后者恐怕也没有专栏能做得到。我能做到的,是给你一些指引,给你一些方向,给你分享我在这个方向上的心得和你可以进一步学习的资料。正如我在 [[开篇词]](https://time.geekbang.org/column/article/169177) 里说的,我会教你的,是让你知道某个 C++ 的功能为什么存在和应该在什么情况下使用——这是目前外面的参考资料中比较缺的。真正的熟练掌握,靠的还是你自己。“师父领进门,修行在自身。”前人不我欺也。
此外,如果你觉得有没完全看明白的地方,你应该反复看,或者过几天再重新看。根据目前的评论我发现,读漏我正文内容的同学还是有一些的——这应该是读第一遍没完全吃透的结果。这个问题不怨你,也不怨我,因为这是学习过程中的自然现象。如果一本教科书,有人读一遍之后可以全部掌握其中的内容,那要么是内容太简单了,要么他是个天才。对于大部分不是天才的我们,需要认识到这是个正常的学习过程,无需担忧和焦虑。我们需要的只是努力前行——旅程就是一个脚步一个脚步地走下来的。
最后的最后,感谢你们,我的读者!不管是你们的鼓励,还是你们的批评,都是我前进的动力,激励我把这个专栏写得更好。即便是你们提的问题,也让我明白了哪些地方我没有阐释得足够透彻。希望如果能有下次机会,我能写得更完美一点,来回报你们对我的信任和对 C++ 的热爱。
不知道为什么,写 C++ 的时候,我常常想起这首老歌:
>
<p>我来唱一首歌古老的那首歌<br>
我轻轻地唱你慢慢地和</p>
C++ 虽然有点古老了,但在新世纪,它仍然给人历久弥新的感觉。为了理想,历经艰苦的日子是必须的,但希望我们不必哭泣,希望我们都可以欢笑着实现自己的梦想。
哦,对了,虽然 C++ 不是纯面向对象的语言,但在某些特殊的日子里,比如今天,我还是要赞美面向对象一下😂。纯代码的人生还是不完备的。所以,祝一句,情人节快乐。
我是吴咏炜,我们后会有期!
[<img src="https://static001.geekbang.org/resource/image/51/cd/51a6f73a5433b7347d68f924c9e3efcd.jpg" alt="">](https://jinshuju.net/f/RoxGGS)

View File

@@ -0,0 +1,14 @@
你好!
截至今天,本专栏的结课问卷就回收完毕了,十分感谢你的参与。现在我们来公布一下获奖用户名单。
在这里,我首先要感谢各位同学给我们的反馈,你们的声音可以促使我们精益求精。在这些反馈中,我们看到了很多非常有价值的信息,也收获了很多的支持与肯定。在此,我们精选出了反馈最为具体、丰富,最有实际价值的 5 位用户,送出“极客时间超大鼠标垫”,或者“价值 99 元的极客时间课程阅码”。中奖名单如下:
<img src="https://static001.geekbang.org/resource/image/9f/47/9f820af6e197f690454825c41d785847.jpg" alt="">
恭喜这 5 位同学,也再次感谢所有参与调研的同学。希望大家今后还能多多支持,给予宝贵意见。
最后,除了公布中奖名单以外,我还要和你同步一件事情。为了方便你编译,目前,本专栏可编译的完整示例代码已经放在 [GitHub](https://github.com/adah1972/geek_time_cpp) 上。如果你使用 CMake还可以用它来生成你使用的平台所需要的工程文件。
专栏的结束是另一种开始,后续我们会和吴咏炜老师继续迭代、优化专栏内容,你可以持续关注。如果你有任何问题,欢迎在留言区中提出。

View File

@@ -0,0 +1,111 @@
<audio id="audio" title="开篇词 | C++这么难为什么我们还要用C++" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b7/ad/b74aebc60774565d3993e1c42fbbe3ad.mp3"></audio>
你好,我是吴咏炜。
C++ 可算是一种声名在外的编程语言了。这个名声有好有坏从好的方面讲C++ 性能非常好,哪个编程语言性能好的话,总忍不住要跟 C++ 来单挑一下;从坏的方面讲,它是臭名昭著的复杂、难学、难用。当然,这样一来,熟练的 C++ 程序员也就自然而然获得了“水平很高”的名声,所以这也不完全是件坏事。
不管说 C++ 是好还是坏不可否认的是C++ 仍然是一门非常流行且非常具有活力的语言。继沉寂了十多年并终于发布语言标准的第二版——C++11——之后C++ 以每三年一版的频度发布着新的语言标准,每一版都在基本保留向后兼容性的同时,提供着改进和新功能。**本专栏主要就是讲这些新特性以及相关的编程实践。**
在讲所有这些细节之前,我想先讲一讲为什么要用 C++,什么时候该用 C++,及如何学习 C++。
## C++ 的意义
C++ 程序员应该都听到过下面这种说法:
>
C++ 是一门多范式的通用编程语言。
多范式,是因为 C++ 支持面向过程编程也支持面向对象编程也支持泛型编程新版本还可以说是支持了函数式编程。同时上面这些不同的范式都可以在同一项目中组合使用这就大大增加了开发的灵活性。因此C++ 适用的领域非常广泛,小到嵌入式,大到分布式服务器,到处可以见到 C++ 的身影。
下面是一些著名的用到 C++ 的场合:
- 大型桌面应用程序(如 Adobe Photoshop、Google Chrome 和 Microsoft Office
- 大型网站后台(如 Google 的搜索引擎)
- 游戏(如 StarCraft和游戏引擎如 Unreal 和 Unity
- 编译器(如 LLVM/Clang 和 GCC
- 解释器(如 Java 虚拟机和 V8 JavaScript 引擎)
- 实时控制(如战斗机的飞行控制和火星车的自动驾驶系统)
- 视觉和智能引擎(如 OpenCV、TensorFlow
- 数据库(如 Microsoft SQL Server、MySQL 和 MongoDB
有些同学可能会觉得,这些应用场景似乎和平时的开发场景有点远啊!你的感觉是对的。有些传统上使用 C++ 的场合现在已经不一定使用 C++,最典型的是个人电脑上的桌面应用。以前 Windows 下开发桌面应用常常用 MFC微软的 C++ 框架,而现在我估计听说过 MFC 的程序员都不多吧。目前很流行的 Visual Studio Code 主要是用 TypeScript 写的,不是 C++。而我自己也用 C# 写过桌面应用,不过界面逻辑之外的计算和处理仍然是用一个 C++ 的 DLL 来完成。典型情况是,需要性能的组件用 C++ 来写,整个应用程序融合多种不同的语言。
前面我提到了, C++ 的传统领域有被侵蚀的风险,那是因为和它相竞争的语言远远不止一个,可以说是上下夹攻。
- 如果专注性能和最小内存占用的话C 仍然是首选——嵌入式领域用 C 非常多,而 Linux 也是用纯 C 写的。
- 如果专注抽象表达和可读性的话,那 Python 之类的脚本语言则要方便得多。
- 图形界面GUI编程传统上是 C++ 的地盘,但近年来 C# 和 JavaScript 占领了很大一部分市场。
- 游戏算是 C++ 的经典强项了,但有了 C++ 写的游戏引擎,游戏用 C# 写也没啥问题了——你可能不一定知道Unity 游戏引擎上的首选开发语言是 C#,而王者荣耀是用什么游戏引擎呢?答案正是 Unity——所以王者荣耀可以认为是用 C# 开发的。
- 还有Go 和 Rust 也加入了战团,对 C++ 形成了一定的竞争……
**看起来C++ 有点危险啊……**
不过,真是这样吗?我们需要回到 C++ 的核心竞争力上来看一下。
- 抽象能力:意味着较高的开发效率,同时,更重要的是,不会因抽象而降低性能。
- 性能:这不用多说了,就是快并且占用资源少。
- 功耗:这是近年来我们越来越关注的问题,跟性能直接相关,性能好了功耗自然就低。
计算机在发明的初期,价格奇高,而性能拿今天的标准来看却是极低的,自然不能不关注性能。慢慢地,计算机的性能“足够”了,性能似乎也就不那么重要了,脚本语言于是也有了用武之地。而随着移动设备的普遍使用,大量设备用电池供电而不接电源了,功耗就逐渐成了我们大家关注的大问题。因此,即使主流移动平台的开发语言不是 C++——而是 Java 和 Objective-C 或 Swift——但任何性能要求高的应用都几乎必然会用到 C++ 开发的组件。
同时,移动设备要联网,也大大刺激了服务器的增加。在服务器端,虽然没有电池电量的问题,但有着服务器集群的供电问题、空调问题、需要的服务器数量问题等,因而 C++ 的使用也是非常广泛的。
前面说到了王者荣耀的客户端是用 Unity + C# 开发的,但我没有说王者荣耀的服务器端——那可还是用 C++ 开发的。另外,有一点我前面还藏着呢!虽然王者荣耀初期是纯用 Unity 开发的,没有用到 C++;但后来,腾讯又用 C++ 把游戏的逻辑部分独立成了一个 GameCore进一步提高了性能 [1]。
目前,跟 C++ 定位差不多、能有直接竞争关系的,也就是既支持高度抽象、又追求高性能的通用编程语言,其实只有 Rust 一种。而 Rust 远没有达到跟 C++ 一样的成熟和普及程度。这也可以从 TIOBE 的排名看出来C++ 是第 4 位,而 Rust 是第 25 位 [2]。
另外,和 C 的兼容性,也是 C++ 的一大优势。虽然现在很多大型程序都混杂了多种语言,但在小项目里,减少语言的数量可以简化开发和部署。前不久,我在 Python 里做了一些加解密运算,发现使用的第三方库性能仍不够高,虽然它已经用了 C 开发的加解密引擎。所以,我找了用 C 写的高性能加解密代码,然后使用 pybind11 库 [3],只手写了一百来行的 C++11 代码,就把性能又提高了几倍。
## 什么时候该用 C++
如此说来C++ 既然性能又好,又支持抽象,为什么没有更流行呢?
因为代价更高。C++ 是一种复杂的语言难以上手和熟练掌握因此也是一种比较容易出错、被误用的语言。C++ 一直与 C 基本保持了向后兼容性,这种兼容性,也一直是 C++ 的安全性和易用性方面的负担。C++ 比起 C 来要更安全更不容易出现缓冲区溢出这类漏洞但跟没有指针概念的语言比起来它仍然是一种“不安全”的语言。我的个人经验完成同样的功能C++ 需要的代码行数一般是 Python 的三倍左右,而性能则可以达到 Python 的十倍以上。
**问题来了:你在开发上额外付出的时间,能从性能上省回来吗?**
显然,这取决于你开发软件的用途和开发时间。举个例子,如果你用 Python 开发需要一天,运行需要十秒,并且不需要反复运行;那么,转用 C++ 开发就意味着开发费用也许要增加两倍,开发加运行的总时间增加两天,大亏。
反之如果用Python开发还是需要一天单次运行需要十秒但是软件会作为服务长时间运行、每天被调用十万次。在这种情况下明显你就需要多台服务器来支撑其使用了。这时如果用 C++ 开发会需要额外的两天,但跟 Python 相比,部署上有望节约十分之九的硬件和电费——那就很值了。
简言之,当你的软件属于运算密集或者内存密集型,你需要性能、且愿意为性能付出额外代价的时候,应该考虑用 C++,特别在你的代码需要部署在多台服务器或者移动设备的场合。反之,如果性能不会成为你开发的软件的瓶颈,那 C++ 可能就不是一个最合适的工具。
此外,在嵌入式应用的场景,那就根本不是值不值、而是行不行的问题。如果程序完成一个功能不能在指定的若干毫秒、甚至微秒内完成,那产品根本是失败、不可用的。在这种场合,能和 C++ 竞争的只有 C但 C 是一种开发效率更低、更需要堆人力的语言了。在嵌入式开发使用 C++ 的最大障碍可能不是技术,而是人力资源——搞嵌入式开发的程序员可能大多都习惯使用纯 C 了。
由于 C++ 是解决性能问题的利器,短时间里在市场上没有真正的竞争对手,对 C++ 的需求会在相当长的时间里一直存在尤其在大公司和像金融机构一样对性能渴求的地方。顺便提一句C++ 之父 Bjarne Stroustrup 目前就职的地方便是摩根斯坦利。
## 如何学习 C++
作为很多聪明人使用过的语言C++ 在某些场合也可能被用来炫技,写出除了本人之外谁都看不懂的高抽象代码。这恰恰是 Bjarne 想努力抵制的方向。他想让 C++ 对初学者变得更为友好,也明确提出过,他不希望 C++ 是一种让人们耍机灵的语言,而是一种让人们更易于使用的语言 [4]。
这同样也是本专栏的一个目标:我希望你能把 C++ 当作一种实用的语言,能用它写出抽象但自然的代码,而非佶屈聱牙、难以卒读的那种。希望我 30 年的 C++ 经验能够给你一点帮助。
学习 C++ 语言就像学一门活跃使用中的外语,你不要期望能够掌握所有的单词和语法规则——那对于世界上 99.999999% 的人来说是不可能的。但语言是服务于人的,语法规则也是服务于人的,是为了让人们能够更好地沟通和表达。虽然 C++ 的每一个新标准都是让语言从定义和规则的角度变得更复杂,但从用法上来说,新标准允许人们能够更简单地表达自己的计算意图。跟学外语一样,我们需要的是多看多写,掌握合适的“语感”,而不是记住所有的规则。
**Bjarne 有一个洋葱理论:**抽象层次就像一个洋葱,是层层嵌套的。如果想用较低的抽象层次表达较高的概念,就好比一次切过了很多层洋葱,你会把自己的眼泪熏出来的。与这个思路相反,教 C++ 往往有一种不好的倾向,从那些琐碎易错的底层教起,自底向上,使得学生常常在尚未领悟到抽象的真谛之前就已经被 C++ 的复杂性吓翻,从入门到放弃;或者,在学了基本的 C 语法和 class 之后就满足了,错过了高级抽象带来的全新境界。他主张学习应当自顶向下,先学习高层的抽象,再层层剥茧、丝丝入扣地一步步进入下层。如果一次走太深的话,挫折可能就难免了。
作为专栏而非具体的工具参考书,我会重点讲是什么和为什么,而不是语法细节。同样,我也不讲或少讲技巧,但在需要的地方,我会给出合适的参考资料。希望你在学习了本专栏之后,能够知道某个 C++ 的功能为什么存在和应该在什么情况下使用。那样的话,本专栏的目的就达到了。
**具体内容上,本专栏共分为四个部分,详情如下:**
- 第一部分——基础篇,讲解现代 C++ 中的最重要特性,帮助你理解基础概念。
- 第二部分——提高篇,讲述几个独立的专题,帮助你掌握 C++ 中的一些高级技巧。
- 第三部分——实战篇,讨论实际的工具和第三方库,帮助你打磨手头的兵器库。
- 第四部分——未来篇,讨论 C++20 中即将引入的一些新特性,帮助你培养前瞻性。
<img src="https://static001.geekbang.org/resource/image/5b/53/5bdb83751b83699dd6f84239f1792053.jpg" alt="">
希望你在完整地学完这四个部分之后,能对现代 C++,这一熟悉而又陌生的语言,有着新的理解,并用它去更好地解决项目中的实际问题。
最后,感谢你的关注。关于 C++ 呢,不知道你有什么样的看法?你学习它的主要原因是什么?你平时使用 C++ 的场景有哪些?欢迎你在留言区和我交流。
## 参考资料
[1] 游戏葡萄“《王者荣耀》技术总监复盘回炉历程没跨过这三座大山就是另一款MOBA霸占市场了”。[https://gameinstitute.qq.com/community/detail/115782](https://gameinstitute.qq.com/community/detail/115782)
[2] TIOBE Index for November 2019. [https://www.tiobe.com/tiobe-index/](https://www.tiobe.com/tiobe-index/)
[3] Wenzel Jakob et al., pybind11. [https://github.com/pybind/pybind11](https://github.com/pybind/pybind11)
[4] Interview, “Whats all the C Plus Fuss? Bjarne Stroustrup warns of dangerous future plans for his C++”. [https://www.theregister.co.uk/2018/06/18/bjarne_stroustrup_c_plus_plus/](https://www.theregister.co.uk/2018/06/18/bjarne_stroustrup_c_plus_plus/)

View File

@@ -0,0 +1,67 @@
<audio id="audio" title="课前必读 | 有关术语发音及环境要求" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/99/de/99201c64feed5c532bccdfd09da05dde.mp3"></audio>
你好,我是吴咏炜。
这一讲是课前必读,主要说明术语发音和环境要求的问题,希望能帮助你更好地学习和实践本专栏的内容。
## 术语发音列表
C++ 里有些术语不是标准的英文单词。在本专栏中使用下面的发音表:
<img src="https://static001.geekbang.org/resource/image/bc/72/bc0ee5d92ae1d741c14b8fef6fb04d72.jpg" alt="">
注意这些发音并没有标准化,列出来只是为了避免发生误解。你不一定必须按照我的读法来。
## 环境要求
在专栏中,我大部分时间都会使用不超过 C++17 标准的语言特性。而要编译示例代码,需要以下所列的至少一种编译器:
- GCC 7 或更新版本
- Clang 5 或更新版本
- Visual Studio 2017 15.9 (MSVC 19.16) 或更新版本
以上三种编译器应当都能够工作——但我无法保证。如果遇到问题的话,请升级到跟我的测试环境相同的版本:
- GCC 8.3
- Clang 9.0
- Visual Studio 2019 16.3 (MSVC 19.23)
如果你对跨平台性不那么讲究的话,推荐使用你的操作系统里最常用的编译器。也就是:
- 用 Windows 的话,使用 MSVC
- 用 Linux 的话,使用 GCC
- 用 macOS 的话,使用 Clang但如果操作系统比 Mojave 老的话,苹果提供的开发工具不能完整支持 C++17你需要升级操作系统或者使用非苹果的编译器——如 Homebrew 提供的 gcc 和 llvm [1]
对在 Windows 上使用 GCC 的开发者,我要特别提醒一句:要获得最全面的功能,你应当使用 MinGW-w64 的 POSIX 线程版本,这样才能完整使用 C++ 标准里的功能。当前版本可从参考资料 [2] 的链接下载。
使用稳定发布版(如 CentOS的 Linux 用户也需要检查一下,你的 GCC 版本有可能比较老。如果早于 GCC 7 的话,建议你安装一个新版本的 GCC不需要覆盖系统的 GCC。比如对于 CentOS 7系统安装的 GCC 版本是 4.8,太老,你可以通过安装 centos-release-scl 和 devtoolset-7-gcc-c++ 两个包来获得 GCC 7随后可以使用命令 `scl enable devtoolset-7 bash``. /opt/rh/devtoolset-7/enable` 来启用 GCC 7。
稍需注意的是,最后在讲到 C++20 新特性时,某些实验功能可能会要求某个特定的编译器。这种情况下,你可能就需要安装、使用非默认的编译器了。不过,只有少数几讲需要这么做,不用担心。
由于专栏涉及到的都是较短的代码,我不会提供工程文件。建议你熟悉编译器的命令行,来快速编译代码。使用 GCC 的话,推荐使用下面的命令行:
`g++ -std=c++17 -W -Wall -Wfatal-errors 文件名`
Clang 的话也比较类似:
`clang++ -std=c++17 -W -Wall -Wfatal-errors 文件名`
MSVC 的命令行风格有点不同,一般需要下面这样子:
`cl /std:c++17 /EHsc /W3 文件名`
另外,即使不用较新的 C++ 特性,你也一定要用比较新的编译器。单单是输出错误信息的友好程度,老版本和新版本就是没法比的。
以 GCC 为例,老版本输出错误信息是单色的,在碰到有模板的代码时,错误信息动辄几百行,以致那时有人专门开发了软件来让错误信息更可读 [3]。幸运的是我们今天不再需要这类软件了编译器的输出比之前友好得多GCC 和 Clang 还会使用颜色来展示问题的重点。下面这张图,就是 GCC 9.2 的输出。
<img src="https://static001.geekbang.org/resource/image/13/23/13fbdc8077a5a330e45c5ccdc94c2923.png" alt="">
明确好以上内容,我们就要正式开始了,你准备好了吗?
## 参考资料
[1] Homebrew. [https://brew.sh/](https://brew.sh/)
[2] MinGW-w64 GCC-8.1.0. [https://sourceforge.net/projects/mingw-w64/files/Toolchains%20targetting%20Win32/Personal%20Builds/mingw-builds/8.1.0/threads-posix/dwarf/](https://sourceforge.net/projects/mingw-w64/files/Toolchains%20targetting%20Win32/Personal%20Builds/mingw-builds/8.1.0/threads-posix/dwarf/)
[3] Leor Zolman, “STLFilt: An STL error message decryptor for C++”. [https://www.bdsoft.com/tools/stlfilt.html](https://www.bdsoft.com/tools/stlfilt.html)