Tech Notes

C++のstd::functionは遅いらしいのでもっと高速なコールバック機構を考えてみた

C++で開発をしていると時折悩むのが、「関数を受け取る場合どのように受け取り保持するか」という問題だ。C++にはこれに対する解が3つほどある。 悩むというのは実現できなくて悩むのではなく、どれを使って実現するかを悩むのである。

  1. 関数ポインタで受け取る。
  2. テンプレートで受け取る。
  3. std::functionで受け取る。

関数ポインタは最も単純な方法だ。しかし外部変数のキャプチャができない。 そこで、多くのC言語ライブラリではコールバック関数に加えてvoid*型の任意値をユーザーが渡せるようにすることで解決する。 しかし現代のまともな C++er であればこう思うだろう――「ラムダ式のキャプチャリストを使わせろ」と。

この点でテンプレートとstd::functionは優れている。通常の関数だけでなく任意の関数オブジェクトが使えるので、どんなデータでも持たせることが可能だ。当然ラムダ式も使える。テンプレートの欠点は静的に解決しなければならないところで、std::functionの欠点は遅いことである。

テンプレートは静的に解決しなければならない。これは全く当然のことなのだが、関数の「受け渡し」だけでなく「保持」を考えると困ったことになる。 次のような通信を行うクラスを考えてみよう。

// 通信クラス
class Tranceiver {
  public:
    template<class F>
    on_connect(F &&callback) { /* ... */ }
    
    template<class F>
    on_receive(F &&callback) { /* ... */ }

    template<class F>
    on_close(F &&callback) { /* ... */ }

    void connect(User user) { /* ... */ }
    void send(Data data) { /* ... */ }
    void close() { /* ... */ }
};

int main() {
    Tranceiver trx;

    trx.on_connect([](){ std::cout << "successfully connected" << std::endl; });
    trx.on_receive([](Data data){ std::cout << "received: " << data << std::endl; });
    trx.on_close([](){ std::cout << "connection was closed" << std::endl; });

    trx.connect("someuser");
    trx.send("somedata");
    // ...
}

このようなTranceiverクラスを実装するときのことを考えてみよう。我々はon_receiveなどのメンバ関数で渡されたcallbackを、メンバ変数か何かとして保持する必要がある。 しかしここでぶち当たる問題が、型情報を保持しない限りメソッドで設定されたcallbackを普通に変数として保持することはできないことである。

trx.on_receive()のようにメソッドを呼ぶことでtrxの型が変わることがあるだろうか?普通変わったら困る。trxの型が変わらない以上、on_receiveとかで渡した関数オブジェクトをまともな形で保持することは不可能だ。じゃあどうするか。std::functionを使うしかないという結論になってしまうのである。

class Tranceiver {
    std::function<void()> on_conenct_callback;
    std::function<void(Data)> on_receive_callback;
    std::function<void()> on_close_callback;

  public:
    template<class F>
    on_connect(F &&callback) { on_connect_callback = std::move(callback); }
    
    template<class F>
    on_receive(F &&callback) { on_receive_callback = std::move(callback); }

    template<class F>
    on_close(F &&callback) { on_close_callback = std::move(callback); }
};

綺麗に書けはしたが、std::functionは遅い。シンプルな関数ポインタや、そもそも静的に解決されるテンプレートに比べれば、場合に依るが最大で数倍程度時間がかかると見積もった方が良いと思われる。(参考)

もちろんC++はそもそもが高速な言語なので問題にならないことも多い。高速とされるライブラリでもstd::functionを使っているものはいくつもある。しかし、余計なオーバーヘッドがかかっていることは事実である。無くてよいオーバーヘッドは極力避けたいと思うのがC++erの性である。

(補足)std::functionは一体内部でどんな魔法を使ってこの挙動を達成しているのだろうか?これは"Type Erasure"と呼ばれるテクニックが使われている。ググれば出てくるが、端的に言えば適当な基底クラスを用意してそれを継承するラッパクラスで目的の型を包むことで任意の型の値を保持できる。std::functionの呼び出しの重さは主に中身の種類による場合分けと、vtableのアクセスによるオーバーヘッドと思われる。同じ手法で軽いstd::functionを作ろうとしても重い車輪の再発明になるだけであろう。

本題

std::functionが重いのは関数を実行時の情報として持っているためである。 しかし実際のところ、上記のTranceiverのようなクラスを利用するプログラマーは、登録するコールバックを動的に変えることがあるだろうか? 状況に応じてon_connectやらon_receiveやらを呼んで別の関数を登録しなおす...ということが一体どれほどあるだろうか?

最初にコールバックを登録してそのまま変えないとするなら、関数登録を静的に解決しない理由はどこにもない。

以下のようなクラスを作ってみた。

template <class Event, class Handler, class Parent>
class EventHandlerContainer : public Parent {
    Handler handler;

  public:
    EventHandlerContainer(Parent &&_parent, Handler &&_handler)
        : Parent(std::move(_parent)), handler{std::move(_handler)} {}

    template <class TargetEvent, class... Args>
    auto call(Args &&...args) {
        if constexpr (std::is_same_v<TargetEvent, Event>) {
            return handler(std::forward<Args>(args)...);
        } else {
            return this->Parent::call<TargetEvent>(std::forward<Args>(args)...);
        }
    };

    template <class NewEvent, class NewF>
    auto on(NewF &&f) && {
        using SelfType = EventHandlerContainer<Event, Handler, Parent>;
        using NewType = EventHandlerContainer<NewEvent, NewF, SelfType>;
        return NewType(std::move(*this), std::move(f));
    }
};

class EventHandlerBuilder {
  public:
    EventHandlerBuilder() {}

    template <class NewEvent, class NewF>
    auto on(NewF &&f) && {
        return EventHandlerContainer<NewEvent, NewF, EventHandlerBuilder>(std::move(*this), std::move(f));
    }
};

これは以下のように使える。

struct Ev1{};
struct Ev2{};

// コールバック保持オブジェクト
auto cb =
    EventHandlerBuilder{}
        .on<Ev1>([](int a) {
            std::cout << "Ev1: " << a << std::endl;
        })
        .on<Ev2>([](double p, const char *q) {
            std::cout << "Ev2: " << p << ' ' << q << std::endl;
        });

cb.call<Ev1>(42);
cb.call<Ev2>(3.14, "hello");

このcallによる関数呼び出しは全て静的に解決される。変数cbの型には、メソッドチェーンにより登録されたコールバック関数の情報が全て内包されているためである。 コードを読めば分かるように実装の都合上登録したコールバックの数だけの再帰が入っているが、これはデバッグビルドでもなければコンパイル時に展開されるためオーバーヘッドは全く無い。

注意点としては、型情報に全てが詰まっているため、このcbを受け取る側はテンプレートでなければならない点だろう。

template<class Tcb>
class Hoge {
    Tcb cb;
  public:
    Hoge(Tcb&& _cb) : cb{std::move(_cb)} {}

    void method() { /* 何らかの処理 */ }
};
Hoge hoge{std::move(cb)};   // コンストラクタでコールバックの情報を引き渡す

これでHogeのメソッドはいつでもユーザーによって登録されたイベントに対するコールバックを呼び出すことができる。

ちょっと発展

せっかくなので、Hogeを利用する側はEventHandlerBuilderとかを意識せず書けるようなのを考えてみる。

template <class Event, class Handler, class Parent, template <class> class TConstract>
class EventHandlerContainer : public Parent {
    Handler handler;

  public:
    EventHandlerContainer(Parent &&_parent, Handler &&_handler)
        : Parent(std::move(_parent)), handler{std::move(_handler)} {}

    template <class TargetEvent, class... Args>
    auto call(Args &&...args) {
        if constexpr (std::is_same_v<TargetEvent, Event>) {
            return handler(std::forward<Args>(args)...);
        } else {
            return this->Parent::call<TargetEvent>(std::forward<Args>(args)...);
        }
    };

    template <class NewEvent, class NewF>
    auto on(NewF &&f) && {
        using SelfType = EventHandlerContainer<Event, Handler, Parent, TConstract>;
        using NewType = EventHandlerContainer<NewEvent, NewF, SelfType, TConstract>;
        return NewType(std::move(*this), std::move(f));
    }

    auto build() && {
        return TConstract{std::move(*this)};
    }
};

template <template <class> class TConstract>
class EventHandlerBuilder {
  public:
    EventHandlerBuilder() {}

    template <class NewEvent, class NewF>
    auto on(NewF &&f) && {
        return EventHandlerContainer<NewEvent, NewF, EventHandlerBuilder, TConstract>(std::move(*this), std::move(f));
    }
};

Hogeクラスの実装。

struct Ev1 {};
struct Ev2 {};

template <class Tcb>
class Hoge {
    Tcb cb;

  public:
    Hoge(Tcb &&_cb) : cb{std::move(_cb)} {}

    void ev1() {
        cb.call<Ev1>(42);
    }
    void ev2() {
        cb.call<Ev2>(3.14, "Hello");
    }
};

using HogeBuilder = EventHandlerBuilder<Hoge>;

こんな感じで書ける。

// Hoge型のオブジェクト
auto hoge =
    HogeBuilder{}
        .on<Ev1>([](int a) {
            std::cout << "Ev1: " << a << std::endl;
        })
        .on<Ev2>([](double p, const char *q) {
            std::cout << "Ev2: " << p << ' ' << q << std::endl;
        })
        .build();

hoge.ev1();     // Ev1: 42
hoge.ev2();     // Ev2: 3.14 Hello

あとがき

この記事で紹介してみたEventHandlerContainerは指定のイベント型に対するコールバックが登録されていなかった場合の処理を書いていない。テンプレートなので、たぶんバケツをひっくり返したような量のコンパイルエラーが出る。適宜実装すれば普通に対策はできると思う。

この記事の着想元としてはRustのライブラリで似たような雰囲気で書けるものがあり(実装は見ていない)、そうかメソッドチェーンで書くようにすれば書きやすさを維持した上で型情報に全部静的に溜め込んでいけるじゃん、ということでやってみたところ出来たというものだ。

シンプルな仕組みなのでどう考えても誰かが既にやってそうだと思うのだが、軽くググった限りで出てこなかったので記事にした。 テンプレートという武器で色々なものを静的に解決できる点こそがC++がC言語にパフォーマンス面で勝れる数少ない面なので、もっと活用してみた方が良いんだと思う。


コメント