技術メモなど

業務や日々のプログラミングのなかで気になったことをメモしています。PHP 成分多め。

PHPで考える開放閉鎖原則

開放/閉鎖原則(OCP:The Open-Closed Principle)とは、オープン・クローズドの原則とも呼ばれる、オブジェクト指向設計の原則のひとつです。
単一責任の原則と同じく、SOLID原則にまとめられています。

ソフトウェアの構成要素は、拡張に対して開いていて、修正に対し閉じていなければならない。

本稿では、PHPソースコードを用いながら、この原則(以下OCP)が意味するところについて考えます。

「開いている」「閉じている」とは

  1. 拡張に対して開かれている(Open)
    あるモジュールが拡張可能である場合、そのモジュールは拡張に対して開かれている(Open)と言います。
  2. 修正に対して閉じている(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.';
    }
}

戦略的に閉じる

上記のソースコードでは、attackmiss に対し変更を加えることなく機能を拡張できるようになりました。
一方で、 jump の振る舞いを変更しなければならなくなった場合(尻尾のついた Mario など)には、既存のソースコードに変更を加える必要があります。この場合、やはりOCP違反となります。
MarioInterface に あらかじめ jump を加えておけばよさそうにも思えますが、変更が発生しないうちはクラスにすべて同じ jump を実装するという不必要な複雑さを招きます。また、そもそも 変更が発生しなかった場合、この対策はまったく無意味です。
変更が確実に発生しない不確かなものである以上、あらゆるケースに完璧に閉じることはできません。 この問題について、Robert C.Martin は「最初の鉛玉は甘んじて食らってみる」という面白い例えをしています。あらゆる要求に対応しようとするのではなく、予測不可能な部分については実際に変更が発生してからOCPを適用するという方針です。早まった設計を行わず、戦略的に閉じていくのが重要といえそうです。

まとめ

  • OCP違反のソースコードは改修が困難になるおそれがある。
  • 機能を拡張する場合、ソースコードを変更するのではなく、追加で対応できるようにしておく。
  • あらゆるケースに対応しようとしない。戦略的に適用する。