オブジェクトを Mutable(可変) にするか Immutable(不変) にするか。これはプログラミング言語を問わず、ソフトウェア設計で繰り返し議論されてきたテーマです。
かつてのオブジェクト指向プログラミングでは、オブジェクトを生成し、プロパティを自由に書き換えながら使い回すスタイルが主流でした。しかし近年では、デフォルトを Immutable にし、必要な場合にだけ Mutable にするという方針が広く推奨されるようになっています。
本記事では「なぜ Mutable なのか」「なぜ Immutable なのか」を論理的に掘り下げ、C# のコードで具体例を示します。
Mutable とは何か
Mutable なオブジェクトは、生成後に内部状態を変更できるオブジェクトです。
public class MutablePoint
{
public int X { get; set; }
public int Y { get; set; }
}
var p = new MutablePoint { X = 10, Y = 20 };
p.X = 30; // 状態を変更できる
プロパティに set があり、外部から自由に値を書き換えられます。
Immutable とは何か
Immutable なオブジェクトは、一度生成したら内部状態を変更できないオブジェクトです。状態を変えたい場合は、新しいインスタンスを作ります。
public class ImmutablePoint
{
public int X { get; }
public int Y { get; }
public ImmutablePoint(int x, int y) { X = x; Y = y; }
}
var p = new ImmutablePoint(10, 20);
// p.X = 30; // コンパイルエラー
var moved = new ImmutablePoint(30, p.Y); // 新しいインスタンスを作る
C# の string は Immutable の代表例です。"hello".ToUpper() は元の文字列を変更せず、新しい "HELLO" を返します。
なぜ Mutable が使われてきたのか
Mutable な設計は長い間デフォルトであり、今でも正当な理由を持つ場面があります。
1. 直感的でシンプル
オブジェクトの状態を「その場で書き換える」のは、現実世界のメンタルモデルに近い操作です。銀行口座の残高は「新しい口座を作る」のではなく「残高を更新する」のが自然に思えます。
account.Balance -= 1000; // 引き出し
2. メモリ効率
Immutable では状態を変えるたびに新しいインスタンスを生成するため、頻繁な変更がある場合はメモリ割り当てと GC の負荷が増えます。Mutable なら既存のメモリ領域をそのまま書き換えられます。
// Mutable: 既存のバッファに追記(効率的)
var sb = new StringBuilder();
for (int i = 0; i < 10000; i++)
sb.Append(i);
// Immutable: 毎回新しい string を生成(非効率)
var s = "";
for (int i = 0; i < 10000; i++)
s += i; // 毎回新しい string が作られ、古い string は GC 対象に
StringBuilder が存在する理由がまさにこれです。string は Immutable なので、大量の文字列連結には Mutable な StringBuilder が必要になります。
3. フレームワークの要求
ORM(Entity Framework など)やデータバインディング(WPF / WinForms)、シリアライザの一部は、パラメーターなしコンストラクタとパブリックな set を前提としています。
// Entity Framework のエンティティ(従来のスタイル)
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = "";
public decimal Price { get; set; }
}
このようなフレームワーク制約のために Mutable にせざるを得ない場面は実際にあります(ただし EF Core 等は近年 Immutable なモデルへの対応を進めています)。
なぜ Immutable が推奨されるのか
Mutable に合理性がある一方で、Immutable をデフォルトの選択にすべきとされる理由は、ソフトウェアが大規模化・並行化するほど顕著になります。
1. 予測可能性 — 「誰かが変えた」問題の根絶
Mutable なオブジェクトを複数箇所で共有すると、ある場所での変更が別の場所に意図せず波及するという問題が発生します。
public class MutableConfig
{
public int Timeout { get; set; } = 30;
}
// 共有された設定オブジェクト
var config = new MutableConfig();
ServiceA.Initialize(config);
ServiceB.Initialize(config);
// ServiceA が勝手にタイムアウトを変更
config.Timeout = 5;
// ServiceB は自分が受け取った config の Timeout が
// いつの間にか 5 に変わっていることに気づかない
Immutable ならこの問題は構造的に起こりません。
public class ImmutableConfig
{
public int Timeout { get; init; } = 30;
}
var config = new ImmutableConfig();
// config.Timeout = 5; // コンパイルエラー — 変更できない
// 変更が必要なら新しいインスタンスを作る(元のオブジェクトは影響を受けない)
var customConfig = new ImmutableConfig { Timeout = 5 };
2. スレッドセーフ — ロック不要の並行処理
Mutable なオブジェクトを複数スレッドから読み書きすると、データ競合が発生します。これを防ぐには lock などの同期機構が必要ですが、ロックはパフォーマンスの低下やデッドロックの原因になります。
// Mutable — ロックが必要
public class MutableCounter
{
private int _count;
private readonly object _lock = new();
public void Increment()
{
lock (_lock) { _count++; }
}
public int Count
{
get { lock (_lock) { return _count; } }
}
}
Immutable なオブジェクトは状態が変わらないため、複数スレッドから同時に読んでもデータ競合が起きません。ロックの必要がなく、並行処理が単純になります。
// Immutable — スレッドセーフが保証される
public record Snapshot(int Count, DateTime Timestamp);
// どのスレッドから読んでも安全
Snapshot current = new(0, DateTime.UtcNow);
// 更新は「新しいスナップショットへの参照の差し替え」で行う
Interlocked.Exchange(ref current, new Snapshot(1, DateTime.UtcNow));
3. デバッグの容易さ — 状態の履歴が追える
Mutable なオブジェクトのバグは「いつ・どこで・誰が状態を変えたのか」の追跡が難しくなります。Immutable なら状態の変化は常に「新しいインスタンスの生成」として表れるため、変更の起点が明確です。
4. 等価性の安定 — ハッシュの破壊を防ぐ
Mutable なオブジェクトを Dictionary のキーや HashSet に入れた後に状態を変更すると、ハッシュコードが変わり二度とそのオブジェクトを見つけられなくなる危険があります。
public class MutableKey
{
public int Value { get; set; }
public override int GetHashCode() => Value;
public override bool Equals(object? obj) => obj is MutableKey k && k.Value == Value;
}
var set = new HashSet<MutableKey>();
var key = new MutableKey { Value = 1 };
set.Add(key);
Console.WriteLine(set.Contains(key)); // True
key.Value = 2; // ハッシュコードが変わる
Console.WriteLine(set.Contains(key)); // False — もう見つからない
Immutable ならハッシュコードが生成時から変わらないため、この種のバグは発生しません。record は値ベースの等価性を自動実装するため、この観点でも安全です。
5. テストの簡潔さ
Immutable なオブジェクトは状態が固定されているため、テストのセットアップが簡単で、テスト間の状態の干渉(テスト汚染)が起きません。
// Immutable なテストデータ — 他のテストから影響を受けない
var order = new Order("ORD-001", DateTime.UtcNow, 1500m);
Assert.Equal("ORD-001", order.Id);
// order の状態は変わりようがないので、後続のテストにも影響しない
ケーススタディ
ケース 1: DTO(Data Transfer Object)
API レスポンスを表す DTO を考えます。
// Mutable DTO(従来のスタイル)
public class UserResponse
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string Email { get; set; } = "";
}
DTO はシリアライズ/デシリアライズされた後に変更される理由がありません。受け取ったデータを表示またはマッピングするだけです。
// Immutable DTO(推奨)
public record UserResponse(int Id, string Name, string Email);
record を使えば Immutable な DTO を簡潔に定義でき、値の等価性や ToString も自動生成されます。
ケース 2: 設定オブジェクト
アプリケーションの設定は、起動時に読み込んで以降は変更しないのが一般的です。
// Mutable な設定(危険)
public class AppSettings
{
public string ConnectionString { get; set; } = "";
public int MaxRetry { get; set; } = 3;
}
// どこかのコードが勝手に変更できてしまう
settings.MaxRetry = 0; // リトライしなくなる
// Immutable な設定(安全)
public class AppSettings
{
public required string ConnectionString { get; init; }
public int MaxRetry { get; init; } = 3;
}
// 初期化後は変更不可
var settings = new AppSettings { ConnectionString = "Server=..." };
// settings.MaxRetry = 0; // コンパイルエラー
ケース 3: ゲームのキャラクター — Mutable が合理的な例
ゲームのキャラクターのように頻繁にリアルタイムで状態が変わるオブジェクトは、毎回新しいインスタンスを生成するコストが見合わない場合があります。
public class Character
{
public string Name { get; init; }
public int Hp { get; set; }
public int Mp { get; set; }
public float X { get; set; }
public float Y { get; set; }
public Character(string name, int hp, int mp)
{
Name = name;
Hp = hp;
Mp = mp;
}
public void TakeDamage(int damage) => Hp = Math.Max(0, Hp - damage);
public void MoveTo(float x, float y) { X = x; Y = y; }
}
フレームごとに位置や HP が変わるオブジェクトを Immutable にすると、大量のインスタンス生成が発生し、GC 圧力が問題になり得ます。こうしたパフォーマンスクリティカルな内部状態は Mutable が合理的です。
ただし、外部に公開するインターフェースは Immutable にすることで、内部の Mutable な状態が外から壊されるのを防げます。
// 外部に公開するスナップショットは Immutable
public record CharacterSnapshot(string Name, int Hp, int Mp, float X, float Y);
public class Character
{
// ... Mutable な内部状態 ...
public CharacterSnapshot ToSnapshot() => new(Name, Hp, Mp, X, Y);
}
ケース 4: イベントソーシング — Immutable が本質的な例
イベントソーシングでは、状態の変更を「イベント」として記録し、イベントの列を再生して現在の状態を復元します。各イベントは一度発生したら変わらない事実なので、本質的に Immutable です。
// イベントは Immutable
public abstract record OrderEvent(string OrderId, DateTime OccurredAt);
public record OrderPlaced(string OrderId, DateTime OccurredAt, string CustomerId)
: OrderEvent(OrderId, OccurredAt);
public record OrderShipped(string OrderId, DateTime OccurredAt, string TrackingNumber)
: OrderEvent(OrderId, OccurredAt);
public record OrderCancelled(string OrderId, DateTime OccurredAt, string Reason)
: OrderEvent(OrderId, OccurredAt);
// イベントを再生して現在の状態を構築
public static OrderStatus Replay(IEnumerable<OrderEvent> events)
{
var status = OrderStatus.Unknown;
foreach (var e in events)
{
status = e switch
{
OrderPlaced => OrderStatus.Placed,
OrderShipped => OrderStatus.Shipped,
OrderCancelled => OrderStatus.Cancelled,
_ => status
};
}
return status;
}
イベントが Mutable だと「過去の事実が書き換わる」ことになり、監査証跡としての意味を失います。
「デフォルト Immutable」の原則
上記のケーススタディを踏まえると、次の原則が導かれます。
まず Immutable で設計し、Mutable にすべき具体的な理由がある場合にだけ Mutable にする。
この原則が広く支持されるのは、Immutable は制約が強い分、考慮すべき問題が少ないからです。
| 観点 | Mutable | Immutable |
|---|---|---|
| 共有時の安全性 | 防御的コピーまたはロックが必要 | そのまま共有できる |
| スレッドセーフ | 同期機構が必要 | 不要 |
| デバッグ | 状態変更の追跡が困難 | 変更の起点が明確 |
| ハッシュの安定性 | 保証できない | 保証される |
| メモリ効率 | ✅ 優れる | 新規インスタンスのコストが発生 |
| リアルタイム性能 | ✅ 優れる | GC 圧力が増す可能性 |
Immutable の唯一の弱点であるメモリ効率とパフォーマンスも、record の with 式や構造共有(Immutable Collections)などの言語・ライブラリの支援によって緩和されてきています。
C# における Immutable 支援機能
C# はバージョンを重ねるごとに、Immutable な設計を書きやすくする機能を追加してきました。
| バージョン | 機能 | 役割 |
|---|---|---|
| 1.0 | readonly フィールド |
フィールドの不変化 |
| 6.0 | get のみの自動実装プロパティ | プロパティの不変化 |
| 9.0 | init アクセサ |
初期化子での設定のみ許可 |
| 9.0 | record 型 |
Immutable なデータモデルの簡潔な定義 |
| 9.0 | with 式 |
Immutable オブジェクトの部分コピー |
| 10 | record struct |
値型の Immutable データモデル |
| 11 | required 修飾子 |
Immutable でも初期化漏れを防止 |
// C# 11 で実現する「必須かつ不変」なデータモデル
public record User
{
public required string Id { get; init; }
public required string Name { get; init; }
public required string Email { get; init; }
}
var alice = new User { Id = "1", Name = "Alice", Email = "alice@example.com" };
var updated = alice with { Email = "new@example.com" }; // 元のオブジェクトは不変
まとめ
- Mutable は直感的でメモリ効率に優れるが、共有・並行処理・デバッグにおけるリスクが高い
- Immutable は予測可能性・スレッドセーフ・等価性の安定を構造的に保証でき、多くの場面で安全な選択肢になる
- パフォーマンスクリティカルな内部状態(ゲームのキャラクター、大量の文字列操作など)は Mutable が合理的
- 外部に公開するインターフェースは Immutable にし、内部の Mutable な状態を保護するのが実践的なパターン
- 「まず Immutable で設計し、理由があるときだけ Mutable にする」が現代のデフォルト
- C# は
readonly→init→record→requiredと段階的に Immutable 支援を強化してきており、Immutable な設計のコストは年々下がっている