使用C++20 的协程创建通用的生成器

在上一篇中,我们通过拆解一个简单的例子描述了C++20 处理协程的过程。在此先简单的回顾一下各个部分的作用

回顾

  1. promise_type
    主要提供一些与协程相关的接口:
  • initial_suspend: 协程实例创建时执行,通过返回值来决定协程创建的同时执行还是等待
  • final_suspend: 协程实例执行完之后是暂停还是继续,继续意味着会销毁这个实例
  • yield_value/await_transform: 协程函数中执行 co_yield/co_await 时负责将后面的操作数转化为等待体awaiter 对象
  • get_return_object: 返回协程对象
  • return_void / return_value: 真正获取协程函数 co_return 的返回值
  • unhandled_exception 协程函数发生异常时调用
  1. 等待体是协程函数中调用 co_await/co_yield 时创建, 主要用来告诉编译器遇到这两个操作符时应该继续执行还是等待, 关于等待体 awaiter 它有如下几个接口:
  • bool await_ready() : 根据返回值决定是继续还是等待
  • await_suspend(coroutine_handle<>) : 协程被挂起时调用
  • await_resume() : 协程被恢复时调用

关于等待体,标准库有两个简单的实现: suspend_alwayssuspend_never

改进生成器

c++ 23 中存在一个标准的生成器 std::generator,我们利用这个生成器可以将整个程序修改为:

#include <coroutine>
#include <iostream>
#include <generator>
#include <ranges>

std::generator<int> fibonacci()
{
	int a = 0;
	int b = 1;
	for (;;)
	{
		co_yield a;
		int next = a + b;
		a = b;
		b = next;
	}
}

int main()
{
	for (auto x : fibonacci() | std::views::take(10))
	{
		std::cout << x << std::endl;
	}
	return 0;
}

本文的目标是最终完成一个简单的、可用的、贴近标准库的 std::generator,进一步理解协程的原理

我们还是按照上一篇的组织方式,先放出完整的源代码,然后依次说明重要的点:

#include <coroutine>
#include <iostream>
#include <generator>
#include <ranges>

template<typename T>
struct Generator
{
	struct promise_type
	{
		std::suspend_always initial_suspend()
		{
			return std::suspend_always{};
		}
		std::suspend_always final_suspend() noexcept
		{
			return std::suspend_always{};
		}

		void unhandled_exception() noexcept
		{
			_exp = std::current_exception();
		}

		Generator get_return_object()
		{
			return Generator(std::coroutine_handle<promise_type>::from_promise(*this));
		}

		void return_void()
		{}

		std::suspend_always yield_value(T value)
		{
			_value = std::move(value);
			return std::suspend_always{};
		}

		void rethrow_if_exception()
		{
			if (_exp)
				throw _exp;
		}
		T _value = {};
		std::exception_ptr _exp = nullptr;
	};

	Generator(std::coroutine_handle<promise_type> h) :
		_handle(h)
	{}

	~Generator()
	{
		if (_handle)
			_handle.destroy();
	}

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

	Generator(Generator&& other) noexcept :
		_handle(std::exchange(other._handle, nullptr))
	{

	}

	Generator& operator=(Generator&& other)
	{
		if (this != &other) {
			if (_handle)
				_handle.destroy(); //删除当前的协程,管理新协程
			_handle = std::exchange(other._handle, nullptr);
		}
		return *this;
	}
	bool is_done() noexcept
	{
		return !_handle || _handle.done();
	}

	T next()
	{
		if (is_done())
			throw std::runtime_error("Generator exhausted");

		_handle.resume();
		_handle.promise().rethrow_if_exception();
		return _handle.promise()._value;
	}

	std::coroutine_handle<promise_type> _handle;

	// 迭代器
	struct Iterator
	{
		Generator& _gen;
		bool _is_end;

		Iterator(Generator& gen, bool is_end = false) :
			_gen(gen),
			_is_end(is_end)
		{}

		void move_next()
		{
			if (!_gen.is_done())
			{
				_gen._handle.resume();
				_gen._handle.promise().rethrow_if_exception();
			}
			else
			{
				_is_end = true;
			}
		}

		T operator*() const
		{
			return _gen._handle.promise()._value;
		}

		Iterator& operator++()
		{
			move_next();
			return *this;
		}

		bool operator!=(const Iterator& other) const
		{
			return other._is_end != _is_end;
		}
	};

	Iterator begin()
	{
		Iterator it{ *this };
		it.move_next();
		return it;
	}

	Iterator end()
	{
		return Iterator{ *this, true };
	}
};

Generator<int> fibonacci()
{
	int a = 0;
	int b = 1;
	for (;;)
	{
		co_yield a;
		int next = a + b;
		a = b;
		b = next;
	}
}

int main()
{
	Generator<int> generator = fibonacci();
	for (auto x : fibonacci())
	{
		std::cout << x << std::endl;
	}

        // std::generator 示例
	//for (auto x : fibonacci() | std::views::take(10))
	//{
	//	std::cout << x << std::endl;
	//}
	return 0;
}

泛型与移动语义的优化

之前我们在 yield_value 函数中传入的参数是int类型,所以在函数中直接采用赋值运算符没有任何问题,但是考虑到泛化之后它可以生成任意类型的数据,在处理大结构的数据时,采用赋值运算将会产生多余的拷贝。因为函数参数中已经进行了拷贝,同时值传递不影响外部真实的数据,我们直接对参数执行移动操作可以节省一次拷贝。

异常处理的支持

上一篇,我们简单的将将 unhandled_exception 设置为空函数,这次我们在 promise_type 中保存了一个 std::exception_ptr 异常类型的指针用来捕获协程函数中的异常。

这个函数的实现也比较简单,我们说在协程函数发生异常时会调用 promise_type 结构中的 unhandled_exception 接口函数。这个函数中通过 _exp = std::current_exception(); 获取最新的异常信息并保存。

在获取下一次的数据的时候,我们直接判断当前是否保存了异常信息,如果有则直接抛出。外界在调用next等函数获取值的时候可以直接捕获

Generator 禁止拷贝、仅支持移动操作

协程句柄 coroutine_handle 独占一段堆上分配的协程栈与 promise 对象,资源唯一不可共享。所以理论上只能有一个对象管理整个协程。

如果允许拷贝,则会出现多个结构同时指向一个协程对象。这些结构在析构时多次调用destroy 造成重复销毁的问题。或者某个对象没有及时更新造成访问无效内存。

而移动语义是所有权的转移,在同一时间有且只有一个对象真正的控制协程、拥有 coroutine_handle 句柄。配合最后的析构释放操作,能防止内存泄漏

move_next 实现

这里我们在迭代器中单独又写了一些与 Generator::next 相同的代码。这里主要出于两点考虑:

  1. Generator 支持通过next 来获取下一个数据,但是该函数返回T类型的下一个数据。外界实际上没有途经判断生成器是否已经无法生成多余的数据。所以我通过抛出异常,外界可以通过异常来判断是否还能生成数据,当然这里也可以使用 std::optional 来判断数据是否合理。既然会抛出异常,那么迭代器中的 move_next 就无法直接调用,谁也不希望范围for最后是靠异常退出的。

  2. 迭代器需要一个end函数,end一般是返回一个越界的迭代器,在协程中我们实际上不需要构建一个额外的Generator 来判断是否越界。所以我在里面额外维护了一个 _is_end。利用该变量来判断迭代器是否越界了。

基于以上理由,我没有复用 Generator::next 的代码

另外在 Generator::begin 函数中,我们首先调用了一次 move_next ,然后才返回迭代器。这是因为我们的 initial_suspend 函数返回 suspend_always,在生成器对象被创建的时候协程还没有开始运行,此时生成器中的值是无效值。begin函数是用来获取它的起始值,此时值应该是有效的,所以提前调用一次 move_next 让它获取第一个值。

小结

当然标准库的 std::generator 实现比上述代码要复杂的多,特别是它支持 ranges 组件来设置范围,而我们的简单的生成器的退出完全依赖协程函数自身的退出条件,无法做到自主退出。

希望通过本节各位读者能对协程的原理有一个更深入的理解。也希望这个简陋的实现可以做到抛砖引玉的作用。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值