💡gntk.dev

リスコフの置換原則 TypeScriptでSOLID原則

SOLID原則のL「リスコフの置換原則」について自分が理解している内容をまとめました。

post-cover

L … Liskov Substitution Principle: リスコフの置換原則

オブジェクト指向の考え方を用いることによって、関連のあるデータ構造とそれを操作する手続きを結びつけて「オブジェクト」とし、それらオブジェクトを組み合わせてソフトウェアを設計・開発することができる。

関連のあるデータ構造とそれを操作する手続きの結びつけ方、オブジェクト同士の組み合わせ方について適切な取り決めを設け、よりよいソフトウェアを設計・開発するために考案された原則として「SOLID原則」が知られている。

SOLID原則は、ソフトウェアエンジニアRobert C. Martinに提唱された多くの設計原則を5つにまとめたものの頭文字をとって命名された。

  • S … Single Responsibility Principle: 単一責任の原則
  • O … Open-Closed Principle: 開放閉鎖の原則
  • L … Liskov Substitution Principle: リスコフの置換原則
  • I … Interface Segregation Principle: インターフェイス分離の原則
  • D … Dependency Inversion Principle: 依存性逆転の原則

SOLID原則に基づいて設計・開発されたソフトウェアは、以下のような特徴を持つと言われている。

  • 変更しやすい
  • 理解しやすい
  • オブジェクトが再利用しやすい

逆に、SOLID原則に違反した設計・開発を行ってしまうと、

  • 新しい機能を追加するために既存のコードに大量の修正を加える必要がある
  • 既存の機能を変更することでバグを生んでしまう可能性が高い
  • 既存のコードを理解するために多くの時間を費やしてしまい、機能の追加や修正に時間がかかりがちになる
  • 機能の追加時に既存のコードを再利用できないため、開発効率が悪くなる

などの不都合が生まれる。

本記事では、SOLID原則のひとつ「リスコフの置換原則」について考え方、アンチパターンとその解決策の例をTypeScriptのサンプルコードとともに紹介する。

概要

部品Tとその派生型である部品Sがあるとき、部品Tが使われている箇所はすべて部品Sで置換可能になるように部品Sはつくられているべきであるという原則。

「置換可能である」とは

オブジェクト指向のクラス設計でよくある部品Tと部品Sの関係として、インターフェイスとそれを実装するクラスの関係などがある。この関係のことをスーパータイプとサブタイプと呼ぶ。

リスコフの置換原則では、スーパータイプとサブタイプが置換可能であるとき、以下の2つのルールに則っているとされている。

  • サブタイプの事前条件はスーパータイプと同一か、それよりも弱めることができる(事前条件をスーパータイプより強めることは出来ない)
  • サブタイプの事後条件はスーパータイプと同一か、それよりも強めることができる(事後条件をスーパータイプより弱めることは出来ない)

事前条件とは、ある操作が実行される直前の状態で満たすべき条件のこと。「事前条件を弱める」というのは、具体的にはインスタンス生成のために必要な引数の数を少なくするなどがある。

事後条件とは、ある操作が実行された直後の状態で満たすべき条件のこと。「事後条件を強める」というのは、具体的にはメソッド実行後に変更されていなければならないプロパティの数を増やすなどがある。

原則に違反してはいけない理由

スーパータイプとサブタイプの関係を置換可能なものにしていない場合、ソフトウェアの拡張性というオブジェクト指向設計の大きなメリットを享受できなくなる。また、置換可能でないスーパークラスとサブクラスの関係を作ってしまったことによりバグが生まれる可能性もある。

リスコフの置換原則に違反している例

TypeScriptのサンプルコードで、リスコフの置換原則に違反している例を紹介する。

長方形を表すinterfaceIRectangleと、それを実装した長方形クラスRectangleがある。

interface IRectangle {
  setWidth: (width: number) => IRectangle;
  setHeight: (height: number) => IRectangle;
  getArea: () => number;
}

class Rectangle implements IRectangle {
  private width: number;
  private height: number;

  public constructor() {
    this.width = 0;
    this.height = 0;
  }

  public setWidth = (width: number) => {
    this.width = width;

    return this;
  };

  public setHeight = (height: number) => {
    this.height = height;

    return this;
  };

  public getArea = () => this.width * this.height;
}

IRectangle型のクラスは、長方形の横の長さ/縦の長さを設定するメソッドsetWidth/setHeightと、長方形の面積を計算するgetAreaを実装しなければならない。サブタイプRectangleはスーパータイプIRectangleを正しく実装できている。

そして、IRectangle型のクラスを利用する関数として、複数の長方形を2x4にして返す関数getTwoByFourRectangleListがある。

const getTwoByFourRectangleList = (rectangleList: IRectangle[]): IRectangle[] =>
  rectangleList.map((rectangle) => rectangle.setWidth(2).setHeight(4));

getTwoByFourRectangleListのユニットテストは以下のようになっている。

describe("2x4の長方形を生成する", () => {
  test("生成された長方形の面積はすべて8である", () => {
    const twoByFourRectangleList = getTwoByFourRectangleList([
      new Rectangle(),
      new Rectangle(),
    ]);

    const expectedArea = 8;

    expect(
      twoByFourRectangleList.every(
        (rectangle) => rectangle.getArea() === expectedArea,
      ),
    ).toBe(true);
  });
});

RectangleIRectangleを置換可能になっており、リスコフの置換原則を満たしているため、このユニットテストは問題なくパスする。

ここに、新たなIRectangleの実装として、正方形を表すSquareクラスを追加してみる。

class Square implements IRectangle {
  private length: number;

  public constructor() {
    this.length = 0;
  }

  public setWidth = (width: number) => {
    this.length = width;

    return this;
  };

  public setHeight = (height: number) => {
    this.length = height;

    return this;
  };

  public getArea = () => this.length * this.length;
}

正方形はすべての辺の長さが同じのため、プロパティをlengthのみにし、IRectangle型が実装しなければならないメソッドsetWidthsetHeightgetAreaをそれに合わせて実装している。

一見、すべてのメソッドが揃っておりサブタイプSquareはスーパータイプIRectangleを正しく実装しているように見える。実際、Squareクラス単体では何も問題はないが、スーパークラスIRectangleとの関係を考えたとき、この関係はリスコフの置換原則に違反している。

違反している理由

Squareは、IRectangleを置換可能になっておらず、getTwoByFourRectangleListのユニットテストはパスしない。

const getTwoByFourRectangleList = (rectangleList: IRectangle[]): IRectangle[] =>
  // Squareのインスタンスが来た場合、4x4の正方形が生成されてしまう
  rectangleList.map((rectangle) => rectangle.setWidth(2).setHeight(4));
describe("2x4の長方形を生成する", () => {
  test("生成された長方形の面積はすべて8である", () => {
    const twoByFourRectangleList = getTwoByFourRectangleList([
      new Rectangle(),
      new Rectangle(),
      new Square(),
    ]);

    const expectedArea = 8;

    expect(
      twoByFourRectangleList.every(
        (rectangle) => rectangle.getArea() === expectedArea,
      ),
    ).toBe(true); // => FAIL
  });
});

RectanglesetWidthは、事後条件として、「widthが変更されていること」、「heightが変更されていないこと」などがあると考えられる。しかし、SquaresetWidthは「lengthが変更されていること」という1つの事後条件しかなく、事後条件が弱まっていると考えることができる。

解決策

アンチパターン的な解決策

getTwoByFourRectangleListのインターフェイスを変更せずに対応しようとすると、以下のようになる。

const getTwoByFourRectangleList = (rectangleList: IRectangle[]): IRectangle[] =>
  rectangleList.map((rectangle) => {
    if (rectangle instanceof Square) {
      // エラーを投げるもしくはスキップするなどの処理
    }

    return rectangle.setWidth(2).setHeight(4);
  });

しかし、この修正では、スーパータイプIRectangleの知識しか持たなかった関数がサブタイプSquareの知識を持つことになってしまう。それにより、Squareに変更があったときに影響を受けてしまう可能性が生まれる。また、今後新たにIRectangleの実装を増やしたとき、Squareと同様にinstanceofなどを使って分岐処理を入れなければいけない可能性があり、これは開放閉鎖の原則にも違反している。

スーパークラス/サブクラスの関係を見直す

  • SquareIRectangleのサブタイプとして不適切である
  • RectangleSquareのスーパークラスを用意する必要があるなら、setWidth/setHeightは共通化出来ないためなくす(「図形」Shapeなどとする)
  • getTwoByFourRectangleListは長方形のみを受け取るようにする

以上のことを考え設計すると、リスコフの置換原則には違反しない状態にすることができる。

interface IShape {
  getArea: () => number;
}

class Rectangle implements IShape {
  private width: number;
  private height: number;

  public constructor() {
    this.width = 0;
    this.height = 0;
  }

  public setWidth = (width: number) => {
    this.width = width;

    return this;
  };

  public setHeight = (height: number) => {
    this.height = height;

    return this;
  };

  public getArea = () => this.width * this.height;
}

class Square implements IShape {
  private length: number;

  public constructor() {
    this.length = 0;
  }

  public setLength = (length: number) => {
    this.length = length;

    return this;
  };

  public getArea = () => this.length * this.length;
}
const getTwoByFourRectangleList = (rectangleList: Rectangle[]): Rectangle[] =>
  rectangleList.map((rectangle) => rectangle.setWidth(2).setHeight(4));
© 2021 gntk.dev