ストーリー
LSPとは
Liskov Substitution Principle(リスコフの置換原則): 基底型のオブジェクトを派生型のオブジェクトに置き換えても、プログラムの正しさが保たれるべきである。
つまり、親クラス(またはインターフェース)を期待する場所で、子クラスに入れ替えても、呼び出し側のコードが壊れてはいけません。
LSP違反の典型例:長方形と正方形
// 基底クラス
class Rectangle {
constructor(protected width: number, protected height: number) {}
setWidth(width: number): void {
this.width = width;
}
setHeight(height: number): void {
this.height = height;
}
getArea(): number {
return this.width * this.height;
}
}
// 派生クラス:正方形は幅と高さが常に同じ
class Square extends Rectangle {
setWidth(width: number): void {
this.width = width;
this.height = width; // 高さも変更 ← 契約違反!
}
setHeight(height: number): void {
this.width = height; // 幅も変更 ← 契約違反!
this.height = height;
}
}
// 呼び出し側のコードが壊れる
function testRectangle(rect: Rectangle): void {
rect.setWidth(5);
rect.setHeight(4);
// Rectangle なら 5 * 4 = 20 を期待
console.log(rect.getArea()); // Square の場合は 4 * 4 = 16!
}
Square は Rectangle を継承していますが、setWidth/setHeight の振る舞いが変わっています。呼び出し側の期待(幅と高さは独立して設定できる)を裏切っています。
LSPに準拠した設計
// 共通のインターフェースで必要な操作だけ定義
interface Shape {
getArea(): number;
}
// 長方形
class Rectangle implements Shape {
constructor(
private readonly width: number,
private readonly height: number
) {}
getArea(): number {
return this.width * this.height;
}
}
// 正方形(継承ではなく別の実装)
class Square implements Shape {
constructor(private readonly side: number) {}
getArea(): number {
return this.side * this.side;
}
}
// どちらでも正しく動く
function printArea(shape: Shape): void {
console.log(`Area: ${shape.getArea()}`);
}
LSP違反のサインと修正パターン
サイン1: 型チェックの出現
// LSP違反のサイン:instanceof で型チェック
function handleBird(bird: Bird): void {
if (bird instanceof Penguin) {
bird.swim(); // ペンギンだけ特別扱い
} else {
bird.fly(); // 他の鳥は飛べる前提
}
}
修正:インターフェースを分離
interface Bird {
move(): void;
}
class Sparrow implements Bird {
move(): void {
console.log('Flying...');
}
}
class Penguin implements Bird {
move(): void {
console.log('Swimming...');
}
}
// 型チェック不要
function handleBird(bird: Bird): void {
bird.move(); // どの鳥でも正しく動く
}
サイン2: 例外を投げるオーバーライド
// LSP違反:親が提供する操作を子が拒否
class FileStorage {
save(data: string): void { /* ファイルに保存 */ }
delete(id: string): void { /* ファイルを削除 */ }
}
class ReadOnlyStorage extends FileStorage {
save(data: string): void {
throw new Error('This storage is read-only'); // 契約違反!
}
delete(id: string): void {
throw new Error('This storage is read-only'); // 契約違反!
}
}
修正:インターフェースを適切に分ける
interface Readable {
read(id: string): string;
}
interface Writable {
save(data: string): void;
delete(id: string): void;
}
class FileStorage implements Readable, Writable {
read(id: string): string { return '...'; }
save(data: string): void { /* ... */ }
delete(id: string): void { /* ... */ }
}
class ReadOnlyStorage implements Readable {
read(id: string): string { return '...'; }
// save/delete は持たない = 例外を投げる必要がない
}
LSPの契約ルール
| ルール | 説明 |
|---|---|
| 事前条件を強めてはいけない | 親が受け入れる入力を子が拒否してはいけない |
| 事後条件を弱めてはいけない | 親が保証する出力の条件を子が破ってはいけない |
| 不変条件を維持する | 親が保持するルールを子も守る |
高橋アーキテクトのアドバイス:
「LSPは”継承は is-a 関係で使え”よりも深い原則だ。数学的には正方形は長方形だが、コードの世界では振る舞いの互換性が重要。“振る舞いとして置換可能か”が判断基準だ」
まとめ
| ポイント | 内容 |
|---|---|
| LSPの定義 | 親を子に置き換えてもプログラムの正しさが保たれる |
| 違反のサイン | instanceof チェック、例外を投げるオーバーライド |
| 修正方法 | インターフェースの分離、継承より合成 |
| 判断基準 | 数学的 is-a ではなく振る舞いの互換性 |
チェックリスト
- LSPの定義を自分の言葉で説明できる
- 長方形と正方形の問題を理解した
- LSP違反のサインを見分けられる
次のステップへ
次はSOLIDの残り2つ — インターフェース分離の原則(ISP)と依存性逆転の原則(DIP)をまとめて学びます。これらはセットで理解すると効果的です。
推定読了時間: 30分