この記事は、保守性の高いコード設計シリーズの一部です。前回の記事「【第1部】 変数の再代入はなぜ悪い?
final
で学ぶ、追跡しやすいコードの書き方」では、final
を使ってメソッド内部のコードを安定させる方法を学びました。
こんにちは。前回の記事では、final
を使って変数の再代入を防ぐことで、メソッドの見通しを良くする方法を解説しました。今回は、その「不変性」という概念をオブジェクト全体に広げ、ソフトウェアの安定性を根底から揺るがす**副作用(Side Effect)**という問題に立ち向かいます。
副作用とは?可変(ミュータブル)インスタンスが引き起こす怪奇現象
副作用とは、ある関数やメソッドが、結果を返す以外に、その外側の世界の何か(他の変数やオブジェクトの状態)を変化させてしまうことを指します。そして、この副作用の最大の原因となるのが、変更可能(ミュータブル)なオブジェクトの共有です。
例えば、商品の割引率を管理するDiscountRate
クラスがあるとします。
【Before】変更可能な(Mutableな)DiscountRateクラス
class DiscountRate {
double rate;
DiscountRate(double rate) { this.rate = rate; }
void applySpecialBonus(double bonus) { this.rate += bonus; } // 自分の状態を変更
}
このクラスは、applySpecialBonus
メソッドで自身のrate
を変更してしまいます。一見問題なさそうですが、このインスタンスが複数の場所で「共有」されると、怪奇現象が起こります。
// 全商品共通の「サマーセール」割引インスタンスを生成
DiscountRate summerSale = new DiscountRate(0.1); // 10%OFF
// 商品Aと商品Bで、同じ割引インスタンスを共有
Product productA = new Product("Tシャツ", 3000, summerSale);
Product productB = new Product("ジャケット", 15000, summerSale);
// 特定の商品Aだけに、特別なタイムセールボーナスを適用したつもりが…
productA.discountRate.applySpecialBonus(0.05); // 5%追加割引
// 商品Aの割引率を確認 → 15%OFF (0.15) …これは正しい
System.out.println("商品Aの割引率: " + productA.discountRate.rate);
// まったく関係ない商品Bの割引率を確認すると…
// なぜかこちらも15%OFFになっている!
System.out.println("商品Bの割引率: " + productB.discountRate.rate);
productA
の割引率を変更しただけなのに、無関係なproductB
の割引率まで変わってしまいました。これが、共有された可変インスタンスが引き起こす副作用の恐ろしさです。productA
とproductB
は、同じ割引率オブジェクトの実体を共有していたため、片方への変更がもう片方へも影響してしまったのです。
解決策:不変(イミュータブル)にして副作用を防ぐ
この問題を根本的に解決するのが、オブジェクトを**不変(イミュータブル)**に設計することです。これは、一度作られたオブジェクトの状態は二度と変わらないことを保証する設計です。
【After】不変(Immutable)で堅牢になったDiscountRateクラス
class DiscountRate {
final double rate; // finalで不変に
DiscountRate(final double rate) {
if (rate < 0.0 || rate > 1.0) { // バリデーションも忘れない
throw new IllegalArgumentException("割引率は0.0〜1.0の範囲で指定してください。");
}
this.rate = rate;
}
// 自分の状態は変えず、「計算結果を持つ新しいインスタンス」を返す
DiscountRate applySpecialBonus(final double bonus) {
return new DiscountRate(this.rate + bonus);
}
}
この新しいDiscountRate
クラスは、applySpecialBonus
を呼んでも自分自身のrate
は変えず、ボーナスが適用された新しいDiscountRate
インスタンスを返します。
このクラスを使うと、先ほどの怪奇現象は起こりません。
DiscountRate summerSale = new DiscountRate(0.1);
Product productA = new Product("Tシャツ", 3000, summerSale);
Product productB = new Product("ジャケット", 15000, summerSale);
// 特別ボーナスを適用すると、「新しい割引率インスタンス」が返ってくる
DiscountRate specialDiscountForA = productA.discountRate.applySpecialBonus(0.05);
// 新しい割引率を商品Aに適用した、新しい商品インスタンスを作る
Product discountedProductA = new Product("Tシャツ", 3000, specialDiscountForA);
// discountedProductAの割引率は15%
System.out.println("特別割引後の商品Aの割引率: " + discountedProductA.discountRate.rate);
// 元のsummerSaleや商品Bには一切影響なし! 割引率は10%のまま
System.out.println("商品Bの割引率: " + productB.discountRate.rate);
このように、不変なオブジェクトは副作用を生まないため、プログラムのどの部分で使われても、予期せぬ状態変化を起こす心配がありません。これにより、コードの挙動は非常に予測しやすくなり、安定性が劇的に向上します。
不変と可変の使い分け
では、すべてのクラスを不変にすべきなのでしょうか?
原則は**「デフォルトは不変に (Immutable by Default)」**です。不変にできるものは、すべて不変に設計しましょう。
しかし、パフォーマンスが極めて重要な場合や、外部のライブラリ、あるいはデータベース接続のように、本質的に「状態」を持つものを表現する際には、可変(ミュータブル)なクラスが必要になることもあります。
その場合は、以下の原則を守って「安全な可変クラス」を設計することが重要です。
- カプセル化を徹底する: フィールドは
private
にし、外部から直接変更できないようにする。 - 状態変更メソッドを責任を持って実装する: 状態を変更するメソッドは、そのクラス自身が提供し、そのメソッド内で必ず「正常な状態」が維持されるようにバリデーションや補正処理を行う。
【安全な可変クラスの例:HP管理】
class HitPoint {
private static final int MIN = 0;
private int amount;
// ...コンストラクタ...
// HPを減らす(状態変更)ロジックはこのメソッド内にカプセル化されている
void damage(final int damageAmount) {
final int nextAmount = this.amount - damageAmount;
// 必ず0未満にならないように補正し、不正な状態を防ぐ
this.amount = Math.max(MIN, nextAmount);
}
}
このHitPoint
クラスは可変ですが、damage
メソッドが責任を持って状態を管理しているため、amount
がMIN
未満になることはありません。
まとめ
- デフォルトは不変に: 不変なクラスは副作用をなくし、コードを安全で予測可能なものにします。
- 可変にする場合は慎重に: もし可変なクラスを設計する必要があるなら、その状態管理の責任をクラス自身が完全に負うように、ロジックをカプセル化しましょう。
安定したソフトウェアを構築する鍵は、「状態の変化」をいかに制御し、影響範囲を限定するかにあります。不変性の活用は、そのための最も強力な武器の一つです。