(... because there's so many degrees of freedom that I feel disoriented!)
For the purpose of understanding coroutines, I've implemented a generator which, given a unary function f
of type T(T)
and initial value x
of type T
, yields the infinite sequence of applications of f
to x
, i.e. the range [x, f(x), f(f(x)), f(f(f(x))), ...]
, via a range interface. (In fact, I was inspired by Haskell's iterate
, hence the name I chose and the functional-programming tag.)
It wasn't that hard, as there's examples of generators everywhere, from cppreference to Josuttis' book.
However, then I started to play with it by moving things around, and I've realized that the degrees of freedom in implementing a couroutine are far more than an example like the one I mentioned needs. And since the functionality that the coroutine in my example provides is actually not a toy¹, I feel like even for production code it is a bit hard to decide how to "distribute work" across the various participants, namely the promise, the interface, the awaiters, and the body of the coroutine. Therefore I wanted to know if there some guidelines, or if C++23 will bring in a bit of clarity to this topic.
To give another hint as to why the whole matter confuses me, it's not clear to me how the "persons" that would write each of those participants would relate to each other. Would they all be the same person?² For instance, I don't see how the coroutine's interface's writer can be other than the same writer of the coroutine's promise.
Going to the concrete example, here is the version I originally came up with, live on Compiler Explorer. The main bits are these:
- The
main
shows how the coroutine is used,int main() { constexpr auto f = [](int i){ return i*2; }; constexpr auto x0 = 1; for (auto const& x : iterate(f, x0) | take(10)) { std::cout << x << std::endl; } // prints the first 10 powers of 2: [1,2,4,8,16,32,64,128,256,512] }
- the coroutine itself is fairly intuitive,
it accepts the functionIterCoro iterate(auto f, auto x) { while (true) { co_yield x; x = f(x); } }
f
and the initial valuex
, and loops infinitely,co_yield
ing the value to the caller and updating it afterwards; - for making the updated value available to the caller,
yield_value
stores it in its promise and accepts the suspension:struct promise_type { int val; constexpr auto yield_value(int i) noexcept { val = i; return std::suspend_always{}; } // "ordinary" implementiation for all the rest };
- as regards the range interface, I think it's relatively ordinary, but one point worth noticing is that
begin
resumes the coroutine via thenext
resumption function:struct IterCoro { // ... struct iterator { //... constexpr void next() { if (hdl) { hdl.resume(); if (hdl.done()) { hdl = nullptr; } } } //... } // ... auto begin() const { if (!hdl || hdl.done()) { return iterator{nullptr}; } iterator it{hdl}; it.next(); // resume to make the first value available return it; } // ... };
But then I thought: yield_value
is a non-const
member function of promise_type
, and it stores stuff in it, but anything else having access to the handle can retrieve the promise and do the same; and one thing that has access to the handle is the await_suspend
method of the Awaiter
returned by yield_value
, so an alternative approach is to construct an Awaiter
with the value to be set, and let its await_suspend
do the job. That's how I came up with the second solution, where
- the couroutine is identical to before,
yield_value
can be madeconst
and simply forward the value to be stored in the promise to theAwaiter
,constexpr auto yield_value(int i) const noexcept { return Awaiter{i}; }
- the
Awaiter
stores the value, accepts the suspension, and stores the value in the promise upon suspension,struct Awaiter { constexpr auto await_ready() const noexcept { return false; } constexpr void await_suspend(auto h) const noexcept { h.promise().val = i; } constexpr auto await_resume() const noexcept {} constexpr Awaiter(int i) : i{i} {} int i; };
- the range interface is identical to before.
I see that the two solutions do look a lot alike, but I wouldn't be sure that they are indeed the same thing (the generated code is not identical down to the bit, after all).
But then I tried one more change in line the following reasoning. The body of the coroutine is just doing the some work after every suspension; but runs right after a suspension? await_suspend
of the Awaiter
! So why not moving the work there? The Awaiter
doesn't have access to the parameters of the coroutine, so how can it do the work of the coroutine without f
and x
? The solution is suggested by the standard, we can make the promise_type
accept the same arguments as the coroutine and store them locally, so that the Awaiter
can retrieve them. That's how I came up with the third solution, where
- The interface of the coroutine is templated, in order to be able to make the types of the function and its arguments available to the nested
promise_type
, for it to store them, for theAwaiter
to use them,template<typename Fun, typename Arg> struct IterCoro { // ... struct promise_type { Fun fun; Arg val; promise_type(Fun f, Arg a) : fun{f} , val{a} {} // yield_value is not even necessary (see below)
- the
Awaiter
, which can now be default-constructed as it is stateless, can then retrieve what it needs for thepromise_type
and update the value,struct Awaiter { constexpr auto await_ready() const noexcept { return false; } constexpr void await_suspend(auto h) const noexcept { h.promise().val = h.promise().fun(h.promise().val); } constexpr auto await_resume() const noexcept {} };
- the coroutine is templated via explicit template arguments rather than the
auto
placeholder, in order to pass those template arguments to the interface, but this time the body of thewhile
loop just needs to suspend, because the update of the value is done right after the suspension by theAwaiter
,template<typename Fun, typename Arg> IterCoro<Fun, Arg> iterate(Fun, Arg) { while (true) { co_await Awaiter{}; } }
- the range interface is almost identical to before, but this time
begin
must not resume the coroutine, because the update of the value is done right after suspension:auto begin() const { if (!hdl || hdl.done()) { return iterator{nullptr}; } return iterator{hdl}; }
To my inexperienced eyes, this does look fairly different from where I started and even from where I got with the first re-elaboration (and the assembly is visibly even if not substantially shorter).
(¹) It might look a toy example at first (and maybe it needs to be made more generic and robust, and maybe I should see what happens with a move-only argument; whatever), but it isn't. Think of how easy it makes to traverse a tree while looking for something:
auto nameOfOldestForeFather = *(iterate(getFather, me) | take_while(alive) | transform(name)).end();
or more generally
for_each(iterate(getParent, leafNode) | take_while(someCond), someWork)
(²) I say "person", but I'm referring to any group of people which work together on the same thing, if needed. As in, if I write a function/class/whatever for doing xyz and somebody helps me to any extent, we are still the same "collective brain" that works on that thing.