[C#]数値から列挙型への安全な変換


突然のC#メモ。すぐ忘れるので。データベースに整数型として持っている値をC#プログラム側のエンティティでは列挙型として保持するのはよくあるケースだと思いますが、数値→列挙型の変換ってあまり例を見ない気がします。単純にキャストでも通るので、そのまま使っちゃってるんだと思いますが……。

キャストだと何がまずいのか

数値を列挙型にキャストする場合、なんと列挙型で定義されていない値がそのまま代入できてしまいます。これ、なんとかならんかったんですかねえ。

enum Hego : int
{
    Uzuki = 1,
    Ren = 2,
    Momoka = 3,
    Koharu = 4,
}

class Program
{
    static void Main(string[] args)
    {
        var hego = (Hego)5; //←代入できてしまう

        switch(hego)
        {
            case Hego.Uzuki:
            case Hego.Ren:
            case Hego.Momoka:
            case Hego.Koharu:
                {
                    throw new Exception();
                }
        }
        //switchに引っかからず抜けてしまう
    }
}

定義されていない値となった列挙型オブジェクトは、特に問題を起こすことなく実行されます。ToString()すると、値はその数値自身になっちゃいます。この場合で問題になってくるのは、switchやif文で予期せぬ挙動が発生することです。switch句だとdefault:が設定されていなければ、そのまま何事もなく通り過ぎてしまいます。if文でも!=などの指定で予期せぬ挙動をすることが考えられます。Dictionaryのkeyにしていた場合、単体テストでいかにも漏れそうなとこですよね。何のために列挙型を使っているのか。

こんな事態が発生するのは基本的に「プログラムが閉じていない場合」、つまりデータベースやファイル入出力などで外部データを読み込む場合ですが、せっかく列挙型という便利な道具を使うわけですから、境界値チェックはきちんとしたいところです。もちろん、列挙型ごとに値をチェックすればよいわけですが、それもちょっとめんどくさい。どうせなら、あらゆる列挙型で「未定義の値はエラーとする」扱いに統一したいものです。

enum変換用の汎用メソッド

というわけで、ジェネリックを用いた、あらゆる列挙型に変換可能な汎用メソッドを作ってみました。

enum Hego : int
{
    Uzuki = 1,
    Ren = 2,
    Momoka = 3,
    Koharu = 4,
}

class Program
{
    static void Main(string[] args)
    {
        Hego hego;
        
        //下記のように使用する
        hego = EnumConverter.ToEnum<Hego>(1);   //Uzukiが代入される
        hego = EnumConverter.ToEnum<Hego>(2);   //Renが代入される
        
        //未定義値は例外がスローされる
        hego = EnumConverter.ToEnum<Hego>(5);           //ArgumentException
        hego = EnumConverter.ToEnum<Hego>("Maccha");    //InvalidCastException
        hego = EnumConverter.ToEnum<Hego>(null);        //ArgumentNullException
    }
}

public static class EnumConverter
{
    public static T ToEnum<T>(object value) where T : struct //←このstructがポイント
    {
        //値が定義されていれば
        if (Enum.IsDefined(typeof(T), value))
        {
            //単純にキャストして返す
            return (T)value;
        }
        else
        {
            //定義されてなければ例外を投げる
            throw new ArgumentException();
        }
    }
}

やってることは単純で、IsDefinedしてからキャストするというただそれだけのことです。これだけであれば、各列挙型に対してコレをするだけで良いのですが、列挙型ごとに実装するのもアホらしい。そのためのジェネリックです。

しかし、enum型のジェネリックというのは果たして可能なのか。このエントリの目的はそこにあります。実は、型パラメーターを汎用のクラス(つまりObjectクラス)にしてしまうと、クライアント側のコンパイルが通りません

列挙型を型パラメーターに指定する場合には「struct」を記述して、値型であることを明示する必要があります。実用上は「struct」だけで構わないですが、少しでも幅を狭くするなら「IConvertible, IFormattable, IComparable」も一緒に指定すると良いかもしれません。これらを指定すると、インタフェースを実装していない汎用の構造体は型パラメーターに指定できなくなります。まあ、それでも「int」などを指定するとコンパイル通っちゃうんですけどね。

データベースとの整合性をとる場合、Null許容するエンティティを用意しなくてはならない場合もあるかもしれません。その場合は、下記のようにNull許容型の別メソッドを用意しておくと良いと思います。

class Program
{
    static void Main(string[] args)
    {
        //Null許容型
        Hego? hego;
        
        hego = EnumConverter.ToNullableEnum<Hego>(3);
        hego = EnumConverter.ToNullableEnum<Hego>(null); //←無事通る
    }
}

public static class EnumConverter
{
    public static T ToEnum<T>(object value) where T : struct //←このstructがポイント
    {
        //値が定義されていれば
        if (Enum.IsDefined(typeof(T), value))
        {
            //単純にキャストして返す
            return (T)value;
        }
        else
        {
            //定義されてなければ例外を投げる
            throw new ArgumentException();
        }
    }

    public static T? ToNullableEnum<T>(object value) where T : struct
    {
        if (value == null) return null;
        return ToEnum<T>(value);
    }
}

この例では、定義外の値が含まれた場合は例外としていますが、要件によっては「未定義値」などの値で置き換えるなどの場合もあるかもしれません(とはいえ、データベースによくわからない値が入っている状態は非常に怖いので、例外投げとくのが無難だとは思いますが)。必要があれば戻り値をアレンジするといいですね。

ちなみに、.net Framework上では列挙型は「System.Enum」を継承しているのですが、こいつをジェネリック指定すると「特殊クラスだから」という理由で怒られちゃいます。なんでやねん。F#だとこれを指定できるという情報もありました。なんでやねん。よくわからないですね。

この変換すごく重要だと思うんだけど、ネットで全然見かけないんだよな。なんか他にいい方法あるのかしら。


コメント(2件)

  • raimon [2016年11月13日(日) 0:04]

    C#のEnumは比較的シンプルというか制約が多い(最近のプログラミング言語ではコンストラクタ、別名イニシャライザを実装して弾きたければそこで弾ける場合もある)ので、用意されてるEnum.IsDefinedを上手く使う本エントリのようなアプローチが王道なのかなぁ。

    別解としては、Enumをswitch文で評価した時にdefaultを強制する(コンパイラが警告かエラーを吐く)という方向の進化もある気がするね。

  • 天蛾 [2016年11月13日(日) 0:29]

    >raimon
    C#の言語レベルでの進化という意味で言うなら、コレと同等の静的メソッドがEnumクラスに追加されてくれるのが良いかなあ。switch文で評価した時にdefaultを強制するってのは前方互換的な意味で良いよね

コメント投稿フォーム