【C#】ValueObjectの実装例

この記事は、int や string の代わりに使用する値はプリミティブ型だけど値が特定の意味を持つため型にして区別したい時に使用する immutable(不変性:一度作ったら以降に内容が変化しないよう) なオブジェクトを実現ための C# での実装例の紹介です。

この ValueObject の使い道ですが、例えば、主に単一フィールドを対象にして、中身は string なんだけど名前を表すから Name クラス、JSON 文字列だから string 型ではなく JSON クラス、double 型だが単位はミリだから Millimeter クラスのように型を作成して(昔あった、LPSTR型のようなエイリアスとして代用する)場合や、設計モデル上のドメイン内の(immutable な性質を持つ)データをクラスとして表現する際の実装に便利に使用できる実装の紹介になります。

ちなみにドメイン駆動設計における ValueObject の意義・意図および一般的な実装方法論は他の技術系サイトや書籍で繰り返し説明されているためこの記事中ではこれ以上詳しく説明しません。

実装環境

この記事は以下の環境で実装、動作確認しています。

  • .NET 6 + C#10.0
  • Visual Studio 2022

単一の値のValueObject

単一の値を表す ValueObject の実装例は以下の通りです。readonly struct(と readonly のフィールド)で immutable を表現しています。必要に応じて演算子やメソッドを追加しますが、あくまで値を表すだけなので、業務処理などは極力入らないように設計します。

// SinleValueObjectSample.cs

// 単一型のValueObjectのテンプレ
public readonly struct SingleValue : IEquatable<SingleValue>
{
    // 不変の値
    public readonly string Value;

    // immutable constructor
    public SingleValue(string value)
    {
        Value = value;
    }

    // 基本型と互換を取る(必要な時だけ実装)
    public static implicit operator string(SingleValue value) => value.Value;
    public static implicit operator SingleValue(string value) => new SingleValue(value);

    // 比較演算子
    public static bool operator ==(SingleValue left, SingleValue right) => !(left == right);
    public static bool operator !=(SingleValue left, SingleValue right) => left.Equals(right);

    // IEquatable<T>の実装
    public bool Equals(SingleValue other) 
        => EqualityComparer<string>.Default.Equals(Value, other.Value);

    public override bool Equals(object? obj)
    {
        return obj is SingleValue sample && Equals(sample);
    }
    public override int GetHashCode() => EqualityComparer<string>.Default.GetHashCode(Value);
    public override string ToString() => $"{{{nameof(Value)}={Value}}}";

    // Tupleの分解のサポート
    public void Deconstruct(out string value) => value = Value;
}

struct なので内容の値が同じであれば同じオブジェクトとなります。

class であれば基底クラスでジェネリックを使えば継承して実装を単純化できそうですが、struct で値型としてオブジェクトを宣言しているので単純化できないのでこれが最小の実装です。

複数の値のValueObject

複数の値を保持する ValueObject の実装例です。複数の値の組み合わせを immutable に保持することができます。複数の値の組み合わせ自体に既に意味があると思いますが、これも値を表すだけなので業務処理が入り込まないよう注意しましょう。

// 複合型のValueObjectのテンプレ
public readonly struct MultiValue : IEquatable<MultiValue>
{
    // immutable
    public readonly int A;
    public readonly SingleValue B;
    public readonly SingleValue C;

    // immutable constructor
    public MultiValue(int a, SingleValue b, SingleValue c)
    {
        this.A = a;
        this.B = b;
        this.C = c;
    }

    // 演算子のオーバーライド
    public static bool operator ==(MultiValue left, MultiValue right) => Equals(left, right);
    public static bool operator !=(MultiValue left, MultiValue right) => !Equals(left, right);

    // IEquatable<T>の実装
    public bool Equals(MultiValue other)
    {
        return EqualityComparer<int>.Default.Equals(A, other.A) &&
               EqualityComparer<SingleValue>.Default.Equals(B, other.B) &&
               EqualityComparer<SingleValue>.Default.Equals(C, other.C);
    }

    public override bool Equals(object? obj) => obj is MultiValue && Equals((MultiValue)obj);
    public override int GetHashCode()
    {
        return EqualityComparer<int>.Default.GetHashCode(A) * -1521134295 +
               EqualityComparer<SingleValue>.Default.GetHashCode(B) * -1521134295 +
               EqualityComparer<SingleValue>.Default.GetHashCode(C);
    }
    public override string ToString() 
        => $"{{{nameof(A)}={A}, {nameof(B)}={B}, {nameof(C)}={C}}}";

    // Tupleの分解のサポート
    public void Deconstruct(out int a, out SingleValue b, out SingleValue c)
    {
        a = A;
        b = B;
        c = C;
    }
}

struct なので内容の値が同じであれば同じオブジェクトとなります。

recordキーワードでValueObject

record キーワードは C# 9.0 で追加された機能です。この機能を使うと簡単に ValueObject が表現できます。

上述の ValueObject は(struct のため継承できない制限もあり)型を作成するのが結構面倒で、実装量が多く、間違えが起きやすいデメリットがありましたが、record キーワードを使えば簡単に ValueObject が宣言できます。しかも値がひとつでも、複数個でも大差なく型を作成できます。

// record型の宣言例
//  → 書き換えできないstringのNameを持つクラスHogeが宣言できる
public record Hoge(string Name);

// 複数のimmutableなフィールドを持つ方も簡単に宣言できる
//   → 書き換えできない3つのフィールドをもつ Barクラスが宣言できる
public record Bar(string Name, int ID, int No);

上記コードはおおむね以下のように展開されます。

public class Hoge : IEquatable<Hoge>
{
    public int ID { get; init; }

    protected virtual Type EqualityContract => typeof(Hoge);

    public Hoge(int value)
    {
        ID = value;
    }
    protected Hoge(Hoge original)
    {
        ID = original.ID;
    }

    // 演算子のオーバーライド
    public static bool operator !=(Hoge left, Hoge right) 
        => !(left == right);
    public static bool operator ==(Hoge left, Hoge right) 
        => (object)left == right || ((object)left != null && left.Equals(right));

    public virtual Hoge Clone() => new Hoge(this);

    // IEquatable<T>の実装
    public virtual bool Equals(Hoge? other)
    {
        return (object)this == other ||
               other is not null &&
               EqualityContract == other.EqualityContract &&
               EqualityComparer<int>.Default.Equals(ID, other.ID);
    }

    public override bool Equals(object? obj) => Equals(obj as Hoge);
    public override int GetHashCode()
    {
        return EqualityComparer<Type>.Default.GetHashCode(EqualityContract) 
               * -1521134295 + EqualityComparer<int>.Default.GetHashCode(ID);
    }
    public override string ToString()
    {
        StringBuilder stringBuilder = new StringBuilder();
        stringBuilder.Append("Hoge");
        stringBuilder.Append(" { ");
        if (PrintMembers(stringBuilder))
        {
            stringBuilder.Append(" ");
        }
        stringBuilder.Append("}");
        return stringBuilder.ToString();
    }
    protected virtual bool PrintMembers(StringBuilder builder)
    {
        builder.Append("value");
        builder.Append(" = ");
        builder.Append(ID);
        return true;
    }

    // Tupleの分解のサポート
    public void Deconstruct(out int value)
    {
        value = ID;
    }
}

触った感じですが

  • 展開されるコードは class
  • 実装がすごい楽
  • immutable なので ValueObject として使用可能
  • フィールドの名前をコンストラクタ風の引数名で指定する関係で、大文字になってるのがやや違和感あり
  • ToString メソッド内の StringBuilder の new はちょっと微妙な局面がありそう

class として展開されます。

record structキーワードでValueObject

C# 10.0から使用可能になった record struct 機能の紹介とValueObjectとして利用できる可能確認です。record キーワードと全く同じように使用できます。展開されるコードは struct になります。

試しに、複合型で生成されるコードを確認してみます。

// 2つのフィールドを持つFugaを生成する
public record  struct Fuga (string Name), int ID;

生成されるコードは以下の通りです。

public struct Fuga : IEquatable<Fuga>
{
    string _name;
    int _id;

    public string Name { get => _name; set => _name = value; }
    public int ID { get => _id; set => _id = value; }

    public Fuga(string name, int id)
    {
        _name = name;
        _id = id;
    }

    public override string ToString()
    {
        StringBuilder stringBuilder = new StringBuilder();
        stringBuilder.Append("Fuga");
        stringBuilder.Append(" { ");
        if (PrintMembers(stringBuilder))
        {
            stringBuilder.Append(' ');
        }
        stringBuilder.Append('}');
        return stringBuilder.ToString();
    }

    private bool PrintMembers(StringBuilder builder)
    {
        builder.Append("Name = ");
        builder.Append((object)Name);
        builder.Append(", ID = ");
        builder.Append(ID.ToString());
        return true;
    }

    public static bool operator !=(Fuga left, Fuga right) => !(left == right);
    public static bool operator ==(Fuga left, Fuga right) => left.Equals(right);

    public override int GetHashCode()
    {
        return EqualityComparer<string>.Default.GetHashCode(_name) * -1521134295
             + EqualityComparer<int>.Default.GetHashCode(_id);
    }

    public override bool Equals(object? obj) => obj is Fuga fuga && Equals(fuga);

    public bool Equals(Fuga other)
    {
        return EqualityComparer<string>.Default.Equals(_name, other._name) 
            && EqualityComparer<int>.Default.Equals(_id, other._id);
    }

    public void Deconstruct(out string Name, out int ID)
    {
        Name = this.Name;
        ID = this.ID;
    }
}

感想ですがrecord キーワードとほぼ同じです。

  • 展開されるコードは struct
    • readonly struct はサポートなし
  • 実装がすごい楽
  • setter があって書き換え可能
    • 書き換え可能のためstructとはいえ純粋なimmutableのValueObjectとして認識しづらい
  • フィールドの名前をコンストラクタ風の引数名で指定する関係で、大文字になってるのがやや違和感あり
  • ToString メソッド内の StringBuilder の new はちょっと微妙な局面がありそう
  • C#10.0 + VisualStudio 2022でしか使えない

readonly struct のサポートはありません。対応忘れ?また構文自体が C#10.0(≒ VS2022) でしか使えないのもマイナスかもしれません。当然 Unity では使用できません。Unity 的には readonly struct があったほうが嬉しかったかもしれません。

また setter があって書き換え可能ですが、struct なので性質は immutable だと思いますが、使用時に視覚的に値が変更できることは、ValueObject と想定していた場合、違和感を覚えるかもしれません。特に C# は利用時に class と struct の違いが見た目では区別できないのであれ?代入できる?となって宣言を確認すると struct でしたの流れは少し負担になるかもしれません。

最後に

完全に余談ですが、Unity2021.3で record が使える!ValueObject!と、一瞬盛り上がったような気がしますが、この record キーワード、生成される実装が(正しく ValueObject の形式ですが)class です。このため、new でオブジェクトを作成 → ヒープに生成 → パフォーマンス要求がシビアなゲームの実装ではやや使いづらい、の流れで Unity では意外に微妙でした。struct のほうが求められていたと思います(=大量の class を毎フレーム new したり破棄したりすると GC が原因でゲームがカクついたりしますす)

ですが、それ以外の用途では使用できると思います。サーバーサイドとか、GUIとか。

また、C# 10.0 からは record struct キーワードが追加され、先ほど紹介した struct の実装が簡単にできるようになりました。ただし、現状、Unity では使用できません(し、当面利用できないと思います)

2022年7月現在 C# 10.0 + Visual Studio 2022 の環境が挑戦的のため、現状この機能はほぼ誰も使えてないような気がします。いつものように時間が解決してくれると思いますが、いずれにしろ大多数の開発者が恩恵にあずかるのは当面先かなと思います。

record キーワードは Unity 視点では完全とは言えませんでしたが、このキーワードは十分強力で役立つ場面も沢山あると思うので積極的に使用していきたいですね。