はじめに
最近、設計の原則を知ってはいるものの、実際に活用できていない!と感じることがありました。
そこで、自分なりに「なぜその原則が重要なのか」を改めて考え直してみることにしました。
この記事では、TypeScriptを使ったコード例を交えながら説明しますが、普段お使いの言語に置き換えて読んでいただけるとありがたいです。
リスコフの置換原則とは
リスコフの置換原則は、オブジェクト指向プログラミングにおける設計原則の一つです。
1994年にバーバラ・リスコフによって提唱され、継承の設計における重要な指針を示しています。
この原則では、基底クラスを継承した派生クラスのオブジェクトは、基底クラスのオブジェクトとして扱われても正しく動作するべきだとされています。
つまり、基底クラスを期待するコード内で派生クラスを使用しても、基底クラスとして一貫した振る舞いが保証される必要があります。
具体例として、Animalクラスを基底クラスとし、それを継承したLionクラスとDogクラスを考えてみましょう。
class Animal {
public bark() {
console.log('吠える');
}
}
class Tiger extends Animal {
public bark() {
console.log('がおー');
}
}
class Dog extends Animal {
public bark() {
console.log('ワンワン!');
}
}
Animalクラスを継承しているクラスであれば、どの派生クラスのインスタンスであってもbarkメソッドを実行することが期待されます。
const tiger = new Tiger();
const dog = new Dog();
(tiger as Animal).bark(); // 「がおー」
(dog as Animal).bark(); // 「ワンワン!」
簡単ですが、これがリスコフの置換原則の基本的な考え方です。
リスコフの置換原則に反している例
では、リスコフの置換原則に反しているクラス設計はどういう場合でしょうか。
ここではよく原則違反の例として引用される四角形(Rectangle)と正方形(Square)をクラス化した場合の例を使って説明します。
以下のように、RectangleクラスとSquareクラスを定義します。
class Rectangle {
constructor(private width: number, private height: number) {}
public setWidth(width: number) {
this.width = width;
}
public setHeight(height: number) {
this.height = height;
}
public getArea() {
return this.width * this.height;
}
}
class Square extends Rectangle {
constructor(length: number) {
super(length, length);
}
public setSquare(length: number) {
this.setWidth(length);
this.setHeight(length);
}
}
SquareクラスはRectangleクラスを継承しているため、setWidthとsetHeightメソッドを呼び出すことができます。
しかし、setWidthメソッドの引数を10、setHeightメソッドを20のように呼んでしまうと正方形ではなくなってしまいます。
const square = new Square(10);
square.setWidth(10);
square.setHeight(20); // 正方形ではなくなる
想定していない状態が作れてしまうため、不具合に繋がる可能性が高い状態です。
このように、SquareメソッドはRectangleを継承しているにも関わらず、Squareメソッドのインターフェースが使えなくなってしまっています。
つまりSquareはRectangleとして振る舞うことができなくなってしまっており、リスコフの置換原則に反している状態です。
リスコフの置換原則に反していた場合の弊害
ではなぜリスコフの置換原則に反してはいけないのでしょうか。
主に下記2つの理由が存在します。
- コードの予測可能性が低くなる
- 変更による影響範囲が読みづらくなる
1. コードの予測可能性が低くなる
リスコフの置換原則に反している場合、同じ基底クラスから継承している派生クラスであっても、基底クラスのコードが動作する場合と動作しない場合が発生します。
先ほどの例のように、SquareクラスはRectangleを継承していますが、setWidthやsetHeightの使用は想定通りに動作しません。
このような実装が存在していると、基底クラスのコードが期待通りに動作するかどうかを派生クラスごとに確認する必要が生じます。
その結果、次のような不要な分岐処理が増え、コードの見通しが悪くなります。
const rectangle: Rectangle = new Square(10);
if (rectangle instanceof Square) {
// 正方形のサイズ変更処理
} else {
// その他の四角形のサイズ変更処理
}
2. 変更による影響範囲が読みづらくなる
基底クラスのインターフェースが派生クラスによって使えない場合があるということは、基底クラスと派生クラスが互いに影響し合っているということです。
これは基底クラスに何かしらの変更を行った場合に、派生クラスにどのような影響を与えるか考慮することが必要になるということになります。
本来は基底クラス内の影響だけ考えて変更すればいいはずが、派生クラスの影響調査を行うコストが発生してしまいます。
これは基底クラスで共通化することを避ける傾向にも繋がり、ますます見通しの悪いコードが増えていくことにも繋がります。
まとめ
当たり前のように根付いてしまっている原則は「そういうもの」と捉えてしまいがちですが、
「なぜその原則ができたんだっけ?」「どんないいことがあるんだっけ?」というところまで考えるとまったく理解の解像度が違うなと感じました。
実際のコードを活用しながら理解を深めるのも大事ですね!