OCP: 開放閉鎖の原則 (Open-Closed Principle)¶
原則の正確な意味¶
ソフトウェアの構成要素は、拡張に対して開いていて(open for extension)、修正に対して閉じている(closed for modification)べきである。
「新しい種類・振る舞いの追加が、既存コードの変更なし(新しいコードの追加だけ)でできる構造にせよ」という意味です。既存コードを一切変更するなという意味ではなく、予想される種類の追加に対して変更が閉じていればよい、という原則です。
よくある誤解¶
- 「既存コードは絶対に修正してはいけない」 — 不可能です。バグ修正も仕様変更も修正です。OCPが対象にするのは「同種の追加が毎回既存コードの修正になる」状況だけです。
- 「あらゆる変更に備えて抽象化しておく」 — 変更の軸は無限にあり、全方向に開くことはできません。実際に追加が繰り返された軸にだけ開くのが実務です。
- 「if/switchがあったらOCP違反」 — 増えないswitchは違反ではありません。
違反している小さなコード例(C++版)¶
// C++20(問題例)
enum class SkillType { Fireball, Heal, Dash };
void CastSkill(SkillType type, Player& caster) {
switch (type) {
case SkillType::Fireball: /* 弾を生成してダメージ */ break;
case SkillType::Heal: /* HP回復 */ break;
case SkillType::Dash: /* 高速移動 */ break;
}
}
float GetCooldown(SkillType type) { switch (type) { /* また全種類 */ } return 0; }
int GetManaCost(SkillType type) { switch (type) { /* また全種類 */ } return 0; }
スキルを1つ追加するたびに、enum・CastSkill・GetCooldown・GetManaCost の4か所以上の既存コードを修正します。追加が「修正」になっている=閉じていない状態です。
改善後のコード例(C++版)¶
// C++20
#include <memory>
#include <string>
#include <vector>
class ISkill {
public:
virtual ~ISkill() = default;
virtual void Cast(Player& caster) = 0;
virtual float Cooldown() const = 0;
virtual int ManaCost() const = 0;
};
class Fireball : public ISkill {
public:
void Cast(Player& caster) override { /* 弾を生成してダメージ */ }
float Cooldown() const override { return 3.0f; }
int ManaCost() const override { return 10; }
};
// スキル追加 = 新クラスを1つ書いて登録するだけ。既存コードは無変更
class SkillList {
public:
void Register(std::unique_ptr<ISkill> skill) { skills_.push_back(std::move(skill)); }
// ... 選択・発動処理は ISkill 経由なので種類を知らない
private:
std::vector<std::unique_ptr<ISkill>> skills_;
};
何が変わったか: 「1スキルの知識」が1クラスに集まり(SRP)、追加が新クラスの追加だけで済むようになった(OCP)。仕組みはポリモーフィズム(→ 第1部)。
なおOCPを満たす手段は仮想関数だけではありません。テンプレート(コンパイル時)、std::functionの登録、データ駆動(スキルをデータで記述 → Type Object)でも同じ性質を実現できます。
C#版(Unityでの例)¶
// 改善版: ScriptableObjectでスキルを「アセットの追加」にする(Unityの定石)
public abstract class Skill : ScriptableObject {
public float cooldown;
public int manaCost;
public abstract void Cast(PlayerContext caster);
}
[CreateAssetMenu(menuName = "Skills/Fireball")]
public class FireballSkill : Skill {
[SerializeField] private GameObject projectilePrefab;
public override void Cast(PlayerContext caster) {
Object.Instantiate(projectilePrefab, caster.FirePoint, caster.AimRotation);
}
}
スキル追加が「新しい .asset ファイルと1クラスの追加」になり、既存のスキル発動コードは無変更。企画がコードを触らずスキルを増やせるところまで到達するのがゲーム開発でのOCPの実利です。
ゲーム開発での例¶
- 実績システム: 実績の種類追加が条件クラス/データの追加だけで済むか → ケーススタディ: 実績システム
- ステータス効果(毒・氷結・燃焼)の追加 → ケーススタディ: ステート異常
- ドロップテーブル、クエスト条件、ダメージ修飾など「運用中に種類が増え続けるもの」全般
原則同士の関係¶
- OCPの実現手段がポリモーフィズムのとき、派生型が約束を破ると成立しない → LSP はOCPの前提条件。
- 追加の受け口(抽象)を呼ぶ側に持たせる構造は DIP と同じ形になる。
- Strategy / Decorator / Observer など多くのGoFパターンは「特定の軸に対してOCPを満たす定型」と読める。
適用しすぎた場合の弊害¶
- 開く軸を外す: 「スキルの種類」に開いたのに、実際の変更は「発動条件の複雑化」だった——抽象が的外れだと、抽象の作り直し+全実装の修正でむしろ高くつく。
- 早すぎる抽象化: 種類が2つの段階で将来の追加を見込んで抽象を作ると、大抵インターフェースの形を誤ります。3回目の追加で抽象化が経験則(→ YAGNI)。
- 可読性の喪失: 全種類がswitchで1画面に並ぶコードは、初見には抽象階層より速く読めます。少種類・低頻度ならswitchが正解。
コードレビュー用チェックリスト¶
- [ ] このswitch/if連鎖は過去に何回「同種の追加」で修正されたか?(2回以上なら開く候補)
- [ ] 追加1件に必要な既存ファイルの修正箇所は何か所か?(0が理想、1(登録箇所)は許容)
- [ ] 開いた軸は、実際に追加が来ている軸と一致しているか?
- [ ] 抽象を導入した場合、種類を横断して見たいもの(バランス表など)を見る手段は残っているか?
- [ ] データ駆動(テーブル追加)で済むものをクラス階層にしていないか?
理解度確認問題¶
- OCPの「閉じている」は何に対して閉じているのですか。バグ修正は違反ですか。
- switchのままでよいのはどんな条件のときですか。
- OCPを仮想関数以外で実現する手段を2つ挙げてください。
- 「全方向に開いた設計」が不可能なのはなぜですか。
解答の要点
1. 「同種の追加」による修正に対して。バグ修正は対象外(違反ではない)。 2. 種類が少なく安定している/追加が予想されない/種類横断の一覧性の方が価値がある場合。 3. テンプレート(コンパイル時多態)、std::function等の登録制、データ駆動(Type Object)。 4. 変更の軸は無限にあり、各軸に開くたび抽象と間接化のコストを払うため。実際に変更が来る軸の予想にしか投資できない。関連項目¶
演習¶
上の ISkill 改善版に「発動時に他スキルのクールダウンを全リセットする」スキルを追加しようとすると、インターフェースの形が問題になります。何が問題で、どう直すか(2案)を考えてください。ヒント: スキルが「他のスキル一覧」を知る必要が生まれる=開いた軸の外の変更。
前: SRP | カテゴリ目次 | 次: LSP: リスコフの置換原則