ネコのために鐘は鳴る

寺院に住み着くパソコ〇好き

(C#) 構造体で列挙型ライクな定義を作る

小ネタです。毎回 C# のあまり一般的ではない珍妙な実装の記事ばかり書いていますが、今回もまた珍妙な実装です。実用性は一応ありますが、一般的ではない珍妙な方法だと理解した上でご利用ください。

概要

構造体をつかって列挙型のような型を作っていきます。

public enum Fruit : int
{
    Apple = 0,
    Orange = 1,
    Grape = 2,
}

上のenum Fruitはよくある感じの列挙型です。これを構造体で似たようなものを作ってみます。

public readonly struct Fruit
{
    private readonly int _v;

    public static Fruit Apple => new Fruit(0);
    public static Fruit Orange => new Fruit(1);
    public static Fruit Grape => new Fruit(2);

    private Fruit(int value) => _v = value;
}

説明用なので、IEquatable<T>実装など構造体に必要な実装は省略していますが、書かれていると思ってください。以下、この方法で実装した構造体を擬似列挙体と呼ぶことにします。

使い方は普通の列挙体と同じです。

var fruit = Fruit.Apple;
if (fruit == Fruit.Apple)
{
    Console.WriteLine("This is an apple.");
}

逆に通常の列挙体と違う点はEnumクラスを使えないことと、switchができないなどの点があります。

// 普通の enum では switch できるが、今回の疑似列挙体では使えない
var fruit = Fruit.Apple;
switch(fruit)
{
    case Fruit.Apple:
        return "This is an apple.";
    default:
        return "Something other";
}
// 普通の enum では Enum 型を使えるが、今回の疑似列挙体では使えない
var allFruits = Enum.GetValues<Fruit>();

メリット・デメリット

構造体で列挙体に似せて作った疑似列挙体のメリットはいくつかあります

  • 定義が存在しない値を生成できない
  • インスタンスメソッド、演算子を定義できる
  • 破壊的変更をせずに内部値を変更できる

1つめは定義が存在しない値を生成できないことです。通常の列挙体の場合、数値型とのキャストが相互に可能なため、列挙体として定義が存在しない値を作れてしまいます。

// Fruit に値が100の要素定義は存在しないが、合法的に作れる。
var fruit = (Fruit)100;

存在しない値を合法的に作れてしまうので、利用者側のエラーチェックが意外と面倒です。疑似列挙体の場合、値を設定できるコンストラクタをprivateにしてしまえば、定義が存在しない値はアンセーフを使わない限り生成できなくなります。

2つめは、疑似列挙体ではメソッドや演算子を定義できることで、これがもともと列挙体にはできないのが意外と不便です。メソッドは拡張メソッドで代用できますが、演算子は普通の列挙体ではできません。例えば他の型と==で比較を定義したい時などがありますが、疑似列挙体ならこれができます。

3つめは破壊的変更をせずに内部値を変更できることです。これはある意味最大のメリットです。列挙体の場合、その内部にある数値も含めて公開情報であるため、数値を変更すると後方互換性が崩れます。疑似列挙体の場合は、その数値をprivateに保持しており、数値型とのキャストや比較などを実装しない限り破壊的変更になりません。 ただし、0の値をもつ定義だけはdefaultが変わってしまうため変更できません。何を0にするかは通常の列挙体と同様、よく考えて定義しましょう。

 

逆にデメリットとしては

  • 定数 (const) になれない
  • メソッドのデフォルト引数値に書けない
  • switch 文で分岐できない
  • Enum クラスの機能が使えない (自分で書く必要がある)

などです。普通ではない実装なので、このデメリットをよく考えた上でメリットのほうが大きいなら使ってもいいかなという感じです。

もっと詳しく

最初に省略した部分も含めて、最低限必要な機能を実装したFruit型の疑似列挙体の全文はだいたい以下のようになると思います。

using System;
using System.Diagnostics;

[DebuggerDisplay("{ToString(),nq}")]
public readonly struct Fruit : IEquatable<Fruit>
{
    [DebuggerBrowsable(DebuggerBrowsableState.Never)]
    private readonly int _v;

    public static Fruit Apple => new Fruit(0);
    public static Fruit Orange => new Fruit(1);
    public static Fruit Grape => new Fruit(2);

    private Fruit(int value) => _v = value;

    public override bool Equals(object? obj) => obj is Fruit f && Equals(f);
    public bool Equals(Fruit other) => _v == other._v;
    public override int GetHashCode() => _v.GetHashCode();
    public static bool operator ==(Fruit left, Fruit right) => left.Equals(right);
    public static bool operator !=(Fruit left, Fruit right) => !(left == right);
    public override string ToString()
    {
        if (this == Apple) { return "Apple"; }
        else if (this == Orange) { return "Orange"; }
        else if (this == Grape) { return "Grape"; }
        else { return _v.ToString(); }
    }
}

DebuggerDisplay属性が書いてあるのは、デバッガ上で表示したときに通常の列挙体と同じような見た目になってほしいからで、DebuggerBrowsableがフィールドについているのは、デバッガでその内部値を見せたくない (将来的に内部値を変更したいという目的のために、デバッガ上でさえ値を公開したくない) という理由です。

見てわかるかと思いますが、記述量が多すぎてすごく面倒くさいです。しかし、その大半は自動的に実装できそうな内容です。これを簡単に生成してくれるソースジェネレータライブラリがほしくなりますね。今作っていますが公開するかは不明です。正直、ソースジェネレータは作るのが面倒なので、自分で使うだけなら T4 Template でいいような気がします。

ものすごく細かいことを言うと、面倒くさいのでやりませんが上記のToStringのなかでifで条件分岐している部分は、本当は内部の値で二分探索すると速くなります。

また、.NET5 (or .NET6 ?) の JIT コンパイラは最適化によって、プリミティブ型をラップした構造体をプリミティブ型と同じ最適化をかけるように改善されたりしているはずなので、ちゃんと書けば速度上のデメリットはないはずです。