🔄

C++ で Delegate っぽいものを作る

2023/08/02に公開

Delegate(デリゲート)とは?

言語によって詳細は異なると思いますが、一般的には 「関数への参照を保持 + 参照関数の呼び出し」 する機構のことをデリゲートと呼びます。1つの参照を持つものをシングルキャストデリゲートと呼び、複数の参照を保持することが可能なものはマルチキャストデリゲートと呼ばれます。
https://learn.microsoft.com/ja-jp/dotnet/csharp/programming-guide/delegates/

UnityC#

C#では言語仕様としてデリゲート構文が存在します。以下のコードはUnityにおいてシーン読み込み完了時に呼ばれるsceneLoadedデリゲートです。とてもシンプルな見た目でよいですよね。私が初めてデリゲートを知ったのがこのコードだったこともあり、サンプルとして選びました。また、+=演算子から推測できるように、1つのデリゲートに対して複数の関数の参照を渡すことができます。

void OnSceneLoaded(Scene scene, LoadSceneMode mode);
SceneManager.sceneLoaded += OnSceneLoaded;

UnrealC++

UnrealEngineC++で独自のデリゲートクラスが実装されています。
シングル/マルチ キャストの2つの実装があります。

void MyClass::OnPreLoad();
FCoreUObjectDelegates::PreLoadMap.AddRaw(this, &MyClass::OnPreLoad);

動機

メンバ関数ポインタの存在

デリゲートはC++の言語仕様には存在せず、関数ポインタを用いて同様の動作が可能です。ただし、C++には関数ポインタだけでなく、「メンバ関数ポインタ」があります。これが厄介でして、メンバ関数ポインタから関数を呼び出すためには、そのメンバ関数を呼び出すためのインスタンスも必要です。これらの理由から、2種類の関数ポインタに対して共通のインターフェースで呼び出しが可能なデリゲートの実装をしたいと思い始めました。

void (*)(void)          // 関数ポインタ
void (MyClass::*)(void) // メンバ関数ポインタ

std::function と std::bind

std::functionでメンバ関数を割り当てる場合はstd::bindを使うのですが、引数の数N個分std::placeholders::_Nを記述する必要があるという弱点があります。
std::bindでも実装は可能ですが、引数の数に依存するので共通のインターフェースにはなりません。引数の数に依らず、関数とインスタンスのみを指定するだけのUnrealC++のような記述が理想です。

void Update(float deltatime);
void MyClass::Update(float deltaTime);

// std::bind ✕
std::function<void(float)> mf = std::bind(&MyClass::Update, this, std::placeholders::_1);
std::function<void(float)> sf = &Update;

// UnrealC++ 〇
delegate.Bind(this, &MyClass::Update);
delegate.Bind(&Update);

実装

UnrealC++ ライクな実装

Delegate 全体コード
#include <functional>

template<typename T>
class Delegate;

template<typename ReturnT, typename... Args>
class Delegate<ReturnT(Args...)>
{
public:
    using FuncT = std::function<ReturnT(Args...)>;

    template<typename Class>
    void Bind(Class* instance, ReturnT(Class::* memFn)(Args...))
    {
        function = [instance, memFn](Args... args) -> ReturnT
        {
            return (instance->*memFn)(args...);
        };
    }

    void Bind(FuncT fn)
    {
        function = fn;
    }

    ReturnT Execute(Args... args)
    {
        return function(args...);
    }

    void UnBind()
    {
        function = nullptr;
    }

private:

    FuncT function;
};

実装内容

std::function<T>に可変長パラメータを渡すため、T戻り値<引数リスト> になるように特殊化

template<typename T>
class Delegate;

template<typename ReturnT, typename... Args>
class Delegate<ReturnT(Args...)>
{
    using FuncT = std::function<ReturnT(Args...)>;
};

インスタンスをキャプチャしたラムダ式を使用することで、std::bindの使用を回避

template<typename Class>
void Bind(Class* instance, ReturnT(Class::* memFn)(Args...))
{
    function = [instance, memFn](Args... args) -> ReturnT {
        return (instance->*memFn)(args...);
    };
}

実装後

UnrealC++ のような記述が可能になった

Delegate<void(int, int)> func;
func.Bind(this, &Class::Func);
func.Excute(1, 2);

std::function を使わない実装

インターフェースはそのままにstd::function が行っている処理を自前の Functionに置き換えようというこころみです。

// std::functionを可変長パラメータで型エイリアス
using FuncT = std::function<TReturn(Args...)>;

// 自前のfunctionに置き換え
using FuncT = Function<TReturn(Args...)>;

なぜ置き換えをするのか?

自前で実装したいというのが一番の理由ですが、関数オブジェクトを格納する変数のサイズを自分で決定できるという理由もあります。std::functionはサイズの小さい関数オブジェクトであれば静的領域を使って動的確保をしない最適化(SBO: Small Buffer Optimization)が行われます。私の記憶では16バイトまでは静的領域に確保されたと思います。おそらく最低限メンバ関数を割り当てる (インスタンス(8) + 関数ポインタ(8)) ための最低バイト数ではないかと思います。それ以上の関数オブジェクト(キャプチャが多いラムダ式など)を割り当てる場合には動的確保されるのでしょう。Functionは割り当てサイズが静的に決定され、それ以上のサイズに再度割り当てする必要がないケースを想定しているので、すべて静的領域に割り当てられます。

関数ポインタに置き換え

まず、std::functionを使っていた部分を取り除き、関数ポインタに置き換えてみると...

//using FuncT = std::function<ReturnT(Args...)>;
using FuncT = ReturnT(*)(Args...);

通常関数であれば渡すことができますが、メンバ関数は渡すことができなくなります。

Delegate<void()> d;
d.Bind(&Hoge);            // 〇
d.Bind(this, &Foo::Func); // ✕
// Delegate<void(void)>::Bind::<lambda_1>' から 'void (*)(void)' に変換できません

引数自体は正しく渡せていますが、エラーが起きてしまいました。原因はvoid (*)(void)という関数ポインタに、ラムダ式を代入しようとしているからです。

メンバ関数とラムダ式

メンバ関数はinstance.Func()のように呼び出しにはインスタンスが必要です。なので、メンバ関数を呼び出す場合はinstance.Func()という処理を行うラムダ式を代入し、それを呼び出すという方法をとる必要があります。ラムダ式は関数ポインタではなく、ラムダ式の内容を実行するoperator()を持った型であり、ラムダ式は関数オブジェクトです。だから関数ポインタに対して値型を渡したことがエラーになったというわけです。

template<typename Class>
void Bind(Class* instance, ReturnT(Class::* memFn)(Args...))
{
    using FuncT = ReturnT(*)(Args...);
    FuncT function;

    // ポインターに対する値型の代入
    function = [instance, memFn](Args... args) -> ReturnT
    {
        return (instance->*memFn)(args...);
    };
}

関数呼び出し可能な型であればなんでも

先ほどのエラー内容からfunctionにメンバ関数を保存するのであれば、functionはラムダ式(関数オブジェクト)を代入可能な型になる必要があることが判明しました。つまり、変数functionに求められる型の条件としては、関数ポインタが代入可能かつ関数オブジェクトが代入可能な型ということです。めちゃくちゃな要求です。とりあえずCall型として保存しておきます。

代入可能かどうかは別として、Call型の型要件としては、function()の記述で関数が呼ばれる必要があります。functionが関数ポインタであれば、関数呼び出し演算子()によって関数が呼ばれますし、関数オブジェクトだった場合、operator()が実行されます。つまりCall型はどんな型かわからないけど関数呼び出しが出来る型であって欲しいわけです。

// 関数オブジェクト
struct FO { void operator()(void){} };
FO obj;

// 関数ポインタ
using FP = void(*)(void);
FP ptr = &Func;

// どちらも関数呼び出し可能な型
obj();
ptr();

// どちらも代入・呼び出し可能
Call function = obj;
Call function = ptr;
function();

FO型FP型Callとしたクラスを実装すればなんとなく実装出来そう?

関数割り当てを行うまで型が決定しない

template <typename Signature>
class Function;

template <typename ReturnT, typename... Args>
struct Function<ReturnT(Args...)>
{
    // 今はわからないけどCall型なんだな...
    template <typename Call>
    void Bind(Call&& call)
    {
        fuction = Call(std::forward<Call>(call));
    }
    
    // パラメータにないけど、Callは何型?
    Call function;
};

薄々気づいていたことですが、これは上手くいきません。Callパラメータは関数パラメータなので呼び出しのたびに評価されますが、functionはメンバ変数なのでインスタンス時に1度しか評価されず、Bind関数呼び出し前に Callを評価しようとするのでエラーが発生するわけです。かといって、クラスにCallテンプレートを追加してしまうと「インスタンス化時に割り当てる関数のタイプが決定しなければいけない」ということになります。これでは目標としたUnrealC++のインターフェースではありません。

これはジレンマです。型の詳細が分からないから(関数ポインタか関数オブジェクト)パラメータCallとして隠蔽したのに、Callが宣言時には判明しないのでインスタンス化できないのです。最終的にCall型に求められるのは テンプレートパラメータ型にはならず、関数呼び出しが出来る型ということになりました。

ポリモーフィズムと型消去(Type Erase)

こんな時、具体的な型データは分からないけど、共通のインターフェース呼び出しだけで実際の型データにあった呼び分けを行う方法がありました。そうです、多態性(ポリモーフィズム)です。前述したように、インターフェースクラスはどんな型かわからないけど関数呼び出しが出来る型が欲しいので、operator()を実装するように強制するICallableクラスを作ります。

// どんな型かわからないけど関数呼び出しが出来る型
struct ICallable
{
    virtual void operator()() const = 0;
};

次は実際の処理を行う派生先です。従来のポリモーフィズムはこの派生クラスを型データ分用意して各々実装しますが、このポリモーフィズムはテンプレートを使うので派生型は1つしか使用しません。Tの派生型の定義をテンプレートが肩代わりし、Tの実際の実装は外部からもらいます。

// 従来
// struct PointerCallable : public ICallable { ... };
// struct FunctorCallable : public ICallable { ... };

template <typename T>
struct Callable : public ICallable
{
    // 関数ポインタ or 関数オブジェクト
    T functor;
    
    // コンストラクタ
    template<typename F>
    Callable(F&& f) : functor(std::forward<F>(f)) {}
    
    // Tに対する関数呼び出し or operator() 呼び出し
    void operator()() const override { return functor(); }
};

実際のコードとは異なりますが、インスタンス化はこのようなイメージです。

ICallable* callable = nullptr;

template <typename F>
void Bind(F&& func)
{
    callable = new Callable<T>(std::forward<F>(func));
}

ICallableはインターフェースであり、具体的な型データを必要としません。つまり、テンプレートパラメータが消せるということです。これで関数をバインドするまで実際の型データがわからないという問題が解決しました。

継承を使ったポリモーフィズムは、インターフェースとなる特定の型を継承することで「インターフェースを介した操作」が可能になるのに対して、ICallableを使ったポリモーフィズムはインターフェースがFunctionクラス内に隠蔽されており、特定のインターフェースを継承しなくても、実行時に渡されたオブジェクトに対して共通のインターフェース操作が可能になります(もちろん、インターフェースが求める操作を実装している必要はあります)

ポリモーフィズムは その型がインターフェースが求める共通の処理を実装している ということを保証する機能でもあります。その点、Callableクラスは、Bind関数で渡されるまで具体的な型として扱わず、どんな型かわからないけど関数呼び出しが出来る型として扱っています。この様に、実行時まで具体的な型を定義ぜず消してしまうことを型消去といいます。

ここまでの実装

仮ではありますが、現状の全体コードの確認です。全ての関数がバインド可能(メンバ関数のバインドは少し工夫が必要)になりました。ただし、動的確保をおこなっている部分があるので、そこを静的領域への割り当てを行うように変更していきたいと思います。

現在の仮コード
#include <type_traits>

template <typename Signature>
class Function;

template <typename ReturnT, typename... Args>
class Function<ReturnT(Args...)>
{
public:

    Function() = default;
    ~Function() { Unbind(); }

public:

    template <typename F>
    void Bind(F&& f)
    {
        callable = new Callable<F>(std::forward<F>(f));
    }

    void Unbind()
    {
        if (callable)
        {
            delete callable;
            callable = nullptr;
        }
    }

private:

    struct ICallable
    {
        virtual ~ICallable() = default;
        virtual ReturnT operator()(Args...) const = 0;
    };

    template <typename T>
    struct Callable : public ICallable
    {
        T functor;

        template<typename F>
        Callable(F&& f) : functor(std::forward<F>(f)) {}

        ReturnT operator()(Args... args) const override
    	{ 
    	    return functor(std::forward<Args>(args)...);
    	}
    };

private:

    ICallable* callable = nullptr;
};

動的確保を無くしていく

あとは動的確保を無くすだけです。newの動的確保をplacement newにかえてクラス内の静的バッファにオブジェクトを構築するようにします。

callable = new (buffer) Callable<F>(std::forward<F>(f));

バッファサイズはテンプレート引数で指定するようにします。任意でデフォルト引数を追加しても良いでしょう。メンバ関数のバインドを考えると最小で16バイト必要なので16にします。

template <typename Signature, std::size_t BufferSize = 16>
class Function;

template <typename ReturnT, typename... Args, std::size_t BufferSize>
class Function<ReturnT(Args...), BufferSize>
{
    //...

    ICallable* callable = nullptr;
    uint8 buffer[BufferSize + sizeof(void*)] = { 0 }; // vtableサイズを考慮
}

それにともなってUnbind関数内も静的確保用に変更します。deleteからデストラクタ呼び出しに変え、メモリを0クリアします。

void Unbind()
{
    if (callable)
    {
        callable->~ICallable();
        callable = nullptr;
    }

    std::memset(buffer, 0, BufferSize + sizeof(void*));
}

呼び出し部分も追加します。インターフェースに対して関数呼び出し()を行うだけで、特別なことはしていません。callableどんな型かわからないけど関数呼び出しが出来る型です。関数ポインタであれば関数呼び出し()になり、関数オブジェクトならoperator()が呼ばれます。

ReturnT Execute(Args... args)
{
    return (*callable)(std::forward<Args>(args)...);
}

完成

これで完成です。本家のstd::functionに到底及びませんが、使えないことはないかなと思います。以下の全体コードでは、文章中にない追加処理やMulticastDelegateも実装しているのでよかったら参考にしてください。

全体コード

https://gist.github.com/Suuta/43b2e7ee973d8d3fda581e89efe28b45
https://gist.github.com/Suuta/e294208c04f346103723a9df5e134d2f
https://gist.github.com/Suuta/d15f8940c2a4d7301020dceac9c51e61

Discussion