การสร้าง python generator ผ่าน c ++ 20 coroutines

Sep 27 2020

สมมติว่าฉันมีรหัส 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 เป็นไปได้ไม่เพียง แต่จะให้บางสิ่งบางอย่างจากเครื่องกำเนิดไฟฟ้าเท่านั้น แต่ยังให้บางสิ่งบางอย่างกับมันด้วย ฉันเชื่อว่าสามารถทำได้ผ่าน co_await ใน C ++

ฉันต้องการให้มันทำงานเช่นนี้:

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 โครูทีน?

คำตอบ

12 NicolBolas Sep 27 2020 at 08:33

คุณมีปัญหาสองประการที่ต้องเอาชนะหากคุณต้องการทำสิ่งนี้

ประการแรกคือ C ++ เป็นภาษาที่พิมพ์แบบคงที่ ซึ่งหมายความว่าต้องทราบประเภทของทุกสิ่งที่เกี่ยวข้องในเวลาคอมไพล์ นี่คือเหตุผลที่generatorประเภทของคุณต้องเป็นแม่แบบเพื่อให้ผู้ใช้สามารถระบุได้ว่าประเภทใดที่เลี้ยงแกะจากโครูทีนไปยังผู้เรียก

ดังนั้นหากคุณต้องการมีอินเทอร์เฟซแบบสองทิศทางนี้บางสิ่งในhelloฟังก์ชันของคุณจะต้องระบุทั้งประเภทเอาต์พุตและประเภทอินพุต

วิธีที่ง่ายที่สุดในการดำเนินการนี้คือเพียงสร้างวัตถุและส่งผ่านการไม่constอ้างอิงถึงวัตถุนั้นไปยังเครื่องกำเนิดไฟฟ้า ทุกครั้งที่ทำ a co_yieldผู้เรียกสามารถแก้ไขอ็อบเจ็กต์ที่อ้างอิงแล้วขอค่าใหม่ โครูทีนสามารถอ่านจากข้อมูลอ้างอิงและดูข้อมูลที่กำหนด

อย่างไรก็ตามหากคุณยืนยันที่จะใช้ประเภทในอนาคตสำหรับโครูทีนเป็นทั้งเอาต์พุตและอินพุตคุณต้องทั้งคู่แก้ปัญหาแรก (โดยการทำให้generatorเทมเพลตของคุณใช้OutputTypeและInputType) รวมถึงปัญหาที่สองนี้

ดูเป้าหมายของคุณคือการได้รับคุณค่าของโครูทีน ปัญหาคือที่มาของค่านั้น (ฟังก์ชันที่เรียกโครูทีนของคุณ) มีวัตถุในอนาคต แต่โครูทีนไม่สามารถเข้าถึงวัตถุในอนาคตได้ และไม่สามารถเข้าถึงวัตถุสัญญาที่อ้างอิงในอนาคตได้

หรืออย่างน้อยก็ไม่สามารถทำได้อย่างง่ายดาย

มีสองวิธีในการดำเนินการกับกรณีการใช้งานที่แตกต่างกัน คนแรกจัดการเครื่องจักรโครูทีนเพื่อลับๆเข้าสู่สัญญา อย่างที่สองจัดการคุณสมบัติของco_yieldการทำสิ่งเดียวกันโดยพื้นฐาน

แปลง

วัตถุสัญญาสำหรับโครูทีนมักจะซ่อนอยู่และไม่สามารถเข้าถึงได้จากโครูทีน วัตถุในอนาคตสามารถเข้าถึงได้ซึ่งสัญญาจะสร้างขึ้นและทำหน้าที่เป็นส่วนต่อประสานกับข้อมูลที่สัญญาไว้ แต่ยังสามารถเข้าถึงได้ในบางส่วนของco_awaitเครื่องจักร

โดยเฉพาะอย่างยิ่งเมื่อคุณดำเนินการco_awaitกับนิพจน์ใด ๆ ในโครูทีนเครื่องจักรจะดูประเภทสัญญาของคุณเพื่อดูว่ามีฟังก์ชันที่เรียกใช้await_transformหรือไม่ ถ้าเป็นเช่นนั้นมันจะเรียกวัตถุสัญญานั้นawait_transformในทุกนิพจน์ที่คุณco_awaitใช้ (อย่างน้อยก็ในสิ่งco_awaitที่คุณเขียนโดยตรงไม่ใช่การรอโดยปริยายเช่นสิ่งที่สร้างโดยco_yield)

ด้วยเหตุนี้เราจึงต้องทำสองสิ่ง: สร้าง overload ของawait_transformประเภทสัญญาและสร้างประเภทที่มีจุดประสงค์เพียงอย่างเดียวเพื่อให้เราเรียกawait_transformใช้ฟังก์ชันนั้นได้

มันจะมีลักษณะดังนี้:

struct generator_input {};

...

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

หนึ่งบันทึกย่อ ข้อเสียของการใช้await_transformเช่นนี้ก็คือการระบุฟังก์ชันนี้มากเกินไปแม้แต่ครั้งเดียวสำหรับคำสัญญาของเราเราส่งผลกระทบต่อทุก co_awaitๆ โครูทีนที่ใช้ประเภทนี้ สำหรับเครื่องกำเนิดไฟฟ้าโครูทีนนั้นไม่สำคัญมากนักเนื่องจากไม่มีเหตุผลอะไรมากที่จะco_awaitเว้นแต่คุณจะทำการแฮ็กเช่นนี้ แต่ถ้าคุณกำลังสร้างกลไกทั่วไปที่ชัดเจนมากขึ้นซึ่งสามารถรอคอยโดยพลการรอคอยซึ่งเป็นส่วนหนึ่งของรุ่นของมันคุณจะมีปัญหา

ตกลงเราจึงมีawait_transformฟังก์ชันนี้ ฟังก์ชั่นนี้ต้องทำอะไร? จำเป็นต้องส่งคืนวัตถุco_awaitที่รอคอยได้เนื่องจากกำลังรอวัตถุนั้นอยู่ แต่วัตถุประสงค์ของออบเจ็กต์ที่รอคอยนี้คือการส่งข้อมูลอ้างอิงไปยังประเภทอินพุต โชคดีที่กลไกที่co_awaitใช้ในการแปลงสิ่งที่รอคอยให้เป็นค่านั้นจัดทำโดยawait_resumeวิธีการของ awaitable ดังนั้นเราสามารถคืนค่า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.
}

นี้จะช่วยให้การเข้าถึง coroutine co_await generator_input{};ค่าโดยกล่าวอ้าง โปรดทราบว่าสิ่งนี้ส่งคืนการอ้างอิงไปยังวัตถุ

generatorประเภทสามารถจะแก้ไขเพื่อให้ความสามารถในการปรับเปลี่ยนInputTypeวัตถุที่เก็บไว้ในสัญญา เพียงเพิ่มคู่ของsendฟังก์ชั่นสำหรับเขียนทับค่าอินพุต:

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

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

นี่แสดงถึงกลไกการขนส่งที่ไม่สมมาตร โครูทีนจะดึงค่าตามสถานที่และเวลาที่เลือกเอง ดังนั้นจึงไม่มีภาระผูกพันที่แท้จริงที่จะต้องตอบสนองทันทีต่อการเปลี่ยนแปลงใด ๆ นี่เป็นสิ่งที่ดีในบางประการเนื่องจากช่วยให้โครูทีนสามารถป้องกันตัวเองจากการเปลี่ยนแปลงที่เป็นอันตรายได้ หากคุณใช้การforวนซ้ำตามช่วงบนคอนเทนเนอร์คอนเทนเนอร์นั้นจะไม่สามารถแก้ไขโดยตรง (โดยส่วนใหญ่) โดยโลกภายนอกมิฉะนั้นโปรแกรมของคุณจะแสดง UB ดังนั้นหากโครูทีนเปราะบางในลักษณะนั้นก็สามารถคัดลอกข้อมูลจากผู้ใช้และทำให้ผู้ใช้ไม่สามารถแก้ไขได้

สรุปแล้วรหัสที่จำเป็นไม่ได้ใหญ่ขนาดนั้น นี่คือตัวอย่างโค้ดของคุณที่สามารถรันได้ด้วยการปรับเปลี่ยนเหล่านี้:

#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_yieldคือเป็นนิพจน์ดังนั้นจึงมีค่า โดยเฉพาะอย่างยิ่งมัน (ส่วนใหญ่) เทียบเท่ากับวัตถุสัญญาอยู่co_await p.yield_value(e)ที่ไหนp(โอ้!) และeเป็นสิ่งที่เราให้ผล

โชคดีที่เรามี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วิธีการที่ชัดเจนคุณไม่สามารถรับค่าก่อนที่จะเริ่มสร้างได้ สิ่งนี้อาจเป็นประโยชน์สำหรับอินเทอร์เฟซบางอย่าง

และแน่นอนคุณสามารถรวมเข้าด้วยกันได้ตามที่เห็นสมควร