LESSON 30分

ストーリー

高橋アーキテクト
正方形は長方形の一種だよね?
あなた
はい、もちろんです
高橋アーキテクト
じゃあ、Rectangle クラスを継承して Square クラスを作れば問題ないかな?
あなた
…何か罠がありそうですね
高橋アーキテクト
その直感は正しい。これがLSPの核心だ

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!
}

SquareRectangle を継承していますが、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分