この記事は、保守性の高いコード設計シリーズの一部です。前回の記事「【第1部】 バグを未然に防ぐクラス設計:完全コンストラクタで実現する自己防衛責務」では、オブジェクトが不正な状態で生成されるのを防ぐ方法を学びました。
こんにちは。前回の記事で、私たちは「完全コンストラクタ」によって、オブジェクトが必ず正常な状態で生まれることを保証できるようになりました。
しかし、新たな疑問が生まれます。「生まれた後のオブジェクトを、どう安全に操作すればよいのか?」ということです。今回は、予期せぬ副作用やバグを防ぐための非常に強力な設計術である**不変性(Immutability)と、それを応用した値オブジェクト(Value Object)**パターンについて解説します。
変更可能なクラスがもたらす混乱
前回のInventory
クラスに、在庫を追加するロジックを加えてみましょう。もし、インスタンスの内部状態を直接変更(上書き)できるように設計したらどうなるでしょうか。
【Before】内部状態が変更できてしまう(Mutableな)クラス
class Inventory {
String productCode;
int quantity;
// ... コンストラクタは省略 ...
// 在庫を追加する(quantityを直接上書きしている)
void add(int amount) {
this.quantity += amount;
}
}
このadd
メソッドは、quantity
フィールドを直接変更しています。このような設計は、一見シンプルですが、複雑な条件分岐が絡むと、オブジェクトの最終的な状態を追跡するのが非常に困難になります。
// 在庫50個でスタート
Inventory stock = new Inventory("A-123", 50);
// 特別キャンペーン中なら…
if (isCampaignActive) {
stock.add(10); // 在庫は60に
}
// 在庫一掃セールなら…
if (isClearanceSale) {
// この時点でstockがどうなっているか、追いかける必要がある
stock.quantity = 0; // 直接上書き!
}
このように状態がコロコロ変わるオブジェクトは、思わぬ副作用の元凶となり、デバッグを困難にします。
解決策①:不変(Immutable)にして副作用を防ぐ
この問題を解決する強力なアプローチが、オブジェクトを**不変(Immutable)**にすることです。これは、一度生成されたインスタンスの内部状態は、その後一切変更できないという設計原則です。Javaでは、フィールドにfinal
修飾子をつけることで実現できます。
class Inventory {
// finalをつけることで、再代入が不可能になる
final String productCode;
final int quantity;
// ... コンストラクタ ...
}
// これで、以下のような再代入はコンパイルエラーになる
// stock.quantity = 0; // Error!
では、状態を変更できないなら、どうやって在庫を追加するのでしょうか?答えは、**「計算結果を持つ、新しいインスタンスを生成して返す」**です。
class Inventory {
// ... フィールドとコンストラクタ ...
// 在庫を追加する(新しいインスタンスを返却する)
Inventory add(final int amountToAdd) {
final int newQuantity = this.quantity + amountToAdd;
return new Inventory(this.productCode, newQuantity);
}
}
このadd
メソッドは、自分自身のquantity
を変更するのではなく、加算後の数量を持つ全く新しいInventory
インスタンスを生成して返します。
Inventory stock_v1 = new Inventory("A-123", 50);
// 在庫を追加すると、新しいインスタンスstock_v2が返ってくる
Inventory stock_v2 = stock_v1.add(10);
// stock_v1の数量は50のまま変わらない
// stock_v2の数量は60になっている
これにより、元のオブジェクトの状態は変わらないことが保証されるため、副作用の心配なく安全にメソッドを呼び出すことができます。
解決策②:「型」で渡し間違いを防ぐ
現在のadd
メソッドは、引数にint
型を受け取ります。しかし、これは「チケットの枚数」や「ユーザーID」のような、在庫数とは全く関係のない数値を誤って渡してしまうリスクを抱えています。
final int userCount = 5;
// 本来は在庫数を渡すべきところに、ユーザー数を渡せてしまう!
stock.add(userCount);
この種の間違いは、コンパイラでは検知できません。そこで、引数の型をint
からInventory
自身に変えることで、型システムに間違いをチェックさせましょう。
class Inventory {
// ... フィールドとコンストラクタ ...
// 同じ型(Inventory)のインスタンスを引数に取る
Inventory add(final Inventory other) {
// 同じ商品コードでなければ、操作は無効
if (!this.productCode.equals(other.productCode)) {
throw new IllegalArgumentException("異なる商品の在庫は合算できません。");
}
final int newQuantity = this.quantity + other.quantity;
return new Inventory(this.productCode, newQuantity);
}
}
この設計により、add
メソッドにはInventory
型以外のインスタンスを渡すことができなくなり、先ほどのような単純な渡し間違いをコンパイル時点で防ぐことができます。
まとめ:値オブジェクトという設計パターン
これまで見てきた設計テクニックをまとめると、以下のようになります。
- 完全コンストラクタで、生成時の正常性を保証する。
- フィールドを
final
にし、**不変(Immutable)**にする。 - 状態を変更する際は、自分自身を変更せず、新しいインスタンスを返却する。
- 関連するロジック(
add
など)をすべてクラス内に凝集させる。 - メソッドの引数にもプリミティブ型ではなく独自の型を使い、間違いを防ぐ。
このように、「値」そのものを表現し、不変であり、自身の正しさを自分で守るように設計されたクラスを、**値オブジェクト(Value Object)**と呼びます。
「完全コンストラクタ」と「値オブジェクト」は、オブジェクト指向設計における最も基本的かつ強力なパターンです。この2つをマスターするだけで、あなたの書くコードは劇的に堅牢で、バグが少なく、そして読みやすいものになるでしょう。