リフレクションを駆使してデバッグメニューを自動構築してみる(その3)

前回までのおさらい

さて、今回の記事で「リフレクションを駆使してデバッグメニューを自動構築してみる」のシリーズは最終回となります。

前々回の記事(その1)では、リフレクションを駆使してデバッグメニューの自動構築を行うための DebugMenuPageBuilder クラスの実装提案を行い、そして前回の記事(その2)で、そのクラスが内部でどのようにしてデバッグメニューの自動構築を実現しているのかについて説明しました。

しかし、まだ課題は残っています。「その1」の記事の最後の方で少し触れていますが、構築されたデバッグメニューの項目の順序がよくわからない順序になっています。また、手動で構築していた頃はわかりやすいようにセパレータを挿入できていましたが、自動構築の場合はセパレータをどうやって挿入すれば良いのでしょうか。

[DebugContract]
public class Character
{
    [DebugMenuItem(caption = "HP")]
    public int Hp { get; set; }
    
    [DebugMenuItem(caption = "座標")]
    public Vector3 Position { get; set; }

    [DebugMenuItem(caption = "無敵")]
    public bool IsInvincible { get; set; }
    
    [DebugMenuItem(caption = "即死")]
    public void Kill() { ... }
    
    :
    :
}

他にも、実際に実装して使用してみると、いろいろと不便なところが見えてきますので、そういったポイントも改善していきたいと思います。

メニュー項目の表示順序を制御する

C# では、リフレクションによって取得されるメンバの順序は未定義です。必ずしもソースコード上で定義された通りの順序で取得できるとは限りません。メンバが定義されている行番号でもわかればそれを基にしてソートを行いたいところです。

.NET Framework 4.5 以降であれば、CallerLineNumber 属性を駆使すれば、DebugMenuItem 属性が記述されている行番号を取得できるので、メンバが定義された行番号に近い値が取得できます。DebugMenuItem 属性のコンストラクタに CallerLineNumber 属性でマークされた order 引数を追加するだけです。

[Conditional("DEBUG")]
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property | AttributeTargets.Method,
                AllowMultiple = false, Inherited = true)]
public class DebugMenuItemAttribute : Attribute
{
    public DebugMenuItemAttribute([CallerLineNumber] int order = 0)
    {
        this.order = order;
    }
    
    public string caption { get; set; }
    public int order { get; private set; }
}

こうしておくと、コンストラクタで明示的に order が指定されなければ、コンストラクタの呼び出し元の行番号(つまりこの属性を使用した行番号)が指定されます。DebugMenuItem を利用している側のソースコードには(順序を明示的に指定したい場合を除いて)変更を加える必要はありません。

クラスを継承していたり、partial クラスだったりする可能性もあるので、実際にはもう少し工夫が必要ですが、そういうケースを除けばこれで定義順通りに表示できます。

また、.NET Framework 4.5 よりも古い環境の場合、この作戦は使えないので、表示順序を示す値を設定できるようにするしかありません。

[DebugContract]
public class Character
{
    [DebugMenuItem(caption = "HP", order = 100)]
    public int Hp { get; set; }

    [DebugMenuItem(caption = "座標", order = 110)]
    public Vector3 Position { get; set; }

    [DebugMenuItem(caption = "無敵", order = 120)]
    public bool IsInvincible { get; set; }

    [DebugMenuItem(caption = "即死", order = 130)]
    public void Kill() { ... }

    :
    :
}

order というint型のプロパティを DebugMenuItem 属性に追加しています。省略された場合のデフォルト値は 0 です。この値によってメンバをソートしてやれば、表示順序を制御することができます。ちょっと面倒臭いですが無いよりマシです…。

セパレータを挿入できるようにする

DebugMenuItem 属性にプロパティを追加して対処しましょう。

[DebugContract]
public class Character
{
    [DebugMenuItem(caption = "HP")]
    public int Hp { get; set; }

    [DebugMenuItem(caption = "座標", separate = true)]
    public Vector3 Position { get; set; }

    [DebugMenuItem(caption = "無敵", separate = true)]
    public bool IsInvincible { get; set; }

    [DebugMenuItem(caption = "即死")]
    public void Kill() { ... }

    :
    :
}

separate というbool型のプロパティを DebugMenuItem 属性に追加しました。省略された場合のデフォルト値は false です。このプロパティを true にすると、その項目の直前にセパレータを挿入します。

これで手動でデバッグメニューを構築していた時の見た目に追いつきましたね。

コレクションからデバッグメニューページを構築する

例えば List<string> 型のインスタンスを DebugMenuPageBuilder に食わせることを考えてみてください。List<string> には DebugContract 属性が付与されていないため、ルールに従えば、全ての public なインスタンスメンバがデバッグメニューに表示されるはずです。

上のイメージは List<string> 型のフィールドの値から自動構築されたデバッグメニューぺージの様子です。ふむ、たしかに public なインスタンスメンバがずらりと一覧できていますね!

いや、違う…。そうじゃない…。

コレクション型のインスタンスからページが自動構築されるなら、コレクションの要素がずらりと一覧できていたほうが嬉しいに決まっています。そこで、コレクション型のインスタンスが与えられた場合には、要素の一覧を生成するように処理を変更します。

これで要素を一覧できるようになりました。いくつかのメソッドはあった方が便利なこともあるので残しておきました。コレクションの種類によってできる操作が異なるので、このあたりはコレクションの種類によって処理を分けて記述する必要がありそうです。

もちろん各要素に対応するメニュー項目の構築には、フィールドからメニュー項目を構築する時と同じような処理が使われているので、単純でない型であれば更にそのメンバを閲覧したりすることができます。

static メンバからデバッグメニューページを構築する

static メンバにデバッグに重要な情報や機能が含まれていたりする場合には、static メンバからデバッグメニューページを自動構築したいことがあるでしょう。

DebugMenuPageBuilder.Build() に static メンバからページを構築するオーバーロードを追加するか、static メンバからページを構築させるようなオプション引数を追加するか… 色々方法は考えられますが、そこは好みに任せます。

重要なのは、DebugMenuPageBuilder.Build() を直接呼び出さない場合に、いかにして static メンバからページを構築させるかです。例えば Application クラスのインスタンスからデバッグメニューのルートページ(トップページ)を自動構築する場合に、ルートページ内の「Math」ボタンが押されたら、System.Math クラスの static メンバの一覧を表示したいとします。

Application クラスをどのように記述すべきでしょうか…? 僕の場合は System.Type 型のフィールド(かプロパティ)の場合に特殊処理をして、その Type の static メンバから構築されたページを表示するようにしました(もっといい方法があれば別の方法でも構いませんよ)。

[DebugContract]
public class Application
{
    :
    :
    
#if DEBUG
    [DebugMenuItem(caption = "Math", separate = true)]
    private Type Math_debug { get { return typeof(Math); } }
#endif
    
    :
    :
}

まとめ

DebugMenuPageBuilder を使うことによって、デバッグメニューの構築に関してはほとんど処理を記述せずに済むようになりました。アプリケーションの大元になるようなクラス(Application クラスなど)のインスタンスに対して DebugMenuPageBuilder.Build() を呼び出す数行のソースコードを記述することを除けば、あとは必要な型やメンバに属性を付与していくだけです!!

また、DebugMenuPageBuilder には構築できないような特殊な構造のページを構築したい場合は、ピンポイントで従来の方法と組み合わせて実現することだってできます。

欠点としては、リフレクションと属性が利用できない言語では(そのままでは)実現できないという点ですね。ゲームプログラムは C++ で組まれてることがまだ多いとは思います。そのようなプロジェクトで実現するには、リフレクションや属性と同等の機能を用意してやる必要があります。例えば、ビルド時に Clang 等で型情報を収集しておいて、その情報をランタイムから参照できるようにするとかですね。

しかし、エンジン部分以外は C# やスクリプト言語で記述されるケースも増えつつありますし、DebugMenuPageBuilder のような仕組みが使える機会も多くなってくるのではないでしょうか(例えばパッと思いつくのは Unity ですね)。

いやー、それにしてもずいぶん長くなってしまいました(すいません…)。

ここまでお付き合いいただき、ありがとうございました。