継承とコンポジション
クラス同士に関係を持たせる方法には、継承とコンポジションの2つがあります。 この章では、それぞれをどう使い分けるかを見ていきます。
継承(is-a関係)
継承は、あるクラスの性質を別のクラスが引き継ぐ仕組みです。
PHPではextendsを使います。
abstract class PaymentMethod
{
abstract public function pay(int $amount): void;
public function logPayment(int $amount): void
{
error_log("決済: {$amount}円");
}
}
class CreditCardPayment extends PaymentMethod
{
public function pay(int $amount): void
{
// クレジットカード決済処理(省略)
}
}
CreditCardPaymentはPaymentMethodを継承しているので、logPaymentメソッドをそのまま使えます。
「CreditCardPaymentはPaymentMethodである」という関係が自然に成り立つとき、継承を使います。
これをis-a関係と呼びます。
コンポジション(has-a関係)
コンポジションは、あるクラスが別のクラスをプロパティとして持つ仕組みです。
class Order
{
public function __construct(
private readonly PaymentMethod $paymentMethod,
) {
}
public function checkout(int $amount): void
{
$this->paymentMethod->pay($amount);
}
}
Orderは決済方法そのものではなく、決済方法を「持っている」だけです。
「OrderはPaymentMethodを持つ」という関係を、has-a関係と呼びます。
OrderがPaymentMethodを継承する(Order extends PaymentMethod)のは不自然です。
注文は決済方法の一種ではないからです。
継承よりコンポジションが優先される理由
is-a関係が成り立つ場面でも、継承には弱点があります。 通知機能を例に考えてみます。 送信チャネル(メール・SMS)と、緊急度(通常・緊急)という2つの軸があるとします。
継承だけでこれを表現しようとすると、次のようになります。
abstract class Notification
{
abstract public function send(string $message): void;
}
class EmailNotification extends Notification
{
public function send(string $message): void { /* ... */ }
}
class SmsNotification extends Notification
{
public function send(string $message): void { /* ... */ }
}
class UrgentEmailNotification extends EmailNotification
{
public function send(string $message): void { /* 緊急扱いのメール送信 */ }
}
class UrgentSmsNotification extends SmsNotification
{
public function send(string $message): void { /* 緊急扱いのSMS送信 */ }
}
軸が2つ(チャネル・緊急度)なら4クラスで済みますが、軸が1つ増えるたびに、クラス数はかけ算で増えていきます。 この「組み合わせの爆発」は、継承の弱点として、GoF(Gang of Four)の『デザインパターン』でも指摘されています1。
コンポジションを使うと、軸ごとに部品を分けられます。
enum Urgency
{
case Normal;
case Urgent;
}
interface NotificationChannel
{
public function send(string $message): void;
}
class EmailChannel implements NotificationChannel
{
public function send(string $message): void { /* ... */ }
}
class SmsChannel implements NotificationChannel
{
public function send(string $message): void { /* ... */ }
}
class Notification
{
public function __construct(
private readonly NotificationChannel $channel,
private readonly Urgency $urgency,
) {
}
public function send(string $message): void
{
$prefix = $this->urgency === Urgency::Urgent ? '【緊急】' : '';
$this->channel->send($prefix . $message);
}
}
送信チャネルと緊急度を、それぞれ独立した部品として持たせています。
チャネルの種類を1つ増やしたい場合も、NotificationChannelを実装したクラスを1つ追加するだけで済み、クラス数がかけ算で増えることはありません。
まとめ
- 「AはBである」が自然に成り立つとき(is-a関係)、継承を使う
- 「AはBを持つ」という関係のとき(has-a関係)、コンポジションを使う
- is-a関係が成り立つ場面でも、軸が複数あると継承はクラス数の組み合わせ爆発を起こしやすい
- コンポジションで軸ごとに部品を分けると、軸が増えてもクラス数は足し算で済む
次に読む
次章では、インターフェースと抽象への依存を扱います。 契約に基づく設計と、依存性逆転の入り口を見ていきます。
練習問題
次のクラス群を、継承からコンポジションを使った設計に書き換えてください
abstract class Discount
{
abstract public function apply(int $price): int;
}
class MemberDiscount extends Discount
{
public function apply(int $price): int
{
return (int) ($price * 0.9);
}
}
class SeasonalMemberDiscount extends MemberDiscount
{
public function apply(int $price): int
{
return (int) ($price * 0.9 * 0.8);
}
}
会員割引(10%引き)と、季節限定割引(さらに20%引き)を、独立に組み合わせたい状況を想定してください。
解答例
interface DiscountRate
{
public function rate(): float;
}
class MemberDiscountRate implements DiscountRate
{
public function rate(): float
{
return 0.9;
}
}
class SeasonalDiscountRate implements DiscountRate
{
public function rate(): float
{
return 0.8;
}
}
class Discount
{
private array $rates = [];
public function addRate(DiscountRate $rate): void
{
$this->rates[] = $rate;
}
public function apply(int $price): int
{
foreach ($this->rates as $rate) {
$price = (int) ($price * $rate->rate());
}
return $price;
}
}
SeasonalMemberDiscountのような組み合わせ専用クラスを新設しなくても、addRateで割引の種類を自由に組み合わせられます。
新しい割引の種類を追加したいときも、DiscountRateを実装したクラスを1つ足すだけで済みます。