PHPで考える開放閉鎖原則
開放/閉鎖原則(OCP:The Open-Closed Principle)とは、オープン・クローズドの原則とも呼ばれる、オブジェクト指向設計の原則のひとつです。
単一責任の原則と同じく、SOLID原則にまとめられています。
ソフトウェアの構成要素は、拡張に対して開いていて、修正に対し閉じていなければならない。
本稿では、PHPのソースコードを用いながら、この原則(以下OCP)が意味するところについて考えます。
「開いている」「閉じている」とは
- 拡張に対して開かれている(Open)
あるモジュールが拡張可能である場合、そのモジュールは拡張に対して開かれている(Open)と言います。 - 修正に対して閉じている(Closed)
あるモジュールが修正に対してソースコードに影響を受けない場合、そのモジュールは修正に対して閉じている(Closed)と言います。
まとめると 「ソースコードを修正せずにモジュールを拡張可能にすべき」という意味になります。
つまるところソフトウェアの機能の拡張というのは、既存のソースコードの「変更」ではなく「追加」によってなされるべきだということです。
OCP違反の例
クラス Mario
を例に考えてみます。
<?php class Mario { private $status = 'normal'; public function __construct($status) { $this->status = $status; } public function jump () { echo 'jump!'; } public function attack() { switch ($this->status) { case 'fire': echo 'fireeeee!!!'; break; case 'hammer': echo 'hammerrr!!!'; break; default: echo '...'; break; } } public function miss() { if($this->status !== 'normal'){ echo 'mamma mia...'; } echo 'game over'; } }
Mario はプロパティ status
に応じて、火やハンマーで攻撃したりあるいは何もしなかったりします。また、missしたときにゲームオーバーか通常 Mario になるかを判定します。
この Mario に対し新たな種類の status を追加する場合、attack
メソッドや miss
メソッドに変更を加える必要があります。修正に対して閉じていないためOCP違反です。
status の種類が少ないうちは良いですが、その数が膨大(Wikipedia に載っているだけでも40種類以上あるそうです)になると、attack や miss の処理は複雑になりそうです。また、改修にともない既存の他の status の振る舞いに影響を与えかねず、極めて不安定なソースコードとなります。
interface を使った解決案
この Mario
を、変更でなく、追加で機能拡張できるようにします。
interface
を使い、各status に共通する振る舞いを抽象化してみます。
<?php interface MarioStatus { public function attack(); public function miss(); }
続いて、上記の interface に対応するよう Mario クラスを変更します。
<?php class Mario { private $status; public function __construct(MarioStatus $status) { $this->status = $status; } public function jump () { echo 'jump!'; } public function attack() { $this->status->attack(); } public function miss() { $this->status->miss(); } }
attack
miss
内部に書かれていた実装を MarioStatus に移譲しました。 これにより、Mario クラスは修正を行うことなく(=閉じたまま)、status による振る舞いを追加できる(=開いている)ようになります。
あとは MarioStatus
インターフェースを実装すれば、随時 Mario クラスの振る舞いを拡張できます。
<?php // 通常マリオ class NormalMario implements MarioStatus { public function attack() { echo '...'; } public function miss() { echo 'game over'; } } // ファイアマリオ class FireMario implements MarioStatus { public function attack() { echo 'fireee!!!'; } public function miss() { echo 'mamma mia..'; } } // ハンマーマリオ class HammerMario implements MarioStatus { public function attack() { echo 'hammerrrr!!!'; } public function miss() { echo 'mamma mia..'; } } // 無敵マリオを追加 class InvincibleMario implements MarioStatus { public function attack() { echo 'I am invincible.'; } public function miss() { echo 'I am invincible.'; } }
戦略的に閉じる
上記のソースコードでは、attack
と miss
に対し変更を加えることなく機能を拡張できるようになりました。
一方で、 jump
の振る舞いを変更しなければならなくなった場合(尻尾のついた Mario など)には、既存のソースコードに変更を加える必要があります。この場合、やはりOCP違反となります。
MarioInterface に あらかじめ jump
を加えておけばよさそうにも思えますが、変更が発生しないうちはクラスにすべて同じ jump
を実装するという不必要な複雑さを招きます。また、そもそも 変更が発生しなかった場合、この対策はまったく無意味です。
変更が確実に発生しない不確かなものである以上、あらゆるケースに完璧に閉じることはできません。
この問題について、Robert C.Martin は「最初の鉛玉は甘んじて食らってみる」という面白い例えをしています。あらゆる要求に対応しようとするのではなく、予測不可能な部分については実際に変更が発生してからOCPを適用するという方針です。早まった設計を行わず、戦略的に閉じていくのが重要といえそうです。