通过C++20协程生成Python生成器

20

假设我有以下这段Python代码:

def double_inputs():
    while True:
        x = yield
        yield x * 2
gen = double_inputs()
next(gen)
print(gen.send(1))

它会输出"2",就像预期的那样。 我可以在c++20中创建这样一个生成器:

#include <coroutine>

template <class T>
struct generator {
    struct promise_type;
    using coro_handle = std::coroutine_handle<promise_type>;

    struct promise_type {
        T current_value;
        auto get_return_object() { return generator{coro_handle::from_promise(*this)}; }
        auto initial_suspend() { return std::suspend_always{}; }
        auto final_suspend() { return std::suspend_always{}; }
        void unhandled_exception() { std::terminate(); }
        auto yield_value(T value) {
            current_value = value;
            return std::suspend_always{};
        }
    };

    bool next() { return coro ? (coro.resume(), !coro.done()) : false; }
    T value() { return coro.promise().current_value; }

    generator(generator const & rhs) = delete;
    generator(generator &&rhs)
        :coro(rhs.coro)
    {
        rhs.coro = nullptr;
    }
    ~generator() {
        if (coro)
            coro.destroy();
    }
private:
    generator(coro_handle h) : coro(h) {}
    coro_handle coro;
};

generator<char> hello(){
    //TODO:send string here via co_await, but HOW???
    std::string word = "hello world";
    for(auto &ch:word){
        co_yield ch;
    }
}

int main(int, char**) {
    for (auto i = hello(); i.next(); ) {
        std::cout << i.value() << ' ';
    }
}

这个生成器只是逐字逐句地产生一个字符串,但是在它里面硬编码了这个字符串。在Python中,不仅可以从生成器中yield出一些东西,还可以向生成器中yield一些东西。我相信在C++中可以通过co_await实现。

我需要它像这样工作:

generator<char> hello(){
    std::string word = co_await producer; // Wait string from producer somehow 
    for(auto &ch:word){
        co_yield ch;
    }
}

int main(int, char**) {
    auto gen = hello(); //make consumer
    producer("hello world"); //produce string
    for (; gen.next(); ) {
        std::cout << gen.value() << ' '; //consume string letter by letter
    }
}

我该如何做到这一点?如何使用c++20协程制作此“生产者”?

2
你为什么想要这样做呢?直接将这个“producer”传递给hello不是更合理吗?我的意思是,你可能可以通过使用co_await来完成它,但是为什么要使用这样的机制,而不是最明显的方法(将其传递给生产者)呢?C++引入协程并不是为了将其变成Python。 - Nicol Bolas
1个回答

17

如果你想要实现这个,你需要解决两个问题。

第一个问题是C++是一种静态类型语言,这意味着在编译时需要知道所有涉及内容的类型。这就是为什么你的generator类型需要成为一个模板,以便用户可以指定它从协程到调用者牧羊的类型。

所以如果你想要拥有这样一个双向接口,那么hello函数中的某些东西必须同时指定输出类型和输入类型。

最简单的方法就是创建一个对象,并将对该对象的非const引用传递给生成器。每次它执行co_yield,调用者就可以修改引用对象,然后请求一个新值。协程可以从引用中读取并查看给定的数据。

但是,如果你坚持使用future类型作为协程的输出和输入,则需要解决第一个问题(通过使你的generator模板获取OutputTypeInputType)以及第二个问题。

你的目标是将一个值传递给协程。问题是该值的来源(调用你的协程的函数)具有future对象。但是协程无法访问future对象。也无法访问future引用的promise对象。

或者至少不容易做到。

有两种方法可以解决这个问题,具有不同的用例。第一种是操纵协程机制,以便在promise中建立一个后门。第二种是操纵co_yield的属性,以基本实现相同的功能。

转换

协程的promise对象通常是隐藏的,并且无法从协程中访问。它可以通过被promise创建并充当承诺数据接口的future对象访问。但是,在某些co_await机制的部分时期,也可以访问它。

特别地,当你在协程中对任何表达式执行co_await时,机器会查看你的promise类型是否有一个名为await_transform的函数。如果有,它将调用该promise对象的await_transform函数,每次你在其中进行co_await的表达式(至少在你直接编写的co_await中,而不是由co_yield创建的隐式等待)。

因此,我们需要做两件事:在promise类型上创建一个await_transform重载,并创建一个唯一目的是让我们调用那个await_transform函数的类型。

所以代码看起来会像这样:

struct generator_input {};

...

//Within the promise type:
auto await_transform(generator_input);

需要注意的是,使用 await_transform 的缺点是,如果我们为我们的 promise 指定了一个重载函数,那么它将影响到使用此类型的任何协程中的每个 co_await。对于生成器协程来说,这并不是很重要,因为除非像这样进行黑客攻击,否则没有太多理由使用 co_await。但是,如果您正在创建一种更通用的机制,该机制可以作为其生成的一部分明确地等待任意可等待对象,那么您就会遇到问题。

好的,现在我们有了这个 await_transform 函数;这个函数需要做什么呢?它需要返回一个可等待对象,因为 co_await 将等待它。但是,这个可等待对象的目的是传递输入类型的引用。幸运的是,co_await 用于将可等待对象转换为值的机制是由可等待对象的 await_resume 方法提供的。因此,我们的方法只需返回一个 InputType&

//Within the `generator<OutputType, InputType>`:
    struct passthru_value
    {
        InputType &ret_;

        bool await_ready() {return true;}
        void await_suspend(coro_handle) {}
        InputType &await_resume() { return ret_; }
    };


//Within the promise type:
auto await_transform(generator_input)
{
    return passthru_value{input_value}; //Where `input_value` is the `InputType` object stored by the promise.
}

通过调用 co_await generator_input{}; , coroutine 可以访问该值。请注意,这将返回对该对象的引用。

generator类型可以轻松修改,以允许修改存储在 promise 中的 InputType 对象的能力。只需添加一对send函数来覆盖输入值:

void send(const InputType &input)
{
    coro.promise().input_value = input;
} 

void send(InputType &&input)
{
    coro.promise().input_value = std::move(input);
} 

这代表了一种不对称的传输机制。协程在自己选择的时间和地点检索值。因此,它没有实质性的义务立即响应任何更改。在某些方面,这是很好的,因为它允许协程与有害变化隔离开来。如果您正在使用基于范围的for循环遍历容器,则该容器不能直接被外部世界(大多数情况下)修改,否则您的程序将表现出未定义行为。所以,如果协程在这方面脆弱,它可以复制用户的数据,从而防止用户修改它。

总体来说,所需代码并不是那么大。这里有一个可运行的代码示例,包含这些修改:

#include <coroutine>
#include <exception>
#include <string>
#include <iostream>

struct generator_input {};


template <typename OutputType, typename InputType>
struct generator {
    struct promise_type;
    using coro_handle = std::coroutine_handle<promise_type>;

    struct passthru_value
    {
        InputType &ret_;

        bool await_ready() {return true;}
        void await_suspend(coro_handle) {}
        InputType &await_resume() { return ret_; }
    };

    struct promise_type {
        OutputType current_value;
        InputType input_value;


        auto get_return_object() { return generator{coro_handle::from_promise(*this)}; }
        auto initial_suspend() { return std::suspend_always{}; }
        auto final_suspend() { return std::suspend_always{}; }
        void unhandled_exception() { std::terminate(); }
        auto yield_value(OutputType value) {
            current_value = value;
            return std::suspend_always{};
        }

        void return_void() {}

        auto await_transform(generator_input)
        {
            return passthru_value{input_value};
        }
    };

    bool next() { return coro ? (coro.resume(), !coro.done()) : false; }
    OutputType value() { return coro.promise().current_value; }

    void send(const InputType &input)
    {
        coro.promise().input_value = input;
    } 

    void send(InputType &&input)
    {
        coro.promise().input_value = std::move(input);
    } 

    generator(generator const & rhs) = delete;
    generator(generator &&rhs)
        :coro(rhs.coro)
    {
        rhs.coro = nullptr;
    }
    ~generator() {
        if (coro)
            coro.destroy();
    }
private:
    generator(coro_handle h) : coro(h) {}
    coro_handle coro;
};

generator<char, std::string> hello(){
    auto word = co_await generator_input{};

    for(auto &ch: word){
        co_yield ch;
    }
}

int main(int, char**)
{
    auto test = hello();
    test.send("hello world");

    while(test.next())
    {
        std::cout << test.value() << ' ';
    }
}

更加灵活的处理

除了使用显式的co_await,我们也可以利用co_yield的一个特性。它是一个表达式,因此具有值。具体地说,它与co_await p.yield_value(e)(其中p是promise对象,e是我们要yield的内容)在大多数情况下是等价的。

幸运的是,我们已经拥有了yield_value函数;它返回std::suspend_always。但它也可以返回一个始终暂停但同时可以被co_await解包成InputType&的对象:

struct yield_thru
{
    InputType &ret_;

    bool await_ready() {return false;}
    void await_suspend(coro_handle) {}
    InputType &await_resume() { return ret_; }
};

...

//in the promise
auto yield_value(OutputType value) {
    current_value = value;
    return yield_thru{input_value};
}

这是一种对称的传输机制;对于你产生的每一个值,你都会收到一个值(可能与之前相同)。与显式的 co_await 方法不同,你不能在开始生成值之前就接收到一个值。这对某些接口可能很有用。

当然,你可以自由地将它们组合使用。


你是一个魔术师,这正是我需要的!非常感谢,如果没有你的帖子,我可能永远都不会明白。 - tort_dla_psa
1
太好了,这个机制已经被实现了吗?或者可以很容易地通过 cppcoro 实现吗? - Claas Bontus
@ClaasBontus: "这个机制是否已经实现". 我不知道你所指的"机制"被"实现"的意思是什么。如果你在谈论await_transform或者yield_value,那些都是C++20的一部分。而且我不知道"cppcoro"提供了什么。 - Nicol Bolas

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接