コンテンツにスキップ

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

Clean Architecture 達人に学ぶソフトウェアの構造と設計の第 9 章を読もう。

概要

リスコフの置換原則(LSP: Liskov Substitution Principle)とは親クラスと子クラスとでどちらかにしかないメンバーを持たせるとメンテナンスが大変だからやめておけ、そもそもそうならないように設計しろ、という原則と思う。

リスコフの置換原則(LSP)をしっかり理解する - Qiita

継承を使うなという意見もある。継承を使わなければこういう問題に遭遇しないからである。以下にはインターフェースを使えとある。

継承を使うな - ノートの端の書き残し

現在は個人的にも積極的な理由がない限り継承ではなくインターフェースを使うべきだと思う。

具体例

図形の面積を計算するアプリケーションを作るプロジェクトを考える。

長方形の面積を計算する

数ヶ月の打ち合わせの末、まず長方形に対応することになった。

src/rectangle.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**
 * 長方形クラス
 */
export class Rectangle {
  constructor(protected _width: number, protected _height: number) {}

  get width(): number {
    return this._width;
  }

  set width(value: number) {
    this._width = value;
  }

  get height(): number {
    return this._height;
  }

  set height(value: number) {
    this._height = value;
  }

  get area(): number {
    // 長方形の面積は幅 * 高さ
    return this._width * this._height;
  }
}
src/app.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import { Rectangle } from "./rectangle";
import { Square } from "./square";

const rectangle: Rectangle = new Rectangle(20, 10);

function printAreas(shapes: Rectangle[]) {
  shapes.forEach((shape) => {
    console.log(`${shape.constructor.name}の面積は${shape.area}です。`);
  });
}

printAreas([rectangle]);

rectangle.width = 30;

printAreas([rectangle]);
src/app.tsの実行結果
1
2
Rectangleの面積は200です。
Rectangleの面積は300です。

動いた。リリースだ。

正方形の面積を計算する

リリースの結果は評判が良く、今度は正方形の面積の計算に対応することになった。

だが対応にあたっては問題があった。長方形のリリースには 1 年もかかったのである。人差し指でキーボードをタイピングするものだからコーディングが遅く、正方形を一から作るのは大変なのだ。

ここで長方形は対応済みだし、正方形は長方形の派生だから、長方形を継承することで割と楽に正方形に対応することができるかもしれない。

しかし正方形は性質上、幅と高さを別々に設定できるようにすべきではない。そこで正方形の一辺はwidthプロパティで設定するルールにして、heightプロパティを設定しようとするとエラーを出すようにした。あまり美しくないが、やむを得ない。

src/square.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import { Rectangle } from "./rectangle";

/**
 * 正方形クラス
 */
export class Square extends Rectangle {
  constructor(size: number) {
    super(size, size);
  }

  set width(value: number) {
    this._width = this._height = value;
  }

  set height(value: number) {
    throw new Error("一辺の長さはwidthプロパティで指定してください。");
  }
}
src/app.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import { Rectangle } from "./rectangle";
import { Square } from "./square";

const rectangle: Rectangle = new Rectangle(20, 10);
const square: Rectangle = new Square(10);

function printAreas(shapes: Rectangle[]) {
  shapes.forEach((shape) => {
    console.log(`${shape.constructor.name}の面積は${shape.area}です。`);
  });
}

printAreas([rectangle, square]);

rectangle.width = 30;
square.width = 30;
// square.height = 40; // これはやってはいけないことにする

printAreas([rectangle, square]);
src/app.tsの実行結果
1
2
3
4
Rectangleの面積は200です。
Squareの面積は100です。
Rectangleの面積は300です。
Squareの面積は900です。

動いた。リリースだ。

円の面積を計算する

このリリースは激ウケだった。ウケ過ぎたために、今度は円の面積の計算に対応することになった。

開発の点では非常に困る。相変わらず指一本でのタイピングなので、既存のコードを変える余裕はない。

前回は長方形の流用が効いたが、円に流用させるのは難しい。しかし他に方法がないのでやるしかない。

円の面積の計算に使う半径を幅や高さに当てはめるのはおかしいので、radiusというプロパティを作成し、widthheightへの参照を禁止することにした。

src/circle.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import { Rectangle } from "./rectangle";

/**
 * 円クラス
 */
export class Circle extends Rectangle {
  constructor(private _radius: number) {
    super(0, 0);
  }

  get width() {
    throw new Error("円に幅は存在しません。");
  }

  set width(value: number) {
    throw new Error("円に幅は存在しません。");
  }

  get height() {
    throw new Error("円に高さは存在しません。");
  }

  set height(value: number) {
    throw new Error("円に高さは存在しません。");
  }

  get radius() {
    return this._radius;
  }

  set radius(value: number) {
    this._radius = value;
  }

  get area() {
    // 円の面積は半径の2乗 * 円周率
    return Math.pow(this.radius, 2) * Math.PI;
  }
}
src/app.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { Circle } from "./circle";
import { Rectangle } from "./rectangle";
import { Square } from "./square";

const rectangle = new Rectangle(20, 10);
const square = new Square(10);
const circle = new Circle(10);

function printAreas(shapes: Rectangle[]) {
  shapes.forEach((shape) => {
    console.log(`${shape.constructor.name}の面積は${shape.area}です。`);
  });
}

printAreas([rectangle, square, circle]);

rectangle.width = 30;
square.width = 30;
// square.height = 40; // これはやってはいけないことにする
circle.radius = 30;
// circle.width = 30; // これはやってはいけないことにする
// circle.height = 30; // これはやってはいけないことにする

printAreas([rectangle, square, circle]);
src/app.tsの実行結果
1
2
3
4
5
6
Rectangleの面積は200です。
Squareの面積は100です。
Circleの面積は314.1592653589793です。
Rectangleの面積は300です。
Squareの面積は900です。
Circleの面積は2827.4333882308138です。

動いた。リリースだ。

五角形の面積を計算する

このリリースは激ウケでウケ過ぎたために以下略。

どんどんコードは汚くなるだろう。

何がまずいのか

設計自体がまずい。センスがあればいきなりRectangleを作成することを避けただろうし、そうでなくても長方形のあとに正方形という概念が現れた時点で設計を見直すべきだった。

具体的にまずいところは長方形Rectangleが親で、正方形Squareが子という関係をもたせておきながら、子であるSquareには幅や高さというプロパティは(正方形の概念的には)存在し得ないところだ。

しかしこれらを親子関係にしてしまったために子が持つべきでないプロパティやメソッドを持たざるを得ない奇妙な設計になっている。

リスコフの置換原則を適用する

長方形、正方形、円は面積を持つ図形という共通点があるので、そこだけをくくるインターフェースを作成する。

1
2
3
export interface Shape {
  readonly area: number;
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { Shape } from "./shape";

/**
 * 長方形クラス
 */
export class Rectangle implements Shape {
  constructor(private _width: number, private _height: number) {}

  get width(): number {
    return this._width;
  }

  set width(value: number) {
    this._width = value;
  }

  get height(): number {
    return this._height;
  }

  set height(value: number) {
    this._height = value;
  }

  get area(): number {
    // 長方形の面積は幅 * 高さ
    return this._width * this._height;
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import { Shape } from "./shape";

/**
 * 正方形クラス
 */
export class Square implements Shape {
  constructor(private _size: number) {}

  get size() {
    return this._size;
  }

  set size(value: number) {
    this._size = value;
  }

  get area() {
    // 正方形の面積は一辺の2乗
    return Math.pow(this._size, 2);
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import { Shape } from "./shape";

/**
 * 円クラス
 */
export class Circle implements Shape {
  constructor(private _radius: number) {}

  get radius() {
    return this._radius;
  }

  set radius(value: number) {
    this._radius = value;
  }

  get area() {
    // 円の面積は半径の2乗 * 円周率
    return Math.pow(this.radius, 2) * Math.PI;
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { Circle } from "./circle";
import { Rectangle } from "./rectangle";
import { Shape } from "./shape";
import { Square } from "./square";

const rectangle = new Rectangle(20, 10);
const square = new Square(10);
const circle = new Circle(10);

function printAreas(shapes: Shape[]) {
  shapes.forEach((shape) => {
    console.log(`${shape.constructor.name}の面積は${shape.area}です。`);
  });
}

printAreas([rectangle, square, circle]);

rectangle.width = 30;
square.size = 30;
circle.radius = 30;

printAreas([rectangle, square, circle]);
1
2
3
4
5
6
Rectangleの面積は200です。
Squareの面積は100です。
Circleの面積は314.1592653589793です。
Rectangleの面積は300です。
Squareの面積は900です。
Circleの面積は2827.4333882308138です。