12
5

More than 1 year has passed since last update.

C#9.0 recordをDDDの値オブジェクトとして利用する際の注意

Last updated at Posted at 2021-03-28

C#9.0で使えるようになったrecordの機能ですが、値ベースの等値性比較、不変性といった機能がDDDにおける値オブジェクトの概念と一致します。C#9.0以前では値オブジェクトを定義するには、大量の定型的コードを書かなけらばなりませんでしたが(コード生成を使ったとしても)、recordを使えば多くをコンパイラの自動生成に任せることができます。
一方で、recordは(多分)DDDのために作られたわけではないため、実装においていくつか難しい点がありました。どういった点に注意して実装すべきか、トレードオフは、等考えた内容のメモです。

環境
Microsoft Visual Studio Community 2019 Version 16.9.1
VisualStudio.16.Release/16.9.1+31105.61
Microsoft .NET Framework
Version 4.8.04084

インストールされているバージョン:Community
C# ツール 3.9.0-6.21124.20+db94f4cc8c78a7cd8cf9cfdae091158d2ba9d974
ターゲットフレーム .NET

C#9.0recordとは

recordの詳細な説明はMicrosoftDocsに任せます。
C# 9.0 の新機能-レコードの種類
レコード型を作成する
レコード
簡単にまとめると、

  • 不変である。ゆえにsetterは使えない(正確には使うことを推奨しない)
  • 値ベースの等値性比較ができる。型が等しくすべてのプロパティの値が等しいとき等値とみなされる

といった特徴があります。これはDDDにおける値オブジェクトとしてまさに求められる性質です。

例:四角形オブジェクト

※以下の例ではC#9.0の最上位レベルのステートメント機能を使いMainメソッドを省いてコードを記しています

Rectangle.cs
var r1 = new Rectangle(10, 10);
var r2 = new Rectangle(10, 10);
var r3 = new Rectangle(9, 11);
System.Console.WriteLine(r1); // Rectangle { Length = 10, Width = 10, Area = 100 }
System.Console.WriteLine(r1 == r2); // True
System.Console.WriteLine(r1 == r3); // False

record Rectangle(int Length, int Width)
{
    public int Area => this.Length * this.Width;
}

operator ==Equalsは定義しなくても等値性を比較できます。
また不変なため変更もできません。

Rectangle.cs
r1.Length = 9; // init 専用プロパティまたはインデクサー 'Rectangle.Length' を割り当てることができるのは、オブジェクト初期化子の中か、インスタンス コンストラクターまたは 'init' アクセサーの 'this' か 'base' 上のみです。

注意点1:不変条件を満たすには"位置指定レコード"は使えない

Rectangleに不変条件を追加します。

  • Length, Heightは正である
  • Areaが100以上である

値オブジェクトは不変条件をファクトリに持たせることができます。

値オブジェクトは完全に不変である。アクティブなライフタイムの中では決して適用されることのないロジックであれば、オブジェクトが持っている必要はない。このような場合、不変条件を入れるのに理にかなった場所はファクトリであり、そうすることで生成物はよりシンプルなままに保たれる。(DDD本6章)

コンストラクタにこのロジックを持たせてみます。(複雑なロジックはポリシーオブジェクトにすべきとか、コンストラクタじゃなくてファクトリクラスに不変条件を担当させるとか流儀はあるでしょうが、今回はシンプル優先)

Rectangle.cs
    public Rectangle(int length, int width)
    {
        if (length <= 0 || width <= 0)
        {
            throw new System.ArgumentException("length and width must be positive value.");
        }
        if (length * width < 100)
        {
            throw new System.ArgumentException("area must be higher than 100");
        }
        (this.Length, this.Width) = (length, width);
    }

このコードは以下のエラーでコンパイルできません。
エラー CS0111 型 'Rectangle' は、'Rectangle' と呼ばれるメンバーを同じパラメーターの型で既に定義しています
位置指定レコードを使った場合、「パラメーターがレコード宣言の位置指定パラメーターと一致するプライマリ コンストラクター」が自動生成されます(プロパティ定義の位置指定構文)。recordにおいて自動生成されるもののいくつかは上書きすることができますが、コンストラクタはその限りではないようです。

レコード型に、いずれかの合成メソッドのシグネチャと一致するメソッドがある場合、コンパイラでそのメソッドは合成されません。
(https://docs.microsoft.com/ja-jp/dotnet/csharp/whats-new/csharp-9#record-types)

なのでこの場合位置指定レコードを使わず自分でプロパティを記載する必要があります。recordで記述量が減ると思ったのにちょっと残念。
位置指定レコードはinit専用を生成します。

レコード宣言で指定される各位置指定パラメーターのパブリック init 専用自動実装プロパティ。 init 専用プロパティは、コンストラクターで、またはプロパティ初期化子を使用して設定できます。
(https://docs.microsoft.com/ja-jp/dotnet/csharp/language-reference/builtin-types/record)

Rectangle.cs
record Rectangle
{
    public int Length { get; init; }
    public int Width { get; init; }
以下略

これでオブジェクト生成時に不変条件を満たさないオブジェクトは作れなくなったのでしょうか。

Rectangle.cs
var r1 = new Rectangle(10, 10); // OK
// var r4 = new Rectangle(10, -1); // NG
// var r5 = new Rectangle(9, 11); // NG

注意点2:init専用セッターは不変条件を容易に破る

init専用プロパティはコンストラクタで、またはプロパティ初期化子を使用して設定できます。プロパティ初期化子構文ではコンストラクトし終えたオブジェクトのプロパティを設定するため、コンストラクタの不変条件ロジックを通過しません。同様に、with式もコピーコンストラクタで生成されたオブジェクトのプロパティを設定するため、コンストラクタの不変条件ロジックを通過しません。

Rectangle.cs
var r6 = new Rectangle(10, 10) { Length = 9, Width = 11 }; // OK?
var r7 = r1 with { Length = 9, Width = 11 }; // OK?

不変条件を満たさないオブジェクトを簡単に作れてしまいました。

注意点3:じゃあinitをやめるか

プロパティ初期化子が使えないようにinitをなくしてみます。

Rectangle.cs
record Rectangle
{
    public int Length { get; }
    public int Width { get; }
以下略

すると、確かにプロパティ初期化子は使えなくなりますが、同時にwith式も使えなくなります。それはちょっとあまりに不便。

Rectangle.cs
var r6 = new Rectangle(10, 10) { Length = 9, Width = 11 }; // CS0200    プロパティまたはインデクサー 'Rectangle.Length' は読み取り専用であるため、割り当てることはできません
var r7 = r1 with { Length = 9, Width = 11 }; // CS0200  プロパティまたはインデクサー 'Rectangle.Length' は読み取り専用であるため、割り当てることはできません

最終的に

結局、initの中でも不変条件を確認することになりました。

Rectangle.cs
var r1 = new Rectangle(10, 10); // OK
// var r6 = new Rectangle(10, 10) { Length = 9, Width = 11 }; // NG
// var r7 = r1 with { Length = 9, Width = 11 }; // NG
var r8 = r1 with { Length = 11 }; // OK

record Rectangle
{
    private int length;
    public int Length
    {
        get => this.length;
        init
        {
            this.Invariant(value, this.Width);
            this.length = value;
        }
    }
    private int width;
    public int Width 
    {
        get => this.width;
        init
        {
            this.Invariant(this.Length, value);
            this.width = value;
        }
    }
    public int Area => this.Length * this.Width;

    public Rectangle(int length, int width)
    {
        this.Invariant(length, width);
        (this.length, this.width) = (length, width);
    }

    private void Invariant(int l, int w)
    {
        if (l <= 0 || w <= 0)
        {
            throw new System.InvalidOperationException("length and width must be positive value.");
        }
        if (l * w < 100)
        {
            throw new System.InvalidOperationException("area must be higher than 100");
        }
    }
}

もっとも単純な位置指定レコードから比べるとずいぶんコード量が増えてしまいました。

まとめ

定型的コードを書かなくていいと期待できたrecordでしたが、不変条件というロジックが加わるとそれなりには複雑にならざるを得ないようです。とはいえEqualsoperator ==、その他with式に相当するものを書かなくてよい分はかなり楽になっています。また、単純なデータオブジェクトとして使うならかなり有用でしょう。
なにかよいパターン等があれば追記しようと思います。コメント等でのご指摘も頂戴できればと思います(どこかのコミュニティではすでに良いパターンが出尽くしてそう……)

12
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
5