C#9.0で使えるようになったrecord
の機能ですが、値ベースの等値性比較、不変性といった機能がDDDにおける値オブジェクトの概念と一致します。C#9.0以前では値オブジェクトを定義するには、大量の定型的コードを書かなけらばなりませんでしたが(コード生成を使ったとしても)、record
を使えば多くをコンパイラの自動生成に任せることができます。
一方で、record
は(多分)DDDのために作られたわけではないため、実装においていくつか難しい点がありました。どういった点に注意して実装すべきか、トレードオフは、等考えた内容のメモです。
環境
インストールされているバージョン:Community
C# ツール 3.9.0-6.21124.20+db94f4cc8c78a7cd8cf9cfdae091158d2ba9d974
ターゲットフレーム .NET
C#9.0record
とは
recordの詳細な説明はMicrosoftDocsに任せます。
C# 9.0 の新機能-レコードの種類
レコード型を作成する
レコード
簡単にまとめると、
- 不変である。ゆえにsetterは使えない(正確には使うことを推奨しない)
- 値ベースの等値性比較ができる。型が等しくすべてのプロパティの値が等しいとき等値とみなされる
といった特徴があります。これはDDDにおける値オブジェクトとしてまさに求められる性質です。
例:四角形オブジェクト
※以下の例ではC#9.0の最上位レベルのステートメント機能を使いMainメソッドを省いてコードを記しています
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
は定義しなくても等値性を比較できます。
また不変なため変更もできません。
r1.Length = 9; // init 専用プロパティまたはインデクサー 'Rectangle.Length' を割り当てることができるのは、オブジェクト初期化子の中か、インスタンス コンストラクターまたは 'init' アクセサーの 'this' か 'base' 上のみです。
注意点1:不変条件を満たすには"位置指定レコード"は使えない
Rectangle
に不変条件を追加します。
-
Length
,Height
は正である -
Area
が100以上である
値オブジェクトは不変条件をファクトリに持たせることができます。
値オブジェクト
は完全に不変である。アクティブなライフタイムの中では決して適用されることのないロジックであれば、オブジェクトが持っている必要はない。このような場合、不変条件を入れるのに理にかなった場所はファクトリ
であり、そうすることで生成物はよりシンプルなままに保たれる。(DDD本6章)
コンストラクタにこのロジックを持たせてみます。(複雑なロジックはポリシーオブジェクトにすべきとか、コンストラクタじゃなくてファクトリクラスに不変条件を担当させるとか流儀はあるでしょうが、今回はシンプル優先)
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)
record Rectangle
{
public int Length { get; init; }
public int Width { get; init; }
以下略
これでオブジェクト生成時に不変条件を満たさないオブジェクトは作れなくなったのでしょうか。
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式
もコピーコンストラクタで生成されたオブジェクトのプロパティを設定するため、コンストラクタの不変条件ロジックを通過しません。
var r6 = new Rectangle(10, 10) { Length = 9, Width = 11 }; // OK?
var r7 = r1 with { Length = 9, Width = 11 }; // OK?
不変条件を満たさないオブジェクトを簡単に作れてしまいました。
注意点3:じゃあinitをやめるか
プロパティ初期化子が使えないようにinitをなくしてみます。
record Rectangle
{
public int Length { get; }
public int Width { get; }
以下略
すると、確かにプロパティ初期化子は使えなくなりますが、同時にwith式
も使えなくなります。それはちょっとあまりに不便。
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
の中でも不変条件を確認することになりました。
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
でしたが、不変条件というロジックが加わるとそれなりには複雑にならざるを得ないようです。とはいえEquals
やoperator ==
、その他with式
に相当するものを書かなくてよい分はかなり楽になっています。また、単純なデータオブジェクトとして使うならかなり有用でしょう。
なにかよいパターン等があれば追記しようと思います。コメント等でのご指摘も頂戴できればと思います(どこかのコミュニティではすでに良いパターンが出尽くしてそう……)