更多课程 选择中心

C/C++培训
达内IT学院

400-996-5531

从 C++ 算法到 C++ 协同程序

  • 发布:Kenny Kerr
  • 来源:微软中国MSDN
  • 时间:2017-11-17 15:51

名为 iota 的 C++ 标准库算法一直以来都让我好奇不已。它有着不寻常的名称和有意思的功能。iota 一词是希腊字母表中一个字母的名称。在英语中,它通常表示非常小的量,往往有否定意味,但并不表示最小量,派生自《马太新约全书注释》中的引文。

此释义(即非常小的量)说明了 iota 算法的功能。在存储初始值并在填充范围前递增初始值的过程中,此算法旨在用增幅很小的值来填充范围。类似如下输出:

#include <numeric>int main()

{

int range[10];

// Range: Random missile launch codes

std::iota(std::begin(range), std::end(range), 0);

// Range: { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }

}

人们常说,C++ 开发者应删除所有 for 裸循环,并将它们替换为算法。当然,iota 算法符合条件,因为它取代了任何 C 或 C++ 开发者无疑已编写数十万次的 for 循环。可以想象,C++ 标准库的实现代码如下:

namespace std

{

template <typename Iterator, typename Type>

void iota(Iterator first, Iterator last, Type value)

{

for (; first != last; ++first, ++value)

{

*first = value;

}

}

}

大家并不想被上述代码的代码评审所困住。库开发者当然除外。iota 算法带来了福音,无需再编写这样的 for 循环,这简直太棒了。但大家知道吗?我从未真正将它用于生产。情况通常是这样的:我需要一个值范围。

在计算机科学领域,这是一项非常基础的任务,一定有适用的标准算法。我又搜遍了 bit.ly/2i5WZRc 中的列表,找到了 iota。抱歉,它需要一个范围,才能用值进行填充。好吧,我能找到的费用最低范围是什么呢…随后,我将值打印出来,以确保我能够正确使用 for 循环:

#include <numeric>#include <stdio.h>int main()

{

int range[10];

std::iota(std::begin(range), std::end(range), 0);

for (int i : range)

{

printf("%d\n", i);

}

}

说实话,此代码唯一让我欢喜的地方是,基于范围的 for 循环。但问题是,我根本就不需要也不想要这个范围。我并不想创建某容器,只用于保留值以供迭代。如果我需要更多的值,该怎么办?我更愿意自己编写 for 循环:

#include <stdio.h>int main()

{

for (int i = 0; i != 10; ++i)

{

printf("%d\n", i);

}

}

更糟的是,这需要键入的内容少了很多。不过,如果有类似 iota 的函数,可以某种方式生成一个值范围,以供基于范围的 for 循环使用,而无需使用容器,这当然很好。我最近在翻阅有关 Python 语言的一本书,并注意到有一个名为 range 的内置函数。我可以使用 Python 编写同一程序,如下所示:

for i in range(0, 10):

print(i)

请谨慎使用缩进。这是 Python 语言用来表示复合语句的方式。书中提到,Python 是以某个英国喜剧命名,而不是以无毒蟒蛇命名。我认为作者并不是在开玩笑。我仍非常喜欢这个代码的简洁性。

我肯定可以在 C++ 中使用这些代码行实现某目标。这的确正是我希望 iota 算法能够做到的,但很可惜。实质上,我需要的是下面这样的 range 算法:

template <typename T>generator<T> range(T first, T last)

{

return{ ... };

}

int main()

{

for (int i : range(0, 10))

{

printf("%d\n", i);

}

}

据我所知,这样的函数并不存在,接下来将开始介绍如何生成它。第一步,使用可用作测试基线的可靠工具模拟算法。在这种情况下,C++ 标准矢量容器就派上用场了:

#include <vector>template <typename T>std::vector<T> range(T first, T last)

{

std::vector<T> values;

while (first != last)

{

values.push_back(first++);

}

return values;

}

它也很好地说明了为什么不需要先生成容器,甚至还能确定就此而言它应有的大小。为什么要有大小上限呢?这依然很有用,因为可以轻松比较此范围生成器的输出与更有效备选方案的输出。好吧,编写更有效的生成器其实并没有那么难。请看图 1。

图 1:经典生成器

template <typename T>struct generator

{

T first;

T last;

struct iterator{ ... };

iterator begin()

{

return{ first };

}

iterator end()

{

return{ last };

}

};

template <typename T>generator<T> range(T first, T last)

{

return{ first, last };

}

range 函数仅创建使用同一对边界值进行初始化的生成器。然后,生成器可以使用这些值,通过常规 begin 和 end 成员函数生成轻型迭代器。最麻烦的部分是,快速实现大部分样本迭代器。

迭代器只能保留给定值,并根据需要进行递增。它还必须提供一组类型别名,向标准算法描述自身。对于基于范围的简单 for 循环,这并不是必须的,但将这添加为未来适用的迭代器却值得一试:

template <typename T>struct generator

{ struct iterator

{

T value; using iterator_category = std::input_iterator_tag; using value_type = T; using difference_type = ptrdiff_t; using pointer = T const*; using reference = T const&;

递增迭代器只能递增基础值。可以安全地删除后递增窗体:

iterator& operator++()

{

++value;

return *this;

}

iterator operator++(int) = delete;

迭代器提供的另一同样重要的函数是比较函数。基于范围的 for 循环使用此函数确定是否已到达范围末尾:

bool operator==(iterator const& other) const

{

return value == other.value;

}

bool operator!=(iterator const& other) const

{

return !(*this == other);

}

最后,基于范围的 for 循环需要取消对迭代器的引用,从而返回范围中的当前值。我可以删除成员调用运算符,因为基于范围的 for 循环不需要此运算符,但这样也会无谓地限制其他算法使用的生成器的利用率:

T const& operator*() const

{

return value;

}

T const* operator->() const

{

return std::addressof(value);

}

生成器及关联的 range 函数可能与类似数值的对象(而不是简单的基元)结合使用。在这种情况下,建议还使用帮助程序地址,但前提是类似数值的对象欺骗了运算符和重载。这样便可以了。我的 range 函数现在可以如预期一样正常运行:

template <typename T>generator<T> range(T first, T last)

{

return{ first, last };

}

int main()

{

for (int i : range(0, 10))

{

printf("%d\n", i);

}

}

当然,这并不是特别灵活。我已经生成了理想的 iota,但它仍只是需要改用协同程序才能完成特定任务的 iota。可以看到,使用协同程序,能够更简洁地编写各类生成器,而无需为要生成的各类范围编写新的生成器类模板。

试想想,只需再编写一个生成器,然后就可以对与 range 类似的函数进行分类,从而按需生成不同的序列。此时,协同程序就派上用场了。可以将有关原始 iota 生成的知识直接融入 range 函数(无需融入生成器),并拥有一个生成器类模板,用于将生产者和使用者联结在一起。那我们开始吧。

首先,我将添加协同程序标头,用于提供 coroutine_handle 类模板的定义:

#include <experimental/coroutine>

我将使用 coroutine_handle,以便生成器可以与协同程序表示的状态机进行交互。这会根据需要进行查询和恢复,以便基于范围的 for 循环(或其他任何循环,就此而言)能够指示协同程序生成数据消耗的拉取模型(而非推送模型)的进度。

此生成器在某种程度上类似于图 1 中的经典生成器。明显的区别在于,此生成器仅向前微移协同程序,而不是直接更新值。图 2 是此生成器的概要。

图 2:协同程序生成器

template <typename T>struct generator

{ struct promise_type{ ... }; using handle_type = std::experimental::coroutine_handle<promise_type>;

handle_type handle{ nullptr }; struct iterator{ ... };

iterator begin()

{

...

handle.resume();

...

}

iterator end()

{ return nullptr;

}

};

所以,此时还需要再执行其他一些操作。不仅有支持基于范围的 for 循环从外部与生成器进行交互的迭代器,还有支持协同程序从内部与生成器进行交互的 promise_type。首先,需要执行一些保养工作:回顾一下,生成值的函数不会直接返回生成器,而是让开发者使用 co_yield 语句,通过生成器将协同程序中的值转发到调用站点。以最简单的生成器为例:

generator<int> one_two_three()

{

co_yield 1;

co_yield 2;

co_yield 3;

}

请注意,开发者从不显式创建协同程序返回类型。这是 C++ 编译器所起到的作用,因为它能够将此代码表示的状态机集合在一起。实质上,C++ 编译器会查找 promise_type,并用它来构造逻辑协同程序框架。不用担心,在某些情况下,当 C++ 编译器优化完代码后,协同程序框架可能会消失。

不管怎样,promise_type 之后用于初始化返回给调用方的生成器。有了 promise_type 后,我就可以获取表示协同程序的句柄,以便生成器能够从外部驱动它。代码如下:

generator(promise_type& promise) :

handle(handle_type::from_promise(promise))

{

}

当然,coroutine_handle 是一个级别相当低的构造函数,我并不想让依赖生成器的开发者不小心在主动协同程序内损坏状态机。解决方案就是,实现移动语义和禁止复制。代码如下(首先,我将为它设定一个默认构造函数,并明确删除特殊复制成员):

generator() = default;

generator(generator const&) = delete;

generator &operator=(generator const&) = delete;

然后,我只需传输协同程序的句柄值,让两个生成器从不指向同一个正在运行的协同程序,即可实现移动语义,如图 3 所示。

图 3:实现移动语义

generator(generator&& other) : handle(other.handle)

{

other.handle = nullptr;

}

generator &operator=(generator&& other)

{ if (this != &other)

{

handle = other.handle;

other.handle = nullptr;

} return *this;

}

现在,由于协同程序是从外部驱动,因此,请务必注意,生成器也有责任销毁协同程序:

~generator()

{ if (handle)

{

handle.destroy();

}

}

这其实与 promise_type 上 final_suspend 的结果更为相关,但我将留待日后讲解。记下来暂时就够了。现在,将开始探究生成器的 promise_type。可以在 promise_type 中轻松方便地驻留任何状态,这样就能在 C++ 编译器为协同程序框架创建的任何分配中添加它。生成器仅为轻型对象,可以轻松地四处移动,并在需要时重新引用相应状态。我真正需要从协同程序传递回调用方的信息只有两条。第一条是要生成的值,第二条是可能已抛出的任何异常:

#include <variant>template <typename T>struct generator

{

struct promise_type

{

std::variant<T const*, std::exception_ptr> value;

尽管可选,我往往仍会在 std::optional 内包装 exception_ptr 对象,因为在 Visual C++ 中实现 exception_ptr 的成本有点高。即使是空的 exception_ptr,也会在构造和销毁时调用 CRT。在 optional 内包装它,巧妙地免除了此类开销。更精确的状态模型是使用变量(如我刚才所述),保留当前值或 exception_ptr,因为它们互斥。当前值仅为指向在协同程序内生成的值的指针。

这样做很安全,因为协同程序在读取值时将会暂停,并且可以在协同程序外部观测值时,安全地保留可能生成的所有临时对象。

协同程序首次向调用方返回内容时,它会要求 promise_type 生成返回值。因为可以通过引用 promise_type 来构造生成器,所以此时我可以直接返回相应引用:

promise_type& get_return_object()

{

return *this;

}

生成生成器的协同程序并不是已启用并发的典型协同程序,通常只生成可听写协同程序的生存期和执行情况的生成器。为此,我指示 C++ 编译器,最初必须暂停协同程序,以便生成器能够控制协同程序的单步执行,比如说:

std::experimental::suspend_always initial_suspend()

{

return {};

}

同样,我指示在返回内容时立即暂停协同程序,而不是让协同程序自动销毁自身:

std::experimental::suspend_always final_suspend()

{

return {};

}

这样可确保在协同程序完成后,我仍可以通过在协同程序框架内分配的 promise_type,查询协同程序的状态。这一点非常重要,因为这样我就可以在失败时读取 exception_ptr,或只是获知协同程序已完成。

如果协同程序在完成时自动销毁自身,我便无法在最后一个暂停点执行调用以恢复协同程序后查询 coroutine_handle,更不用说查询 promise_type 了。捕获要生成的值现在相当简单:

std::experimental::suspend_always yield_value(T const& other)

{

value = std::addressof(other);

return {};

}

我只需再次使用便捷的帮助程序地址即可。promise_type 还必须提供 return_void 或 return_value 函数。尽管未用于此示例,但它暗示了 co_yield 其实只是 co_await 的抽象表达:

void return_void()

{

}

稍后将对此进行详细介绍。接下来,我将略微介绍一下如何防止误用,就是为了让开发者能够更轻松地发现问题。可以看到,生成值的生成器暗示了,只有在协同程序完成时,才能读取值。如果协同程序要包含 co_await 表达式,可以想象,它可能会暂停,而不显示任何值,并且无法将此信息传达给调用方。

为此,我建议开发者不要编写 co_await 语句,如下所示:

template <typename Expression>Expression&& await_transform(Expression&& expression)

{

static_assert(sizeof(expression) == 0,

"co_await is not supported in coroutines of type generator");

return std::forward<Expression>(expression);

}

包装 promise_type 后,我只需负责捕获信息,如可能已抛出的任何异常。C++ 编译器将确保调用 promise_type 的 unhandled_exception 成员:

void unhandled_exception()

{

value = std::current_exception();

}

然后,为了便于实现,我可以提供一个便捷函数,用于在相应的上下文中选择性地重新抛出异常:

void rethrow_if_failed()

{

if (value.index() == 1)

{

std::rethrow_exception(std::get<1>(value));

}

}

有关 promise_type 的介绍已够多的了。现在,我有了能够正常运行的生成器,但我还将添加一个简单的迭代器,以便可以通过基于范围的 for 循环轻松地驱动它。与以往一样,迭代器将包含样本类型别名,用于向标准算法描述自身。不过,迭代器只依赖 coroutine_handle:

struct iterator

{

using iterator_category = std::input_iterator_tag;

using value_type = T;

using difference_type = ptrdiff_t;

using pointer = T const*;

using reference = T const&;

handle_type handle;

与较简单的 iota 迭代器相比,递增迭代器的操作稍微复杂一点,因为这是生成器与协同程序进行交互时的主要焦点。递增迭代器暗示了迭代器有效,并且其实可能会递增。由于“end”迭代器保留 ullptr 句柄,因此我可以直接提供迭代器比较,如下所示:

bool operator==(iterator const& other) const

{

return handle == other.handle;

}

bool operator!=(iterator const& other) const

{

return !(*this == other);

}

假设迭代器有效,我首先会恢复协同程序,以便它能够执行和生成下一个值。然后,我需要检查此执行是否导致协同程序停止运行。如果是,传播可能已在协同程序内抛出的任何异常:

iterator &operator++()

{

handle.resume();

if (handle.done())

{

promise_type& promise = handle.promise();

handle = nullptr;

promise.rethrow_if_failed();

}

return *this;

}

iterator operator++(int) = delete;

否则,迭代器将被视为已到达末尾,句柄会被直接清除,以便它能够成功地与 end 迭代器进行比较。抛出任何未被捕获的异常前,需要小心清除协同程序句柄,以防任何人在最后一个暂停点不小心恢复协同程序,因为这会导致未定义的行为。

生成器的 begin 成员函数执行大致相同的逻辑,以确保在到达第一个暂停点前能够一致地传播已抛出的任何异常:

iterator begin()

{

if (!handle)

{

return nullptr;

}

handle.resume();

if (handle.done())

{

handle.promise().rethrow_if_failed();

return nullptr;

}

return handle;

}

主要区别在于,begin 是生成器的成员,拥有协同程序句柄,因此不得清除协同程序句柄。最后,简单来说,我只需返回对 promise_type 内存储的当前值的引用,即可实现迭代器取消引用:

T const& operator*() const

{

return *std::get<0>(handle.promise().value);

}

T const* operator->() const

{

return std::addressof(operator*());

}

大功告成了。我现在可以编写各式算法,并使用此通用的生成器生成各种已生成的序列。图 4 展示了可带来启迪的范围生成器。

图 4:可带来启迪的范围生成器

template <typename T>

generator<int> range(T first, T last)

{ while (first != last)

{

co_yield first++;

}

}int main()

{ for (int i : range(0, 10))

{

printf("%d\n", i);

}

}

到底谁需要有限制的范围?由于我现在拥有拉取模型,因此可以直接让调用方决定是否够用,如图 5 所示。

图 5:无限制的生成器

template <typename T>

generator<int> range(T first)

{ while (true)

{

co_yield first++;

}

}int main()

{ for (int i : range(0))

{

printf("%d\n", i); if (...)

{ break;

}

}

}

一切皆有可能!当然,还有更多关于生成器和协同程序的知识,我在这里只做了肤浅的研究。若要详细了解 C++ 协同程序,请关注我的后续文章。有关本文中的完整示例,可以访问 Compiler Explorer (#/g/NXHBZR)。

Kenny Kerr 是作者、系统程序员和 C++/WinRT 创建者。同时,他还是 Microsoft Windows 团队的一名工程师,负责规划 Windows 适用 C++ 的未来,让开发者可以超级轻松地编写高性能的精彩应用和组件。

衷心感谢以下技术专家对本文的审阅:Gor Nishanov

本文内容转载自网络,本着分享与传播的原则,版权归原作者所有,如有侵权请联系我们进行删除!

预约申请免费试听课

填写下面表单即可预约申请免费试听!怕钱不够?可就业挣钱后再付学费! 怕学不会?助教全程陪读,随时解惑!担心就业?一地学习,可全国推荐就业!

上一篇:C++11新特性- 范围for语句
下一篇:C++11新特性-容器的cbegin和cend函数

C语言创建windows窗口实例

C++回调函数是什么?

C++ shared_ptr和动态数组

C语言有哪些关键词,C语言44个关键词大全

Copyright © 2023 Tedu.cn All Rights Reserved 京ICP备08000853号-56 京公网安备 11010802029508号 达内时代科技集团有限公司 版权所有

选择城市和中心
黑龙江省

吉林省

河北省

湖南省

贵州省

云南省

广西省

海南省