💨

【PHP】 オープン・クローズドの原則について OCP

に公開

はじめに

オープン・クローズドの原則は、既存の成果物を変更せず拡張できるようにすべき[1]とする原則です。
ここでは、「商品価格を計算する」処理を例に挙げてオープン・クローズドの原則について解説します。
まずはこの原則の違反例から見ていきます。

違反例

<?php

namespace Calculate;

use DateTimeImmutable;

class PriceCalculator
{
    /**
     * 商品価格を計算する
     * 簡単にするために消費税の計算は省略しています
     */
    public function calculate(int $basePrice, DateTimeImmutable $currentDate): int
    {
        if ($this->isChristmasPeriod($currentDate)) {
            // クリスマス限定割引を適用する
            // 3割引した価格を返す
            return $this->discountCalculate($basePrice, 0.3);
        }

        return $basePrice;
    }

    /**
     * クリスマス期間かどうかを判定する
     */
    private function isChristmasPeriod(DateTimeImmutable $currentDate): bool
    {
        $currentYear = (new DateTimeImmutable())->format('Y');
        $start = new DateTimeImmutable("{$currentYear}-12-24");
        $end = new DateTimeImmutable("{$currentYear}-12-26");

        return $currentDate >= $start && $currentDate <= $end;
    }

    /**
     * 割引後の価格を計算する
     */
    private function discountCalculate(int $basePrice, float $discountRate): int
    {
        $discountAmount = round($basePrice * $discountRate);
        return $basePrice - (int)$discountAmount;
    }
}

calculateメソッドでは商品価格を計算しています。もし、クリスマス期間中だったら3割引して、それ以外だったら割引せずにそのままの価格を返しています。
ここで、クリスマス期間中の割引だけでなく新年の場合も割引できるように改修することになりました。改修後のPriceCalculatorクラスを下記に示します。

<?php

namespace Calculate;

use DateTimeImmutable;

class PriceCalculator
{
    /**
     * 商品価格を計算する
     * 簡単にするために消費税の計算は省略しています
     */
    public function calculate(int $basePrice, DateTimeImmutable $currentDate): int
    {
        if ($this->isChristmasPeriod($currentDate)) {
            // クリスマス限定割引を適用する
            return $this->discountCalculate($basePrice, 0.3);
        } 
        
        if ($this->isNewYearPeriod($currentDate)) {
            // 条件分岐を追加
            // 新年割引を適用する
            return $this->discountCalculate($basePrice, 0.4);
        }

        return $basePrice;
    }

    /**
     * クリスマス期間かどうかを判定する
     */
    private function isChristmasPeriod(DateTimeImmutable $currentDate): bool
    {
        $currentYear = (new DateTimeImmutable())->format('Y');
        $start = new DateTimeImmutable("{$currentYear}-12-24");
        $end = new DateTimeImmutable("{$currentYear}-12-26");

        return $currentDate >= $start && $currentDate <= $end;
    }

    /**
     * 新年かどうかを判定する
     * こちらのメソッドも追加
     */
    private function isNewYearPeriod(DateTimeImmutable $currentDate): bool
    {
        $currentYear = (new DateTimeImmutable())->format('Y');
        $newYearDate = new DateTimeImmutable("{$currentYear}-01-01");
        return $currentDate->format('Y-m-d') === $newYearDate->format('Y-m-d');
    }

    /**
     * 割引後の価格を計算する
     */
    private function discountCalculate(int $basePrice, float $discountRate): int
    {
        $discountAmount = round($basePrice * $discountRate);
        return $basePrice - (int)$discountAmount;
    }
}

新年の場合は4割引する条件分岐を追加し、新年かどうかを判定するprivateメソッドも追加しました。

ここで、PriceCalculatorクラスの問題点についてまとめていきます。
PriceCalculatorクラスでは、期間限定の割引処理を追加する度に、calculateメソッドに条件分岐を追加する対応と、割引期間中かどうかを判定するprivateメソッドを追加する必要があります。そのため、期間限定の割引処理を追加するとPriceCalculatorクラス全体に変更の影響が出てしまいます。例えば、新年の場合の割引処理を追加すると、クリスマス期間中の割引処理にも少なからず変更の影響が出てしまいます。今回の場合は想像しづらいかもしれませんが、最悪の場合、商品価格を計算する処理自体に不具合が入り込んでしまい、正常に商品価格を計算できなくなってしまうこともありえます。

もっと複雑で規模が大きい処理を想像してみると分かりやすいと思います。複雑で規模が大きい処理の中に条件分岐を追加したりなどの行変更を行うと、他の部分に影響が出てしまうかもしれないのでリスクが高く変更が大変です。

この問題は、行変更をすることなく機能の拡張ができれば解決できます。今回の場合は、期間限定の割引処理を追加する対応を行っても、PriceCalculatorクラスを修正することなく割引処理を追加できれば、上記の問題を全て解決できます。

オープン・クローズドの原則の適用例

先ほどの「商品価格を計算する」処理を、オープン・クローズドの原則を考慮してリファクタリングしてみます。

<?php

namespace Calculate;

use DateTimeInterface;

class PriceCalculator
{
    public function __construct(
        private IDiscount $discount
    ) {}

    /**
     * 商品価格を計算する
     * 簡単にするために消費税の計算は省略しています
     */
    public function calculate(int $basePrice, DateTimeInterface $currentDate): int
    {
        if ($this->discount->canApply($currentDate))  {
            // 割引が適用可能な場合
            return $this->discountCalculate($basePrice);
        }

        return $basePrice;
    }

    /**
     * 割引後の価格を計算する
     */
    private function discountCalculate(int $basePrice): int
    {
        return $basePrice - $this->discount->discountAmount($basePrice);
    }
}


interface IDiscount
{
    /**
     * 割引額を取得する
     */
    public function discountAmount(int $basePrice): int;

    /**
     * 割引が適用可能かどうかを判断する
     */
    public function canApply(DateTimeInterface $currentDate): bool;
}
<?php

namespace Discount;

use Calculate\IDiscount;
use DateTimeImmutable;
use DateTimeInterface;


/**
 * クリスマス限定割引
 */
class ChristmasDiscount implements IDiscount
{
    private const DISCOUNT_RATE = 0.3;

    public function discountAmount(int $basePrice): int
    {
        return (int)round($basePrice * self::DISCOUNT_RATE);
    }

    public function canApply(DateTimeInterface $currentDate): bool
    {
        $currentYear = (new DateTimeImmutable())->format('Y');
        $start = new DateTimeImmutable("{$currentYear}-12-24");
        $end = new DateTimeImmutable("{$currentYear}-12-26");

        return $currentDate >= $start && $currentDate <= $end;
    }
}
// 使用例
$priceCalculater = new PriceCalculator(
    new ChristmasDiscount()
);
$priceCalculater->calculate(1000, new DateTimeImmutable());

CalculateパッケージとDiscountパッケージの依存関係
図1: CalculateパッケージとDiscountパッケージの依存関係
期間限定の割引を追加する場合に、変更する必要がある詳細部分をPriceCalculatorクラスから切り離して、IDiscountインターフェイスとして抽象化しました。そのため、PriceCalculatorクラスには、期間限定の割引を追加しても一切変更する必要がない本質部分しか残らなくなりました。
試しに、新年の場合の割引処理を追加してみます。

<?php

namespace Discount;

use Calculate\IDiscount;
use DateTimeImmutable;
use DateTimeInterface;

/**
 * 新年の場合の割引
 * クラスを追加
 */
class NewYearDiscount implements IDiscount
{
    private const DISCOUNT_RATE = 0.4;

    public function discountAmount(int $basePrice): int
    {
        return (int)round($basePrice * self::DISCOUNT_RATE);
    }

    public function canApply(DateTimeInterface $currentDate): bool
    {
        $currentYear = (new DateTimeImmutable())->format('Y');
        $newYearDate = new DateTimeImmutable("{$currentYear}-01-01");
        return $currentDate->format('Y-m-d') === $newYearDate->format('Y-m-d');
    }
}
// 使用例
$priceCalculater = new PriceCalculator(
    new NewYearDiscount() // 新年割引を適用する場合
);
$priceCalculater->calculate(1000, new DateTimeImmutable());

機能追加後のCalculateパッケージとDiscountパッケージの依存関係
図2: 機能追加後のCalculateパッケージとDiscountパッケージの依存関係
新年の場合の割引処理を追加する場合に、IDiscountインターフェイスを実装したNewYearDiscountクラスを1つ追加するだけで済んでいます。既存のPriceCalculatorクラスは一切変更していません。そのため、新たな割引処理を追加しても、既存の他の割引処理や商品価格を計算する処理に変更の影響が出てしまうことは確実にないと言い切ることができます。

このように、既存の処理を変更することなく、新たなクラスなどの処理を追加して仕様変更や機能の拡張を行えるようにすることで、変更のリスクを低減でき変更容易性を高めることができます。これが、オープン・クローズドの原則がある理由です。

まとめ

既存の成果物を変更せず拡張できるようにすべき[1:1]とするのがオープン・クローズドの原則です。この原則を守って、仕様変更や機能の拡張をする際も、既存の処理を行変更するのではなく、新たな処理を追加して変更を行えるようにすることで、変更のリスクを低減でき変更容易性を高めることができます。

脚注
  1. Robert C. Martin他, Clean Architecture, 2018年7月27日, 株式会社ドワンゴ, P87 ↩︎ ↩︎

Discussion