リスコフの置換原則

SOLID の L - サブタイプはスーパータイプと置換可能でなければならない原則

SOLID設計原則

リスコフの置換原則とは

リスコフの置換原則 (Liskov Substitution Principle, LSP) は、SOLID 原則の L にあたる設計原則である。スーパータイプを期待して書かれたコードにサブタイプのオブジェクトを渡しても、そのコードの正しさが保たれるように継承関係を設計せよ、という要求だ。

出どころは Barbara Liskov が 1987 年の OOPSLA で行った基調講演「Data Abstraction and Hierarchy」(Addendum to the Proceedings of OOPSLA '87、17 から 34 ページ) である。そこで示されたのは規範ではなくサブタイプの定義で、型 S の各オブジェクト o1 について、T を用いて書かれたすべてのプログラム P の振る舞いが o2 を o1 に置き換えても変わらないような型 T のオブジェクト o2 が存在するなら、S は T のサブタイプである、という条件文の形をとっている。この考え方は後に Barbara Liskov と Jeannette M. Wing の論文「A Behavioral Notion of Subtyping」(ACM Transactions on Programming Languages and Systems 16 巻 6 号、1994 年 11 月、1811 から 1841 ページ) で、事前条件・事後条件・不変条件を用いた形に形式化された。

実務で押さえたいのは、これが文法の話ではなく振る舞いの話だという点である。継承を書いてコンパイラを通した時点で満たせている気になりやすいが、求められているのは呼び出し側から見た振る舞いが変わらないことなので、型検査を通っても壊れる設計は成り立つ。

違反の例

継承の題材として繰り返し引かれる長方形と正方形で見るのが早い。数学の上では正方形は長方形の特殊な場合なので、SquareRectangle のサブクラスにするのは自然に見える。

class Rectangle {
  constructor(protected width: number, protected height: number) {}
  setWidth(w: number) { this.width = w; }
  setHeight(h: number) { this.height = h; }
  area() { return this.width * this.height; }
}

class Square extends Rectangle {
  setWidth(w: number) { this.width = w; this.height = w; } // 正方形は幅=高さ
  setHeight(h: number) { this.width = h; this.height = h; }
}

// LSP 違反: Rectangle を期待するコードが壊れる
function resizeTo4x5(rect: Rectangle) {
  rect.setWidth(4);
  rect.setHeight(5);
  return rect.area(); // Rectangle なら 20、Square だと 25 が返る
}

Square の実装そのものは間違っていない。壊れているのは、幅と高さを独立に設定できるという Rectangle が呼び出し側に与えていた前提であり、setWidth が高さまで書き換えることでそれが破られる。しかも型検査は通るので、コンパイラは何も教えてくれない。継承を疑うべきサインは数学的な分類ではなく、呼び出し側が当てにしている前提を守れるかどうかに現れる。

準拠の例

直し方は、継承で実装を分け合うのをやめて、共通の振る舞いだけをインターフェースとして切り出すことである。幅と高さを独立に変えられるという前提を Square に持ち込まなければ、置換しても壊れない。

// ✅ LSP 準拠: 共通のインターフェースで振る舞いを統一
interface Shape {
  area(): number;
}

class Rectangle implements Shape {
  constructor(private width: number, private height: number) {}
  area() { return this.width * this.height; }
}

class Square implements Shape {
  constructor(private side: number) {}
  area() { return this.side * this.side; }
}

// どちらも Shape として安全に使える
function printArea(shape: Shape) {
  console.log(`Area: ${shape.area()}`);
}

LSP 違反のサイン

  • サブクラスのメソッドが例外をスローする (親クラスではスローしない)
  • サブクラスが親クラスのメソッドを空実装にする
  • instanceof で型チェックして分岐する必要がある
  • サブクラスが親クラスの事前条件を強化する

TypeScript での実践

クラスの継承だけの話ではなく、型どうしの置換可能性としても同じ構図が現れる。TypeScriptreadonly number[]number[] の関係がわかりやすい。

const scores: readonly number[] = [80, 90];

function addItem(arr: number[]) {
  arr.push(42);
}

// error TS2345: Argument of type 'readonly number[]' is not assignable
// to parameter of type 'number[]'.
addItem(scores);

// 読み取りしかしないなら広い方の型で受ける (number[] も渡せる)
function sumItems(arr: readonly number[]): number {
  return arr.reduce((a, b) => a + b, 0);
}

置換できる向きは一方通行である。number[]readonly number[] が期待される場所へ渡せるが、逆はできない。書き換えられる配列を読み取り専用として扱っても呼び出し側の期待は裏切らないのに対し、読み取り専用の配列を書き換え可能なものとして扱うと push のような操作が成り立たなくなるからだ。この向きでいえば number[]readonly number[] のサブタイプにあたる。継承関係があるわけではない点にも注意したい。標準の型定義では ArrayReadonlyArray はどちらも extends を持たない独立したインターフェースで、TypeScript は構造を見て代入できるかどうかを判定している。

見落としやすいのは、この検査が効くのはコンパイル時までだという点だ。readonly はトランスパイル後の JavaScript に痕跡を残さず、実行時の配列が凍結されるわけでもない。as で型を外したり any を経由したりすれば書き換えは通ってしまうので、実行時に本当に変更を防ぎたい場面では Object.freeze のような手段を別に用意する。

引数の型を決めるときの指針としては、読み取りしかしない関数は readonly を付けた広い型で受け、書き換えが必要な関数だけ書き換え可能な型を要求する。こうすると呼び出し側が渡せる値の幅が広がり、意図しない書き換えも型検査で止まる。

他の SOLID 原則との関係

LSP は開放閉鎖原則 (OCP) の前提条件だ。サブタイプが安全に置換できなければ、拡張に対して開いた設計は実現できない。

直し方の側はインターフェース分離の原則 (ISP) につながる。置換すると壊れる操作を型から外し、その操作を持つ型と持たない型に分けるという処方は、そのまま ISP の実践になる。上の長方形と正方形も、寸法を書き換える操作を共通の型から切り離し、面積を返す振る舞いだけを共有した結果として整合した。

LSP 違反の判定基準

LSP 違反になりやすい変更と、その直し方を以下に整理する。

変更呼び出し側で起きること直し方
サブクラスが例外を投げる親の型として呼んでいる箇所が想定外の例外で落ちるその操作を持たない型として切り出す
事前条件を強化親では通っていた入力が、差し替えた途端に弾かれる受け付ける範囲は親と同じか広くする
事後条件を弱化親の保証を前提に書いた後続処理が崩れる返す値の保証は親と同じか強くする
不変条件を破る親の型が守っていた前提が壊れ、離れた場所で矛盾が出る不変条件の検査を親と共通の場所に置く
メソッドをオーバーライドして何もしない呼んでも何も起きず、成功と区別できない操作を持つ型と持たない型に分ける

リスコフの置換原則を扱う関連書籍も多い。

この記事は役に立ちましたか?

関連用語

関連する記事