副作用の局所化はソフトウェアの設計アプローチです。対象のコンポーネントの副作用の発生可能性の軽減と、副作用の影響範囲の局所化を行うアプローチを指します。副作用の局所化は疎結合設計を支え、バグが少なく保守しやすいコードを生むために普遍的に重要な設計アプローチと言えます。
副作用とは
ソフトウェアの設計やプログラミングにおける副作用は、意図した直接の出力とは別におこる作用を指します。例えば特定の値を返すことを目的とした関数を対象とした場合、値を返す以外に、内部でグローバルな状態・静的な状態を更新していれば、その状態更新が副作用になります。
副作用を起こすものに以下があります。
- グローバルな変数の更新
- 静的な変数の更新
- 共有するリソースやデータの更新
- I/Oの実行
- バッファオバーフローといった異常状態の発生
- 上記を発生させる関数の呼び出し
副作用は状態更新以外のものもあります。意図せず他の処理のタイミングや時間、順序を不適切に変えてしまう作用(例えばRTOSで動くソフトウェアにおいて、プロセッサリソースを占有して、他タスクの処理シーケンスを乱してしまうなど)も副作用です。ただし、副作用の局所化で語られる参照透過性や不変性などのコンテキストの中では、もっぱら副作用は状態更新に絞られます。
副作用の局所化
副作用の局所化とは、副作用が発生する領域をなるべく小さな領域に閉じ込めること、副作用の発生可能性を軽減することを推進する設計アプローチです。
状態更新の副作用に対しては、以下のような設計アプローチが、副作用の局所化に該当します。
- 変数やリソースなどの状態のスコープを小さくする。グローバル変数を避ける
- 静的な状態を少なくする
- 横断的に共有される状態、静的な状態は、操作処理や、ガードといった防御的設計をまとめたオブジェクトとしてカプセル化する。状態更新についての防御的設計を複数に散在させない
- 妥当な範囲で、参照透過性や不変性を確保する。例えば純粋関数(入力だけで出力を決め、外部に他の影響を発生させない関数)や、不変オブジェクト(初期化以外で状態を更新しない)として処理を実装する
- バッファオーバーフローといった異常状態については、開発言語機能、型システム、コーディングルール、静的解析といった多重の手段で解消を目指す
この副作用の局所化は保守性向上を中心に、さまざまな恩恵を提供します。主要なものを以下に示します。
- デバッグが容易になる。バグ原因の発生可能性の範囲を狭く絞り込める
- テストが容易になる。テストの入力・出力が明確化され、小さく絞り込まれる。ユニットテストなどで対象を切り出すのも容易になる
- 再利用性を高める。再利用の際の前提条件や注意事項が小さくなる
不変オブジェクト
副作用の局所化の実装手段として不変オブジェクトがあります。不変オブジェクトは、オブジェクト生成時の初期化を除いて、オブジェクトの状態を変更できないオブジェクトを指します。不変オブジェクトとして実装すれば副作用をなくせるわけではありませんが、副作用の発生可能性を下げ、副作用の範囲を削減する手段として有効です。
例えばC++で以下のクラスUserがあるとします。
class User { public: std::string name; int age; User(const std::string& name_str, int age_val) : name(name_str), age(age_val) {} ...さまざまなメソッド... };
このUserクラスはnameとageのパラメータを持ちます。この実装ではインスタンス化した後もnameとageを更新できるため、これは可変オブジェクトになります。外部から自由に更新できるため、Userのインスタンスは副作用の影響を受けやすいと言えます。
このUserクラスを不変オブジェクトとして実装する場合、次のようになります
class User { private: const std::string name_; const int age_; public: User(const std::string& name, int age) : name_(name), age_(age) {} std::string getName() const { return name_; } int getAge() const { return age_; } ...さまざまなメソッド... };
すべてのパラメータについてconstを付与し、不変性を担保しています。Getterを定義する場合は、const宣言で内部のパラメータを更新しないことを担保します。
ドメインプリミティブな設計
副作用の局所化と組み合わせるのが効果的な設計アプローチに、ドメインプリミティブという設計指針があります。これはドメイン駆動設計での用語で、プリミティブ型(開発言語の標準の基本型。intやstringなど)を使用せず、ドメインのコンテキストに合わせた独自型を使用すべきというものです。ドメインプリミティブの設計では、型にバリデーションを組み込み、オブジェクトが常に正しい状態の実現も目指します。ドメインプリミティブを遵守することで、不正な状態更新を抑止し、有害な副作用の影響を抑えることができます。
例えば前述のUserクラスをドメインプリミティブの設計に基づいて改善した例を以下に示します。
class User { private: const std::string name_; const int age_; static std::string validateName(const std::string& name) { if (name.length() < 1 || name.length() > 20) { throw std::invalid_argument("name must be between 1 and 20 characters"); } return name; } static int validateAge(int age) { if (age < 0 || age > 200) { throw std::invalid_argument("Age must be between 0 and 200"); } return age; } public: User(const std::string& name, int age) : name_(validateName(name)), age_(validateAge(age)) {} std::string getName() const { return name_; } int getAge() const { return age_; } ...さまざまなメソッド... };
ドメインプリミティブの設計では、ユーザの名前や年齢をstring型やint型そのままで定義するのではなく、上記のUserクラスで定義するように統一します。
このUserクラスでは、値の初期化時に入力バリデーションを行い、不正な状態が指定された場合は例外で弾くように実装しています。こうした事前条件の評価・防御的処理を組み込むことで、常に正しい状態のオブジェクトしか存在しないように実装できます。これにより、有害な影響を発生させる副作用を抑止できます。