メインコンテンツまでスキップ

リスコフの置換原則 — 型が通っても契約は守られない

LSP は 5 原則のなかで唯一、破ったかどうかを機械的に判定できそうに見えて、実際にはできない原則です。継承関係が型として成立していても、置き換えたときに壊れることがあります。

この章では、置き換え可能かを判定する基準を原典から取り出します。そして TypeScript の型検査がその基準のどこまでを見ているかを確認します。

原典は何を言ったか — 2 つの定式化

LSP には出自の異なる 2 つの定式化があります。

Liskov と Wing による定式化

理論側の出発点は、Barbara Liskov と Jeannette Wing の 1994 年の論文です1。彼女たちは部分型の要件をこう述べます。

Subtype Requirement: Let φ(x) be a property provable about objects x of type T. Then φ(y) should be true for objects y of type S where S is a subtype of T. (部分型の要件: φ(x) を型 T のオブジェクト x について証明できる性質とする。このとき S が T の部分型であるならば、型 S のオブジェクト y についても φ(y) が成り立つべきである。)

「置き換えられる」という言い方より強い主張です。基底型について証明できることは、派生型についても成り立たなければならないと言っています。

論文は、保つべき性質を 2 種類に分けます。

We focus on two kinds of such properties: invariants, which are properties true of all states, and history properties, which are properties true of all sequences of states. (私たちは次の 2 種類の性質に注目する。すべての状態について真である不変条件と、状態のすべての列について真である履歴性質である。)

そして例を挙げます。

an invariant property of a bag is that its size is always less than its bound; a history property is that the bag's bound does not change. (bag の不変条件の例は、その大きさが常に上限より小さいことである。履歴性質の例は、bag の上限が変化しないことである。)

この 2 つの違いが重要です。 不変条件は「どの瞬間を切り取っても真」という性質で、1 つの状態を見れば判定できます。履歴性質は「時間を通じて真」という性質で、状態の移り変わりを見ないと判定できません。

Martin による定式化

実務側でよく引かれるのは、Martin の 2000 年の文書の言い方です2

Subclasses should be substitutable for their base classes. (サブクラスは、その基底クラスと置き換え可能であるべきである。)

Martin はこの原則を Liskov に帰属させたうえで、Meyer の契約による設計から導けると書きます。そして契約の言葉で言い直します。

a derived class is substitutable for its base class if:

  1. Its preconditions are no stronger than the base class method.
  2. Its postconditions are no weaker than the base class method.

Or, in other words, derived methods should expect no more and provide no less. (派生クラスが基底クラスと置き換え可能であるのは次のときである。1. 事前条件が基底クラスのメソッドより強くない。2. 事後条件が基底クラスのメソッドより弱くない。言い換えれば、派生メソッドはより多くを求めず、より少なく提供しないべきである。)

「より多くを求めず、より少なく提供しない」— これが実務で使える判定基準です。

置換可能性のチェック項目

確認すべき項目は 4 つです。Liskov と Wing の論文はこの 4 つをすべて定義に含めており、Martin が実務向けに取り出したのは前半 2 つです。

項目派生型に許されること破ったときに起きること
事前条件弱めてよい。強めてはいけない基底型では受け付けられた入力が、派生型で拒否される
事後条件強めてよい。弱めてはいけない呼び出し側が保証されていたはずの結果を得られない
不変条件保つ。緩めてはいけない基底型が前提としていた状態の整合が崩れる
履歴性質保つ各時点の状態は正しいのに、基底型が禁じていた状態の変わり方をする

方向が逆になる点に注意してください。 入力側 (事前条件) は緩める方向が安全で、出力側 (事後条件) は厳しくする方向が安全です。呼び出し側から見れば、どちらも「基底型でできたことは派生型でもできる」を意味します。

論文の定義は、この 4 つに加えて型の形についての規則も課しています。引数の型は反変 (基底型の引数型より広くてよい)、結果の型は共変 (基底型の結果型より狭くてよい)、そして例外については次の規則です。

Exception rule. The exceptions signaled by m_σ are contained in the set of exceptions signaled by m_τ. (例外規則。派生型のメソッドが通知する例外は、基底型のメソッドが通知する例外の集合に含まれる。)

基底型が投げない例外を、派生型が投げてはいけないという規則です。次節でこれが効いてきます。

なぜ守るのか — 抽象への依存が成り立つ前提

LSP は単独で価値を持つ原則ではありません。前章の OCPが成り立つための前提です。

抽象に依存すれば修正せずに拡張できる、というのが OCP の主張でした。しかし実装が期待どおりに振る舞わなければ、抽象に依存した側は結局その実装を特別扱いすることになります。Martin は原典でこう書いています。

Thus, violations of LSP are latent violations of OCP. (したがって LSP 違反は、潜在的な OCP 違反である。)

置き換えが壊れると、呼び出し側に「この実装のときだけ別処理」という分岐が生まれます。その分岐こそ OCP が消そうとしていたものです。

どう誤用されるか — 事後条件を弱める

最も多い違反は、派生型が基底型より少ないことしか保証しなくなる形です。

図書館の貸出記録で見ます。基底型は貸出期間を延長できます。

❌ Bad: 派生型が事後条件を弱めている
class LendingRecord {
constructor(
readonly itemId: string,
protected dueOn: Date,
) {}

/** 返却期限を days 日ぶん延長する。呼び出し後、dueOn は必ず days 日後になる */
extend(days: number): void {
this.dueOn = new Date(this.dueOn.getTime() + days * 86_400_000);
}

get dueDate(): Date {
return this.dueOn;
}
}

class ReservedLendingRecord extends LendingRecord {
/** 予約が入っている資料は延長しない */
extend(days: number): void {
// 何もしない
}
}

extend の事後条件は「呼び出し後、期限が延びている」でした。ReservedLendingRecord はそれを保証しません。しかも黙って保証しません。 例外も投げず、戻り値も無いので、呼び出し側は延長できたと思い込みます。

❌ Bad: 呼び出し側は延長されたと信じている
function extendAllLoans(records: readonly LendingRecord[], days: number): void {
for (const record of records) {
record.extend(days);
// ここで record.dueDate が延びていることを前提に通知を送ると、
// ReservedLendingRecord では嘘の通知になる
}
}

例外を投げる形にしても解決しません。基底型の extend が例外を投げないなら、派生型が投げることは前節の例外規則に反します。派生型が通知してよい例外は、基底型が通知する例外の集合に含まれていなければならないからです。呼び出し側の視点で言えば、基底型では常に呼べたメソッドが、派生型では条件付きになっています。

解決は、契約そのものを設計し直すことです。

✅ Good: 延長できないことを型と戻り値で表す
type ExtensionResult =
| { readonly extended: true; readonly newDueDate: Date }
| { readonly extended: false; readonly reason: string };

/**
* 貸出記録。
* 履歴性質: dueOn は前倒しされない (どの操作を経ても過去方向へは動かない)
*/
class LendingRecord {
constructor(
readonly itemId: string,
protected dueOn: Date,
) {}

/** 延長を試みる。成否は戻り値で示され、呼び出し側が必ず受け取る */
tryExtend(days: number): ExtensionResult {
this.dueOn = new Date(this.dueOn.getTime() + days * 86_400_000);
return { extended: true, newDueDate: this.dueOn };
}

get dueDate(): Date {
return this.dueOn;
}
}

class ReservedLendingRecord extends LendingRecord {
tryExtend(days: number): ExtensionResult {
return { extended: false, reason: "予約が入っています" };
}
}

事後条件が「延長される」から「延長の可否を返す」に変わりました。派生型はこれを満たします。契約を弱いところに合わせて設計し直したのであって、派生型を無理に基底型へ合わせたのではありません。

履歴性質の違反は見つけにくい

事前条件と事後条件はメソッド単位で確認できますが、履歴性質はそうはいきません。基底型の LendingRecord は「dueOn は前倒しされない」という性質を契約として書いていました。状態の列についての約束なので、メソッド 1 つを見ても確かめられません。

❌ Bad: 各時点の状態は正しいが、状態の変わり方が違う
class RecallableLendingRecord extends LendingRecord {
/** 予約が入った資料を回収する。期限を今日まで繰り上げる */
recall(): void {
this.dueOn = new Date();
}
}

継承した tryExtend には手を入れていないので、事前条件も事後条件もそのままです。どの時点で状態を切り取っても、itemIddueOn を持つ正しい貸出記録に見えます。破っているのは履歴性質です。基底型は dueOn を前倒ししないと約束しており、recall はその約束が禁じた向きへ状態を動かします。

違反が見えるのは、recall の前後の状態を並べて比べたときだけです。各時点のスナップショットを見ても分からないので、テストでも取りこぼしやすくなります。Liskov と Wing が不変条件と履歴性質を分けたのは、この違いを扱うためです。

定番の例は何を示しているか

LSP の説明でよく使われるのは、長方形と正方形の例です。正方形は長方形の一種なのに継承すると壊れる、という話です。本サイトの SOLID 原則でもこの形で説明しています。

Martin の原典が使っているのは円と楕円の例です。 構造は同じで、円は楕円の退化した形なのに、焦点を設定するメソッドを持たせると置き換えが壊れる、という説明です。彼はその理由を事後条件で説明します。焦点を設定するメソッドは「渡した 2 点がそのまま保持される」ことを保証していたのに、円はそれを保証できない、というのが違反の中身です。

この種の例から引き出すべき教訓は、「正方形は長方形ではない」ではありません。 数学的な包含関係は正しいのです。壊れるのは、幅と高さを別々に変えられる操作を型に持たせた瞬間です。

つまりこれは、幾何学の話ではなく可変オブジェクトの継承が難しいという話です。読み取り専用の型として設計すれば、正方形も円も問題なく部分型になります。壊す操作を持たせなければ壊れません。

定番の例をそのまま暗記すると「継承には落とし穴がある」という漠然とした教訓しか残りません。どの操作が契約を壊したのかまで降りると、自分のコードで判定できるようになります。

いつ破っていいのか

LSP は、他の 4 原則と性質が違います。破ってよい場面がほとんどありません。

理由は、LSP 違反が型の嘘だからです。SRP を破っても動くコードは動きます。OCP を破れば追加のたびに手が増えますが、正しく動きます。LSP を破ると、呼び出し側が正しいと信じたことが成り立たなくなります。

現実的な選択肢は「破る」ではなく「継承をやめる」です。

  • 契約が合わないなら継承しない。 委譲に切り替えれば、置き換え可能性の要求そのものが発生しません
  • 契約を弱い側に合わせて設計し直す。 上の tryExtend がこの形です
  • 共通部分だけを別の型として切り出す。 読み取り専用の操作だけなら置き換えが成立することは多くあります

ただし、**LSP は言語が強制してくれる原則ではありません。**次節で見るように、型検査を通ったからといって契約が守られているとは限りません。

TypeScript での現在地 — 型検査は契約を見ていない

Liskov と Wing は、型システムの規則だけでは不十分だと論文の序盤で論じています。

these rules are not strong enough to ensure that the program containing the above assignment will work correctly for any subtype of T, since all they do is ensure that no type errors will occur. (これらの規則は、型エラーが起きないことを保証するだけなので、その代入を含むプログラムが T のどの部分型に対しても正しく動くことを保証するには十分な強さがない。)

彼女たちが挙げる例が的確です。

stacks and queues might both have a put method to add an element and a get method to remove one. According to the contravariance rule, either could be a legal subtype of the other. However, a program written in the expectation that x is a stack is unlikely to work correctly if x actually denotes a queue, and vice versa. (スタックとキューはどちらも要素を追加する put と取り除く get を持ちうる。反変規則に照らせば、どちらも他方の正当な部分型になりうる。しかし x がスタックであることを期待して書かれたプログラムは、x が実際にはキューであるとき正しく動きそうにない。逆もまた同じである。)

TypeScript の構造的型システムでは、この状況がより起きやすくなります。 名前ではなく形が一致すれば代入できるので、意味の違う型が互いに代入可能になります。

❌ Bad: 形が同じなら代入できてしまう
type LendingQueue = {
add(itemId: string): void;
take(): string | undefined; // 先に入れたものを取り出す
};

type LendingStack = {
add(itemId: string): void;
take(): string | undefined; // 後に入れたものを取り出す
};

declare const stack: LendingStack;
const queue: LendingQueue = stack; // 型エラーにならない

型は通ります。取り出す順序という契約は、型のどこにも書かれていないからです。

メソッド構文とプロパティ構文で検査が変わる

もう 1 つ、TypeScript 固有の落とし穴があります。メソッドを宣言する構文によって、引数の型検査の厳しさが変わります。

引数の反変チェックは、メソッド短縮記法では働かない
type Borrower = { id: string };
type StudentBorrower = Borrower & { schoolId: string };

// メソッド短縮記法で宣言した場合
type MethodStyle = {
notify(borrower: StudentBorrower): void;
};
declare const methodStyle: MethodStyle;
// 狭い引数しか受けられない関数を、広い引数を受けると約束した型に代入できてしまう
const widened: { notify(borrower: Borrower): void } = methodStyle;

// プロパティ関数型で宣言した場合
type PropertyStyle = {
notify: (borrower: StudentBorrower) => void;
};
declare const propertyStyle: PropertyStyle;
// @ts-expect-error strictFunctionTypes により引数が反変で検査され、代入できない
const widenedStrict: { notify: (borrower: Borrower) => void } = propertyStyle;

同じ意味に見える 2 つの書き方で、検査の結果が変わります。メソッド短縮記法は歴史的な互換性のために双変 (どちらの向きの代入も許す) として扱われ、strictFunctionTypes の対象外です。プロパティ関数型で書くと反変として検査されます。

反変であることは、Liskov と Wing の定義が要求している性質です。 論文の型の形についての規則は、引数について次のように述べます。

Contravariance of arguments. m_τ and m_σ have the same number of arguments. If the list of argument types of m_τ is α_i and that of m_σ is β_i, then ∀i . α_i < β_i. (引数の反変性。基底型と派生型のメソッドは同じ数の引数を持つ。基底型の引数型リストを α_i、派生型のそれを β_i とすると、すべての i について α_i は β_i の部分型である。)

つまり派生型のメソッドは、基底型より広い引数型を受け取らなければなりません。メソッド短縮記法はこの検査を省いているので、論文の定義では部分型でないものが、TypeScript では代入可能になります。

契約を型で守らせたいなら、プロパティ関数型で宣言する方が厳しく検査されます。 ただしこれで守れるのは引数の型だけです。取り出す順序や、上で見た履歴性質は、依然として型の外にあります。

型検査は契約の一部しか見ていません。残りは設計とテストで守るしかない、というのがこの節の結論です。

まとめ

  • Liskov と Wing の要件は「基底型について証明できる性質は、派生型についても成り立つ」。論文は保つべき性質を不変条件履歴性質に分けた
  • Martin は契約の言葉で 2 条件にまとめた。より多くを求めず、より少なく提供しない
  • 確認すべきは 4 項目 — 事前条件は弱めてよく、事後条件は強めてよく、不変条件と履歴性質は保つ
  • 履歴性質の違反は各時点の状態を見ても分からない。テストで取りこぼしやすい
  • LSP 違反は潜在的な OCP 違反。置き換えが壊れると呼び出し側に分岐が生まれる
  • **LSP には「破ってよい場面」がほとんど無い。**選ぶべきは継承をやめるか、契約を設計し直すか
  • 型検査は契約の一部しか見ない。構造的型システムでは、意味の違う型が互いに代入可能になる

次に読む

次章ではインターフェース分離の原則を扱います。この章で見た「型が契約を語らない」という性質は、インターフェースをどう切るかの判断にも影響します。

練習問題

次の派生型は 4 項目のどれを破っていますか。破っていないなら、なぜ違和感があるのかを説明してください
class HoldQueue {
protected entries: string[] = [];

/** 予約を末尾に追加する。呼び出し後、必ず件数が 1 増える */
add(borrowerId: string): void {
this.entries.push(borrowerId);
}

/** 先頭の予約を取り出す。空なら undefined */
takeNext(): string | undefined {
return this.entries.shift();
}

get size(): number {
return this.entries.length;
}
}

class PriorityHoldQueue extends HoldQueue {
add(borrowerId: string): void {
if (borrowerId.startsWith("staff-")) {
this.entries.unshift(borrowerId);
} else {
this.entries.push(borrowerId);
}
}
}

解答例

答えは基底型の契約をどう読むかで変わります。 問題文のドキュメントコメントをそのまま契約と読むなら、事後条件違反です。

件数についての約束だけを見れば、4 項目のどれも破っていません。

  • 事前条件: add はいつでも呼べます。基底型と同じで、強めていません
  • 事後条件: 「呼び出し後、必ず件数が 1 増える」は満たされます。unshift でも push でも件数は 1 増えます。ここで見ているのはドキュメントコメントの後半 (件数) だけで、前半の「末尾に」については後述します
  • 不変条件: 「件数は 0 以上」といった状態の性質は保たれています
  • 履歴性質: 件数の増減の仕方は基底型と同じです。追加すれば増え、取り出せば減ります

それでも違和感がある理由は、基底型のドキュメントコメントに書かれた「末尾に追加する」が、契約として扱われていないからです。add の説明は「予約を末尾に追加する。呼び出し後、必ず件数が 1 増える」でした。前半は位置についての約束で、後半は件数についての約束です。派生型は後半だけを守っています。

ここが判断の分かれ目です。

「末尾に追加する」を契約に含めるなら、事後条件違反です。 takeNext が先頭から取り出す以上、追加位置は取り出し順序を決めます。順序を前提に書かれた呼び出し側 (たとえば「予約した順に通知する」) は壊れます。これは Liskov と Wing がスタックとキューの例で指摘した状況そのものです。

含めないなら、違反ではありません。 ただしその場合、基底型の説明から「末尾に」を削るべきです。契約に含めないものをコメントに書くと、読み手はそれを契約だと思って依存します。

実務上の答えは「基底型の契約を明確にする」であって、派生型を直すことではありません。順序が意味を持つなら契約に書き、書いたなら派生型で変えない。順序が意味を持たないなら、基底型の説明から順序への言及を消します。


Footnotes

  1. 出典: Barbara H. Liskov, Jeannette M. Wing, "A Behavioral Notion of Subtyping", ACM Transactions on Programming Languages and Systems, Vol. 16, No. 6, November 1994, pp. 1811-1841。本章の引用は第 1 節 (序論) と、部分型関係の定義を与える第 5 節の Fig. 4 からです。論文は形式仕様の記法で部分型関係を定義しています。本章は Fig. 4 の規則のうち、記法を追わずに読める 2 つ (引数の反変性と例外規則) だけを引用し、証明の体系には立ち入りません。

  2. 出典: Robert C. Martin, "Design Principles and Design Patterns" (2000)、8-12 ページの LSP の節。参照した PDF については第 1 章の脚注を参照してください。円と楕円の例、および契約による再定式化はこの節にあります。