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

開放閉鎖の原則 — 予測が外れたときのコスト

OCP は SOLID のなかで最も実践しにくい原則です。守るには変更がどの方向から来るかを事前に知っている必要があるからです。そして多くの場合、私たちはそれを知りません。

拡張点を用意しておけば、次の変更は追加だけで済みます。用意した場所と違うところから変更が来れば、用意した抽象ごと作り直しになります。この章では、その賭けをいつ引き受けるかを扱います。

原典は何を言ったか — 鍵は抽象

Martin の 2000 年の文書は、OCP をこう定義しています1

A module should be open for extension but closed for modification. (モジュールは拡張に対して開いており、修正に対して閉じているべきである。)

続けて、この原則の出どころを明記しています。

Of all the principles of object oriented design, this is the most important. It originated from the work of Bertrand Meyer. (オブジェクト指向設計の原則のなかで、これが最も重要である。この原則は Bertrand Meyer の仕事に由来する。)

Martin はこれをオブジェクト指向設計の原則のなかで最も重要だと言っています。 後の章で見るように、DIP を「OCP を実現する主要な手段」と位置づけ、LSP 違反を「潜在的な OCP 違反」と説明します。彼の体系では OCP が中心にあります。

では、どうやって実現するのか。原典は複数の手法を挙げたうえで、こうまとめます。

All of these techniques are based upon abstraction. Indeed, abstraction is the key to the OCP. (これらの手法はすべて抽象に基づいている。実際、抽象こそが OCP の鍵である。)

最初に挙げられる手法は "Dynamic Polymorphism" です。原典の例では、モデムの種類ごとに分岐していた LogOn 関数を、Modem インターフェースに依存させることで修正不要にしています。

「継承すれば OCP」ではない

OCP を「継承して機能を足せばよい」と読む解釈があります。原典はそう言っていません。挙げられているのは抽象への依存であって、実装の継承ではありません。

Meyer が元々どう書いたかについては、実装継承を軸にしていたという説明が解説記事に見られます。ただし本連載は Meyer の原典を確認していないため、この対比は二次情報として扱い、以下では立ち入りません。 確実に言えるのは、Martin が示した手段が抽象への依存だという点です。

なぜ守るのか — 変更点を 1 か所に閉じ込める

OCP が下げるのは、1 つの追加のために複数の場所を直す手間です。

図書館の延滞料金を例にします。会員の種別によって日額が変わるとします。

❌ Bad: 種別が増えるたびに複数箇所を直す
type MemberTier = "general" | "student" | "senior";

class FineCalculator {
dailyRate(tier: MemberTier): number {
if (tier === "general") return 50;
if (tier === "student") return 20;
return 10;
}

maxFine(tier: MemberTier): number {
if (tier === "general") return 3000;
if (tier === "student") return 1000;
return 500;
}

gracePeriodDays(tier: MemberTier): number {
if (tier === "general") return 0;
if (tier === "student") return 3;
return 7;
}
}

会員種別に「教職員」を足すとき、3 つのメソッドすべてに手を入れます。しかも足し忘れても動いてしまいます — 最後の return が既定値になっているので、教職員は自動的に高齢者と同じ扱いになります。エラーは出ず、料金だけが間違います。

種別ごとに 1 か所へまとめると、追加が 1 か所で済みます。

✅ Good: 種別ごとに 1 か所へまとめる
type FinePolicy = {
readonly dailyRate: number;
readonly maxFine: number;
readonly gracePeriodDays: number;
};

const finePolicies: Record<MemberTier, FinePolicy> = {
general: { dailyRate: 50, maxFine: 3000, gracePeriodDays: 0 },
student: { dailyRate: 20, maxFine: 1000, gracePeriodDays: 3 },
senior: { dailyRate: 10, maxFine: 500, gracePeriodDays: 7 },
};

MemberTier に "faculty" を足すと、Record<MemberTier, FinePolicy> の型が合わなくなりコンパイルエラーになります。足し忘れを型が捕まえます。

ここで注目してほしいのは、インターフェースもクラスも使っていないことです。OCP が求めるのは「追加が追加で済む構造」であって、特定の言語機能ではありません。

どう誤用されるか — 来なかった拡張のために払う

OCP の誤用は、まだ来ていない拡張のために抽象を先に作ることです。実装が 1 つしかないインターフェースが並ぶ状態がその典型です。

❌ Bad: 実装が 1 つしかない抽象
interface FineCalculationStrategy {
calculate(overdueDays: number, tier: MemberTier): number;
}

class StandardFineCalculation implements FineCalculationStrategy {
calculate(overdueDays: number, tier: MemberTier): number {
const policy = finePolicies[tier];
const days = Math.max(0, overdueDays - policy.gracePeriodDays);
return Math.min(days * policy.dailyRate, policy.maxFine);
}
}

「将来ほかの計算方式が来るかもしれない」という予測で切られた抽象です。読み手は FineCalculationStrategy を見て「実装が複数あるのだろう」と考え、探して 1 つしかないことを知ります。

Martin Fowler は、この種の投資を YAGNI (You Aren't Gonna Need It) の観点から 4 つのコストに分けています2

コスト内容
cost of build使われない機能の分析・実装・テストにかけた労力
cost of delayその労力を必要な機能に向けなかったことによる遅れ
cost of carry抱えている間ずっと、コードを読みにくく変更しにくくする
cost of repair後から現実に合わないと分かったときの作り直し

予測的抽象化で見落とされやすいのは cost of carry です。作った時点のコストは 1 回きりですが、抱えているコストは毎日かかります。読む人全員が、使われていない抽象の意味を推測する時間を払います。

さらに厄介なのは、予測が半分当たったときです。Sandi Metz は、間違った抽象がどう腐っていくかを段階で説明しています3。要約すると、抽象がほぼ合う要求が来る → 引数と条件分岐を足して対応する → それが繰り返される → 誰にも読めなくなる、という進行です。

彼女の結論はこうです。

Duplication is far cheaper than the wrong abstraction (重複は、間違った抽象よりはるかに安い)

When the abstraction is wrong, the fastest way forward is back. (抽象が間違っているとき、前に進む最短の道は後戻りである。)

「間違った抽象は、重複より高くつく」 — これが予測的抽象化の代償です。重複は目に見えて、消すのも簡単です。間違った抽象は正しく見えるので、誰も消そうとせず、条件分岐を足して延命されます。

いつ破っていいのか

OCP を適用しない判断が妥当になるのは、次のときです。

拡張の方向が実測されていないとき。 「増えるかもしれない」ではなく「増えた」を待ちます。同じ種類の追加を 2 回経験してから抽象化すれば、3 回目以降が楽になり、しかも抽象の形が実際の要求に合います。

分岐が 1 か所にしかないとき。 OCP が防ぐのは、1 つの追加で複数箇所を直す事態です。分岐が 1 か所なら、追加時に直すのも 1 か所です。抽象を挟んでも減りません。

追加を型が捕まえられるとき。 上の Record<MemberTier, FinePolicy> のように、足し忘れがコンパイルエラーになるなら、修正に対して閉じていなくても事故は起きません。原典が掲げた目標は「既存コードを変更せずに、追加だけで機能を足せること」そのものでした。本連載はそこから一歩引いて、実務で効いてくるのは「直し忘れ」の防止であり、「直すこと」自体は型が守ってくれるなら許容できる、という立場を採ります。

Fowler は YAGNI の適用範囲について、こう限定しています。

Yagni only applies to capabilities built into the software to support a presumptive feature, it does not apply to effort to make the software easier to modify. (YAGNI が当てはまるのは、想定した機能を支えるために組み込まれた能力に対してのみである。ソフトウェアを変更しやすくするための労力には当てはまらない。)

つまり YAGNI は「設計をするな」ではありません。 テストを書く、名前を整える、依存を減らすといった作業は対象外です。対象になるのは「まだ無い機能のための仕組み」だけです。

TypeScript での現在地 — 網羅性チェックという第 3 の道

Martin が挙げた手段は、抽象への依存でした。TypeScript にはもう 1 つの道があります。判別可能ユニオンと網羅性チェックです。

✅ Good: 分岐は残すが、追加漏れを型が捕まえる
type FineRule =
| { readonly kind: "flat"; readonly amount: number }
| { readonly kind: "perDay"; readonly rate: number }
| { readonly kind: "capped"; readonly rate: number; readonly cap: number };

function calculateFine(rule: FineRule, overdueDays: number): number {
switch (rule.kind) {
case "flat":
return rule.amount;
case "perDay":
return rule.rate * overdueDays;
case "capped":
return Math.min(rule.rate * overdueDays, rule.cap);
default: {
// FineRule に種類が増えると、rule の型が never でなくなりコンパイルエラーになる
const exhaustive: never = rule;
return exhaustive;
}
}
}

この書き方は修正に対して閉じていませんFineRule に種類を足せば calculateFine も直します。しかし直し忘れることはできません。忘れた瞬間にコンパイルが通らなくなるからです。

どちらを選ぶかは、何が増えるかで決まります。

増えるもの向いている書き方理由
種類 (新しい料金ルール)抽象への依存新しい実装を足すだけで済む
操作 (料金に加えて猶予期間も計算したい)判別可能ユニオン関数を 1 つ足すだけで済む

抽象への依存は種類の追加に強く、操作の追加に弱いという性質があります。インターフェースにメソッドを足すと、実装すべてに手を入れることになるからです。判別可能ユニオンはその逆で、操作の追加に強く、種類の追加で全関数を見直すことになります。

どちらの軸で増えるかが読めないなら、判別可能ユニオンから始める方が安全です。 ただし漏れを捕まえるのは、分岐のたびに上のような網羅性チェックを置いたときだけです。if の連鎖や default で既定値を返す形にすると、種類を足しても型は黙ります — 冒頭の FineCalculator と同じ失敗です。それさえ守れば、分岐が散らばっても型が漏れを捕まえます。抽象を先に切ると、間違ったときに Metz の言う後戻りが必要になります。

まとめ

  • Martin は OCP をオブジェクト指向設計の原則のなかで最も重要と位置づけ、実現手段として抽象への依存を挙げた。"abstraction is the key to the OCP"
  • OCP が下げるのは、1 つの追加のために複数箇所を直す手間。特定の言語機能を要求しているわけではない
  • 誤用は、まだ来ていない拡張のために抽象を先に作ること。実装が 1 つしかないインターフェースがその印
  • Fowler の 4 コストのうち cost of carry は毎日かかる。作ったコストは 1 回きりだが、抱えるコストは続く
  • Metz の "Duplication is far cheaper than the wrong abstraction" — 間違った抽象は重複より高い
  • 拡張の方向が実測されるまで待つ。型が追加漏れを捕まえるなら、修正に開いていても事故は起きない

次に読む

次章ではリスコフの置換原則を扱います。この章で見た「抽象への依存」が成り立つには、抽象を実装する側が期待どおりに振る舞う必要があります。その条件を契約という形で扱います。

練習問題

次のインターフェースは実装が 1 つしかありません。今のまま残すか、消して具象クラスに戻すか。判断に必要な追加情報を 1 つ挙げてください
interface RenewalPolicy {
canRenew(currentRenewals: number, hasReservation: boolean): boolean;
}

class StandardRenewalPolicy implements RenewalPolicy {
canRenew(currentRenewals: number, hasReservation: boolean): boolean {
return currentRenewals < 2 && !hasReservation;
}
}

解答例

追加情報として最も価値があるのは「2 つ目の実装が必要になる具体的な予定があるか」です。

「予約が入っている本は延長できない」というルールは、図書館ごとに違いそうに見えます。しかし違いそうに見えること自体は根拠になりません。予測的抽象化はまさにその感覚から生まれます。

判断は次のように分かれます。

残す場合: 複数館への導入がすでに決まっており、館ごとにルールが違うことが確認されている。あるいはテストで差し替える必要が実際にある。この場合、抽象は予測ではなく既知の要求に応えています。

消す場合: 上記のような具体的な予定が無い。この場合、インターフェースは cost of carry を毎日払わせるだけです。読み手は実装を探し、1 つしかないことを確認する時間を使います。2 つ目が来た時点で切り出せばよく、そのときの方が抽象の形は実際の要求に合います。

なお canRenew の引数が増え続けているなら、それは別の兆候です。 Metz の言う「ほぼ合う抽象に引数と条件分岐を足す」進行に入っている可能性があります。その場合はインターフェースの有無より先に、この判定が何を決めているのかを見直す方が効果があります。


Footnotes

  1. 出典: Robert C. Martin, "Design Principles and Design Patterns" (2000)。本章の引用は 4-6 ページの OCP の節からです。参照した PDF については第 1 章の脚注を参照してください。

  2. 出典: Yagni (Martin Fowler, 2015-05-26)。4 つのコスト区分と、YAGNI の適用範囲を限定する記述はこの記事からの引用です。

  3. 出典: The Wrong Abstraction (Sandi Metz, 2016-01-20)。間違った抽象が腐っていく段階は、同記事が示す 8 段階の進行を要約したものです。