📝

リスコフの置換原則 を破るとどうなる?

2025/02/14に公開

柔軟なシステム設計を学ぶために、SOLID原則について調べていた。
その中で、「リスコフの置換原則(LSP)」が「オープン・クローズドの原則(OCP)」とも関係していることがわかった。
LSPを違反すると、OCPを守れず、思わぬバグや設計の破綻を招く可能性がある。

リスコフの置換原則 (LSP: Liskov Substitution Principle)

派生型はその基本型と置換可能でなければならない。

「サブクラスはスーパークラスの代わりとして振舞えなければならない」という原則。

違反するとどんな問題が発生するか?

OCP(開放/閉鎖原則)を守れなくなる

OCP(開放/閉鎖原則)は「既存のコードを修正せずに、新しい機能を追加できるようにするべき」という原則。

しかし、LSP違反があると、「サブクラスが親クラスと異なる振る舞いをするせいで、既存のコードを修正せざるを得なくなる」 という問題が生じることがある。

例えば、Bird クラスに fly() を定義してしまい、ペンギンなどの飛べない鳥が Bird を継承するとLSP違反が発生してしまう。

abstract class Bird {
  abstract fly(): void;
}

class Sparrow extends Bird {
  fly() {
    console.log("空を飛びます。");
  }
}

class Penguin extends Bird {
  fly() {
    throw new Error("ペンギンは空を飛べない。"); // 例外を投げて対応(LSP違反)
  }
}

LSP違反があると、親クラスを前提に作ったコードが、子クラスの挙動のせいで壊れてしまう
→ 親クラスの修正が必要になってしまう。

Birdを継承しているので全員飛べると期待した関数が作られてしまうと...

function birdsFly(birds: Bird[]) {
  birds.forEach(bird => bird.fly()); // 全員飛ぶことを期待
}

const birds: Bird[] = [new Sparrow(), new Penguin()];
birdsFly(birds);

ペンギンが飛べないのでエラーに…

問題を解決するために、以下のように fly を呼ぶ前に canFly という判定を追加するなどの変更が必要が出てきてしまう。

class Bird {
  canFly: boolean = true;
  fly() {
    if (!this.canFly) {
      console.log("この鳥は飛べません");
      return;
    }
    console.log("空を飛びます!");
  }
}

class Penguin extends Bird {
  constructor() {
    super();
    this.canFly = false;
  }
}

意図しない不具合の発生

LSP違反としてよく挙げられる例として、Rectangle(長方形)と Square(正方形) の関係がある。

class Rectangle {
  constructor(protected width: number, protected height: number) {}

  setWidth(width: number) {
    this.width = width;
  }

  setHeight(height: number) {
    this.height = height;
  }

  getArea(): number {
    return this.width * this.height;
  }
}

class Square extends Rectangle {
  constructor(size: number) {
    super(size, size);
  }

  setWidth(width: number) {
    this.width = width;
    this.height = width; // 正方形は常に幅=高さ
  }

  setHeight(height: number) {
    this.height = height;
    this.width = height; // 正方形は常に幅=高さ
  }

Rectangle のつもりで Square を扱うと、 setWidth が setHeight も変更してしまうため、意図しない挙動になる。

const rect: Rectangle = new Square(5);
rect.setWidth(10);
console.log(rect.getArea()); // 期待値: 50, 実際: 100

無理に共通化せず、「異なる振る舞い」を持つ場合は適切にクラスやインターフェースを分ける。

abstract class Shape {
  abstract getArea(): number;
}

class Rectangle extends Shape {
  constructor(protected width: number, protected height: number) {
    super();
  }

  getArea(): number {
    return this.width * this.height;
  }
}

class Square extends Shape {
  constructor(protected size: number) {
    super();
  }

  getArea(): number {
    return this.size * this.size;
  }
}

まとめ

継承関係にあるクラスは、「親クラスの仕様を満たしている」という前提で使っているが、LSP違反があると、その前提が崩れてしまう。

  • 一見問題なく動作しているように見えても、少しずつ矛盾が生じ、既存コードの修正が必要になる
  • 意図しないバグを引き起こしてしまう可能性が高くなる
  • 開発者が「このクラス、親クラスを本当に継承しているの?」と毎回疑わなければならなくなってしまう
  • LSPを守ることでサブクラスはスーパークラスの代わりに振る舞えるようになり、新しくコードを追加するだけで機能拡張が可能となるので、OCPを守る設計につながる

Discussion