5
\$\begingroup\$

Here is a coroutine generator class... Just like Python yields. It was a bit tedious to support both co_yield and co_return (should manage the cases flow out of function body and co_return separately) but it works. I wonder if it's okay, especially from the point of view of undefined behavior and memory leaks.

#include <iostream>
#include <coroutine>
#include <functional>

using namespace std;

template<typename T>
class generator
{
public:
    struct promise_type
    {
        T t_;
        bool returned_with_value_{false};

        promise_type() = default;
        ~promise_type() = default;

        std::suspend_always initial_suspend() { return {}; }
        std::suspend_always final_suspend() {}
        void unhandled_exception() {}
        generator get_return_object() { return {std::coroutine_handle<promise_type>::from_promise(*this)}; }

        std::suspend_always yield_value(T t) { t_ = t; return {}; }
        void return_value(T t) { t_ = t; returned_with_value_ = true; }
        void return_void() {}
    };

private:
    std::coroutine_handle<promise_type> h_;

    auto& promise() { return h_.promise(); }

public:
    generator(std::coroutine_handle<promise_type> h) : h_(h) {}
    ~generator() = default;

    bool operator()()
    {
        if(promise().returned_with_value_) // From previous operator() call
        {
            h_.destroy();
            return false;
        }

        h_();

        if(!promise().returned_with_value_ && h_.done())
        {
            h_.destroy();
            return false;
        }
        else
        {
            return true;
        }
    }

    [[nodiscard]] const T& get() const
    {
        return h_.promise().t_;
    }

    operator const T&() const
    {
        return get();
    }
};

generator<int> foo()
{
    int i = 0;

    while(1)
    {
        co_yield ++i;
        if( i == 100) co_return i;
    }
}

int main()
{
    auto handle = foo();

    while(handle())
    {
        cout << "val: " << handle.get() << endl;
    }

    return 0;
}
\$\endgroup\$
1
  • \$\begingroup\$ Trying this on g++ 13.1.1 and it doesn't compile or work at all. \$\endgroup\$
    – Lothar
    Commented May 22, 2023 at 21:29

2 Answers 2

7
\$\begingroup\$

Minor compile issues

Not all compilers support coroutines fully yet, and at least on GCC 10 your generator fails to compile, because:

gen.cc:70:16: error: the coroutine promise type ‘std::__n4861::__coroutine_traits_impl<generator<int>, void>::promise_type’ {aka ‘generator<int>::promise_type’} declares both ‘return_value’ and ‘return_void’

It is indeed mentioned in the C++20 standard, in §9.5.4 paragraph 6, that:

The unqualified-ids return_void and return_value are looked up in the scope of the promise type. If both are found, the program is ill-formed.

So it seems GCC is correct here. There is also an example implementation of struct generator in the standard, that suggests that you should implement yield_value() and return_void(). Having the latter is important, since as user673679 mentioned, without it it leads to undefined behavior if you don't have a co_return statement in your coroutine.

What about the copy and move constructors?

You didn't specify any copy or move constructors. What happens if I copy handle in main()? What if I move from handle? This currently does not work correctly. Either delete the unwanted constructors or add implementations that make sure the copy or move is handled correctly. Also think about the assignment operators when you do this; in particular, follow the rule of zero, three or five.

The value 100 is returned twice

Your example generator foo() returns the last value twice. A more natural way to write it would be:

generator<int> foo()
{
    for (int i = 0; i <= 100; ++i)
        co_yield i;
}

Which works fine with your generator implementation.

Make it work with range-for

Your generator doesn't have begin() and end() functions, so you cannot use it in a range-for expression. In particular, it would be nice if your main() function could have been written this way:

int main()
{
    for (auto &i: foo())
        std::cout << i << '\n';
}

Going further

Your generator works for the simple example you have written. However, in real applications you might want to use generators to return objects that might not be copy constructible, or want to be allocator-aware, or have other properties that you did not think about. If you want this to be usable in real applications, then I suggest you create a test suite first, where you cover all the possible use cases of your generator, and then improve your implementation. Also look at how others have tried to create a generator class, in particular look at Lewis Baker's cppcoro library.

\$\endgroup\$
5
\$\begingroup\$

    T t_;
    bool returned_with_value_{false};
  • It would be better to use a std::optional<T> t_;. This would allow us to support types without default constructors, and be certain that the value has actually been initialized and set to something when we access it.

  • As you say, it would be better to have separate return and yield values. We could do that with two separate std::optionals. Perhaps we could use separate template arguments for the return and yield types.

  • Of course, we might need to select from one of several promise_type classes if we want to support void return values, void yield values and combinations of them. Right now it's very hard to write multi-purpose or reusable coroutine code in C++.

  • Note that this generator doesn't need a return_value.


    std::suspend_always final_suspend() {}

Doesn't this need a return {}; to compile? If not, consider turning up your compiler warning level, and set warnings to errors.

According to the GCC it must also be declared noexcept:

    std::suspend_always final_suspend() noexcept { return {}; }

    void return_value(T t) { t_ = t; returned_with_value_ = true; }
    void return_void() {}

This also produces a compiler error on GCC:

error: the coroutine promise type 'std::__n4861::__coroutine_traits_impl<generator, void>::promise_type' {aka 'generator::promise_type'} declares both 'return_value' and 'return_void'

I can't actually find anything definite specifying that coroutines must only have one or the other... so I don't know if that error is correct. Though I guess it's at consistent with the rest of C++, where functions can only have a single return type. /o\

Of course, we then have to be careful about falling off the end of the function without returning anything (apparently that's undefined behavior and not anything sane like a compiler error - thanks O'Bjarne!).


bool operator()()
{
    if(promise().returned_with_value_) // From previous operator() call
    {
        h_.destroy();
        return false;
    }

    h_();

    if(!promise().returned_with_value_ && h_.done())
    {
        h_.destroy();
        return false;
    }
    else
    {
        return true;
    }
}
  • I'd suggest giving this function a meaningful name (e.g. resume()), so the user knows exactly what it does.

  • This function seems unnecessarily complicated. We can instead require that the function is only called when it's actually possible to resume. destroy() can be handled in the class destructor. e.g. something like:

    ~generator()
    {
        if (h_)
            h_.destroy();
    }
    
    bool is_resumable() const
    {
        return h_ && !h_.done();
    }
    
    bool resume()
    {
        assert(is_resumable());
        
        h_();
        
        return !h_.done();
    }
  • We need to consider copy and move of the generator object too (even if it's just to = delete those operators).

operator const T&() const
{
    return get();
}

Implicit conversions are generally a bad idea. This operator should at least be explicit, but it would be better to just delete it and use a get_return_value or get_yield_value function.


This coroutine_handle reference page has a decent example of how to write a generator supporting range-based for loop iteration.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.