コポうぇぶろぐ

コポコポによる備忘録&Tipsブログ

UnityでのC#属性(Attribute)の使いどころ

「Unityゲームプログラミングバイブル」をエゴサ?したときに見かけたコメントにて、
属性についての解説がないとのご指摘があったので、まとめてみました。
(それよりもシナリオ全体のフォローをせねばいかんところですが…)

属性の役割と特徴

このページを見ているということは…
恐らく属性というものの存在はご存知かと思います。

Unityでよく見かける代表的な属性というと…?

[System.Diagnostics.Conditional("DEBUG")]
[System.NonSerializeble]
[UnityEngine.SerializeField]
[UnityEngine.Tooptip("説明")]
[UnityEngine.ContextMenuAttribute("Test")]

これら属性の役割は、

補助機能の設定だったり、目印としての役割だったり、目的はそれぞれあります。
補助機能であればIDEやUnityEditorでカバーできるものがあったりしますが
(DefaultExecutionOrderのような実行順だったり、RequireComponentだったり)、
クラス単位や変数単位での設定のしやすさは属性ならではだと思います。

属性の特徴をまとめてみました

  • クラスの型に依存しない、依存せずに追加情報を埋め込める。
  • コードがスッキリし、可読性アップしやすい。(後述)
  • Unityの場合はEditor周りでかなり恩恵がある。
  • リフレクションを使うケースに活躍する。
  • 本来の機能とは別の機能の設定を担うことが多い。
  • 頻繁にアクセスするデータには向いていない

こんなときに使えるかも

  • 初期化の時に欲しいデータがある
  • リフレクションを使ってデータを集めている
  • エディタ専用のデータが欲しいけど置き場がない
  • Getterしかないインターフェースを継承し、各型で定数を返す定義をするのが面倒

独自に属性を定義してみる

折角なので、ノベルゲームのソースから引用していきます。
ノベルゲームで定義している属性は、

  • NovelCommandAttribute
  • NovelCommandEditorAttribute

この2つがあります。それぞれ役割が違います。

NovelCommandAttribute

ではまずNovelCommandAttributeについてです。
この属性が使われているクラスは、全てシナリオのコマンドの処理が実装されています。例えば、テキストを変えるとか、キャラの立ち絵を表示するとかとかです。そのような各コマンドの処理を定義したクラスに、属性を使ってコマンドIDの情報を追加しています。

    // なし
    [NovelCommandAttribute(NovelCommandType.None)]
    public class None : NovelCommandInterface
    {
        public IEnumerator Do(SharedData share, SharedVariable variable) { yield break; }
        public IEnumerator Undo(SharedData share, SharedVariable variable) { yield break; }
        public IEnumerator Event(SharedData share, SharedVariable variable, EventData e) { yield break; }
    }

    // テキストの書き込み
    [NovelCommandAttribute(NovelCommandType.TextWrite)]
    public class TextWrite : NovelCommandInterface
    {
        public IEnumerator Do(SharedData share, SharedVariable variable) { share.view.Text.text += ...; yield break; }
        public IEnumerator Undo(SharedData share, SharedVariable variable) { yield break; }
        public IEnumerator Event(SharedData share, SharedVariable variable, EventData e) { yield break; }
    }

ここで一度属性のことは置いといて、シナリオデータとコマンドを紐づける実装を考えてみましょう。
シナリオデータは上から順に処理されるコマンドIDが記述されています。このコマンドIDを順番に受け取った時に、コマンドIDと対になるコマンド(処理/クラス)が必要になります。C#等のプログラムをご経験されている方にはすぐ連想されることと思いますが、Dictionaryが必要になります。Dictionary<コマンドID,クラス>ですね。実際には、

Dictionary<CommandID,Command> commandDic = new Dictionary<CommandID,Command>()
{
    {CommandID.TextWrite, typeof(TextWriteClass)},
    {CommandID.TextShow, typeof(TextShowClass)},
    {CommandID.Image, typeof(ImageClass)},
    {CommandID.WaitTime, typeof(WaitTimeClass)},
    {CommandID.WaitEvent, typeof(WaitEventClass)},
    ...
    ...
}

このような定義をします。これでコマンドIDとコマンドが対になった連想配列が作られました。これでcommandDic[id]でコマンドクラスが取得でき、処理を行うことが出来ます
……もちろん、これで問題ありません。ただデメリットを上げると、コマンドを新規追加したい時に、毎回このcommandDicに手動で追加をしなければならないのです。

①CommandID列挙体に追加、②新規Commandクラス実装、③commandDicに登録、という作業になるのです……これが結構面倒臭いんです。実際commandDic登録を忘れてバグに気づかないことがありました。コマンド情報も分散してしまうので、可読性は下がり運用もしづらいです。

ここでこの問題を解決するのが”属性”です。
クラスに属性でCommandID情報を付加しておけば、実行時にDictionaryを自動的に作ることが出来ます。実際にDictionaryを作っている場所を見てみましょう。NovelExecuterのコンストラクタを見てみてください。

// NovelCommandのインナークラスをすべて取得
var nestedType = typeof(NovelCommand).GetNestedTypes(System.Reflection.BindingFlags.Public);

// コマンド以外のクラスも含まれている為、除外しつつコマンドを集計
commandTypeDic = nestedType
    .Where(type => 0 < type.GetCustomAttributes(typeof(NovelCommandAttribute), false).Length)
    .Select(type => type)
    .ToDictionary(type => ((NovelCommandAttribute)type.GetCustomAttributes(typeof(NovelCommandAttribute), false).First()).id);

LINQに慣れていない方もいらっしゃると思うので、従来の書き方だとこうなります。

// NovelCommandのインナークラスをすべて取得
var nestedType = typeof(NovelCommand).GetNestedTypes(System.Reflection.BindingFlags.Public);

// コマンド以外のクラスも含まれている為、除外しつつコマンドを集計
commandTypeDic = new Dictionary<int, Type>();
foreach (var type in nestedType)
{
    var attributes = type.GetCustomAttributes(typeof(NovelCommandAttribute), false);

    if (0 < attributes.Length)
    {
        commandTypeDic.Add(attributes[0].id, type);
    }
}

Sytem.TypeクラスにあるGetCustomAttributesメソッドで自作した属性を取り出し、
属性が一つ以上付加されていたら、1番目の属性からIDを取り出してAddする。この処理だけで、commandDicをわざわざ自分で作らなくても実行時に自動生成することが出来ますね。

コマンドを追加するときは、

[NovelCommandAttribute(NovelCommandType.NewCommand)]
public class NewCommand{}

だけで実行準備が整うというわけです。ちょっと手間が減るので便利には違いないと思います。これがこの属性の役割です。

NovelCommandEditorAttribute

次にNovelCommandEditorAttributeです。
これはシナリオデータのエディタの視覚的フォローのための情報が詰まった属性です。属性に定義されている変数を見てみると、name(コマンド名)、parameterCount(引数の数)、color(背景色)といった表示に必要な変数の定義がなされています。

// 注釈行
[NovelCommandEditorAttribute(NovelCommandType.Comment, 0.3f, 0.7f, 0.3f, 0.5f, parameterCount = 1)]
public class Comment : NovelCommandPropertyDrawerBase{ … }

NovelCommandPropertyDrawerBaseを継承しているクラスは、各コマンドの引数や説明を記述したエディタ表示処理が実装されています。

ですが、コマンド名は背景色はどのコマンドにおいても必ず表示されるので、共通処理側が属性から付加情報を取得して表示しています。

まとめ

2つを通してみると
「それってpublic virtual CommandID id{ get; set; }のようなプロパティで出来るんじゃ?」
「それってpublic virutal CommandID GetCommand();のような仮想関数で出来るんじゃ?」
と思うかもしれないですが、実際はvirtualを使って情報を付加していくことは可能です。

ただ、定数を返すだけのインターフェースを定義したり、クラス定義の度にoverrideしていくのは面倒なうえに、コードも無駄に行数を使うので可読性も下がります。(個人的に見づらいと思ってます)
そんな状況になった時は、属性を使えるかもしれないです。

おまけ

こんな使い方もありかもーという属性です。

[Url("http://example.com/hogehoge")]
public class Login
{
public LoginRequest request;
public LoginResponse response;
}

ちょっとうろ覚えですけど、DTO(Data Transfar Object)というJava発祥の考え方?ですかね。データ送信するパラメータは一つのクラスにまとめて送信しようぞ、という設計思想だったような。そこで使われてた属性です。URLが入ってますね。

[Prefab("Character/SpecialBoss.prefab")]
public class SpecialBoss : MonoBehaviour{}

プレハブとクラスを一体型にするために属性を利用した例です。MonoBehaviourを共通化出来ない場合に有効な手段だと思います。

public enum Result
{
    [Option("")]
    None,
    [Option("完了しました。")]
    Success,
    [Option("致命的なエラーが発生しました。")]
    FatalError,
    [Option("ロードに失敗しました。")]
    FailedLoad,
}
Debug.Log(result.ToOption());

Enumでファイルロードや通信処理等で処理結果を返す場合に、Debug.Logやデバッグ表示等を行う際にかなり便利でした。

さいごに

以上になりますが、いかがでしたでしょうか。
こういう使い方もしたことあるよーみたいなアイデアがございましたら、教えていただけますと幸いでございます。

素晴らしいサイト
UnityのAttribute(属性)についてまとめてメモる。 - テラシュールブログ
属性(Attribute)とは【C#】【Unity】【属性】 - (:3[kanのメモ帳]
Enumにカスタム属性を使ってint型以外の値を返せるようにする - Qiita