イミュータブル設計の基本

設計 難易度: ★★★☆☆(中級)

前提知識

先にこちらを読んでおくと、この記事がすらすら読めます。

イミュータブル(不変)とは、一度生成したインスタンスの状態を、その後変更できないようにする設計方針だ。

なぜ必要か

同じインスタンスを複数の箇所で共有していると、どこかで値を書き換えたときに、その影響が他の箇所にまで及ぶ。原因を1行ずつ追わないと、どこで値が変わったか分からなくなる。イミュータブルにしておけば生成後は値が変わらないと保証できるので、この追跡が要らなくなる。

動かしてみる

var price = new Money(1000m);
var discounted = price.Add(-100m);

Console.WriteLine(price.Amount);
Console.WriteLine(discounted.Amount);

public record Money(decimal Amount)
{
    public Money Add(decimal amount) => this with { Amount = Amount + amount };
}
1000
900

Addthisを書き換えず、with式(『レコード(record)の基本』で説明した、一部のプロパティだけ変えた新しいインスタンスを作る構文)でAmountだけ変えた新しいMoneyを返す。だからpriceAddを呼んだ後も1000のままで、priceを持っている別のコードには影響しない。「値を変えたい操作は、新しいインスタンスを返す」という形が、イミュータブル設計の基本パターンだ。

最低限の理解

⚠️ プロパティがinitでも中のListは書き換えられる

initで宣言したプロパティ自体は差し替えられないが、プロパティの型がList<T>だと、そのリストの中身は外からAddRemoveで変更できてしまう。

// NG: プロパティはinitだが、Listの中身は外から変更できる
var cart = new Cart(new List<string> { "apple" });
cart.Items.Add("banana");

Console.WriteLine(cart.Items.Count);

public record Cart(List<string> Items);
2
// OK: Add・Removeを持たない型にして、うっかり追加を防ぐ
var cart = new Cart(new List<string> { "apple" });
// cart.Items.Add("banana"); // コンパイルエラーになる: CS1061

Console.WriteLine(cart.Items.Count);

public record Cart(IReadOnlyList<string> Items);
1

ただしIReadOnlyList<string>にしても、渡した元のList<string>を呼び出し元がまだ持っていれば、その元のリストを書き換えることで中身が変わってしまう。書き換える手段自体を無くしたいならImmutableList<T>を使う。

// OK: ImmutableList<T>ならAddしても新しいインスタンスが返るだけ
using System.Collections.Immutable;

var items = ImmutableList.Create("apple");
var cart = new Cart(items);
var updated = items.Add("banana");

Console.WriteLine(cart.Items.Count);
Console.WriteLine(updated.Count);

public record Cart(ImmutableList<string> Items);
1
2

課題

自分の担当ドメインのデータ(注文、商品など)を1つ選び、値を変える操作のたびに新しいインスタンスを返すメソッドを持つrecordとして定義してみる。