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,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/)