面向修复编译器报错编程——基于 C++20 协程写一个生成器

Generator, atomic lover!(错乱)


好久没有写过 blog 了,借此机会水一篇。

协程是个很好用的东西,利用协程就可以把一些比较复杂的逻辑用比较线性的方法表述出来。相比普通的函数只有调用方调用和被调方返回两种操作,协程多出了挂起和恢复的操作,代码执行的流程就好像是在调用方和被调方之间打乒乓球一样。像 C#,Python 等语言中的 generator 以及 async/await 构造其实都是某种协程。C++ 作为一个现代语言当然也要包含一些这种实用的构造,经过多年的积累和沉淀协程终于合并进了 C++20 标准。(豆知识:协程本来是目标 C++17 的,结果在 2017 年好像还有不少的标准化措辞比较不尽人意,于是协程被踢到了 2020 年,然后差点都没赶上 C++20 这班车。据说现在的协程标准仍然有一些人不满意,但是大家都觉得早加进来利大于弊。)

那什么是挂起和恢复呢?用直白一点的语言来讲就是协程可以执行到一半的时候把当前的执行进度存下来,通过“挂起”操作把控制转给调用方,而之后调用方可以再通过“恢复”的操作把控制转给协程,让协程从上一次停下来的位置继续执行。下面的伪代码描述的就是用协程实现的生成器的运作过程(很多细节都没有考虑):

gen()
    print("协程被调用")
    yield 42    // 返回一个值 42 并且挂起,将控制转回主函数
    print("恢复执行")
    yield 120
    print("再恢复")    // 这之后没有挂起点了,会直接执行到结束
    return    // 协程执行完毕

main()
    coro = gen()    // 调用协程 gen 直到第一次挂起
    print($"第一次返回的是 {coro.value}")    // 这里会接收到第一次挂起时传出来的 42
    coro.resume()    // 控制重新交给协程,从上次挂起的位置 yield 42 之后继续
    print($"第二次返回的是 {coro.value}")    // 这里会接收到 120
    coro.resume()    // 再次恢复,这次协程会直接执行到最后,完成整个过程
    print("结束了")

/*
 * 输出:
 *     协程被调用
 *     第一次返回的是 42
 *     恢复执行
 *     第二次返回的是 120
 *     再恢复
 *     结束了
 */

再给两个实际的 C++ 例子吧。第一个就是一个生成斐波那契数列的协程(要问为什么那个关键字前面有个 co_ 嘛……没办法,好看的词都被用了,为了不破坏现有代码的正确性新标准只能选难看的词了):

generator<int> fib()
{
    int x = 1, y = 0;
    while (true)
    {
        const int next = x + y;
        x = y;
        y = next;
        co_yield next;
    }
}

int main() // NOLINT
{
    for (const int i : fib())
    {
        if (i > 50) break;
        std::cout << i << ' ';
    }
    // 输出: 1 1 2 3 5 8 13 21 34 
    return 0;
}

第二个例子中的 seq 协程用来生成从 0 开始的无限整数列,而 filter_prime 协程仅将一个数列中的素数筛选出来:

bool is_prime(const int value)
{
    if (value < 2) return false;
    for (int i = 2; i * i < value; i++)
        if (value % i == 0)
            return false;
    return true;
}

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

generator<int> filter_prime(generator<int> gen)
{
    for (const int i : gen)
    {
        if (is_prime(i))
            co_yield i;
    }
}

int main() // NOLINT
{
    for (const int i : filter_prime(seq()))
    {
        if (i > 20) break;
        std::cout << i << ' ';
    }
    // 输出: 2 3 4 5 7 9 11 13 17 19 
    return 0;
}

那么,哪里能买到呢要怎么操作才能用上 C++20 的协程呢?目前来讲三大主流 C++ 编译器(gcc 10+, clang 8+, MSVC 19.25+)都已经全部或部分支持 C++20 协程。MSVC 应该会在 19.28 版本正式支持 C++20 协程,目前来讲要在 VS 里面使用协程的话需要手动添加 /await 编译选项,使用的头文件是 <experimental/coroutine>,所有的类都在 std::experimental 命名空间中(在 19.28 以后可能可以直接使用 C++ 最新标准的选项 /std:c++latest 启用协程,头文件即是正式的 <coroutine>)。

下一个问题就是这个 generator 在哪里可以获取到了。不过非常遗憾,C++20 的标准库中并没有此类的辅助类,所有跟协程相关的实用函数和类(包括 generatortask 之类)都需要自己实现(或使用第三方库,目前比较好的有 Lewiss Baker 的 cppcoro,不过这个库在 MSVC 好像暂时并不能正常使用)。比较幸运的是 MSVC 给我们提供了 generator 的实现,可以包含 <experimental/generator> 来使用。另外,启用 /await 选项以后同样也会包含 std::future 的协程支持。

自己实现支持协程的类其实是很麻烦的,就连最简单最朴素的 generator 的实现都牵扯到很多协程底层的概念。不过仔细想想 C++ 这个语言一直都是这样,用户使用起来比较方便的东西实现起来经常都很费事(试试自己实现一个符合标准的 std::optional 吧,你会体会到我说的是什么意思的)……

我们先试着让下面这段简单的代码通过编译(以下内容均使用 MSVC 19.27 即 Visual Studio 2019 16.7 编译),协程 seq 生成从 0 开始的 count 个整数:

class int_generator final
{
};

int_generator seq(const int count)
{
    for (int i = 0; i < count; i++)
        co_yield i;
}

int main() // NOLINT
{
    int_generator gen = seq(10);
    return 0;
}

试着点一下编译……结果编译器报了这样的奇怪错误信息:”promise_type”: 不是 “std::experimental::coroutine_traits<int_generator,int>” 的成员。这是什么意思呢?

如果之前用过 std::future 的话会知道,std::futurestd::promise 相关联,std::future 作为一个访问异步操作结果的“钥匙”,而 std::promise 就是那个具体存储了返回值和期间抛出异常信息的“盒子”。这里协程的承诺类型 promise_type 也是类似的道理:我们的 int_generator 类就扮演了“钥匙”的角色,它只保存当前协程的句柄,而真正存储 co_yield 的返回值的“箱子”其实是这个 promise_type

class int_generator final
{
public:
    class promise_type final {};
};

那么 promise_type 里面应该具备哪些函数呢?作为协程的承诺类型,promise_type 需要具备 get_return_object 函数;要对我们的 int_generator::promise_type 使用 co_yield 的话,promise_type 需要具备 yield_value 函数。get_return_object 函数即是返回当前 promise_type 的这个“箱子”所对应的 int_generator “钥匙”。

前面说到,int_generator 这个“钥匙”实际是不存储运算结果的,那它应该具备什么性质呢?通过阅读 cppreference 上协程的介绍我们可以知道,每个协程与一个承诺对象和一个协程句柄相关联,这个协程句柄就相当于是“钥匙齿的样式”了,这里我们只在 int_generator 类里面存储这样的一个句柄。协程句柄 coroutine_handle<promise_type> 可以通过对应的承诺对象来获取到。因为目前我使用的是实验版的协程实现所以这些类都在 std::experimental 当中,接下来我会用 stdx 代替这个命名空间名;若使用正式版实现的话就可以直接用 std 命名空间。下面就是这个 get_return_object 相关的实现了,为了完整性这里包含了 int_generator 的移动构造函数以及析构函数(一定记得销毁协程句柄)。

class int_generator final
{
public:
    class promise_type;
    using handle_type = stdx::coroutine_handle<promise_type>;

    class promise_type final
    {
    public:
        int_generator get_return_object() { return int_generator{ handle_type::from_promise(*this) }; }
    };

private:
    handle_type handle_;

public:
    explicit int_generator(const handle_type handle): handle_{ handle } {}
    int_generator(int_generator&& other) noexcept:
        handle_{ std::exchange(other.handle_, {}) } {}
    ~int_generator() noexcept
    {
        if (handle_)
            handle_.destroy();
    }
};

接下来就是 yield_value 函数了。仍然是在 cppreference 上面我们可以读到,co_yield x; 相当于 co_await promise.yield_value(x);,那么这里显然 yield_value 函数要接受一个 int 作为参数。另一方面 co_await 运算符如果直接在 cppreference 上面看可以看到很长的一段说明,关于如何获得可等待体和等待器对象之类的,并且它们还跟当前协程需不需要挂起有关。反过来来看我们的应用场景,每次 co_yield 返回值之后我们都希望控制转回调用方,也就是这里 co_await 的东西要总让协程挂起。这里标准库为我们提供了这样的一个基础类,那就是 suspend_always,名字可以说是非常直观了。前面还说过返回值都是存储在承诺对象里面的,那这里就给承诺对象添加一个成员变量记录这个返回值,并且增加一个函数来让外面可以获取到这个值:

class promise_type final
{
private:
    int value_{};

public:
    int_generator get_return_object() { return int_generator{ handle_type::from_promise(*this) }; }
    stdx::suspend_always yield_value(const int value)
    {
        value_ = value;
        return {};
    }

    int get_value() const { return value_; }
};

好了,现在再点击编译,看看我们还少什么函数。这一次的报错内容是 “int_generator::promise_type” 不声明成员 “initial_suspend()”,这又是什么?

C++ 的协程在刚被调用和结束整个协程体的时候可以选择是否要挂起,控制这个行为的函数就是 initial_suspendfinal_suspend 了。这里我们要实现的类作为一个生成器,我们希望刚调用的时候我们只是获得一个协程的关联对象之后再选择何时开始生成值,所以这里面 initial_suspend 也需要返回 suspend_always;类似的理由 final_suspend 也是如此。

class promise_type final
{
    ...
public:
    stdx::suspend_always initial_suspend() { return {}; }
    stdx::suspend_always final_suspend() { return {}; }
    ...
};

接下来编译器的报错是 int_generator::promise_type: 协同例程的承诺必须声明“return_value”或“return_void”。这两个函数与在协程中使用 co_return 相关,因为这里并不需要在协程结束时返回任何值所以这里需要实现 return_void。因为这里不需要在协程结束时做什么特殊处理,所以实现起来相当简单,有没有被感动到……

class promise_type final
{
    ...
public:
    void return_void() {}
    ...
};

这里再点编译不会报错了,不过正式的协程规范中协程的承诺类型还需要有一个 unhandled_exception 函数,用于在协程内部出现异常时选择异常的处理方案。我们这里需要这个异常直接传递给调用方,所以直接抛出原异常就好了:

class promise_type final
{
    ...
public:
    void unhandled_exception() { throw; }
    ...
};

现在再点击编译,我们的代码终于可以无事通过编译了。

下一步就是给我们的 int_generator 类加上范围 for 循环的支持了。关于如何给一个类加上范围 for 的支持可以参照我之前写的这一篇 blog。这里我们仍然是创造一个哨兵类用来标识序列的结尾,迭代器中存储我们的协程句柄用来获取对应的承诺。

class int_generator final
{
    ...
public:
    struct sentinel final {};

    class iterator final
    {
    private:
        handle_type handle_;

    public:
        explicit iterator(const handle_type handle): handle_{ handle } { /* ? */ }
    };

    iterator begin() const { return iterator{ handle_ }; }
    sentinel end() const { return {}; }
};

协程句柄类提供一些成员函数用来获得对应的承诺对象,恢复协程,以及询问协程是否已经终止。这里构造迭代器以后,我们希望协程立即恢复一次,用来得到生成器产生的第一个值;每次迭代器后移时,同样恢复协程来产生下一个值;解引用迭代器时,从对应的承诺对象中获取返回值;最后,与哨兵类比较的时候询问协程是否已经生成完所有的值,已经结束。实现如下:

class iterator final
{
private:
    handle_type handle_;

public:
    explicit iterator(const handle_type handle):
        handle_{ handle }
    {
        handle_.resume();
    }

    iterator& operator++()
    {
        handle_.resume();
        return *this;
    }

    int operator*() const { return handle_.promise().get_value(); }

    friend bool operator==(const iterator it, sentinel) { return it.handle_.done(); }
};

至此,一个支持范围 for 的整数生成器类就大功告成了。运行一下下面的代码看一看吧!

int_generator seq(const int count)
{
    for (int i = 0; i < count; i++)
        co_yield i;
}

int main() // NOLINT
{
    int_generator gen = seq(10);
    for (int i : gen) std::cout << i << ' ';
    // 输出: 0 1 2 3 4 5 6 7 8 9 
    return 0;
}

掌握了这些思想以后我们很容易就能将这里的整数生成器扩展成一个泛用的生成器类模板,下面就是我的一个实现:

template <typename T>
class generator final
{
public:
    class promise_type;
    using handle_type = stdx::coroutine_handle<promise_type>;

    class promise_type final
    {
    private:
        T value_;

    public:
        generator get_return_object() { return generator{ handle_type::from_promise(*this) }; }
        stdx::suspend_always initial_suspend() { return {}; }
        stdx::suspend_always final_suspend() { return {}; }
        stdx::suspend_always yield_value(T value)
        {
            value_ = std::move(value);
            return {};
        }
        void return_void() {}
        void unhandled_exception() {}

        const T& get_value() const { return value_; }
    };

    struct sentinel final {};

    class iterator final
    {
    private:
        handle_type handle_;

    public:
        explicit iterator(const handle_type handle):
            handle_{ handle }
        {
            handle_.resume();
        }

        iterator& operator++()
        {
            handle_.resume();
            return *this;
        }

        const T& operator*() const { return handle_.promise().get_value(); }

        friend bool operator==(const iterator it, sentinel) { return it.handle_.done(); }
    };

private:
    handle_type handle_;

public:
    explicit generator(const handle_type handle): handle_{ handle } {}
    generator(generator&& other) noexcept:
        handle_{ std::exchange(other.handle_, {}) } {}
    ~generator() noexcept
    {
        if (handle_)
            handle_.destroy();
    }

    iterator begin() const { return iterator{ handle_ }; }
    sentinel end() const { return {}; }
};

generator<char> lower_chars(const char* str)
{
    while (*str)
        co_yield static_cast<char>(std::tolower(*str++));
}

int main() // NOLINT
{
    for (char ch : lower_chars("HELLO WORLD!"))
        std::cout << ch;
    // 输出: hello world!
    return 0;
}