保守性と拡張性を高めるためのSOLID原則とシステム設計
第6話: リスコフの置換原則(2)具体例
(最終更新日:2024.09.28)
(絵が小さい場合はスマホを横に)
リスコフの置換原則に従って、オブジェクト指向設計を強化しよう
リスコフの置換原則(Liskov Substitution Principle, LSP)は、
オブジェクト指向設計において子クラスと親クラスが互換性を持ち、予測可能な振る舞いを提供するための重要な原則だ。
この原則を守ることで、子クラスが親クラスの代わりとして問題なく機能し、システムの拡張性や保守性が向上する。
本記事では、LSPの具体例を交えながら、子クラスと継承の正しい使い方を通じて、強固で柔軟な設計を実現する方法を解説する。
1.互換性と予測可能性を維持するための設計
本項では、LSPに基づき、子クラスと親クラスの互換性と予測可能性を維持するための設計手法を詳しく解説する。
LSPを守ることで、子クラスが親クラスの代わりに使用されてもプログラムが正しく動作し、予期しないバグやエラーを防ぐことができる。
これにより、システム全体の信頼性と拡張性が向上させていく。
1.1 互換性を保つための設計
互換性を保つためには、子クラスが親クラスと同じ振る舞いを提供し、同じように機能する必要がある。
親クラスの期待を壊さないというのが基本だ。これを実現するために、次のような設計指針が重要になる。
1.1.1メソッドの一貫性を守る:
子クラスで親クラスのメソッドをオーバーライドする場合、メソッドのシグネチャ(引数や戻り値)や動作が親クラスと一致していなければならない。
子クラスが親クラスの代わりに使われたときに、同じメソッド呼び出しが予想通りの結果を返すことを保証する。
下記の例では、SparrowはBirdの子クラスであり、fly()メソッドをオーバーライドしても親クラスの基本的な動作(飛ぶこと)を変えていない。
親クラスとして扱っても互換性が保たれる、
正しい互換性
1.2 2. 子クラスの前提条件と後提条件を維持する
親クラスのメソッドが持つ事前条件(メソッドが呼び出される前の条件)や事後条件(メソッドが呼び出された後の結果)を子クラスで変えてはいけない。
事前条件を強化してしまうと、親クラスが想定していた状況で子クラスが動作しなくなる恐れがある。
同様に、事後条件を弱めてしまうと期待される結果が得られない。
下記の例では、PenguinがBirdのfly()メソッドを変更し、飛べないという動作にしている。
これにより、PenguinはBirdとして期待された動作を提供できなくなり、互換性が失われている。
互換性を壊す
1.2 予測可能性を維持するための設計
システムの予測可能性を高めるためには、コードが常に一貫して動作するように設計することが必要だ。
特に、子クラスが親クラスのインターフェースの意図を守り、振る舞いを保つことが重要になる。
共通のインターフェースを使用する:LSPを守る設計では、共通のインターフェースを使うことで、子クラスと親クラスが同じ操作を提供できるようにする。
これにより、開発者はクラスがどう動作するかを容易に予測でき、子クラスであっても親クラスとしての操作が常に一貫して行うことが保証される。
継承と拡張のバランスを保つ:子クラスは親クラスを拡張することが目的であり、動作を無効にしたり、制限したりしてはいけない。
子クラスは親クラスの機能を自然に広げ、元の機能を損なわないように設計する必要がある。
これにより、システムが子クラスで拡張されても、親クラスと同じ予測可能な動作を期待できる。
下記の例では、Animalという抽象クラスがmove()メソッドを定義し、BirdやFishはそれぞれの動作に応じて適切なmove()メソッドを実装している。
これにより、Animalとして使う場合でも動作が一貫しており、予測可能な結果が得られる。
予測可能な動作
1.3 互換性と予測可能性を保つための具体的な方法
インターフェースや抽象クラスを活用する:インターフェースや抽象クラスを使って、共通の操作や振る舞いを明確に定義し、
子クラスがそれを拡張する形で機能を追加する。
テストの活用:親クラスや子クラスが意図した通りに動作することを確認するために、単体テストや統合テストを実施する。
これにより、互換性や予測可能性の担保ができる。
設計パターンを活用する:戦略パターンやデコレータパターンなどを活用し、子クラスで新たな振る舞いを導入しても、
元のクラスの動作を変更せずに拡張できる設計を行う。
1.4 まとめ
リスコフの置換原則は、子クラスが親クラスの代わりに問題なく動作するように設計するための重要な原則だ。
これにより、システム全体の安定性と拡張性が向上する。
LSPに違反しない設計を行うことで、クラスの継承階層が正しく機能し、子クラスを導入する際に予期せぬバグが発生するリスクを減らせる。
2.良い例と悪い例
今まで述べてきたLSPに従った設計と、そうでない設計を以下の表にまとめた。 正しい設計の重要性を解説する。
項目 | 悪い例 | 良い例 |
---|---|---|
互換性 | PenguinがBirdのfly()メソッドを変更し、例外をスローするため、互換性がなく、予期しない動作を引き起こす。 | move()メソッドを通じて、PenguinもSparrowも親クラスBirdとして正しく機能し、動作が一貫している。 |
予測可能性 | 子クラスのPenguinがfly()メソッドで例外をスローするため、動作が予測できず、他のBirdクラスとは異なる動作をする。 | 各子クラスがmove()を適切に実装しているため、親クラスとしての動作が一貫しており、予測可能。 |
保守性 | Penguinのfly()メソッドを無効化することで、他のクラスとの整合性がなくなり、将来的な修正やメンテナンスが複雑化する。 | move()の実装がクラスごとに適切に分かれているため、拡張や修正が容易で保守性が高い。 |
拡張性 | 子クラスが親クラスの動作を壊しているため、クラスを拡張したときに整合性が取れず、新しいクラスの追加に影響が出る。 | 各子クラスは親クラスの契約を守っているため、新しいクラスを追加しても既存のコードに影響がなく、拡張性が高い。 |
2.2 解説
リスコフの置換原則を守ることで、子クラスが親クラスの代替として常に予測通りに動作し、システム全体が安定しやすくなる。
良い設計では、子クラスが親クラスの契約(メソッドの振る舞い)を守りつつ、自分自身に適した機能を実装することで、互換性と拡張性が確保される。
一方、LSPに違反する設計では、親クラスとしての動作が破壊され、システムの保守性や拡張性が低下する。
3.リファクタリングの具体例
既存のコードが不適切な設計や原則違反によって複雑化している場合、それを改善するためにコードを整理・再構成する方法を解説する。 リファクタリングの目的は、コードの保守性と拡張性を向上させることだ。 以下では、リファクタリング前の問題点を示し、どのようにリファクタリングすることでコードを改善できるかを具体的に説明する。
3.1 リファクタリング前のコード
以下のOrderServiceクラスは、注文の検証、支払い処理、配送手続き、確認メールの送信など、複数の責任を一度に行っている。
これにより、クラスが肥大化し、変更や拡張が難しくなっている。
リファクタリング前
単一責任の原則(SRP)違反:OrderServiceクラスが、注文の検証から支払い処理、配送、メール送信といった複数の責任を持っているため、
変更が困難になる。たとえば、支払い方法が増えた場合や、配送方法が変更された場合にこのクラスを変更する必要が生じ、
他の機能にも影響を及ぼすリスクがある。
拡張性が低い:支払い方法や配送手続きを追加・変更する際に、OrderServiceクラスに直接変更を加えなければならず、コードが複雑化する。
再利用性が低い:支払い処理や配送処理、メール送信のロジックは他の場所でも使えそうだが、このままでは再利用がむずかしい。
3.2 リファクタリング後のコード
リファクタリングでは、単一責任の原則を守り、各機能を個別のクラスに分割する。
これにより、保守性が向上し、拡張が容易になる。また、各機能は独立しているため、再利用性も高まる。
1.注文の検証を専用クラスに分離
2.支払い処理をインターフェースで抽象化
3.配送処理を専用クラスに分離
4.メール送信を専用クラスに分離
OrderServiceクラスは、各機能を外部のクラスに委譲し、注文処理の流れを管理するだけの単一の責任を持つクラスに変更する。 リファクタリング後のコードは以下のようになる。
1~4を委譲したリファクタリング後のコード
3.3 リファクタリングの利点
単一責任の原則(SRP)に従った設計:各クラスが単一の責任を持つため、OrderServiceクラスは注文処理の流れを制御するだけになり、
検証や支払い、配送、メール送信などの機能は別々のクラスで管理される。これにより、各クラスの役割が明確になり、コードの可読性が向上する。
開放/閉鎖の原則(OCP)の適用:支払い処理に関しては、PaymentProcessorインターフェースを導入することで、
新しい支払い方法を追加する際に既存のコードを変更せずに、PaymentProcessorの新しい実装クラスを追加するだけで済むようになっている。
これにより、コードが拡張しやすくなる。
再利用性の向上:PaymentProcessorやShippingService、EmailServiceなどのクラスは独立しているため、他のプロジェクトやシステムでも再利用が容易になる。
例えば、EmailServiceは、確認メール以外にもさまざまな通知メールの送信に使うことができる。
テストのしやすさ:クラスごとに役割が分かれているため、個々のクラスを単体でテストすることが容易になる。
例えば、支払い処理をテストする際は、PaymentProcessorをモック化し、注文処理の流れをテストできる。これにより、コードの品質が向上する。
4.まとめ
リスコフの置換原則(LSP)は、子クラスが親クラスの代替として正しく機能することを求めるオブジェクト指向設計の基本原則だ。 この原則を守ることで、システム全体の互換性と予測可能性が向上し、コードの保守性と拡張性が高まることが理解できたと思う。 今回は、LSPに従った子クラスと継承の正しい使い方を解説し、実際のコード例を通じて、柔軟で信頼性の高いシステム設計の方法を示した。 LSPを用いて、保守性と拡張性の高いコードを書いていこう。