TypeScriptを使って学ぶSOLID原則3 ”リスコフの置換原則(Liskov Substitution Principle)”
モチベーション
ソフトウェアを設計する際に重要な5つのガイドラインであるSOLID原則について学んでいます。
前回はオープン/クローズドの原則(Open/Closed Principle)についてアウトプットを行いました。
今回はリスコフの置換原則(Liskov Substitution Principle) について学んだので、アウトプットの一環で記事を執筆しました。
リスコフの置換原則(Liscov Substitution Principle)とは
リスコフの置換原則とは下記のことを表します。
Derived or child classes must be substitutable for their base or parent classes.
出典:https://www.geeksforgeeks.org/solid-principle-in-programming-understand-with-real-life-examples/
子クラス(サブタイプ)は親のクラス(スーパータイプ)と置換可能でなければならないという原則です。
- スーパータイプ(Super type):継承元となるクラス
- サブタイプ(Sub type):スーパータイプを継承したクラス
この原則はOOPにおける正しい継承の使い方を示す指針となるものです。もし、リスコフの置換原則を満たしていないのならば、継承は使うべきではありません。
OOPにおける継承は「Is-aの関係」と言われています。しかし、リスコフの置換原則における継承は、その振る舞いも一致していることが求められます。
リスコフの置換原則を満たした継承=「Is-aの関係」+「振る舞いが同じ」
コンパイルエラーが出ないので、意識して実装を行わないと気付かないうちに原則に違反してしまうので注意が必要です。
実装例
リスコフの置換原則をコードで表すクラシックな例として長方形と正方形の面積を計算する実装が使われることが多いので、その例を紹介します。
守られていない実装例
以下のようにRectangle
というスーパクラスとSquare
というサブクラスを定義します。
// スーパータイプ
class Rectangle {
width = 0;
height = 0;
// 幅を指定
setWidth(width: number) {
this.width = width;
}
// 高さを指定
setHeight(height: number) {
this.height = height;
}
// 面積を計算する
getArea(): number {
return this.width * this.height;
}
}
// サブタイプ
class Square extends Rectangle {
// 幅を指定 widthの値で幅も高さも更新している
setWidth(width: number) {
super.setWidth(width);
super.setHeight(width);
}
// 高さを指定 heightの値で幅も高さも更新している
setHeight(height: number) {
super.setWidth(height);
super.setHeight(height);
}
}
この例がどのようにリスコフの置換原則に違反しているかというと、Square
クラスのwidthのsetterがRectangle
のものと異なる振る舞いをする点です。
Rectangle
クラスのwidthのsetterはheightを変更しないがSquare
クラスのwidthのsetterはheightを変更しています。(heightのsetterも同様) 言い換えるとSquare
クラスは継承元であるRectangle
クラスと同じように振る舞わないのでリスコフの置換原則に違反しているということになります。
守られている実装例
先ほどの実装をリスコフの置換原則を満たすように修正を加えたものを紹介します。
方針としてはShape
という抽象度の高いスーパータイプを実装しRectangle
とSquare
の両方のサブタイプはそのスーパータイプを継承するようにします。
// 抽象度の高いスーパータイプであるShape
interface Shape {
getArea():number;
}
// 上記のShapeを継承したサブタイプRectangle
class Rectangle implements Shape(
private width = 0;
private height = 0;;
setWidth(width:number){
this.width = width;
}
setHeight(height:number){
this.height = height;
}
getArea():number(
return this.width * this.height;
)
)
// 上記のShapeを継承したサブタイプSquare
class Square implements Shape(
private length = 0;
setLength(length:number){
this.length = length;
}
getArea():number(
return this.length * this.length;
)
)
これによりRectangle
とSquare
の両サブタイプはShape
と同じ振る舞いをするようになり、リスコフの置換原則を満たしているということになります。
原則に違反した場合はどうなるか
リスコフの置換原則に違反した場合、以下のような影響が出ます。
-
利用者の想定していない挙動によるバグが発生する
- 利用者はサブタイプまで全て理解した上でそのクラスを使用しなければならなくなり、実装漏れなどのミスが起こり得る
-
オープン/クローズドの原則にも違反した実装になる可能性が高くなる
- サブクラスを追加するたびにスーパークラスの実装変更が必要になるため(条件分岐等)
原則を守った場合のメリット
リスコフの置換原則を守ることによるメリットはスーパータイプの仕様さえ理解すれば、それを継承したサブタイプの実装内容を確認することなく利用することができることです。つまり保守性と拡張性が向上します。
新機能を実装する際に既存のコードを変更しなくてもよくなるのでより簡単に変更を加えることができます。
どうすれば違反しない設計にできるか
ユニットテストを書く
ユニットテストを書くことによりサブタイプがスーパータイプの振る舞いを変えていないかを確認することができます。
サブタイプにしかないメソッドを追加しない
サブタイプにしかないメソッドを追加すると、スーパータイプのインスタンスと同じ振る舞いをしなくなってしまいます。そのメソッドを条件分岐を追加して実装するとオープン/クローズドの原則も破った実装になってしまいます。
まとめ
SOLID原則の一つであるリスコフの置換原則について、初心者なりにまとめました。
継承を使う上でのガイドラインであり、似ているというだけで継承を使ってしまうと、かえって保守性の低い実装になってしまいまう。なので振る舞いも同じであることが担保された上で継承を使うとより良い実装になると思います。
原則を守った実装をするために抽象度を一段階上げたインターフェイスを実装するという点ではオープン/クローズドの原則と似たようなことをしていると感じ、両者の原則の結びつきの高さが伺えました。
このリスコフの置換原則は契約による設計と一緒になって議論されることが多いそうです。今回は割愛いたします。
Discussion