9月 042016
 

(2017/10/14 EnumUtilの初期化を静的コンストラクタに変更。何故最初からそうしなかったのか)

なんだかんだUnityとの付き合いが多いので、メモがてらあんまりネット上で見なかったり見つけにくかったりする情報をまとめる感じで。

今回はenum型(列挙型、列挙体)のシリアライズについての話です。前回「C#のenumの使い方」の続きでもあります。本当は同じ日に仕上げるつもりでしたが、長くなったので分離しました。enumそのものの使い方は前回の記事にまとめておいたので、その知識は前提とします。

enumのシリアライズ

ここでいうシリアライズはUnityの機能によるシリアライズを指します。下記のケースを想定してます。

  • インスペクタで設定できるフィールド(メンバ変数)
  • ScriptableObject
  • JsonUtilityで変換して得るJSON

enumはシリアライズ可能です。コンポーネントのシリアライズ可能フィールド(publicあるいはSerializeField)なんかにしてやると、インスペクタ上でこのフィールドが設定できるようになります。このときドロップダウンリストでenum値を指定できます。Unityが勝手にリストにしてくれて、名前で指定できるのは大変ありがたいですね。

ただし、プログラマたるもの、それがどういう形でシリアライズされるかは気にする必要があります。

シリアライズされたenumの確認

下記の設定を済ませておくと、シーンファイル(.unity)やプレハブ(.prefab)がテキスト(YAML形式)で保存されるため、中身がテキストエディタで見れるようになります。これでenum値がシリアライズされた結果を確認することができます。マージできるので基本的にテキストがいいと思ってます。

  1. メニューバーから「Edit」→「Project Settings」→「Editor」を開く
  2. 「Asset Serialization」の「Mode」を「Force Text」に設定

JsonUtilityでシリアライズした結果は普通にログに出力するとかすればいいので、こちらも確認は簡単ですね。

enumはC#では名前を取得するなどオブジェクトとして振る舞えますが、内部表現としてはC/C++と同様に整数値です。シリアライズすると内部表現である整数値だけが残ります。余計な情報がなくてシンプルです。

ですが、この素直な挙動はちょっと扱いに気をつける必要があると言えます。

スクリーンショット貼るのめんどくさいので、JsonUtility使ってコードだけで挙動を示します。

using UnityEngine;

public class EnumSerializeSample : MonoBehabiour
{
    public enum Type1
    {
        Invalid,
        A,
        B,
        C,
    }
    public enum Type2
    {
        Invalid,
        A,
        C,
    }

    [Serializable]
    private class Sample1
    {
        public Type1 type = Type1.Invalid;
    }
    [Serializable]
    private class Sample2
    {
        public Type2 type = Type2.Invalid;
    }

    private void Start()
    {
        var obj1 = new Sample1();
        obj1.type = Type1.B;
        Debug.Log("Type: " + obj1.type);
        var json = JsonUtility.ToJson(obj1);
        Debug.Log("Json: " + json);
        var obj2 = JsonUtility.FromJson<Sample2>(json);
        Debug.Log("Type: " + obj2.type);
    }
}

出力は下記になります。

Type: B
Json: {"type":2}
Type: C

やってることは、JSONを経由して、同じインターフェースを持つ異なるオブジェクトへの変換ですね。そんな変わったこと普通しないと思いますが、ここで問題として挙げたかったのは、「同じ型であっても開発の途中で変更されれば、古い型情報を元にシリアライズされたデータを、新しい型情報でデシリアライズすると値の対応が破綻する」ということです。それを擬似的に再現するためのサンプルでした。

ちなみに、これはUnityの機能によるシリアライズの結果であって、他のシリアライザを使用する場合は、その挙動を個別に確認する必要があります。例えば、XmlSerializerでシリアライズされたenumは名前で保存されます。

余談。定数は大文字スネークケースにすべきという考えが一般的な気がしますが、C言語でマクロであることを示すために表記を変えていたことが本質だと思うので、キャメルケースで読みやすくまとめるのが最近の私のやり方。

問題

enumってそもそもなんらかの値リストを、直値のまま使ってマジックナンバーとなることを避けて、「名前」をつけてわかりやすく管理したい、という需要で使うと思います。型として定義するため、意図しない値が入ることを避ける意味もあります(完全ではないですが)。整数値はただの内部表現でしかなく、具体的な値が何なのかは必ずしも意識する必要はありません。

だから、enumの本質は「名前」をつけることにあると思います。整数値でシリアライズすると、ここに名前情報はありません。デシリアライズしたあとで、整数値に対応するenum情報を引っ張ることになります。

それ自体は特に問題なく動きますが、先程の例で確認したとおり、整数値とenum値の対応が変更されないという保証をしてやらないと、その後の運用次第で破綻します。

enumのシリアライズには運用面を考慮する必要があることがわかったと思いますので、運用方法を考えましょうか。

運用方法

方針1:直値を指定する

値そのものに意味があり、それに「名前」をつけたい場合、つまり定数を定義したいときにenumを使えば、定義した値そのものを変えるようなことがない限りは完全性が保たれます。

ならば、すべてのenum値に個別に整数値を設定してやって、定義の追加や削除によって二度と同じ値を使わないという運用を徹底できるのならば破綻しないといえます。かなり強力な縛りです。

public enum Type {
    Invalid = 0,
    A = 1,
    B = 2,
    C = 3,
}

例えば、上記の定義を下記の定義に変更してもシリアライズされたデータはそのまま使えます。Bが削除されたので、存在する値かどうかのチェックは必要ですが。

public enum Type {
    Invalid = 0,
    A = 1,
    C = 3,
    D = 4,
}

ただ、人の手によって保証してやるというのは、めんどくさいし、スマートではないとは思います。特に開発中の仕様変更・追加が多発するゲームプログラムにおいては、あんまり採用したくない方針かなと。

方針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

using System;
using System.Collections.Generic;
using UnityEngine.Assertions;

public static class EnumUtil<T>
    where T : struct, IConvertible
{
        public static int count { get { return _table.Count; } }

        public static T getValue(string name)
        {
            return _table[name];
        }
        public static T parseOrDefault(string name)
        {
            T value;
            _table.TryGetValue(name, out value);
            return value;
        }
        public static T? parseOrNull(string name)
        {
            T value;
            if(_table.TryGetValue(name, out value)) {
                return value;
            }
            return null;
        }

        public static bool isDefined(string name)
        {
            return _table.ContainsKey(name);
        }

        static EnumUtil()
        {
            // 型チェック
            var type = typeof(T);
            Assert.IsTrue(type.IsEnum, type.Name + " is not enum.");

            if(!type.IsEnum) return;

            // キャッシュ作成
            var values = Enum.GetValues(type);
            _table = new Dictionary<string, T>(values.Length);
            foreach(T value in values) {
                var name = value.ToString();
                _table.Add(name, value);
            }
        }

        private static Dictionary<string, T> _table = null;
}

TryParse系は記述が冗長で好きじゃないので、存在しないキーに対してデフォルト値を返すメソッドとnullを返すメソッドを用意しました。

1回の走査でキャッシュ作りたかったのでEnum.GetValues()で済ませてますが、同じ整数値を持つ名前が複数ある時は、先に定義されているほうが使われます。なので、そういう定義をしない運用を前提とします。同じ意味を持つ名前を持たせるなってことです。あんまり機能を持たせすぎるとかえって使いにくくなるだけなので、ひとまずこんなもので。

補足

もう少し思考の過程を共有しておきます。

enumのToString()も遅い処理ですが、一度キャッシュ作るのに致命的なわけでもないので、そのままです。そこの高速化は必要になったらやればいいです。時間は有限なので、取捨選択は適切に。

また、Dictionary.ContainsValue()は公式ドキュメントによるとO(N)操作だそうで、おそらく内部実装は線形探索でしょう。重い処理を気軽に実行できるようにするのも危険なので、整数値のisDefined()は用意しませんでした。やるなら整数値のハッシュリストも作るべきですが、わざわざそこまで準備するほど必要な操作ではないと判断しました。

参考までに、式木を利用して動的にswitch-case文相当の処理を構築する方法もあるようです。UnityのC#バージョンで動くか知りませんが。もしかしたら、iOSアプリなどIL2CPPの事情で動かないかもしれません。

メタメタな機能を使うよりは、素直に辞書作ったほうがチームで保守しやすいと思ってるんで、あまりこういうことしないです。

インスペクタの拡張

ドロップダウンリストで編集できるようにしたいのでエディタ拡張をします。

Editorフォルダ以下に配置したスクリプトは、エディタ拡張スクリプトとして、ゲーム用スクリプトとは異なるアセンブリにまとめられます。エディタ拡張用のアセンブリからUnityEditor名前空間が参照可能です(Editorフォルダの外でもUnityエディタ上で実行する分には、UnityEditor名前空間が利用できますが、ビルドでコケることになるので注意)。

インスペクタでのフィールド編集をカスタムするにはPropertyDrawerが利用できます。PropertyDrawerを継承して独自のプロパティドロワーを作り、どのプロパティに対して適用するか決めるためCustomPropertyDrawer属性を併用します。

CustomPropertyDrawerに指定できるのは、シリアライズ可能(Serializable)な型か、PropertyAttributeを継承した属性クラスです。どっちの方法も考えてみたので、このあと両方示していきます。

Unityのエディタ拡張では、ドロップダウンリスト(プルダウンメニュー)はPopupと呼ぶそうです。ちょっとこの名前はわかりにくい・・・。

enum版PopupとしてEnumPopupが用意されているので、これを利用してみます。実はこれに気付く前に一度Popupで作っちゃったのが悔やまれますが、EnumPopupを利用した実装だけまとめておきます。

PropertyAttributeを使う方法

まずenum型の型情報を受け取って保持する属性を作ります。

EnumStringPropertyAttribute.cs

using System;
using UnityEngine;
using UnityEngine.Assertions;

public class EnumStringPropertyAttribute : PropertyAttribute
{
    public Type type { get { return _type; } }

    public EnumStringPropertyAttribute(Type type)
    {
        Assert.IsNotNull(type);
        Assert.IsTrue(type.IsEnum, "Type must be enum: " + type.Name);
        _type = type;
    }

    private Type _type = null;
}

この属性を持つプロパティに対するPropertyDrawerを定義します。

EnumStringPropertyDrawer.cs

using UnityEngine;
using UnityEditor;
using System;

[CustomPropertyDrawer(typeof(EnumStringPropertyAttribute))]
public class EnumStringPropertyDrawer : PropertyDrawer
{
    public new EnumStringPropertyAttribute attribute { get { return (EnumStringPropertyAttribute)base.attribute; } }

    public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
    {
        if(property.propertyType == SerializedPropertyType.String) {
            property.stringValue = getValue(position, property, label);
            return;
        }

        EditorGUI.LabelField(position, label, "EnumStringProperty must be string.");
    }

    private string getValue(Rect position, SerializedProperty property, GUIContent label)
    {
        var type = attribute.type;

        Enum selected = null;

        if(Enum.IsDefined(type, property.stringValue)) {
            selected = (Enum)Enum.Parse(type, property.stringValue);
        } else {
            // デフォルト値(0)を取得
            selected = (Enum)Activator.CreateInstance(type);
        }

        var value = EditorGUI.EnumPopup(position, label, selected);
        return value.ToString();
    }
}

あとはこんな感じで使います。

[SerializeField]
[EnumStringProperty(typeof(Type))]
private string _string = default(Type).ToString();

指定した属性クラスは、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

using UnityEngine;
using System;

[Serializable]
public abstract class EnumStringBase
{
    public virtual string text { get { return _text; } protected set { _text = value; } }

    [SerializeField]
    private string _text = "";
}

[Serializable]
public class EnumString<T> : EnumStringBase
    where T : struct, IConvertible
{
    public static Type type { get { return typeof(T); } }

    public static bool isDefined(string text)
    {
        return EnumUtil<T>.isDefined(text);
    }

    public static T parseOrDefault(string name)
    {
        return EnumUtil<T>.parseOrDefault(name);
    }

    public static T? parseOrNull(string name)
    {
        return EnumUtil<T>.parseOrNull(name);
    }

    public override string text
    {
        get { return base.text; }
        protected set
        {
            base.text = value;
            _cache = null;
        }
    }

    public T value
    {
        get
        {
            if(_cache == null) {
                _cache = parseOrDefault(text);
            }
            return (T)_cache;
        }
        set
        {
            text = value.ToString();
            _cache = value;
        }
    }

    public T? cache { get { return _cache; } }

    public EnumString() { }

    public EnumString(T value)
    {
        this.value = value;
    }

    public static implicit operator T(EnumString<T> obj) { return obj.value; }

    private T? _cache = null;
}

ちと長いですが、ジェネリッククラスEnumStringとしてenum値のキャッシュやEnumUtilを利用したパース機能を持たせておくことで、enumともstringとも扱えるような設計にしました。valueプロパティへの代入は文字列更新のためToString()を伴いますが、頻繁に呼ぶ処理ではないと思うので問題にならないでしょう。

PropertyDrawerを基底クラスEnumStringBaseに対して作ってやります。Typeオブジェクトを取得してやる必要があるので非ジェネリックな基底クラスで取るわけです。

EnumStringDrawer.cs

using System;
using System.Reflection;
using UnityEngine;
using UnityEditor;

[CustomPropertyDrawer(typeof(EnumStringBase), true)]
public class EnumStringDrawer : PropertyDrawer
{
    private Type fieldType { get { return fieldInfo.FieldType; } }
    private PropertyInfo typePropertyInfo { get { return fieldType.GetProperty("type", BindingFlags.Public | BindingFlags.FlattenHierarchy | BindingFlags.Static); } }

    public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
    {
        var textProperty = property.FindPropertyRelative("_text");
        textProperty.stringValue = getValue(position, textProperty, label);
    }

    private string getValue(Rect position, SerializedProperty property, GUIContent label)
    {
        var type = getEnumType();
        Debug.Assert(type != null, "type is null.");
        if(type == null) return "";

        Enum selected = null;

        if(Enum.IsDefined(type, property.stringValue)) {
            selected = (Enum)Enum.Parse(type, property.stringValue);
        } else {
            // デフォルト値(0)を取得
            selected = (Enum)Activator.CreateInstance(type);
        }

        var value = EditorGUI.EnumPopup(position, label, selected);
        return value.ToString();
    }

    private Type getEnumType()
    {
        var propertyInfo = typePropertyInfo;
        if(propertyInfo != null) {
            var methodInfo = propertyInfo.GetGetMethod();
            if(methodInfo != null) {
                return (Type)methodInfo.Invoke(null, null);
            }
        }
        return null;
    }
}

Unityの仕様でジェネリッククラスはシリアライズ対象ではないため、実際に利用する時は、下記のように継承して非ジェネリッククラスを定義してやる必要があります。

[Serializable]
public class TypeString : EnumString<Type> { }

[SerializeField]
private TypeString _typeString = new TypeString();

PropertyDrawer.fieldInfoでフィールド情報が参照できるので、ここからリフレクションでEnumStringのtypeゲッターを呼び出して、enum型情報を取得します。あとはさっきとやってることは同じです。

やってることは簡単なんですが、GetProperty()の挙動がよくわからなくて、typeプロパティの取得には苦労しました。

このへん参考にして、引数にBindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy突っ込んだら取得できた感じです。

余談。enumで書いたコードをほぼそのまま書き換えずに済むようにEnumStringを実装したかったんですが、C#では代入の演算子オーバーロードがなく、キャストのオーバーロードで解決しようにも、変換元か変換先が自身の型でなければならず、CRTP的解決法も取れずお手上げでした。何を言っているのかわからねーと思うがry

サンプル

簡単なサンプルでenumをそのまま保存、「PropertyAttributeを使う方法」、「特定の型に対するPropertyDrawerを作る方法」の結果を比較します。

下記のコンポーネントを適当なゲームオブジェクトにアタッチします。

using System;
using UnityEngine;

public class EnumTest : MonoBehaviour
{
    public enum Type
    {
        Invalid,
        A,
        B,
        C,
    }

    [Serializable]
    public class TypeString : EnumString<Type> { }

    private void Start()
    {
        Debug.Log(_type.ToString());
        Debug.Log(Enum.Parse(typeof(Type), _string).ToString());
        Debug.Log(_typeString.value.ToString());
    }

    [SerializeField]
    private Type _type = Type.Invalid;

    [SerializeField]
    [EnumStringProperty(typeof(Type))]
    private string _string = default(Type).ToString();

    [SerializeField]
    private TypeString _typeString = new TypeString();
}

インスペクタ上では区別がつきません。

2016-09-04-01

この状態でシーンを保存しました。YAMLでシリアライズされた部分だけ抜粋します。

  _type: 2
  _string: C
  _typeString:
    _text: A

今回用意した手法ではちゃんと文字列で保存されていますね。

enumひとつでだいぶ話が長くなりましたが、だいたい説明しました。こういう地味なところが大事ですよね。

コメントを残す(Markdown有効)

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください

Top