LoginSignup
10

More than 1 year has passed since last update.

posted at

updated at

C++20コルーチンで遊ぶ with OpenSiv3D

はじめに

VisualStudio 2019 Communityのほうでもコルーチンが使用できるようになったので早速いろいろ試してみてた。

そんなのねぇよという方は、まずお手元のVisualStudioをアップデートしていただいて、/std:c++latestをお忘れなく

先に言っておきますと、
この記事はOpenSiv3Dを一緒に扱っていますが、この後例で取り上げるTaskの実装自体はフレームワークにまったく依存してないのでOpenSiv3DにかぎらずC++20環境なら適用できます。

今回のゴールは、コルーチンを理解することというより、
コルーチンを使えばこんなことができる!と知ってもらうことにします。

コルーチンとは?

簡単に言うと処理の途中で中断や再開ができる関数みたいなものですが
ここではコルーチンの詳しい解説をしたいわけではないので参考になるものを張っておきます

https://cpprefjp.github.io/lang/cpp20/coroutines.html
https://www2.slideshare.net/yohhoy/20c20

なんだか難しいと思ったかもしれませんが、安心してください。
利用者が覚えるキーワードは3つだけです。

  • co_yield
  • co_await
  • co_return

そしてこの三つのキーワードを使用した際に、具体的にどのような動作をさせるのかを決めるのはコルーチンライブラリの実装者です。

co_yield co_await

co_yieldco_awaitは処理の中断に使用するするキーワードです
基本的にはどちらも、キーワード単体では使用せず、何かしらの値とセットで使うことになります。

Hoge Coroutine()
{
    // co_yieldで中断
    {
        co_yield 1;
        auto a = co_yield 1; // 復帰時に値が変えることもある
    }
    // co_awaitで中断
    {
        co_await 1;
        auto a = co_await  1; // 復帰時に値が変えることもある
    }
}

co_awaitにはAwaiterと呼ばれるものを渡すことになる。
このAwaiterが中断するかどうか、中断時、再開時に行う処理や返り値等を決めている。
そのためco_yieldco_awaitを使えば、必ず中断されるというわけではない

上記の例ではint型をわたしているが、これは

  • await_transform や
  • operator co_await のオーバーロード

を使用することで任意の型をAwaiterの型に変換することができる
(もちろんこれをするのはライブラリ実装者なので、利用者が考えることではない)

co_yieldco_awaitするシンタックスシュガーにすぎず
値をうけとって、Awaiterに変換しているにすぎない

co_await p.yield_value( e )と等価

co_yieldco_awaitはどちらも処理の中断に使用できるが、これらの使い分けは結局のところ実装次第でもあるので、使用するコルーチンごとに変わってきます。

co_return

co_returnは関数のreturn同様に以降の処理をせず、そこでコルーチンを終了させるキーワードです。
また、co_yieldco_awaitも使用しない際に、これが通常の関数ではなくコルーチンであることを明示する際にも使用します。

// 関数
Hoge Func()
{
    return; // error Hoge型の値を返そう
}
// コルーチン
Hoge Coroutine()
{
    co_return; // ok (※Hoge型がco_returnでvoidが返せるように実装されているなら)
    // 以降の処理しない
}

モチベーション

Unityのコルーチンみたいなもんが欲しい!

IEnumerator Hoge()
{
    // Something A
    // 1秒待機する
    yield return new WaitForSeconds(1.0f);
    // Something B
}

Unityを使用したことがある人ならUnityコルーチンを使用したことがある人も多いと思いますが
ゲーム制作などではこのようなdelay処理ができると便利な場面が多々あるので

今回はこのようなことができるのを目指して実装することに

実装しました

Task というstructでコルーチンの実装しました。


Task.hpp
Task.hpp
#pragma once
#include <coroutine>
#include <memory>

namespace detail
{
    struct Yield
    {
        constexpr Yield():
            count(1)
        {}
        constexpr Yield(std::uint32_t _count) :
            count(_count)
        {}
        std::uint32_t count;
    };
    /// <summary>
    /// タスク用インターフェース
    /// </summary>
    struct ITask
    {
        virtual ~ITask() = default;
        virtual bool moveNext() const = 0;
    };
}
/// <summary>
/// タスク
/// </summary>
template <class T = void>
struct Task : detail::ITask
{
    struct promise_type; // coroutine_traitsを特殊化してないのでスネイクケース
    using Handle = std::coroutine_handle<promise_type>;
    struct PromiseValue
    {
        void return_value(const T& value)
        {
            this->value = value;
        }
        const T& getValue() const
        {
            return value;
        }
        T value;
    };
    struct promise_type : PromiseValue
    {
        static Task get_return_object_on_allocation_failure()
        {
            return Task{ nullptr };
        }
        auto get_return_object() { return Task{ Task::Handle::from_promise(*this) }; }
        auto initial_suspend() { return std::suspend_always{}; }
        auto final_suspend() noexcept { return std::suspend_always{}; }
        void unhandled_exception() { std::terminate(); }
        auto yield_value(const detail::Yield& _yield)
        {
            struct Awaiter
            {
                bool await_ready() const noexcept
                {
                    return ready;
                }
                void await_suspend(std::coroutine_handle<>){}
                void await_resume(){}
                bool ready = false;
            };
            if (_yield.count == 0) {
                return Awaiter{true};
            }
            --(this->yield = _yield).count;
            return Awaiter{false};
        }
        template<class U>
        auto yield_value(Task<U> other)
        {
            auto nextTask = std::make_shared<Task<U>>(std::move(other));
            auto ready = !nextTask->moveNext();
            next = nextTask;
            struct Awaiter
            {
                bool ready = false;
                std::shared_ptr<Task<U>> pTask;
                bool await_ready() const { return ready; }
                decltype(auto) await_resume()
                {
                    return pTask->get();
                }
                void await_suspend(std::coroutine_handle<>)
                {}
            };
            return Awaiter{ ready, nextTask };
        }
        detail::Yield yield{0};
        std::shared_ptr<detail::ITask> next;
    };
    Task(Handle h) :
        coro(h)
    {}
    Task(Task const&) = delete;
    Task(Task&& rhs) noexcept
        : coro(std::move(rhs.coro))
    {
        rhs.coro = nullptr;
    }
    ~Task()
    {
        if (coro) {
            coro.destroy();
        }
    }
    /// <summary>
    /// 再開
    /// </summary>
    /// <returns></returns>
    bool moveNext() const override
    {
        if (!coro) {
            return false;
        }
        if (coro.done()) {
            return false;
        }
        // Yield
        {
            auto& yield = coro.promise().yield;
            if (yield.count > 0 && yield.count-- > 0) {
                // カウンタが残ってるなら
                return true;
            }
        }
        // 割り込み別タスク
        {
            auto& next = coro.promise().next;
            if (next) {
                if (!next->moveNext()) {
                    next = nullptr;
                } else {
                    return true;
                }
            }
        }
        coro.resume();
        return !coro.done();
    }
    /// <summary>
    /// 完了したか
    /// </summary>
    /// <returns></returns>
    [[nodiscard]] bool isDone()const
    {
        if (!coro) {
            return false;
        }
        return coro.done();
    }
    /// <summary>
    /// 取得
    /// </summary>
    /// <returns></returns>
    [[nodiscard]] decltype(auto) get() const
    {
        return coro.promise().getValue();
    }
private:
    Handle coro;
};
// void特殊化
template<>
struct Task<void>::PromiseValue
{
    void return_void() {}
    void getValue() const
    {}
};
template<class T, class U>
[[nodiscard]] Task<void> operator & (Task<T> a, Task<U> b)
{
    while (true) {
        a.moveNext();
        b.moveNext();
        if (a.isDone() && b.isDone()) {
            co_return;
        }
        co_yield{};
    }
}
template<class T, class U>
[[nodiscard]] Task<void> operator | (Task<T> a, Task<U> b)
{
    while (true) {
        a.moveNext();
        b.moveNext();
        if (a.isDone() || b.isDone()) {
            co_return;
        }
        co_yield{};
    }
}
template<class T, class U>
[[nodiscard]] Task<U> operator + (Task<T> a, Task<U> b)
{
    while (a.moveNext()) {
        co_yield{};
    }
    while (b.moveNext()) {
        co_yield{};
    }
    co_return b.get();
}


見ていただくとわかるように実装者側は多くのカスタマイズポイントを使用してゴネゴネ書きまくらないといけない。

利用者側は以下みたいなコードを書くことになる

Task<void> Hoge()
{
    // Something A
    // 1秒待機する
    co_yield WaitForSeconds(1.0s);
    // Something B
}

今回実装した Task の特徴と使い方

メソッドmoveNextを呼ぶことで処理を再開させる

Main.cpp
Task<void> Sample()
{
    int32 a = 0;

    Print << ++a;
    co_yield{}; // ただ待機したいときは co_yield{} と記述するだけいいように実装した

    Print << ++a;
    co_yield{};

    Print << ++a;
    co_yield{};
}

void Main()
{
    auto task = Sample();

    task.moveNext(); // 1を出力
    task.moveNext(); // 2を出力
    task.moveNext(); // 3を出力

    while (System::Update()) {
    }
}

この特徴を生かし、メインループに混ぜ込むことで
co_yieldを1フレーム待機という意味合いにすることができる

Main.cpp
void Main()
{
    auto task = Sample();
    while (System::Update()) {
        // 毎フレーム再開させることで待機は1フレーム待機の意味になる
        task.moveNext(); 
    }
}

ちょうどUnityのコルーチンでもyield return nullは1フレーム待機なのでそれと同じようなイメージにできます。

また、TaskではmoveNextを明示的に呼ぶことで処理を進めるその特徴から
処理の中断はmoveNextを呼ぶのをやめるだけで良いので容易にできます。
(インスタンスの実態は残ってるのでメモリ解放などは起こらないが)

Taskの中で別のTaskを実行することができる

以下は2秒ごとにカウントアップした数値を表示するコルーチンです

Main.cpp
// 指定秒待機
Task<void> WaitForSeconds(const s3d::Duration& duration)
{
    Timer timer(duration, StartImmediately::Yes);
    while (!timer.reachedZero()) {
        co_yield{};
    }
}

Task<void> Sample()
{
    int32 a = 0;

    while (true) {
        Print << ++a;
        // 2秒待つ
        co_yield WaitForSeconds(2s);
    }
}

void Main()
{
    auto task = Sample();

    while (System::Update()) {
        task.moveNext();
    }
}

このようにco_yieldに別のタスクを指定することで、moveNextで再開する際に別のタスク側の処理を進めることができる。

Unityの場合はStartCroutineを呼ぶことで別のコルーチンを開始させ、待機することができるが
それを実現しようとするとTask struct単体で解決できないので、今回はこのような仕組みとした。

複数のタスクを扱う便利なoperatorオーバーロード

これはほぼ遊びで付けた要素だが、せっかくなので紹介する

operator &

複数のタスクを並行で実行し、両方完了したら完了
(スレッドを分けてるわけではないので、左辺が必ず先に処理させる)

Task<void> Sample()
{
    Print << U"1";
    co_yield{};
    Print << U"2";
}
Task<void> Sample2()
{
    Print << U"A";
    co_yield{};
    Print << U"B";
    co_yield{};
    Print << U"C";
}
void Main()
{
    auto task = Sample() & Sample2();

    while (System::Update()) {
        task.moveNext();
    }
}

// 出力
1 A 2 B C

これもまたTaskなので別のTask内からco_yieldで待つことも可能

operator |

複数のタスクを並行で実行し、いづれか完了したら完了
(スレッドを分けてるわけではないので、左辺が必ず先に処理させる)

Task<void> Sample()
{
    Print << U"1";
    co_yield{};
    Print << U"2";
}
Task<void> Sample2()
{
    Print << U"A";
    co_yield{};
    Print << U"B";
    co_yield{};
    Print << U"C";
}
void Main()
{
    auto task = Sample() | Sample2();

    while (System::Update()) {
        task.moveNext();
    }
}

// 出力
1 A 2 B

operator +

右辺のタスクを左辺の後に実行

Task<void> Sample()
{
    Print << U"1";
    co_yield{};
    Print << U"2";
}
Task<void> Sample2()
{
    Print << U"A";
    co_yield{};
    Print << U"B";
    co_yield{};
    Print << U"C";
}
void Main()
{
    auto task = Sample() + Sample2();

    while (System::Update()) {
        task.moveNext();
    }
}

// 出力
1 2 A B C

任意の型を返すことができる 

co_returnで返した値をget()メソッド取得可能

Task<int> Sample()
{
    co_yield WaitForSeconds(2s);
    co_return 10; // co_returnで10を返す
}

void Main()
{
    auto task = Sample();

    while (System::Update()) {
        ClearPrint();
        if (!task.moveNext()) { // タスクが完了するとmoveNextがfalseを返す
            Print << task.get(); // 出力 10
        }
    }
}

Task内からであればco_yieldしてそのまま値を受け取れる

Task<int> Sample()
{
    co_yield WaitForSeconds(2s);
    co_return 10;
}

Task<void> Sample2()
{
    int32 a = 0;
    while (true) {
        a += co_yield Sample();
        Print << a;
    }
}
void Main()
{
    auto task = Sample2();

    while (System::Update()) {
        task.moveNext();
    }
}

// 出力
10 20 30 ...

サンプルプリセット紹介

Taskで実装したコルーチンのサンプルとしていくつか紹介しておきます。

WaitForSeconds

指定秒まつ

    [[nodiscard]] Task<void> WaitForSeconds(const s3d::Duration& duration)
    {
        Timer timer(duration, StartImmediately::Yes);
        while (!timer.reachedZero()) {
            co_yield{};
        }
    }

WaitForFrame

指定フレームまつ

    [[nodiscard]] Task<void> WaitForFrame(s3d::uint32 frame)
    {
        co_yield{frame};
    }

WaitUntil

条件をみたすまで待つ

    template<class Pred, std::enable_if_t<std::is_invocable_r_v<bool, Pred>>* = nullptr>
    [[nodiscard]] Task<void> WaitUntil(Pred pred)
    {
        while (!pred()) {
            co_yield{};
        }
    }

WaitWhile

条件をみたす間待つ

    template<class Pred, std::enable_if_t<std::is_invocable_r_v<bool, Pred>>* = nullptr>
    [[nodiscard]] Task<void> WaitUntil(Pred pred)
    {
        while (pred()) {
            co_yield{};
        }
    }

Async

別スレッドで処理し完了するまで待機

    template<class Fty>
    [[nodiscard]] auto Aysnc(Fty func)->Task<decltype(func())>
    {
        auto f = std::async(std::launch::async, func);
        while (f.wait_for(0s) != std::future_status::ready) {
            co_yield{};
        }
        co_return f.get();
    }

Taskの使える場面

やはりディレイ処理など時間に関与するものを実装する際は便利です

  • イージング等のアニメーションの組み合わせ
  • キャラクターの攻撃パターンロジックの実装
  • 定期実行する処理
  • etc...

まとめ

  • C++20コルーチンを使えば↑みたいな感じでプログラミングができる
    • 3つのキーワード co_yield, co_await, co_return
  • コルーチンを使うことでdelay処理などが簡単にでき、ゲームプログラミング等とは相性がよい場面がある
  • 今回紹介したTaskは1例にすぎません、自分だけの最強コルーチンを作ってみてください。
    • 実装者はちょっと大変。使用者には優しく。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
10