オープンクローズドの原則(Open-Closed Principle、OCP)は、新しい機能を既存のコードを書き換えずに、新しいコードを足すことで追加できるようにしておく設計原則です。標語は「ソフトウェアの構成要素は拡張に対して開かれ、修正に対して閉じられているべきである」。SOLID原則の「O」にあたります。この記事では、意味と守る理由を押さえたあと、型で分岐する違反コードをTypeScriptで直し、そのうえで「何に対して閉じるか」の判断、1988年のメイヤー版と1996年のマーチン版の違い、原論文がこの原則から導いた3つの慣習までを扱います。
まとめ
- 意味は「振る舞いの追加は新しいコードで行い、すでに動いているコードは書き換えない」。種類の追加に対する違反の典型は、種類を見て分岐する
switchやifの連鎖。 - 直し方は、種類ごとの処理をインターフェースの実装クラスへ移し、利用側をインターフェースだけに依存させる。種類を1つ足しても利用側の処理と既存クラスを書き換えずに済めば、その軸に対して閉じている。
- 閉じられるのは特定の種類の変更だけ。クラスで書けば型の追加に閉じる代わりに処理の追加には開いたままになり、判別共用体で書けばその逆になる。どちらに閉じるかは、増える頻度が高いほうで決める。
- 原典は Bertrand Meyer『Object-Oriented Software Construction』(1988年)。有名な一文はマーチンが1996年にメイヤーを言い換えたもので、SOLIDの「O」はマーチン版の解釈を指す。
- 変更の軸が不明なら、同種の変更が繰り返された時点で抽象化を検討する。実装数だけで拡張点の有無は判断しない。
オープンクローズドの原則の意味と「開く」「閉じる」の対象
現在広く使われている定義は、ロバート・C・マーチンが1996年に C++ Report 誌の連載「Engineering Notebook」第1回として書いた論文「The Open-Closed Principle」にあります。マーチンはこの原則を「すでに動いている古いコードを変えるのではなく、新しいコードを足すことで振る舞いを拡張する」ものと説明しました。
マーチンの定義にある2つの属性
同論文は、原則に従うモジュールが持つ属性を2つ挙げています。1つ目は「拡張に対して開いている(Open For Extension)」で、要件が変わったときにモジュールに新しい振る舞いをさせられること。2つ目は「修正に対して閉じている(Closed for Modification)」で、論文の言葉では「そのモジュールのソースコードは不可侵」です。
振る舞いを変えるにはふつうソースを書き換えるので、2つは矛盾して見えます。論文はこれを「抽象が鍵(Abstraction is the Key)」という節で解きます。固定された抽象型の背後に、いくらでも増やせる実装を置けばよい、という答えです。
振る舞いの拡張と既存ソースコードの修正
「開く」の対象は振る舞いの拡張、「閉じる」の対象は既存ソースコードの修正です。誤解されやすいのは、閉じる相手が「変更全般」ではなく「特定の種類の変更」だという点です。決済手段の追加に対して閉じた設計でも、税率計算の仕様変更に対しては開いたままかもしれません。何に対して閉じるのかを決めない限り、この原則を満たしているかどうかは判定できません。
別名・英語表記・略称(開放閉鎖の原則/開閉原則/OCP原則)
日本語では「開放閉鎖の原則」「開放/閉鎖原則」「開閉原則」と訳され、日本語版Wikipediaの項目名は「開放/閉鎖原則」です。英語は Open-Closed Principle、略称は OCP で、「OCP原則」という重ね表記や、中黒を入れた「オープン・クローズドの原則」も同じものを指します。OCP は制御工学の最適制御問題(Optimal Control Problem)の略でもあるため、英語で調べるときは principle を添えると絞り込めます。
修正に閉じる理由:1つの変更が連鎖する設計の損失
マーチンの1996年の論文は、冒頭で問題をこう定義しています。1つの変更が依存しているモジュールへ連鎖的な変更を引き起こすプログラムは、壊れやすく(fragile)、硬く(rigid)、予測できず、再利用できない。オープンクローズドの原則は、この連鎖を断つための原則です。
2014年にマーチンが自身のブログに書いた「The Open Closed Principle」は、損失をもっと実務的に言い直しています。古いコードが1行も変わらなければ、それを再コンパイルする必要も再デプロイする必要もなく、新機能は新しいコードだけを配布すれば済む。逆に、新機能を入れるたびに既存コードのあちこちへ手を入れる状態を、マーチンはマーティン・ファウラーが『Refactoring』(1999年)で名付けた「Shotgun Surgery(散弾銃手術)」として挙げています。
レビューとテストの重点も同じ理屈で絞れます。書き換えたファイルは、そのファイルを使っている箇所まで含めて確かめ直す必要があります。新しいファイルと登録箇所は重点的な確認対象になりますが、既存機能との組み合わせや共有状態への影響もあるため、必要な結合テストと回帰テストは残ります。
違反コードと修正例(TypeScript)
ここでは、種類の追加に対して閉じていない典型例として、種類を見て分岐する処理を扱います。マーチンの論文の例も、図形の種類で分岐する描画関数でした。以下のコードは TypeScript 7.0.2 の tsc でコンパイルし、Node.js で実行して出力を確認しています。
種類で分岐する違反例
type Shape =
| { kind: "circle"; r: number }
| { kind: "rectangle"; w: number; h: number };
function area(s: Shape): number {
switch (s.kind) {
case "circle":
return Math.PI * s.r ** 2;
case "rectangle":
return s.w * s.h;
}
}
function label(s: Shape): string {
switch (s.kind) {
case "circle":
return "円";
case "rectangle":
return "長方形";
}
}
const shapes: Shape[] = [{ kind: "circle", r: 1 }, { kind: "rectangle", w: 2, h: 3 }];
for (const s of shapes) console.log(label(s), area(s).toFixed(2));
// 円 3.14
// 長方形 6.00
三角形を追加するには、Shape の型定義に加えて area と label の両方に case を足す必要があります。修正に対して閉じていません。同じ kind による判定は、描画・検証・保存と処理が増えるほど別の関数へ複製されていき、1種類の追加が複数ファイルの修正を呼ぶようになります。
インターフェースによる図形追加への対応
interface Shape {
area(): number;
label(): string;
}
class Circle implements Shape {
constructor(private readonly r: number) {}
area(): number {
return Math.PI * this.r ** 2;
}
label(): string {
return "円";
}
}
class Rectangle implements Shape {
constructor(private readonly w: number, private readonly h: number) {}
area(): number {
return this.w * this.h;
}
label(): string {
return "長方形";
}
}
// このクラスを追加し、呼び出し側で生成・登録する。既存のクラスと report は変更不要
class Triangle implements Shape {
constructor(private readonly b: number, private readonly h: number) {}
area(): number {
return 0.5 * this.b * this.h;
}
label(): string {
return "三角形";
}
}
function report(shapes: Shape[]): void {
for (const s of shapes) console.log(s.label(), s.area().toFixed(2));
}
report([new Circle(1), new Rectangle(2, 3), new Triangle(4, 5)]);
// 円 3.14
// 長方形 6.00
// 三角形 10.00
三角形を利用するには Triangle クラスの追加と呼び出し側の配列への登録が必要ですが、report にも既存の2クラスにも手が入りません。判定の目安は1つです。新しい種類を足したときに、閉じる対象とした既存の処理を変更せずに済むか。生成・登録箇所の変更と、閉じる対象である処理の変更は区別します。ファイルへの追記の有無だけでは判定できません。インターフェースそのものの考え方はインターフェースの意味とコードの契約で、利用側が実装を知らずに呼べる仕組みはポリモーフィズム(多態性)の種類と実装例で確認できます。
型の追加と処理の追加に対する設計の選択基準
修正版にも、閉じていない変更があります。周囲長を求める perimeter() を足すと、Shape インターフェースと Circle・Rectangle・Triangle の全クラスを書き換えることになります。クラスで書いた設計は、種類の追加に閉じる代わりに、処理の追加には開いたままです。
違反例として出した判別共用体の書き方は、ちょうどその逆です。perimeter 関数は新しい関数を1つ書けば足せ、既存の area と label には触れません。一方で種類を足すと、全関数の switch が修正対象になります。ただし、この例のように戻り値の型を明示し、strictNullChecks を有効にした TypeScript では、型に triangle を足しただけの段階で、case の無い関数がコンパイルエラー(TS2366「Function lacks ending return statement」)になり、直し漏れはビルドで止まります。
| 変更 | インターフェース+クラス | 判別共用体+switch |
|---|---|---|
| 種類を1つ足す | 新しいクラス1つ | 全関数のswitchを修正 |
| 処理を1つ足す | 全クラスを修正 | 新しい関数1つ |
| 直し漏れの検出 | implementsの型検査 | 網羅性の型検査 |
この対称性は、フィリップ・ワドラーが1998年11月12日のメーリングリスト投稿「The Expression Problem」で名付けた問題そのものです。ワドラーは、種類を行、関数を列とする表にたとえ、関数型言語は行が固定で列を足しやすく、オブジェクト指向言語は列が固定で行を足しやすいと書いています。ここで示した2つの書き方では、両方の追加を既存コードの修正なしには扱えません。ワドラーの投稿は、この制約を超えるための解法も提示しています。
判断は、どちらが頻繁に増えるかで決めます。決済手段・通知チャネル・ファイル形式のように種類が増え続け、処理は数個で固定されている領域はインターフェースとクラスで閉じます。構文木の解析や状態遷移のように種類は固定で、処理が増え続ける領域では、判別共用体と網羅性チェックのほうが変更は少なく済みます。後者で switch を書くことは、OCP違反ではなく、閉じる軸を選んだ結果です。
メイヤー版(1988年)とマーチン版(1996年)の違い
同じ原則名でも、実現手段として想定しているものが両者で違います。この差を知らずに古い解説と新しい解説を並べて読むと、継承すべきなのかインターフェースを切るべきなのかで矛盾した結論になります。
| 観点 | メイヤー版(1988年) | マーチン版(1996年) |
|---|---|---|
| 出典 | Object-Oriented Software Construction | C++ Report 1996年1月号 |
| 拡張の手段 | 実装継承 | 抽象基底クラスの実装 |
| 閉じている状態 | 仕様が安定し、利用可能 | 想定した拡張で既存モジュールの修正不要 |
| 利用側の参照先 | 親クラス | 抽象型 |
| 説明の焦点 | 利用可能なモジュールの拡張 | 抽象を介した既存コードの保護 |
メイヤー版:継承による既存クラスの拡張
メイヤーの定義では、モジュールが open であるとは、データ構造にフィールドを足したり関数を加えたりできる、まだ拡張可能な状態を指します。closed であるとは、仕様がよく定義されて安定し、コンパイルされてライブラリに置かれ、他のモジュールから使える状態を指します。メイヤー自身のクラスについての説明は次のとおりです。
「A class is closed, since it may be compiled, stored in a library, baselined, and used by client classes. But it is also open, since any new class may use it as parent, adding new features.」(クラスはコンパイルされ、ライブラリに格納され、ベースラインとして確定し、クライアントクラスから使われているという意味で閉じている。同時に、任意の新しいクラスがそれを親として使い、新しい機能を加えられるという意味で開いている)
実現手段は実装継承です。配布したクラスは凍結し、拡張が要るときは子クラスを派生させて機能を足します。親クラスが具象クラスとして実装を持つことを前提にしており、抽象クラスを必須にしていません。継承の使いどころと委譲との使い分けが広く議論される前の設計観なので、「継承より合成」という今の慣習とは前提が違います。マーチンも2014年のブログで、この定義は「明らかに古い」と書いたうえで、システムを修正せずに振る舞いを拡張できるべきだという本質は残る、としています。
マーチン版:安定した抽象と実装クラスの差し替え
マーチン版の拡張点は継承階層ではなく抽象です。原文の表現は abstract base class で、C++では純粋仮想関数を持つ抽象基底クラスで契約を表現しています。この例の契約はTypeScriptではインターフェースで表せます。安定させるのはメソッドのシグネチャと振る舞いの契約で、実装はその契約を満たす複数のクラスが個別に持ちます。上位モジュールを抽象に依存させる構造なので、実装レベルでは依存性逆転の原則(DIP)の適用とほぼ重なります。
SOLIDの「O」とマーチンによる原則の整理
有名な一文「software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification」は、マーチンが1996年の論文でメイヤーを紹介するときに付けた言い換えで、原文には To paraphrase him(彼を言い換えると)と前置きがあります。マーチンは2000年の論文「Design Principles and Design Patterns」で複数の設計原則をまとめ、それをSOLIDという頭字語に整理したのはマイケル・フェザーズ(2004年ごろ)です。SOLIDの「O」はマーチン版の解釈を指すので、実務ではマーチン版を基準にして問題ありません。
原論文がOCPから導いた3つの慣習(private・グローバル変数・RTTI)
マーチンの1996年の論文は、「メンバ変数はprivateに」「グローバル変数は避けよ」「RTTI(実行時型識別)は危険」というよく知られた慣習を挙げ、その根拠がオープンクローズドの原則にあると論じています。慣習を丸暗記するより、どの変更に対して閉じられなくなるのかで考えると、例外を判断できるようになります。
| 慣習 | 閉じられなくなる理由 | 論文が認める例外 |
|---|---|---|
| メンバ変数はprivate | 変数の変更が依存関数に波及 | 閉じる問題が起きない稀な場合 |
| グローバル変数を使わない | 書き込む全モジュールに依存 | 依存が少ない場合(cout・cin) |
| RTTIは危険 | 型判定の分岐が種類追加で壊れる | 種類が増えても変わらない使い方 |
private の根拠は、変数が変わればその変数に依存する関数はすべて変更が要る、つまりその変数に対して閉じられない、という点です。論文はクラスの派生クラスも含めて変数を見せるべきでないとし、public だけでなく protected も避ける立場を取ります。グローバル変数は、書き込む可能性のある他のモジュールすべてに対して閉じられなくなります。
RTTIの扱いが最も実務に効きます。論文は、dynamic_cast で図形の種類を順に判定して描き分けるコードは新しい種類が増えるたびに修正が要るので違反、一方で「正方形だけを取り出して描く」ために型を調べる関数は、種類が増えても変更が要らないので違反ではない、と分けています。経験則は「OCPに違反しないRTTIの使い方は安全」です。TypeScriptの instanceof や型ガードも同じ基準で判定でき、instanceof の連鎖で種類を全部列挙しているなら違反、特定の1種類だけを抜き出すフィルタなら問題ありません。
オープンクローズドの原則を適用すべきでない場面
マーチン自身が論文の「Strategic Closure(戦略的な閉鎖)」の節で、意味のある規模のプログラムで100%閉じたものはあり得ないと書いています。例として挙がるのは、図形の描画関数を抽象で閉じたあとに「円を必ず四角より先に描く」という要求が来るケースで、描画関数はこの変更には閉じていません。そのうえで「閉じることは完全にはできないので、戦略的でなければならない」と述べ、どの種類の変更に閉じるかを選ぶには経験に基づく先読みが要るとしています。ここでの closure は関数閉包のクロージャではなく「閉じること」です。
変更の軸がまだ分からない段階では、拡張点を増やしすぎない方針を勧めます。以下は本記事で提案する判断の目安で、OCPが定める必須条件ではありません。
- 同種の変更が2回目に来るまで閉じない:1回目は素直に既存コードを修正する。2回目で初めて変更の軸が観測されるので、そこで抽象を切る。
- 実装数だけでインターフェースの必要性を決めない:実装が1つでも、想定する変更から利用側を守る契約なら拡張点になる。テスト用のモックが目的なら、OCPではなくテストのしやすさを理由に正当化する。
- 変更頻度がゼロに近い領域では適用しない:保存済みデータを読むための旧版専用の変換など、対応する仕様を固定した処理を不要に拡張可能にしても、読む手間が増えるだけになる。
観測されていない変更に備えて抽象を先に置くのは投機的一般化と呼ばれるアンチパターンで、YAGNI(ヤグニ)原則が禁じている先回り実装にあたります。オープンクローズドの原則とYAGNIは対立して見えますが、実際には「閉じる軸が観測されたら閉じる、されていないなら閉じない」という同じ判断の表と裏です。論文の結びも、オブジェクト指向言語を使うだけでこの原則は満たされず、変更されると見込んだ部分に抽象を適用する設計者の判断が要る、としています。
他のSOLID原則・プラグイン設計との接続
SRP・LSP・DIPとの適用順序
本記事で勧める検討順序は、変更理由でクラスを分ける(SRP)、分かれた境界のうち変更が繰り返される側に抽象を置く(OCP)、上位モジュールをその抽象に依存させる(DIP)となります。SRPを飛ばしてOCPから入ると、何を境界にすべきかが決まらないまま抽象だけが増えるので、分割の粒度で迷うならSRP違反と過剰分割の見極め方を先に押さえてください。派生クラスが親の契約を破れば差し替えそのものが壊れるため、リスコフの置換原則(LSP)はOCPの前提条件にあたります。
実装パターンとしては、振る舞いを抽象へ切り出して差し替えるStrategyパターンが最も直接的な適用例です。生成処理を閉じたい場合はFactory系、既存クラスの前後に処理を足したい場合はDecoratorが対応し、全体の見取り図はGoFデザインパターン23種類の一覧で確認できます。
プラグインアーキテクチャという到達点
マーチンは2014年のブログで、Eclipse・IntelliJ・Visual Studio・Vim・Minecraft のように本体を修正も再デプロイもせずにプラグインで機能を足せるシステムを、オープンクローズドの原則の「究極の到達点」と呼びました。成立条件として挙げているのは依存の向きで、プラグインの中の依存はすべて本体を向き、本体からプラグインを向く依存は1つも無い、という形です。本体はプラグインを知らず、プラグインが本体を知っている。データベースやGUIまでプラグインとして差し替える設計に踏み込むなら、クリーンアーキテクチャの4層と依存性ルールが次の段階です。
よくある質問
オープンクローズドの原則は日本語で何と呼びますか?
「開放閉鎖の原則」「開放/閉鎖原則」「開閉原則」がいずれも同じ原則の訳語で、日本語版Wikipediaの項目名は「開放/閉鎖原則」です。英語は Open-Closed Principle、略称は OCP です。
オープンクローズドの原則は誰が提唱したのですか?
バートランド・メイヤーが1988年の著書『Object-Oriented Software Construction』で示したのが最初です。ロバート・C・マーチンは1996年1月号の C++ Report で抽象を前提とする形に定義し直した人物で、原則そのものの提唱者ではありません。SOLIDという頭字語を付けたのはマイケル・フェザーズで、2004年ごろです。
「修正に閉じる」とはバグ修正も禁止という意味ですか?
いいえ。マーチンの1996年の論文も2014年のブログも、対象にしているのは要件が変わったときに新しい振る舞いや機能を足す場面で、バグ修正には触れていません。正しく動いていないコードを直すのは拡張ではないので、この原則の外にあります。避けるべきなのは、機能を足すたびに既存の分岐へ手を入れる構造のほうです。
単一責任の原則(SRP)との違いは何ですか?
SRPは「どこで分けるか」、OCPは「分けた後どう拡張するか」を扱います。SRPが変更理由の異なるコードを別クラスへ分ける基準であるのに対し、OCPは分けた境界のどちら側を抽象にして固定するかを決める原則です。本記事では、変更理由を整理するSRPから検討する順序を勧めます。
すべてのクラスに最初からインターフェースを用意すべきですか?
いいえ。実装クラスが1つでも、契約に従う別実装を追加できれば拡張点として働きます。ただし、変更の見込みや契約を分離する理由がなければ、導入は見送ります。同じ種類の変更が2度目に発生した時点で抽象を切る運用にすると、必要な場所にだけ拡張点が残ります。