(2017/10/14 EnumUtilの初期化を静的コンストラクタに変更。何故最初からそうしなかったのか)
なんだかんだUnityとの付き合いが多いので、メモがてらあんまりネット上で見なかったり見つけにくかったりする情報をまとめる感じで。
今回はenum型(列挙型、列挙体)のシリアライズについての話です。前回「C#のenumの使い方」の続きでもあります。本当は同じ日に仕上げるつもりでしたが、長くなったので分離しました。enumそのものの使い方は前回の記事にまとめておいたので、その知識は前提とします。
enumのシリアライズ
ここでいうシリアライズはUnityの機能によるシリアライズを指します。下記のケースを想定してます。
- インスペクタで設定できるフィールド(メンバ変数)
- ScriptableObject
- JsonUtilityで変換して得るJSON
enumはシリアライズ可能です。コンポーネントのシリアライズ可能フィールド(publicあるいはSerializeField)なんかにしてやると、インスペクタ上でこのフィールドが設定できるようになります。このとき**ドロップダウンリストでenum値を指定できます。**Unityが勝手にリストにしてくれて、名前で指定できるのは大変ありがたいですね。
ただし、プログラマたるもの、それがどういう形でシリアライズされるかは気にする必要があります。
シリアライズされたenumの確認
下記の設定を済ませておくと、シーンファイル(.unity)やプレハブ(.prefab)がテキスト(YAML形式)で保存されるため、中身がテキストエディタで見れるようになります。これでenum値がシリアライズされた結果を確認することができます。マージできるので基本的にテキストがいいと思ってます。
- メニューバーから「Edit」→「Project Settings」→「Editor」を開く
- 「Asset Serialization」の「Mode」を「Force Text」に設定
JsonUtilityでシリアライズした結果は普通にログに出力するとかすればいいので、こちらも確認は簡単ですね。
enumはC#では名前を取得するなどオブジェクトとして振る舞えますが、内部表現としてはC/C++と同様に整数値です。**シリアライズすると内部表現である整数値だけが残ります。**余計な情報がなくてシンプルです。
ですが、この素直な挙動はちょっと扱いに気をつける必要があると言えます。
スクリーンショット貼るのめんどくさいので、JsonUtility使ってコードだけで挙動を示します。
出力は下記になります。
Type: B
Json: {"type":2}
Type: C
やってることは、JSONを経由して、同じインターフェースを持つ異なるオブジェクトへの変換ですね。そんな変わったこと普通しないと思いますが、ここで問題として挙げたかったのは、**「同じ型であっても開発の途中で変更されれば、古い型情報を元にシリアライズされたデータを、新しい型情報でデシリアライズすると値の対応が破綻する」**ということです。それを擬似的に再現するためのサンプルでした。
ちなみに、これはUnityの機能によるシリアライズの結果であって、他のシリアライザを使用する場合は、その挙動を個別に確認する必要があります。例えば、XmlSerializerでシリアライズされたenumは名前で保存されます。
余談。定数は大文字スネークケースにすべきという考えが一般的な気がしますが、C言語でマクロであることを示すために表記を変えていたことが本質だと思うので、キャメルケースで読みやすくまとめるのが最近の私のやり方。
問題
enumってそもそもなんらかの値リストを、直値のまま使ってマジックナンバーとなることを避けて、「名前」をつけてわかりやすく管理したい、という需要で使うと思います。型として定義するため、意図しない値が入ることを避ける意味もあります(完全ではないですが)。整数値はただの内部表現でしかなく、具体的な値が何なのかは必ずしも意識する必要はありません。
だから、enumの本質は**「名前」**をつけることにあると思います。整数値でシリアライズすると、ここに名前情報はありません。デシリアライズしたあとで、整数値に対応するenum情報を引っ張ることになります。
それ自体は特に問題なく動きますが、先程の例で確認したとおり、整数値とenum値の対応が変更されないという保証をしてやらないと、その後の運用次第で破綻します。
enumのシリアライズには運用面を考慮する必要があることがわかったと思いますので、運用方法を考えましょうか。
運用方法
方針1:直値を指定する
値そのものに意味があり、それに「名前」をつけたい場合、つまり定数を定義したいときにenumを使えば、定義した値そのものを変えるようなことがない限りは完全性が保たれます。
ならば、すべてのenum値に個別に整数値を設定してやって、定義の追加や削除によって二度と同じ値を使わないという運用を徹底できるのならば破綻しないといえます。かなり強力な縛りです。
例えば、上記の定義を下記の定義に変更してもシリアライズされたデータはそのまま使えます。Bが削除されたので、存在する値かどうかのチェックは必要ですが。
ただ、人の手によって保証してやるというのは、めんどくさいし、スマートではないとは思います。特に開発中の仕様変更・追加が多発するゲームプログラムにおいては、あんまり採用したくない方針かなと。
方針2:文字列で保存する
「名前」が大事ならば、名前を文字列で保存すればいいですね。Enumクラスを使って文字列をパースしてenumに変換することはできるので、変更の可能性のあるenumは文字列でシリアライズして保存すれば、あとで数値が変更になっても名前で正しい対応を取ることができます。パフォーマンスを犠牲にして、管理面を強化する考え方ですね。ビットフラグとしての利用には向きませんが、ビットフラグこそ直値指定で扱うと思うので、問題ないかと。
また、文字列として扱うと、意図しない文字列が混入する可能性を生みますし、いちいちチェックしながらパースするのもめんどくさいので、もう少しすっきり使いやすくまとめたいところです。実装を考えたので、あとで説明します。
方針3:IDを割り当てる
すべてのenum値に変更されない一意のIDを割り振って、シリアライズ時にはそのIDで保存してやるという方法を取れば、手が込んでいる分、かなり強力な管理ができると思います。方針1に似たようなものですが、整数値を破綻しないよう自動で割り振るようなイメージですね。
IDは一意であればなんでもいいので、ハッシュでもいいし、ID管理用のプログラムを作って、そっちで連番にしてしまってもいいです。実行時にIDとenumの変換テーブルを再現すれば、シリアライズとデシリアライズが簡単にできますね。文字列のパースよりも高速に動作するでしょう。番号の割り振り方によっては、シリアライズデータのサイズも名前で保存するより小さく済むでしょう(そこはあまり気にしてませんが)。
今回はシンプルに済ませたいので、方針3は置いておきます。そういう選択もあるよ、ということで。
文字列でenumを扱う方法を整理する
文字列でenumをシリアライズするとして、以下を要件とします。
- enumのパースをいちいち書くのめんどくさいんで、さらっと書けるようにしたい。
- 通常のenumみたいにインスペクタ上で、ドロップダウンリストから選択できるようにしたい。
運用する上での面倒を解決して、ヒューマンエラーの入り込む余地をなくしたいわけです。
enum情報のキャッシュ
まずはenum関連でよくやる処理をユーティリティクラスにまとめるついでに、名前と値のペアをキャッシュして検索を高速化します。Enum.IsDefined()でいちいちチェックしてからパースし直すのは処理効率が悪いためです。文字列をキーにしたDictionaryはかなり高速に処理できます。
EnumUtil.cs
TryParse系は記述が冗長で好きじゃないので、存在しないキーに対してデフォルト値を返すメソッドとnullを返すメソッドを用意しました。
1回の走査でキャッシュ作りたかったのでEnum.GetValues()で済ませてますが、**同じ整数値を持つ名前が複数ある時は、先に定義されているほうが使われます。**なので、そういう定義をしない運用を前提とします。同じ意味を持つ名前を持たせるなってことです。あんまり機能を持たせすぎるとかえって使いにくくなるだけなので、ひとまずこんなもので。
補足
もう少し思考の過程を共有しておきます。
enumのToString()も遅い処理ですが、一度キャッシュ作るのに致命的なわけでもないので、そのままです。そこの高速化は必要になったらやればいいです。時間は有限なので、取捨選択は適切に。
また、Dictionary.ContainsValue()は公式ドキュメントによるとO(N)操作だそうで、おそらく内部実装は線形探索でしょう。重い処理を気軽に実行できるようにするのも危険なので、整数値のisDefined()は用意しませんでした。やるなら整数値のハッシュリストも作るべきですが、わざわざそこまで準備するほど必要な操作ではないと判断しました。
参考までに、式木を利用して動的にswitch-case文相当の処理を構築する方法もあるようです。UnityのC#バージョンで動くか知りませんが。もしかしたら、iOSアプリなどIL2CPPの事情で動かないかもしれません。
メタメタな機能を使うよりは、素直に辞書作ったほうがチームで保守しやすいと思ってるんで、あまりこういうことしないです。
インスペクタの拡張
ドロップダウンリストで編集できるようにしたいのでエディタ拡張をします。
Editorフォルダ以下に配置したスクリプトは、エディタ拡張スクリプトとして、ゲーム用スクリプトとは異なるアセンブリにまとめられます。エディタ拡張用のアセンブリからUnityEditor名前空間が参照可能です(Editorフォルダの外でもUnityエディタ上で実行する分には、UnityEditor名前空間が利用できますが、ビルドでコケることになるので注意)。
- Unity - マニュアル: Property Drawer
- Unity - スクリプトリファレンス: PropertyDrawer
- Unity - スクリプトリファレンス: CustomPropertyDrawer
- Unity - スクリプトリファレンス: PropertyAttribute
インスペクタでのフィールド編集をカスタムするにはPropertyDrawerが利用できます。PropertyDrawerを継承して独自のプロパティドロワーを作り、どのプロパティに対して適用するか決めるためCustomPropertyDrawer属性を併用します。
CustomPropertyDrawerに指定できるのは、シリアライズ可能(Serializable)な型か、PropertyAttributeを継承した属性クラスです。どっちの方法も考えてみたので、このあと両方示していきます。
Unityのエディタ拡張では、ドロップダウンリスト(プルダウンメニュー)はPopupと呼ぶそうです。ちょっとこの名前はわかりにくい・・・。
enum版PopupとしてEnumPopupが用意されているので、これを利用してみます。実はこれに気付く前に一度Popupで作っちゃったのが悔やまれますが、EnumPopupを利用した実装だけまとめておきます。
PropertyAttributeを使う方法
まずenum型の型情報を受け取って保持する属性を作ります。
EnumStringPropertyAttribute.cs
この属性を持つプロパティに対するPropertyDrawerを定義します。
EnumStringPropertyDrawer.cs
あとはこんな感じで使います。
指定した属性クラスは、PropertyDrawer.attributeで参照できるので扱いやすいです。EnumStringPropertyAttributeで保持していたenum型情報を簡単に取得できました。
しかし、カスタム対象のプロパティ値そのものに直接触れることはできません。SerializedPropertyというシリアライズされたプロパティを表現するクラスオブジェクトを介して間接的に操作します。ゲームとエディタがプロパティの差分を通信で飛ばし合うときに、SerializedPropertyという入れ物でやりとりしている、というイメージかな。そのため、ここでアクセスできる情報にはかなり制限があり、悩まされます。
ややこしいですがEnumクラスは参照型のため、nullが使えます。Enum.IsDefined()で名前の存在チェックをして、見つかればパース、見つからなければデフォルト値(0)を与えて、これを現在値として、EnumPopupに渡しています。パース周りが効率悪いですが、先に用意したEnumUtilはジェネリックで使うものなので、ここでは使えません。よほど問題とならなければ、エディタ拡張のパフォーマンスまで気にしなくていいでしょう。
enumのデフォルト値が0なのは言語仕様ですが、0からEnumクラスにキャストするのはコンパイラが許してくれませんでした。(Enum)(object)0
としても実行時エラーとなりました。仕方ないので、リフレクションでデフォルト値を生成することとしました。その処理がActivator.CreateInstance()です。
EnumPopupに型情報を渡していませんが、これでちゃんとドロップダウンリストになります。渡した値からリフレクションでenum型の定義を取得しているんでしょうね。
特定の型に対するPropertyDrawerを作る方法
先の方法では、インスペクタ上ではenum型のように編集できるようにしましたが、プログラムから見ればただの文字列型です。ちょっとこのままではenum型として扱うのは苦しい気がしますので、ラッパークラスを考えます。そんで、そのラッパークラスに対してプロパティドロワーを定義します。
EnumString.cs
ちと長いですが、ジェネリッククラスEnumStringとしてenum値のキャッシュやEnumUtilを利用したパース機能を持たせておくことで、enumともstringとも扱えるような設計にしました。valueプロパティへの代入は文字列更新のためToString()を伴いますが、頻繁に呼ぶ処理ではないと思うので問題にならないでしょう。
PropertyDrawerを基底クラスEnumStringBaseに対して作ってやります。Typeオブジェクトを取得してやる必要があるので非ジェネリックな基底クラスで取るわけです。
EnumStringDrawer.cs
Unityの仕様でジェネリッククラスはシリアライズ対象ではないため、実際に利用する時は、下記のように継承して非ジェネリッククラスを定義してやる必要があります。
PropertyDrawer.fieldInfoでフィールド情報が参照できるので、ここからリフレクションでEnumStringのtypeゲッターを呼び出して、enum型情報を取得します。あとはさっきとやってることは同じです。
やってることは簡単なんですが、GetProperty()の挙動がよくわからなくて、typeプロパティの取得には苦労しました。
このへん参考にして、引数にBindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy
突っ込んだら取得できた感じです。
余談。enumで書いたコードをほぼそのまま書き換えずに済むようにEnumStringを実装したかったんですが、C#では代入の演算子オーバーロードがなく、キャストのオーバーロードで解決しようにも、変換元か変換先が自身の型でなければならず、CRTP的解決法も取れずお手上げでした。何を言っているのかわからねーと思うがry
サンプル
簡単なサンプルでenumをそのまま保存、「PropertyAttributeを使う方法」、「特定の型に対するPropertyDrawerを作る方法」の結果を比較します。
下記のコンポーネントを適当なゲームオブジェクトにアタッチします。
インスペクタ上では区別がつきません。
この状態でシーンを保存しました。YAMLでシリアライズされた部分だけ抜粋します。
_type: 2
_string: C
_typeString:
_text: A
今回用意した手法ではちゃんと文字列で保存されていますね。
enumひとつでだいぶ話が長くなりましたが、だいたい説明しました。こういう地味なところが大事ですよね。