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

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

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原則のひとつ「リスコフの置換原則」について考え方、アンチパターンとその解決策の例をTypeScriptのサンプルコードとともに紹介します。

リスコフの置換原則とは?

概要

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

「置換可能」とは

オブジェクト指向のクラス設計においてよくある部品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は、setWidthsetHeightgetAreaいずれも実装できているため、スーパータイプIRectangleを正しく実装できています。

そして、IRectangle型のクラスを利用する関数として、複数の長方形を2×4にして返す関数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));

所感

リスコフの置換原則は、調べると「S型とT型が〜」とか「契約プログラミングの文脈では〜」など、結構難しい言葉で説明されがちですが、要は「親の決まりを子が破ってはいけない」という、クラス設計のルールだと私は考えています。親子の関係をしっかり意識してクラス設計しないと、例に上げたようなバグに直結する重要な原則だと思います。

余談ですが、SOLID原則のうちLだけ人名の頭文字なので、毎回「Lってなんの略だっけ…」となり、個人的にあまり好きな命名ではないです(?)。

採用情報

メンバーズエッジで最高のチームで最高のプロダクトを作りませんか?

最高のプロダクトをつくる 最高のチームで働く

在宅でも、地方でも、首都圏でも。多様な働き方で最高のチームをつくり、お客様のプロダクトパートナーを目指します。アジャイル開発を通じ、開発現場の第一線で活躍し続けたいエンジニアを募集しています。