【PHP】リスコフの置換原則について LSP
はじめに
リスコフの置換原則は、「派生クラスは、その基底クラスと完全に置き換え可能でなければいけない」とする原則です
「価格を計算する」処理を例に挙げてこの原則について解説します。
まずはこの原則の違反例から見ていきます。
違反例
class PriceCalculator
{
public function __construct(protected float $taxRate = 0.1) {}
public function calculate(int $basePrice): int
{
$tax = floor($basePrice * $this->taxRate);
return (int)($basePrice + $tax);
}
}
calculateメソッドでは税込み価格を計算しています。
PriceCalculatorクラスのクライアント側の処理を下記に記載します。
function showPrice(PriceCalculator $calculator, int $price)
{
$finalPrice = $calculator->calculate($price);
echo "価格は{$finalPrice} 円です。";
}
showPrice(new PriceCalculator(), 1000); // 価格は1100 円です。
ここで、一律500円OFFキャンペーンをすることになりました。
クライアント側の処理はそのまま使いたかったのと、元の価格計算処理は修正せずにそのまま残したかったため、500円引きする処理をPriceCalculatorクラスを継承してcalculateメソッドをオーバーライドすることにより実装しました。
class DiscountedPriceCalculator extends PriceCalculator
{
private const DISCOUNT_AMOUNT = 500;
public function calculate(int $basePrice): int
{
$discountedPrice = $basePrice - self::DISCOUNT_AMOUNT;
$tax = floor($discountedPrice * $this->taxRate);
return (int)($discountedPrice + $tax);
}
}
showPrice(new DiscountedPriceCalculator(), 1000); // 価格は550 円です
showPriceメソッドを修正せずに500円引の価格が表示されるように変更できましたが、ここで問題が発生しました。
元の価格が500円未満の場合に、マイナスの価格が表示されるようになってしまったのです。
// 500円未満の場合にマイナスの価格が表示されるようになってしまった
showPrice(new DiscountedPriceCalculator(), 400); // 価格は-110 円です。
この問題は、間違った継承をしてしまっていることが原因で起きてしまいました。
本来であれば、DiscountedPriceCalculatorクラスはPriceCalculatorクラスと完全に置き換え可能になっていなければいけません。しかし、今回の場合、PriceCalculatorクラスを使っている箇所をDiscountedPriceCalculatorクラスに置き換えたところ、価格がマイナスになってしまう場合があり、プログラムの前提が崩れてしまいました。これでは、DiscountedPriceCalculatorクラスとPriceCalculatorクラスは完全に置き換え可能とは言えません。
継承を使う場合は、基底クラスの事前条件と事後条件を逸脱してはいけません[1]。逸脱してしまうと、今回の場合のように予期しない不具合に繋がってしまいます。
リスコフの置換原則の適用例
DiscountedPriceCalculatorクラスが、PriceCalculatorクラスと完全に置き換え可能となるように修正したいと思います。まず、一番簡単な修正例を下記に記載します。
class DiscountedPriceCalculator extends PriceCalculator
{
private const DISCOUNT_AMOUNT = 500;
public function calculate(int $basePrice): int
{
// 割引後の価格が0円未満にならないようにする
$discountedPrice = max(0, $basePrice - self::DISCOUNT_AMOUNT);
$tax = floor($discountedPrice * $this->taxRate);
return (int)($discountedPrice + $tax);
}
}
showPrice(new DiscountedPriceCalculator(), 400); // 価格は0 円です。
割引後の価格が0円未満にならないように、calculateメソッドを修正しました。
これで、PriceCalculatorクラスをDiscountedPriceCalculatorクラスに置き換えても、マイナスの価格が表示されてしまうというプログラムの前提が崩れてしまうことはなさそうです。
より良い修正例
リスコフの置換原則とは話がそれてしまいますが、より良い修正例について一応まとめておきます。
今回は、継承とオーバーライドを使って元の価格から500円引きする処理を実装しました。しかし、本来であればこのように密結合な継承を無暗に使ってしまうことは避けるべきです。継承ではなく、より疎結合なコンポジションを使うことをまず検討するべきです。
コンポジションを使って、先ほどの500円引きする処理を実装した例を下記に記載します。
<?php
namespace Calculator;
class PriceCalculator
{
public function __construct(
private IDiscountCalculator $discountCalculator,
private float $taxRate = 0.1
) {}
public function calculate(int $basePrice): int
{
$discountedPrice = $this->discountCalculator->calculate($basePrice);
$tax = floor($discountedPrice * $this->taxRate);
return $discountedPrice + $tax;
}
}
interface IDiscountCalculator
{
public function calculate(int $basePrice): int;
}
<?php
namespace Discount;
use Calculator\IDiscountCalculator;
class FiveHundredYenDiscountCalculator implements IDiscountCalculator
{
private const DISCOUNT_AMOUNT = 500;
public function calculate(int $basePrice): int
{
return max(0, $basePrice - self::DISCOUNT_AMOUNT);
}
}
showPrice(
new PriceCalculator(new FiveHundredYenDiscountCalculator()),
1000
); // 価格は550 円です
showPrice(
new PriceCalculator(new FiveHundredYenDiscountCalculator()),
400
);// 価格は0 円です
割引価格を計算する部分をIDiscountCalculatorインターフェイスとして抽象化しました。そして、PriceCalculatorクラスがこのインターフェイスを内部で保持するように修正しました。
このように修正すると、割引価格を変更したい場合に、PriceCalculatorクラスを一切修正せずに変更することが可能になります。継承よりもコンポジションを使った方がより疎結合になり、変更容易性を高めることができます。
まとめ
リスコフの置換原則は、正しい継承とは何かを述べた原則[1:1]と言われています。リスコフの置換原則を守って、派生クラスと基底クラスが完全に置き換え可能にすることで、想定外の不具合を減らすことができます。
Discussion