🚀

【SOLID原則(OCP編)】開発現場最恐のジレンマが解決!?拡張性は「本質」の見極めが決め手

に公開

はじめに

前回はSRPを学びました。

そこでは各クラスの責務はたった1つに適切に規定しなければ、あっという間にコードの取り合いや冗長な防衛コードで溢れかえってしまう状況を見てきました。

今回は、開発者が日常的に抱える最も深刻なジレンマについての原則です。

開発現場最恐のジレンマとは?

「機能追加はどんどんしたい。でも、今動いているコードには指一本触れたくない」

矛盾しているように聞こえるこのジレンマを解決する魔法、それがOCPです。

OCP: 「ソフトウェアの構成要素は、拡張に対してオープン(開いている)であり、変更に対してクローズ(閉じている)であるべき」

OCPを「プラグイン」とイメージする

この原則を直感的に理解するために、「Webブラウザと拡張機能(プラグイン)」の関係を想像してください。

あなたはブラウザに好きな拡張機能をインストールして、機能をどんどん追加できます(Open)。

しかし、拡張機能を追加するたびに、ブラウザ本体のソースコードを書き換えたり、ブラウザを作り直したりする必要はあるでしょうか?

ありませんね。

ブラウザ本体は、拡張機能の追加という変更の影響を受けません(Closed)

このような、「本体(本質)」と「プラグイン(仕様)」の関係を、あなたのコードの中に作り出すこと。

これがOCPのゴールです。

具体的なコードの状態

  • Open(拡張に開いている):
新しい機能(仕様)を追加するとき、既存のコードを書き換えるのではなく、新しいクラス(ファイル)を追加するだけで済む状態。
  • Closed(変更に閉じている):
新しいクラスを追加しても、呼び出し元のメインロジック(本質)は一切修正しなくていい状態。

「ベタ付け」が開発プロセスを破綻させる ~FizzBuzzの失敗~

では、OCPに違反したらどうなるのか?

簡単なFizzBuzzの実装を通じて、その「災難」を体感してみましょう。

FizzBuzzの機能要件:

  • 整数の入力値をあるルールに従って変換表示する
  • 3の倍数なら "Fizz"
  • 5の倍数なら "Buzz"
  • 両方なら "FizzBuzz"
  • それ以外は入力値をそのまま表示

ベタ付けの実装(OCP違反)

要件に単純に従って、if文を連ねて実装してみます。

func fizzBuzz(num int) string{
	if num % 3 == 0 && num % 5 == 0{
		return "FizzBuzz"
	}else if num % 3 == 0{
		return "Fizz"
	}else if num % 5 == 0{
		return "Buzz"
	}else{
		return strconv.Itoa(num)
	}
}

「ベタ付け」に隠された時限爆弾

このコードは動きます。しかし、ここには 「時限爆弾」 が埋まっています。

ここで新たな機能が追加されることを想像してください。

新たな機能内容:「7の倍数だったらJazzと表示する」

このために既存の関数の中にあるif文のロジックにメスを入れ、コードを 「手術」 しなければなりません。

手術における2つのリスク

  1. 既存機能の破壊リスク:
if-elseの複雑な連鎖をいじることで、これまで動いていた「3の倍数」の判定ロジックをうっかり壊してしまうかもしれません。

  2. テストの崩壊:
たった1つのルールを追加しただけなのに、この関数全体が書き換わったため、既存の全てのルール(Fizz, Buzz, そのまま...)に対して再テストが必要になります。

機能が増えれば増えるほど、この関数は肥大化し、近寄りたくない恐怖の「開かずの間」となっていきます。

これがOCP違反の末路です。

仕様と抽象を分離する ~FizzBuzzの再生~

このジレンマを解決するには、「仕様(変わりやすいもの)」を削ぎ落とし、「本質(変わらないもの)」だけを残す必要があります。

恣意的なもの?本質の抽出

まず、FizzBuzzという機能を冷静に分析し 「仕様」と「本質」 に分解します。

仕様(Specification) = 開発者や要望によって恣意的に決められた内容

  • 「3」や「5」という数字
  • 「Fizz」や「Buzz」という文字列
  • これらは、明日にも変わる可能性があります。

本質(Essence) = この機能が成立するための土台・仕組み

  • 整数を入力として受け取る
  • 変換ルールは複数存在するかもしれない
  • ルールを順番に適用し、結果を累積する
  • 最終的な文字列を返す
  • これらは、ルールの中身が「7」になろうが「Jazz」になろうが、変わりません。

OCPを適用した構造

この分析に基づき、パッケージを2つに分けます。

  • coreパッケージ(本質): ルールを順次適用する「ルールエンジン」の仕組みだけを持つ。具体的な「3」や「Fizz」のことは一切知らない。
  • specパッケージ(仕様): 具体的なルール(CyclicnumberRule, PassThroughRule)を持つ。

UML図:

実装のイメージ

上記のような構成にすることで、コードは劇的に変わります。

まずは、本質(core)のコードです。

core/converter.go
package core

type Converter struct {
	Rules []Rule
}

func NewConverter(rules []Rule) *Converter {
	return &Converter{Rules: rules}
}

func (f Converter) Execute(inputNumber int) string {
	out := ""
	for _, rule := range f.Rules {
		if rule.Match(inputNumber, out) {
			out = rule.Apply(inputNumber, out)
		}
	}

	return out
}

core/rule.go
package core

type Rule interface{
	Match(n int, carry string) bool
	Apply(n int, carry string) string
}

以下が、仕様(spec)の実装です。

spec/cyclicNumberRule.go
package spec

type CyclicNumberRule struct{
	base int
	replacement string
}

func NewCyclicNumberRule(n int, replacement string) *CyclicNumberRule{
	return &CyclicNumberRule{
		base :n,
		replacement: replacement,
	}
}

func (c CyclicNumberRule) Match(n int, carry string) bool{
	return n % c.base == 0
}

func (c CyclicNumberRule) Apply(n int, carry string) string{
	return carry + c.replacement
}
spec/passThroughRule.go
package spec

import "strconv"

type PassThroughRule struct{

}

func NewPassThroughRule() *PassThroughRule{
	return &PassThroughRule{}
}

func (p PassThroughRule) Match(n int, carry string) bool{
	return carry == ""
}

func (p PassThroughRule) Apply(n int, carry string) string{
	return strconv.Itoa(n)
}

最終的にmain.goで使ってみます。手順はたったの4ステップ

  1. match, applyを実装した具象クラスインスタンスを作成
  2. インターフェースのリストに格納
  3. Converterクラスインスタンスにインターフェースを持たせる
  4. Converterクラスメソッドであるexecuteに入力値を渡す
main.go
package main

import (
	"fmt"
	"FizzBuzz/core"
	"FizzBuzz/spec"
)

func main() {
	var rules []core.Rule

	rules = append(rules, spec.NewCyclicNumberRule(3, "Fizz")) // 3で割り切れる時の処理
	rules = append(rules, spec.NewCyclicNumberRule(5, "Buzz")) // 5で割り切れる時の処理
	rules = append(rules, spec.NewPassThroughRule())           // 全ての条件が合致しない時の条件式・処理

	converter := core.NewConverter(rules)

	fmt.Println(converter.Execute(3))  //Fizzと返ってくる
	fmt.Println(converter.Execute(5))  //Buzzと返ってくる
	fmt.Println(converter.Execute(15)) //FizzBuzzと返ってくる
	fmt.Println(converter.Execute(4))  //4が返ってくる
}

この構造にしたことで、先ほどの「7の倍数ならJazz」という追加要望はどうなるでしょうか?更に、「JazzはBuzzの前に必ず来るようにする」という希望にも応えてみましょう。

main.go
...
	var rules []core.Rule
	rules = append(rules, spec.NewCyclicNumberRule(3, "Fizz")) // 3で割り切れる時の処理
        rules = append(rules, spec.NewCyclicNumberRule(7, "Jazz")) // 7で割り切れる時の処理
	rules = append(rules, spec.NewCyclicNumberRule(5, "Buzz")) // 5で割り切れる時の処理
	converter := core.NewConverter(rules)
	fmt.Println(converter.Execute(3))  //Fizzと返ってくる
	fmt.Println(converter.Execute(35)) //JazzBuzzと返ってくる
...

coreパッケージのメインロジックは、1行も書き換える必要がありません。もちろん、再テストも不要です

まるでブラウザに新しいプラグインを入れるように、安全かつ簡単に機能を拡張できるようになりました。

これこそが、OCPが目指す世界です。

本質を救い出すコツ

最後に、この「本質(Core)」をどうやって見極めれば良いのでしょうか?

ここで、パッケージ原則 SAP(安定抽象の原則) を思い出してください。

「抽象的であるほど、安定する」

本質を抽出する際の最大のヒントは 「このプログラムが長期運用されるとしたら、何が変わり、何が変わらないだろう?」 と問いかけることです。

  • 変わりやすいもの(仕様) は、具象クラスとして外側に追い出す。
  • 変わらないもの(本質) は、抽象(インターフェース)や制御ロジックとして中心に据える。

そして、「変わりやすい仕様」が「変わらない本質」に依存する形を作ること。

これはパッケージ原則の SDP(安定依存の原則) を守ることにもなり、結果としてシステム全体が堅牢になります。

https://zenn.dev/hashidev/articles/8ec55cc6c23c66

OCPとは、単なるコーディングテクニックではありません。

プロダクトの「本質」を見極めて変化の波から守り抜くための、設計者の哲学なのです。

参考文献:「ちょうぜつソフトウェア設計入門――PHPで理解するオブジェクト指向の活用 著: 田中ひさてる」

Discussion