メインコンテンツに移動

LSP(リスコフの置換原則)とは?継承設計で守るべき重要原則を徹底解説

ソフトウェア開発では、継承を使うことで既存の機能を再利用し、新しい機能を効率よく追加できます。しかし、継承は便利である一方、使い方を誤ると保守性を大きく低下させる原因にもなります。親クラスの代わりに子クラスを使ったときに想定外の動作が起きる設計では、利用側のコードが不安定になり、バグや修正コストが増加します。

LSP(リスコフの置換原則)は、こうした継承設計の問題を防ぐための重要な設計原則です。LSPはSOLID原則を構成する原則の一つであり、「派生クラスは親クラスの代わりとして問題なく利用できなければならない」という考え方を示します。単に親クラスを継承しているだけでは不十分であり、親クラスが持つ契約や利用者の期待を子クラスが守る必要があります。

本記事では、LSPの基本概念から、置換可能性の意味、LSPが重要な理由、親クラスの契約、メソッドオーバーライド時の注意点、RectangleとSquare問題、OCPとの関係、インターフェース設計、コンポジションとの比較、実務での活用例まで体系的に解説します。継承設計を安全に使いたい方や、保守性の高いオブジェクト指向設計を学びたい方に向けて、実務で役立つ視点を紹介します。

1. LSP(リスコフの置換原則)とは?

LSP(リスコフの置換原則)とは、親クラスのオブジェクトが使われている場所では、子クラスのオブジェクトを代わりに使ってもプログラムの正しさが保たれるべきだという設計原則です。正式名称はLiskov Substitution Principleで、SOLID原則の中でも継承や抽象化の品質に深く関係する考え方です。子クラスが親クラスを継承している場合でも、親クラスと同じように扱えなければ、LSPに違反している可能性があります。

LSPの本質は、型の互換性だけではなく、振る舞いの互換性を守ることにあります。プログラミング言語上は親クラス型として子クラスを代入できても、実際の動作が親クラスの期待と異なるなら安全な置換とはいえません。つまり、LSPはコンパイルが通るかどうかではなく、利用者が期待する動作を子クラスが守れるかどうかを問う原則です。

主な特徴

項目内容
正式名称Liskov Substitution Principle
略称LSP
所属SOLID原則
提唱者Barbara Liskov
基本思想派生クラスは親クラスと置換可能でなければならない

1.1 LSPの基本概念

LSPの基本概念は、親クラスを利用するコードが、子クラスに差し替えられても正しく動作することです。たとえば、親クラスとしてPaymentを定義し、その子クラスとしてCreditCardPaymentやBankTransferPaymentを作る場合、利用側はPaymentとして同じように決済処理を呼び出せる必要があります。子クラスによって突然例外が発生したり、前提条件が大きく変わったりする場合、置換可能性が壊れます。

この考え方は、継承だけでなくインターフェース設計にも適用できます。あるインターフェースを実装するクラスは、そのインターフェースの契約を守らなければなりません。利用側は「この型ならこのように動くはずだ」と期待してコードを書いているため、実装クラスがその期待を裏切ると、システム全体の信頼性が低下します。

1.2 「置換可能」とは何か

「置換可能」とは、親クラスやインターフェースが期待されている場所に子クラスや実装クラスを渡しても、利用側のコードを変更せずに正しく動作する状態を指します。単に同じメソッドを持っているだけではなく、そのメソッドの意味や戻り値、例外、状態変化が利用者の期待と一致している必要があります。

たとえば、親クラスのメソッドが「常に正の値を返す」と期待されている場合、子クラスが負の値を返すような実装をすると、利用側の処理が壊れる可能性があります。LSPでは、子クラスは親クラスよりも扱いにくい存在になってはいけません。親クラスを使うコードが、子クラスでも自然に動くことが重要です。

2. LSPが重要な理由

LSPが重要な理由は、継承や抽象化を安全に使うためです。オブジェクト指向では、親クラスやインターフェースを通じて複数の実装を扱うことが多くあります。このとき、子クラスが親クラスの契約を守っていなければ、利用側は各子クラスの細かい違いを意識しなければならず、抽象化の意味が失われます。

LSPを守ることで、コードの保守性や拡張性が向上します。親型を使うコードが子型に依存せず安定して動作すれば、新しい子クラスを追加しても既存コードを大きく変更する必要がありません。これは、OCPやDIPなど他のSOLID原則を実現するうえでも重要な前提になります。

2.1 継承の安全性向上

LSPを守ることで、継承の安全性が向上します。継承は既存の機能を再利用できる便利な仕組みですが、子クラスが親クラスと異なる前提や動作を持つと、利用側にとって危険な設計になります。親クラスとして扱えるはずなのに、実際には特別な注意が必要な子クラスは、継承関係として適切ではありません。

安全な継承では、子クラスは親クラスの振る舞いを自然に拡張します。親クラスの利用者が期待する動作を壊さず、必要な範囲で具体的な実装を追加することが重要です。LSPは、継承を単なるコード再利用ではなく、正しい型関係として使うための基準になります。

2.2 バグの防止

LSPを守らない設計では、親クラス型として扱ったときに子クラスが想定外の動作をするため、バグが発生しやすくなります。特に、利用側が親クラスの仕様を前提に処理している場合、子クラスがその仕様を破ると、実行時エラーや不正な結果につながります。コンパイル時には問題が見えにくいことも多いため、設計段階で注意が必要です。

たとえば、親クラスでは問題なく実行できるメソッドを、子クラスでは「未対応」として例外を投げるような設計は危険です。利用側は親クラスのメソッドが利用できると期待しているため、子クラスだけ特別扱いしなければならなくなります。このような設計は、LSP違反によるバグの温床になります。

2.3 保守性向上

LSPを守ると、保守性が向上します。親クラスやインターフェースを利用するコードが、具体的な子クラスの違いを意識しなくてよくなるためです。利用側が子クラスごとに条件分岐を持つ必要がなくなり、コードの見通しが良くなります。新しい実装を追加するときも、既存コードへの影響を抑えやすくなります。

保守性の高い設計では、抽象化が信頼できます。つまり、親型やインターフェースを見れば、利用側が期待できる動作を理解できます。LSPが守られていないと、実装クラスごとに例外的な振る舞いを確認する必要があり、開発者の負担が増えます。LSPは、抽象化を信頼できるものにするための原則です。

3. LSPの基本ルール

LSPの基本ルールは、子クラスが親クラスの契約を守ることです。ここでいう契約とは、メソッドの名前や引数だけでなく、前提条件、戻り値、例外、状態変化、利用者が期待する振る舞いを含みます。子クラスは、親クラスよりも厳しい前提を要求したり、親クラスが保証していた結果を弱めたりしてはいけません。

LSPを守るためには、利用者の視点で考えることが重要です。子クラスの内部実装がどれだけ合理的でも、親型として利用されたときに期待通りに動かなければ問題があります。LSPは、継承階層の内部事情ではなく、利用側から見た一貫性を重視する原則です。

3.1 親クラスの契約を守る

親クラスの契約を守るとは、親クラスが利用者に約束している振る舞いを子クラスでも維持することです。たとえば、親クラスのsaveメソッドが「データを保存し、保存後のIDを返す」と定義されているなら、子クラスでも同じ期待を満たす必要があります。子クラスが保存せずに何もしない、あるいは別の意味の値を返す場合、契約違反になります。

契約はコード上の型定義だけでは表現しきれないことがあります。メソッド名、コメント、ドキュメント、テスト、利用実績などから、利用者が期待する振る舞いが形成されます。LSPを守るには、子クラスがその期待を壊さないように実装する必要があります。

3.2 利用者の期待を裏切らない

LSPでは、利用者の期待を裏切らないことが重要です。親クラス型として使うコードは、子クラスの詳細を知らずに処理を呼び出します。そのため、子クラスだけが特殊な制約を持っていたり、特定の順序で呼ばないと動かなかったりすると、利用者に余計な知識を要求することになります。

利用者の期待を守る設計では、子クラスごとの違いは内部に閉じ込められます。外から見ると、どの子クラスでも同じ契約に従って扱えるため、利用側のコードはシンプルになります。LSPは、抽象化を利用する側の負担を減らすための考え方でもあります。

3.3 動作の一貫性維持

LSPを守るには、親クラスと子クラスの動作に一貫性が必要です。完全に同じ処理をする必要はありませんが、親クラスの契約や意味を壊してはいけません。たとえば、親クラスが「読み取り可能なストレージ」を表すなら、子クラスも読み取り操作に対して一貫した結果を返す必要があります。

動作の一貫性が保たれていれば、利用側は具体的な実装を意識せずにコードを書けます。逆に、実装ごとに細かな例外ルールを知る必要がある場合、抽象化は機能していません。LSPは、継承やインターフェースによる設計を一貫したものにするための基準です。

4. 「置換可能」の意味

LSPにおける「置換可能」とは、親クラス型やインターフェース型で書かれたコードが、どの子クラスや実装クラスを受け取っても問題なく動作することを意味します。これは、型として代入できるという形式的な互換性だけでなく、実行時の振る舞いが期待に合っていることを含みます。

置換可能性が守られている設計では、利用側のコードは具体クラスの名前や内部実装を知らなくても動作します。これにより、拡張性が高まり、新しい実装を追加しやすくなります。置換可能性は、OCPやポリモーフィズムを実務で安全に使うための前提条件です。

4.1 親型で扱える

置換可能な子クラスは、親型として自然に扱えます。たとえば、Animalという親クラスにmakeSoundメソッドがあり、DogやCatがそれを実装している場合、利用側はAnimalとしてmakeSoundを呼び出せます。DogやCatの具体的な違いを知らなくても、期待された動作が得られることが重要です。

親型で扱えるということは、利用側が子クラスごとの特別な条件分岐を持たなくてよいということでもあります。もし、ある子クラスだけ「このメソッドを呼んではいけない」「先に特別な初期化が必要」といった制約があるなら、親型として安全に扱えません。その場合、LSPに違反している可能性があります。

4.2 動作結果が変わらない

置換可能性では、親クラスが保証していた動作結果を子クラスが壊さないことが求められます。もちろん、具体的な処理内容は子クラスごとに異なっても構いません。しかし、利用側が期待する結果の範囲や意味は維持される必要があります。親クラスでは成功する操作が、子クラスでは正当な理由なく失敗する設計は問題です。

たとえば、親クラスのcalculateメソッドが数値を返す契約であるにもかかわらず、子クラスが特定条件でnullや未対応例外を返す場合、利用側の期待が壊れます。このような動作差は、親型で扱うコードを不安定にします。LSPでは、子クラスは親クラスの意味を自然に引き継ぐ必要があります。

4.3 実装の透明性

実装の透明性とは、利用側が具体的な実装の違いを意識しなくてもよい状態を指します。親クラスやインターフェースを通じて操作でき、実際にどの子クラスが使われているかを知らなくても正しく動く設計は、実装の透明性が高いといえます。

実装の透明性が高いと、コードの変更や拡張が容易になります。新しい子クラスを追加しても、利用側は同じインターフェースで扱えるため、既存コードを大きく変える必要がありません。LSPは、この透明性を保つことで、保守性と拡張性を高める原則です。

5. LSP違反の代表例

LSP違反は、継承関係が見た目だけ正しく、実際の振る舞いが親クラスの期待を満たしていない場合に発生します。代表的な例として、不適切な継承、想定外の動作変更、親クラスのメソッドを子クラスで無効化する設計などがあります。これらは、コンパイル時には問題が見えにくく、実行時にバグとして現れることがあります。

LSP違反があると、利用側は親クラスとして扱えるはずの子クラスを特別扱いしなければならなくなります。これは抽象化の失敗です。抽象化が信頼できない状態では、ポリモーフィズムやOCPを活かすことも難しくなります。LSP違反を避けるには、継承関係が本当に意味的に正しいかを慎重に判断する必要があります。

5.1 不適切な継承

不適切な継承とは、コード再利用だけを目的として、本来は親子関係ではないクラス同士を継承させることです。たとえば、共通メソッドを使いたいだけで、意味的には別物のクラスを親子関係にしてしまうと、LSP違反が起こりやすくなります。継承は「同じ種類である」という関係を表すべきであり、単なる便利な再利用手段ではありません。

不適切な継承では、子クラスが親クラスの一部メソッドを使えなかったり、親クラスの前提を満たせなかったりすることがあります。その結果、利用側は子クラスの種類を確認して処理を分ける必要が出てきます。このような設計は、継承よりもコンポジションを使った方が適切な場合が多いです。

5.2 想定外の動作変更

子クラスがメソッドをオーバーライドして、親クラスの期待と異なる動作に変更する場合もLSP違反になりやすいです。たとえば、親クラスのdeleteメソッドがデータを削除する契約であるにもかかわらず、子クラスでは何もしない、あるいは別の状態変更だけを行う場合、利用側の期待が壊れます。

想定外の動作変更は、特に継承階層が深い場合に見落とされやすくなります。利用側は親クラスの仕様を前提にコードを書いているため、子クラスで意味が変わると不具合の原因になります。オーバーライドする場合は、親クラスの契約を保ちながら拡張することが重要です。

5.3 実務でよくある問題

実務でよくあるLSP違反には、「一部の実装だけ未対応メソッドを持つ」「子クラスだけ例外条件が多い」「親クラスより厳しい入力制限を要求する」などがあります。これらは、共通インターフェースを作ったものの、実装ごとの性質が十分に整理されていない場合に発生します。

たとえば、すべてのストレージ実装にwriteメソッドを持たせたものの、読み取り専用ストレージではwriteできないため例外を投げる、という設計は注意が必要です。この場合、読み取り専用と書き込み可能を同じ契約にまとめること自体が適切でない可能性があります。インターフェースを分けることでLSP違反を防げます。

6. RectangleとSquare問題

RectangleとSquare問題は、LSP違反を説明する有名な例です。数学的には正方形は長方形の一種ですが、オブジェクト指向の継承関係としてSquareをRectangleの子クラスにすると問題が起こる場合があります。なぜなら、長方形は幅と高さを独立して変更できることが期待される一方、正方形は幅と高さが常に等しくなければならないからです。

この例は、現実世界の分類がそのままプログラム上の継承関係として適切とは限らないことを示しています。継承設計では、「AはBの一種である」という表面的な関係だけでなく、親クラスの振る舞いを子クラスが安全に満たせるかを確認する必要があります。

6.1 有名なLSP違反例

RectangleクラスにsetWidthとsetHeightがあり、幅と高さを別々に設定できるとします。このとき、SquareクラスがRectangleを継承すると、Squareでは幅と高さを常に同じ値に保つ必要があります。そのため、setWidthを呼ぶと高さも変える、setHeightを呼ぶと幅も変える、という動作になります。

しかし、Rectangleとして扱う利用側は、幅と高さを独立して変更できると期待します。たとえば、幅を5、高さを10に設定したら面積は50になると考えます。しかし実体がSquareであれば、どちらかの設定によって両方の値が同じになり、期待した面積になりません。これがLSP違反の典型例です。

6.2 なぜ問題になるのか

問題の本質は、数学的な分類とプログラム上の契約が一致していないことです。数学では正方形は長方形の特殊形ですが、プログラム上のRectangleが「幅と高さを独立して変更できる」という契約を持つ場合、Squareはその契約を守れません。つまり、型の関係としては自然に見えても、振る舞いの関係としては不適切です。

この問題は、継承を設計するときに「is-a関係」だけで判断してはいけないことを教えてくれます。重要なのは、子クラスが親クラスの利用者にとって同じように扱えるかどうかです。置換したときに利用側の期待が壊れるなら、その継承関係は見直すべきです。

6.3 設計上の教訓

RectangleとSquare問題から得られる教訓は、継承よりも契約を重視することです。親クラスがどのような振る舞いを保証しているのかを確認し、子クラスがその契約を満たせる場合にのみ継承を使うべきです。見た目の分類が自然でも、振る舞いが合わないなら継承は適していません。

この問題を避けるには、RectangleとSquareを共通のShapeインターフェースとして扱い、面積計算などの共通操作だけを定義する方法があります。幅や高さの変更方法は、それぞれの図形に適した形で設計します。LSPは、継承関係を慎重に設計するための実践的な判断基準です。

7. 継承とLSPの関係

LSPは継承設計と深く関係しています。継承は、親クラスの性質を子クラスが引き継ぐ仕組みですが、LSPでは単にコードを引き継ぐだけでなく、親クラスの契約や振る舞いも引き継ぐ必要があります。子クラスが親クラスの代わりとして使えないなら、その継承関係は適切ではありません。

継承を使うときは、コード再利用よりも型としての関係を優先するべきです。共通処理を使いたいだけなら、継承ではなくコンポジションやユーティリティ関数を使う方が適している場合があります。LSPは、継承を安易に使わないための重要な基準になります。

7.1 継承=is-a関係

継承は一般的に「is-a関係」を表すと説明されます。つまり、子クラスは親クラスの一種であるべきです。たとえば、DogはAnimalの一種であり、CreditCardPaymentはPaymentの一種である、という関係であれば継承やインターフェース実装が自然になる場合があります。

しかし、is-a関係は表面的に判断すると危険です。現実世界では一種に見えても、プログラム上の振る舞いが一致しない場合があります。LSPでは、単に「AはBの一種」と言えるかだけでなく、Bとして期待される操作をAが安全に満たせるかを確認する必要があります。

7.2 誤った継承設計

誤った継承設計では、子クラスが親クラスの一部機能を使えなかったり、親クラスのメソッドを不自然に無効化したりします。たとえば、親クラスにsaveメソッドがあるのに、子クラスでは保存できないため例外を投げる場合、親クラスとして安全に扱えません。これはLSP違反になりやすい設計です。

誤った継承は、時間が経つほど保守性を低下させます。利用側は子クラスごとの違いを意識しなければならず、条件分岐が増えます。また、新しい子クラスを追加するたびに、既存の前提が崩れる可能性もあります。継承を使う前に、本当に置換可能かを検討することが重要です。

7.3 継承利用の判断基準

継承を使うか判断する際は、子クラスが親クラスの契約を自然に満たせるかを確認します。親クラスのすべての公開メソッドが子クラスでも意味を持ち、利用側が特別な例外処理を必要としないなら、継承は適切な選択になり得ます。逆に、一部のメソッドが子クラスで不自然になるなら、継承は避けるべきです。

また、継承を使う目的が単なるコード再利用であれば注意が必要です。共通処理を再利用したいだけなら、コンポジションや委譲を使う方が柔軟です。LSPを守るためには、継承を「再利用の道具」ではなく「正しい型関係を表す仕組み」として扱うことが重要です。

8. 契約(Contract)の考え方

LSPを理解するうえで、契約(Contract)の考え方は非常に重要です。契約とは、クラスやメソッドが利用者に対して約束する振る舞いのことです。メソッドの引数や戻り値だけでなく、どのような条件で呼び出せるか、呼び出した後に何が保証されるか、常に守られる状態は何かも契約に含まれます。

子クラスは、親クラスの契約を破ってはいけません。親クラスよりも厳しい前提条件を要求したり、親クラスが保証していた結果を弱めたりすると、利用者の期待が壊れます。LSPでは、子クラスが親クラスの契約を安全に満たすことが求められます。

8.1 前提条件

前提条件とは、メソッドを呼び出す前に満たされている必要がある条件です。たとえば、「引数はnullではない」「数値は0以上である」「ログイン済みである」といった条件が前提条件になります。LSPでは、子クラスが親クラスよりも厳しい前提条件を要求してはいけません。

親クラスでは任意の正の整数を受け取れるメソッドなのに、子クラスでは10以上でなければ例外を投げる、という実装は問題になります。利用側は親クラスの契約を信じて値を渡しているため、子クラスだけ厳しい条件を持つと置換可能性が壊れます。子クラスは、親クラスと同等か、それより緩い前提条件を保つべきです。

8.2 事後条件

事後条件とは、メソッドを実行した後に保証される結果です。たとえば、「保存後にIDが返る」「計算結果は0以上である」「処理後に状態が更新される」といったものが事後条件です。LSPでは、子クラスが親クラスの事後条件を弱めてはいけません。

親クラスでは必ず有効な結果を返すメソッドなのに、子クラスではnullを返す場合、利用側の期待が壊れます。子クラスは、親クラスが約束していた結果を維持する必要があります。事後条件を守ることは、利用側が安心して抽象型を使うために重要です。

8.3 不変条件

不変条件とは、オブジェクトが常に満たすべき状態のルールです。たとえば、口座残高は負にならない、ユーザーIDは作成後に変わらない、図形の辺の長さは0以上である、といった条件が不変条件です。LSPでは、子クラスも親クラスの不変条件を守る必要があります。

子クラスが親クラスの不変条件を破ると、親型として扱うコードが正しく動作しなくなります。たとえば、親クラスが常に有効な状態を保証しているのに、子クラスが一時的に不正な状態を許す場合、利用側の処理が壊れる可能性があります。不変条件を守ることは、クラス設計の信頼性を保つうえで重要です。

9. メソッドオーバーライド時の注意点

メソッドオーバーライドは、子クラスで親クラスの処理を変更できる便利な仕組みです。しかし、オーバーライドによって親クラスの契約を壊すと、LSP違反になります。特に、戻り値の意味、例外の扱い、状態変更、前提条件を変更する場合は注意が必要です。

オーバーライドは、親クラスの振る舞いを自然に拡張するために使うべきです。親クラスの意味を完全に変えてしまうようなオーバーライドは、利用側の期待を裏切ります。子クラスで異なる振る舞いが必要な場合でも、親クラスの契約の範囲内で実装することが重要です。

9.1 振る舞い変更の危険性

オーバーライドによって振る舞いを大きく変更すると、利用側のコードが壊れる可能性があります。親クラスのメソッドが「必ず処理を実行する」と期待されているのに、子クラスで「何もしない」実装にすると、親型として扱ったときに予期しない結果になります。これはLSP違反の典型的なパターンです。

振る舞いを変更する場合は、親クラスの契約を維持しているかを確認する必要があります。内部処理が異なること自体は問題ではありませんが、外部から見た結果や意味が変わってはいけません。オーバーライドは、実装の差し替えであって、契約の破壊ではないという意識が必要です。

9.2 インターフェース維持

子クラスは、親クラスやインターフェースが定めた操作を維持する必要があります。メソッド名や引数の型が同じであっても、意味が違っていればインターフェースを守っているとはいえません。利用者は、インターフェースを見て期待される使い方を判断するため、実装側はその期待に従う必要があります。

インターフェースを維持するには、メソッドの意味を明確にすることが大切です。ドキュメントやテストで契約を表現しておけば、実装クラスがそれを守っているか確認しやすくなります。LSPは、インターフェースを単なる形ではなく、意味を持つ契約として扱う原則です。

9.3 一貫した動作

オーバーライドされたメソッドは、親クラスと一貫した動作を保つ必要があります。処理の詳細は異なっても、利用側が期待する結果や副作用は維持されるべきです。たとえば、親クラスのconnectメソッドが接続状態を確立するなら、子クラスのconnectも同じ意味で接続状態を確立する必要があります。

一貫した動作がないと、利用側は子クラスごとの違いを知る必要があります。これは抽象化の価値を下げます。LSPを守るためには、子クラスが親クラスの期待を自然に満たしているかを常に確認することが重要です。

10. 例外処理とLSP

例外処理は、LSP違反が起こりやすい領域です。子クラスが親クラスでは発生しない例外を投げたり、親クラスが保証していた処理を子クラスで未対応にしたりすると、利用側のコードが壊れる可能性があります。例外は実行時に現れるため、設計段階で注意する必要があります。

LSPでは、子クラスは親クラスよりも利用者に厳しい条件を課してはいけません。例外の追加は、利用側に新しい対応を要求することになります。親型として安全に扱えるようにするには、例外の種類や発生条件も親クラスの契約に沿って設計する必要があります。

10.1 例外の追加

子クラスで新しい例外を追加する場合、LSP違反にならないか注意が必要です。親クラスのメソッドでは正常に処理される前提なのに、子クラスだけ特定条件で未対応例外を投げる場合、利用側はその子クラスを特別扱いしなければなりません。これは置換可能性を損ないます。

例外を追加する場合は、それが親クラスの契約の範囲内かを確認する必要があります。親クラスの契約で「失敗する可能性がある」と明示されているなら、適切な例外は許容される場合があります。しかし、親クラスが成功を保証している操作に対して、子クラスだけ失敗する設計は危険です。

10.2 契約違反

例外によって親クラスの契約を破る場合、それはLSP違反になります。たとえば、親クラスのreadメソッドが常にデータを返すことを期待されているのに、子クラスが「この実装では読み取れない」として例外を投げる場合、親型として扱えません。このような設計では、そもそも同じ抽象にまとめるべきかを見直す必要があります。

契約違反を避けるには、抽象化の粒度を適切に設計することが重要です。読み取り可能なものと書き込み可能なものを同じインターフェースにまとめると、未対応メソッドが発生しやすくなります。必要に応じてインターフェースを分割することで、LSP違反を防げます。

10.3 利用者への影響

子クラスが予期しない例外を投げると、利用者への影響が大きくなります。利用側は親クラスの仕様を前提にエラーハンドリングを設計しているため、子クラス固有の例外に対応できない場合があります。その結果、アプリケーションの異常終了や不正な状態につながることがあります。

LSPを守る設計では、利用者が具体実装を意識せずに例外処理を行えるようにします。例外の種類や発生条件を抽象レベルで整理し、実装クラスごとに大きな差が出ないようにすることが大切です。例外処理も、置換可能性を判断する重要な要素です。

11. OCPとの関係

LSPは、OCP(オープン・クローズドの原則)と深く関係しています。OCPでは、既存コードを変更せずに新しい機能を追加できる設計を目指しますが、そのためには新しい子クラスや実装クラスが既存の抽象に対して安全に置換可能である必要があります。つまり、LSPが守られていなければ、OCPも実現しにくくなります。

新しい実装を追加しても、親型として扱ったときに期待通りに動かないなら、利用側のコードを変更せざるを得ません。これはOCPに反します。LSPは、拡張を安全に行うための前提条件であり、SOLID原則の中でも他の原則を支える役割を持っています。

11.1 拡張性の前提条件

OCPでは、既存コードを修正せずに新しい実装を追加することが重視されます。しかし、新しい実装が親クラスやインターフェースの契約を守っていなければ、安全に追加できません。利用側が新しい実装だけ特別扱いする必要があるなら、それは拡張性の高い設計とはいえません。

LSPを守ることで、新しい実装を既存の抽象に自然に追加できます。たとえば、新しい決済方法を追加する場合でも、Paymentインターフェースの契約を守っていれば、既存の決済フローに組み込みやすくなります。LSPは、OCPの拡張性を実現するための土台です。

11.2 安全な機能追加

LSPが守られている設計では、安全に機能追加できます。新しい子クラスや実装クラスを追加しても、親型として扱うコードがそのまま動作するため、既存コードへの影響を抑えられます。これにより、機能追加時の修正範囲やテスト範囲を小さくできます。

安全な機能追加は、長期運用されるシステムで特に重要です。機能が増えるたびに既存処理を修正していると、バグ混入リスクが高まります。LSPを守った抽象化を行うことで、新しい機能を追加しながら既存機能の安定性を保てます。

11.3 SOLID内での役割

SOLID原則の中で、LSPは継承や抽象化の正しさを保証する役割を持ちます。SRPが責任分離を促し、OCPが拡張性を求めるのに対して、LSPはその拡張が安全に行われるための条件を示します。インターフェースや抽象クラスを使う設計では、LSPが守られているかが重要な確認ポイントになります。

LSPを無視すると、抽象化が信頼できなくなります。抽象に依存する設計をしていても、実装ごとに例外的な扱いが必要なら、SOLID原則の効果は十分に発揮されません。LSPは、保守性と拡張性を支える基礎的な原則です。

12. インターフェース設計とLSP

インターフェース設計では、LSPを強く意識する必要があります。インターフェースは実装クラスが守るべき契約を定義するため、実装クラスがその契約を満たせない場合、LSP違反が発生します。適切なインターフェース設計は、置換可能性を高め、柔軟な実装を可能にします。

インターフェースが大きすぎたり、複数の責任を含んでいたりすると、一部の実装クラスが不要なメソッドを持つことになります。その結果、未対応メソッドや不自然な例外処理が発生しやすくなります。LSPを守るには、インターフェースを適切な粒度で設計することが重要です。

12.1 抽象化との関係

LSPは抽象化の品質を判断するための原則でもあります。抽象クラスやインターフェースを作っても、実装クラスがその抽象の契約を守れなければ、抽象化は失敗しています。抽象化は、共通点をまとめるだけでなく、利用側が安心して扱える契約を定義する必要があります。

抽象化を行う際は、すべての実装が自然に満たせる操作だけを含めることが大切です。無理に多くの機能を1つのインターフェースに詰め込むと、実装側に不自然な制約を課すことになります。LSPは、抽象化の範囲が適切かどうかを確認する基準になります。

12.2 共通契約の重要性

インターフェースは、実装クラスに共通する契約を定義します。たとえば、Storageインターフェースがreadとwriteを定義する場合、それを実装するすべてのクラスは読み書きできる必要があります。もし読み取り専用ストレージが存在するなら、ReadOnlyStorageのように別のインターフェースを用意する方が自然です。

共通契約が適切に設計されていれば、利用側は具体実装を意識せずにコードを書けます。逆に、契約が不適切だと、実装ごとに未対応処理や例外が増えます。LSPを守るには、インターフェースが本当にすべての実装に共通する振る舞いだけを定義しているかを確認することが重要です。

12.3 柔軟な実装

LSPを守ったインターフェース設計では、実装クラスを柔軟に追加できます。共通契約が明確で、実装側がその契約を自然に満たせるなら、新しい実装を追加しても利用側のコードを変更する必要が少なくなります。これはOCPやDIPにもつながる重要なメリットです。

柔軟な実装を実現するには、インターフェースを小さく保つことが効果的です。必要以上に多くのメソッドを持つインターフェースは、実装クラスに余計な責務を押し付けます。実装が自然に契約を守れるよう、用途ごとに適切なインターフェースを設計することが大切です。

13. コンポジションとの比較

LSPを守るうえで、継承だけでなくコンポジションも重要な選択肢になります。コンポジションとは、あるオブジェクトが別のオブジェクトを部品として持ち、その機能を利用する設計です。継承が「is-a関係」を表すのに対し、コンポジションは「has-a関係」を表します。

継承が適切でない場合、コンポジションを使うことで柔軟な設計にできます。コード再利用だけが目的なら、継承よりもコンポジションの方が安全なことが多いです。LSP違反が起きそうな継承関係は、コンポジションに置き換えることで改善できる場合があります。

13.1 継承の代替手段

コンポジションは、継承の代替手段としてよく使われます。たとえば、共通のログ機能を使いたい場合、ログ機能を持つ親クラスを継承するのではなく、Loggerオブジェクトを内部に持つ設計にできます。これにより、型の親子関係を無理に作らずに機能を再利用できます。

継承では、親クラスの契約を子クラスが守る必要があります。しかし、単なる機能再利用が目的の場合、その契約が不自然になることがあります。コンポジションを使えば、必要な機能だけを部品として利用できるため、LSP違反を避けやすくなります。

13.2 has-a関係

コンポジションは「has-a関係」を表します。たとえば、CarはEngineを持つ、OrderServiceはPaymentProcessorを持つ、UserControllerはUserServiceを持つ、という関係です。このような関係では、あるオブジェクトが別のオブジェクトの機能を利用しますが、親子関係ではありません。

has-a関係を使うことで、設計の柔軟性が高まります。部品を差し替えたり、組み合わせを変えたりしやすくなるためです。継承によって型関係を固定するよりも、コンポジションによって必要な機能を組み合わせる方が、変更に強い設計になることがあります。

13.3 柔軟性向上

コンポジションを使うと、実装の差し替えや機能の組み合わせが容易になります。たとえば、通知処理を持つサービスが、EmailNotifierやSlackNotifierを内部に持つ設計にすれば、通知手段を差し替えやすくなります。これは、継承よりも柔軟な拡張を可能にします。

LSP違反が起きる継承関係では、子クラスが親クラスの契約を自然に守れないことが多いです。その場合、継承をやめてコンポジションに切り替えると、設計がシンプルになることがあります。実務では、「継承よりコンポジションを優先する」という考え方がよく使われます。

14. 実務でのLSP活用

LSPは理論的な原則に見えますが、実務でも多くの場面で役立ちます。決済システム、通知システム、ストレージ実装、外部API連携、ファイル出力、認証方式など、複数の実装を同じ抽象で扱う場面では、LSPを守ることが重要です。実装ごとに例外的な扱いが必要になる場合は、抽象化や継承関係を見直すべきです。

実務でLSPを活用するには、インターフェースや親クラスがどのような契約を持つのかを明確にすることが大切です。実装クラスはその契約を守り、利用側が具体実装を意識しなくても動く状態を目指します。これにより、保守性と拡張性の高いシステムを構築できます。

14.1 決済システム

決済システムでは、クレジットカード、銀行振込、電子マネー、外部決済サービスなど、複数の決済方法を同じ抽象で扱うことがあります。このとき、Paymentインターフェースが「決済を実行し、結果を返す」という契約を持つなら、すべての決済実装はその契約を守る必要があります。

ある決済方法だけ結果形式が大きく異なったり、特定の前提条件を追加で要求したりすると、利用側はその実装だけ特別扱いしなければなりません。これはLSP違反につながります。決済システムでは、共通契約を慎重に設計し、各決済方式が自然に置換可能になるようにすることが重要です。

14.2 通知システム

通知システムでもLSPは重要です。メール通知、SMS通知、プッシュ通知、チャット通知などをNotifierインターフェースで扱う場合、すべての実装は「通知を送信する」という契約を守る必要があります。ある通知方式だけ送信処理を行わず、常に未対応例外を投げるような設計は適切ではありません。

通知方式ごとに必要な情報が大きく異なる場合は、インターフェースの設計を見直す必要があります。共通のsendメソッドに無理にすべてを詰め込むと、不自然な実装が増える可能性があります。LSPを守るには、共通化できる部分と方式ごとに異なる部分を丁寧に分けることが大切です。

14.3 ストレージ実装

ストレージ実装では、ローカルファイル、クラウドストレージ、メモリストレージ、読み取り専用ストレージなど、さまざまな実装が考えられます。共通インターフェースを設計する場合、すべての実装がその契約を自然に満たせるかを確認する必要があります。

たとえば、Storageインターフェースにreadとwriteを定義している場合、読み取り専用ストレージはwriteを実装できません。このとき、writeで例外を投げる設計はLSP違反になりやすいです。ReadStorageとWritableStorageのようにインターフェースを分けることで、置換可能性を保ちやすくなります。

おわりに

LSP(リスコフの置換原則)は、「派生クラスが親クラスの代わりとして利用できること」を求める重要な設計原則です。継承やインターフェースを使う場合、型として代入できるだけでは不十分であり、親クラスやインターフェースが持つ契約を実装側が守る必要があります。LSPは、オブジェクト指向設計における継承の品質を判断するための基準です。

LSPの本質は、利用者の期待を裏切らないことにあります。子クラスが親クラスより厳しい前提条件を要求したり、親クラスが保証していた結果を弱めたり、未対応メソッドとして例外を投げたりすると、置換可能性が壊れます。こうした設計は、バグや保守コストの増加につながります。

LSPは、OCPやDIPなど他のSOLID原則とも深く関係しています。新しい実装を追加して既存コードを変更せずに拡張するには、その実装が既存の抽象に対して安全に置換可能である必要があります。つまり、LSPは拡張性の高い設計を支える前提条件でもあります。

実務では、継承を使う前に本当に親子関係として適切かを確認することが重要です。コード再利用だけが目的なら、継承ではなくコンポジションを選ぶ方が安全な場合もあります。LSPを意識して契約を守る設計を行うことで、保守性、拡張性、テスト容易性の高いシステムを実現できます。

LINE Chat