tak0kadaの何でもノート

発声練習、生存確認用。

医学関連は 医学ノート

CRTPを使ってMonoBehaviourを継承したクラスのコンストラクタっぽいものを書く

これは「inonoa Advent Calendar 2020」10日目の記事です。

adventar.org

--

ここ最近、KMCの「みんげー」という新入生プロジェクトでUnityというゲームエンジンを触っています(1回生ではありませんが、KMCは1年目なので許して…)。世の中のゲームは高度なものばかりで基準が狂いがちですが、自分で書いてみるとゲーム製作は管理する状態が多く、単純なものでも意外と難しいことが分かります。プログラムの技術が向上する感じがして良いですね。アドベントカレンダー主催者のinonoaさんには、Unityの便利機能を大量に教えてもらっていることから、カレンダーの方に何か書いて盛り上げていく、という趣旨(?)の記事です。

問題提起

さて、Unityでは、C#を使って普通のプログラムを書きます。ただし、実行時に「インスタンスを生成する際に自分でnewを呼べない」という点で、普通のC#と異なります。他にも細かい違いは色々ありそうですし、左記の特徴はMonoBehaviourを継承しないクラスには認めませんが。それはともかく、この問題を良い感じに解決したいと思います。

不完全な解決策

コンストラクタ自体は呼べなくても、AddComponent経由でインスタンスを得ることは出来るので、static関数内に記述してみます。

// 不完全な例
public class PlayerCharacter : MonoBehaviour
{
    private int val;


    // var pc = PlayerCharacter.New(3) などとすればインスタンスを生成できる
    // 関数の名前はNew、Create、Instantiate、その他何が一番良いのか分かりませんが、今回はNewとしました
    public static PlayerCharacter New(int val)
    {
        var obj = new GameObject("PlayerCharacter");
        var pc = obj.AddComponent<PlayerCharacter>() as PlayerCharacter;
        pc.Construct(val);
        return pc;
    }

    private void Construct(int val)
    {
        // 単純なのでNew()の中に直接書いてしまうことも多い
        this.val = val;
    }
}

Prefab(事前に生成したオブジェクト)をResources.Load()する場合は、このスクリプトは事前にアタッチされているはずなので、AddComponentではなくGetComponentを使えば良さそうです。

しかし、このクラスを継承したクラスをNewする場合を考えると、返り値の型は子クラスでなく、親クラスになってしまいます。これは不完全な実装です。

解決策

CRTP(curiously recurring template pattern)というC++テンプレートテクニックという本で仕入れたテクニックを濫用(?)します(出版社ページ)。CRTP自体は以下のようなもので、子クラスの型を親クラスに渡すことができるものです。

class Base<T>
{
    T func()
    {
        return this;
    }
}


// func()を呼べばDerivedが返ってくる
class Derived : Base<Derived>
{}

CRTPの名前に再帰が含まれるのは、Derivedが2回記述されているところを見ればなるほど、という感じです。

以下が完全(のはず、ほんまか)な解決策です

public interface IPlayerCharacter
{}


public abstract class PlayerCharacter<T> : MonoBehaviour, IPlayerCharacter where T : MonoBehaviour
{
    private int val;


    // var pc = Derived.New(3) などとすればインスタンスを生成できる
    public static T New(int val)
    {
        var obj = new GameObject(typeof(T).ToString());
        var pc = obj.AddComponent<T>() as PlayerCharacter<T>;
        pc.Construct(val);
        return pc as T;
    }

    protected virtual void Construct(int member)
    {
        this.val = val;
    }
}


public class Derived : PlayerCharacter<Derived>
{
    protected override void Construct(int val)
    {
        this.val = val * 2;
    }
}

ややこしくなってしまいました。unityは呼べないので少し異なりますが、似たようなものをhttps://wandbox.org/permlink/uwoQbMCTiD6oz79wにも置きました。

AddComponent<>の型引数にはTを渡しています。問題はas PlayerCharacter<T>のところです。単純にas Tとすると、TにはMonoBehaviourという制約しかないため、Construct()が定義されていることをコンパイラに伝えられません。そこで、親クラスになるPlayerCharacterへキャストし、さらに、Constructを仮想関数にすることで子クラスに定義したものが呼ばれるようにしてあります。

ついでにインターフェースを指定しておくと、親子とも好きなインターフェースにキャストして便利に使えます。MonoBehaviourのメンバに触りたいときは、PlayerCharacter -> Base -> Derivedのように、もう1回余分に継承した上でBaseにキャストする必要があるかもしれません。継承より委譲の方が安全だという話はあり、みんげー用のコードでも上記のようなコードは数ヶ所ありましたが、整理すると残ったのは1ヶ所だけでした。とはいえ、共通の振る舞いがあると継承を使いたい時はあるような気もします。そのような場合、親クラスにこのような関数を仕込んでおくと、子クラスで便利かもしれません。