😺

std::function<bool()> を使った簡易な疑似並行処理

2024/12/13に公開

C++ Advent Calendar 2024 の13日目の記事です。

はじめに

30FPS やら 60FPS やら毎秒 n回の周期で動かす「ゲームループ」を想定した話です。
毎フレームごとに、外部から渡された std::function<bool()>(bool値で「継続/終了」を返す関数)を呼び出して処理が終わるまで待つ…といったコードをたまに使います。(引数はあったりなかったり)

// 毎フレームの処理.
void Foo_GameTask::tick() {
    //【略】
    // 外部から設定された処理が終了するまで待つ.
    if (sub_())
        return;
    //【略】
}

sub_() は読み込み待ちとか何か完了待ちとか単純なことが多いですが、

    sub_ = [step=0u]() mutable {
        switch(step) {
        case XXX:
            ++step;
         //【略】
        }
        return false;
    };

のように軽めの処理をその場で描くこともあり。

で、さらに複数をまとめて

using Act = std::function<bool()>;
Act act = [](){ /* ... */ };

Act acts = Para(           // 同一フレーム内で引数順にAct実行.
    []() { /* ... */ },
    [step = 0u]() mutable { ++step; /* ... */ return true; },
    std::move(act)
);

みたいなのも書けそうかな、と。

実装

ということで、試しに std::function<bool()> を束ねる、疑似的な並行実行のラッパと直列実行のラッパを用意してみました。

TinyActFlow.hpp
#ifndef TINYACTFLOW_HPP
#define TINYACTFLOW_HPP

#include <algorithm>
#include <functional>
#include <vector>

namespace TinyActFlow {

// 処理アクションを表す関数型.
using Act = std::function<bool()>;

namespace detail {
    // 可変引数をベクタに詰め込むヘルパー
    template<class VEC>
    void vec_add(VEC&) { }
    template<class VEC, typename T, typename ...Args>
    void vec_add(VEC& vec, T&& t, Args... args) {
        vec.emplace_back(std::move(t));
        vec_add(vec, args...);
    }
}

// Seq: アクションを順番に実行.
inline Act Seq(std::vector<Act>&& acts) {
    return [tbl = std::move(acts), idx = 0u]() mutable {
        while (idx < tbl.size()) {
            auto& act = tbl[idx];
            if (act())
                break;
            ++idx;
        }
        return idx < tbl.size();    // true なら継続、false なら完了.
    };
}

template<typename ...Args>
inline Act Seq(Act&& act, Args... args) {
    std::vector<Act> vec;
    vec.reserve(1 + sizeof...(args));   // 予め要素分メモリ確保.
    vec.emplace_back(std::move(act));
    detail::vec_add(vec, args...);
    return Seq(std::move(vec));
}

// Para: アクションを疑似並行的に実行.
inline Act Para(std::vector<Act>&& acts) {
    return [tbl = std::move(acts)]() mutable {
        bool running = false;
        for (auto& act : tbl) { // 登録されている act を登録順に一巡して実行.
            if (!act)           // 削除済みなら次へ.
                continue;
            if (act())          // 1つ実行. trueが帰ってきたら
                running = true; // Para自体も継続.
            else
                act = nullptr;  // 登録削除.
        }
        return running;         // 一つでもactが残ってたら継続、なければ完了.
    };
}

template<typename ...Args>
inline Act Para(Act&& act, Args... args) {
    std::vector<Act> vec;
    vec.reserve(1 + sizeof...(args));   // 予め要素分メモリ確保.
    vec.emplace_back(std::move(act));
    detail::vec_add(vec, args...);
    return Para(std::move(vec));
}

} // namespace TinyActFlow

#endif // TINYACTFLOW_HPP

このヘッダでは、

  • Act:std::function<bool()>で表す1つのアクション
  • 各Actはtrueで「継続」、falseで「完了」を示す
  • Seq:複数のActを、引数並びの順番(直列)に毎フレーム一つ実行
  • Para:複数のActを、(引数順に) 同時進行風/疑似並行 に実行

としています。

Seq や Para は、可変引数テンプレートで任意個数のActを受け取り、std::vector 化したものをmoveでキャプチャして保持します。キャプチャした変数を変更するため mutable にしています。

使い方はざっくり

using Act = std::function<bool()>;
Act act = [](){ /* ... */ };

Act acts = Para(           // 同一フレーム内で引数順にAct実行.
    []() { /* ... */ },
    [step = 0u]() mutable { ++step; /* ... */ return true; },
    Seq(                   // 引数順に1フレームに1つAct実行.
        []() { /* ... */ },
        []() { /* ... */ }
    ),
    Act(act),      // actをコピー
    std::move(act) // actを移動
);

引数として受け取る Act を std::move で受けるため、変数を渡す場合はコピーしたいなら Act(act)、移動したいなら std::move(act) と明示する必要があります。

使用例

ちょっと「動きのある hello world」 です。 Windows の DOS プロンプト用。
※ 汎用的に書いたつもりだったのですが、各端末の画面更新頻度を考慮不足で、動きにならない……

#include "TinyActFlow.hpp"
#include <cstdio>
#include <cstdlib>
#include <thread>
#include <chrono>

int main() {
    using namespace std;
    using namespace TinyActFlow;

    bool end_req = false;
    // 処理終了を要求するアクション.
    Act endReq = [&end_req]() {
        end_req = true;
        return false;
    };

    const char* str = "hello world!";

    // メインアクション定義.
    Act mainAct = Para(
        // 初回のみの数行空行を出力.
        []() {
            for (int i = 0; i < 100; ++i)
                printf("\n");
            return false; // 一回限り.
        },
        // 一行消去.
        []() {
            printf("%*c\r", 40, ' ');
            return true; // 継続.
        },
        // カウンタ表示.
        [step = 0u]() mutable {
            ++step;
            printf("[%2u]  ", step);
            return true; // 継続.
        },
        // hello world! を変化させて表示.
        Seq(
            // 文字列を1文字ずつ表示.
            [step = 0u, str]() mutable {
                if (str[step]) {
                    char buf[64];
                    snprintf(buf, sizeof(buf), "%*c", 12, ' ');
                    buf[step] = str[step];
                    ++step;
                    printf("%s", buf);
                    return true;
                }
                return false;
            },
            // 文字列を段階的に伸ばして表示.
            [step = 0u, str]() mutable {
                if (str[step]) {
                    printf("%*.*s", (int)step, (int)step, str);
                    ++step;
                    return true;
                }
                return false;
            },
            // 一定時間(約16回)文字列を表示し続ける.
            [step = 0u, str]() mutable {
                printf("%s", str);
                return ++step < 16;
            },
            // 終了リクエスト.
            Act(endReq) // std::move(endReq)
        )
    );

    // メインアクション(mainAct)が継続する限り実行(0.1秒単位に更新)
    while (mainAct() && !end_req) {
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
    }

    return 0;
}

Para で(上から順に)並行して動作する要素は、

  • 一度だけ画面を大量の改行でクリア風にする処理
  • 毎フレーム一行を消去する処理
  • カウンタを表示する処理
  • Seq で「hello world!」を徐々に表示していく処理

となっています。

Seq による hello world 表示は、一文字づつ表示と、1文字づつ長くなる表示、を続けてから出しっぱなし、計40カウント. 最後に終了を変数 end_req で通知。

上記を束ねた mainAct の実行は最後の while で 0.1 秒更新で回しています。この例では mainAct 自体は完了することないので実質 end_req で終了判定しています。

おわりに

好みの問題ですが、書きやすい気はします。
std::function だしコスト感は使うところ次第といったところでしょうか。

書いておいてなんですが、ネタに困って急遽でっち上げたモノで使用実績ないので... 使えそうな気はしてますが、まあトイということで。

Discussion