PHPで深い継承ツリーを作っていませんか?アンチパターンを回避するコンポジションの基本

PHPにおける深い継承ツリーのアンチパターンと、コンポジションによる柔軟な設計への移行方法を解説するプログラミング記事のアイキャッチ画像 アーキテクチャ

PHPの開発現場において、多層の継承ツリーが安易に構築されているケースをしばしば目にします。
しかし、深い継承階層はオブジェクト指向設計において重大な構造的脆弱性を孕んでいます。
継承は型階層の表現に適したメカニズムですが、コードの再利用や振る舞いの共有を目的とした濫用は、いわゆる「壊れやすい基底クラス問題」を引き起こす典型的なアンチパターンです。

深い継承はクラス間に強固なコンパイル時依存関係を形成します。
基底クラスの実装詳細に対する変更が、派生クラス全体に連鎖的な影響を及ぼすため、カップリングが増大し、コードの予測可能性とテスト容易性が著しく低下するのです。
これは単なる好みの問題ではなく、ソフトウェア工学において明確に立証された設計上の欠陥です。

この構造的課題を解決する鍵となるのが、コンポジションです。
コンポジションは「has-a」関係に基づき、オブジェクトが他のオブジェクトを内部に保持して機能を委譲することで、柔軟で結合の緩やかな構造を実現します。

  • 継承はクラス間に強い依存関係を生み、変更の影響範囲を予測しにくくします
  • コンポジションはインターフェースに対するプログラミングを促進し、実装の詳細を適切に隠蔽します
  • 実行時の振る舞いの変更が可能となり、ポリモーフィズムの利点を損なわずに拡張性が向上します

以下は、継承に依存した典型的なアンチパターンの一例です。

class Controller {}
class BaseAdminController extends Controller {}
class UserAdminController extends BaseAdminController {}
class PremiumUserAdminController extends UserAdminController {}

この構造は一見論理的に見えますが、各階層で状態やメソッドが暗黙的に蓄積されていくため、最終的にクラスの振る舞いを追跡することが極めて困難になります。
暗黙の継承は、見えない契約の積み重ねであり、認知的負荷の増大を招く要因です。

対照的に、コンポジションを用いた設計では、必要な機能を独立したクラスに分離し、委譲によって組み立てます。
これにより、単一責任の原則に忠実な、テスト可能で保守しやすいコードベースが構築できるのです。

本記事では、PHPにおける深い継承階層の具体的なリスクを体系的に整理し、コンポジションを適切に適用するための設計パターンと実装上のベストプラクティスを解説します。

  1. PHPにおける深い継承ツリーの問題点とは?継承濫用の現状とリスク
  2. なぜ深い継承は設計上のアンチパターンと呼ばれるのか
    1. 壊れやすい基底クラス問題の本質と継承による影響範囲の拡大
    2. 継承による強い結合がテスト容易性と保守性を低下させるメカニズム
    3. 暗黙の振る舞いを追跡する認知的負荷の増大とコードの予の増大とコードの予測可能性の低下
  3. コンポジションの基本:継承との違いと「has-a」関係の設計思想
    1. コンポジションの定義とオブジェクト指向における委譲の位置づけ
    2. 「is-a」関係と「has-a」関係の使い分け基準と設計判断のフレームワーク
    3. インターフェースと委譲を組み合わせた結合度の低い構造の実現
  4. PHPでのコンポジション実装:委譲と依存性注入の具体的な書き方
    1. コンストラクタインジェクションによるコンポジションの実装方法とメリット
    2. インターフェースを活用した実装詳細の隠蔽と結合度の低減手法
    3. 継承からコンポジションへ移行するリファクタリングのステップと注意点
  5. 実践的なリファクタリング:深い継承ツリーをコンポジションで解体する方法
    1. 基底クラスの責務を分析して独立した委譲オブジェクトへ分離する具体的な手法
    2. トレイトとコンポジションの使い分けと水平方向のコード再利用の判断基準
  6. デザインパターンとの接続:ストラテジーとデコレーターによる振る舞いの拡張
    1. ストラテジーパターンを用いた実行時の振る舞い変更とポリモーフィズムの活用
    2. デコレーターパターンによる機能の重ね合わせと継承に代わる柔軟な構造
  7. 継承を完全に排除すべきか?適切に使い分けるための設計判断のフレームワーク
    1. 型階層の表現とポリモーフィズムにおいて継承が妥当なケースと条件
    2. コンポジションを選ぶべきシグナルと継承濫用を防ぐためのチェックリスト
  8. まとめ:PHP開発者が実践すべきコンポジション指向の設計原則

PHPにおける深い継承ツリーの問題点とは?継承濫用の現状とリスク

PHPの深い継承ツリーがもたらす設計上のリスクと継承濫用の問題を示すイメージ

PHPの開発現場、特に長年運用されているレガシーなコードベースや、かつての主流フレームワークの設計思想に強く影響を受けたプロジェクトでは、5層を超える継承階層が珍しくありません。
基底となる抽象クラスを定義し、それを細分化した中間クラスを経由して、最終的な具象クラスを実装するというパターンは、一見すると型階層を体系的に表現しているかのように見えます。
しかし、これはしばしば継承という強力なメカニズムの濫用であり、オブジェクト指向設計の本質である結合の低減と責務の分離から逸脱した構造です。

なぜPHPの世界ではこのような深い継承ツリーが形成されやすいのでしょうか。
歴史的な経緯から考察すると、PHPはWebアプリケーションの迅速な構築を重視して発展してきた言語であり、フレームワークの初期バージョンにおいて継承を中心とした拡張モデルが広く採用されてきました。
開発者は共通の振る舞いを基底クラスに集約することで、短時間での機能実装を実現してきたのです。
しかし、この「便利さ」は長期的な設計上の負債として蓄積し、最終的にコードベースの柔軟性を著しく損ないます。

深い継承ツリーの本質的な問題は、クラス間に形成される強固なコンパイル時依存関係にあります。
派生クラスは基底クラスの実装詳細に対して暗黙的に結合しており、基底クラスの変更は派生クラス全体に連鎖的な影響を及ぼします。
これは「壊れやすい基底クラス問題」と呼ばれる現象であり、ソフトウェア工学の分野で広く知られたアンチパターンです。
基底クラスの作者は派生クラスの存在を完全には把握できず、派生クラスの作者は基底クラスの内部実装を完全には理解しないまま機能を拡張します。
この非対称な知識の状態が、予期しない副作用と回帰バグの温床となります。

具体的なリスクを整理すると、以下のような構造的欠陥が顕在化します。

  • 基底クラスのメソッドやプロパティの変更が、派生階層全体に波及し、影響範囲の予測が極めて困難になります
  • 中間クラスが暗黙的に蓄積した状態と振る舞いが、最終クラスのインターフェースを不透明にし、認知的負荷を増大させます
  • 単体テストにおいて、派生クラスを隔離して検証することが困難になり、テストダブルの作成コストが高騰します
  • メソッドのオーバーライドと親メソッドの呼び出しタイミングの錯綜により、制御フローの追跡が複雑化します

これらの問題は、単なる「コードの見通しが悪い」という感覚的な不快感ではありません。
以下の表は、継承階層の深さと発生しやすい設計上の問題の関係を整理したものです。

継承階層の深さ 主な設計上の問題 影響を受ける品質特性 修正の困難度
2〜3層 比較的制御可能だが、過度の抽象化の兆候あり 可読性 低
4〜5層 中間クラスによる状態の暗黙的蓄積が顕著化 保守性、テスト容易性 中
6層以上 壊れやすい基底クラス問題の完全な発現、変更拒否性 柔軟性、信頼性 高

深い継承ツリーがもたらす最も深刻な影響は、変更に対するコードベースの抵抗性です。
新機能の追加や既存機能の修正が、基底クラスの調整を必要とし、その調整が派生クラスに予期しない副作用をもたらすという悪循環に陥ります。
結果として、開発者は変更を恐れ、ハック的な回避策を重ねることになり、技術的負債は雪だるま式に増大します。

以下は、深い継承がどのようにしてクラスの振る舞いを不透明にするかを示す簡潔な例です。

abstract class BaseModel {}
abstract class TimestampableModel extends BaseModel {}
abstract class SoftDeleteModel extends TimestampableModel {}
class UserRecord extends SoftDeleteModel {}

この構造において、UserRecordが持つメソッドとプロパティの完全な一覧を把握するためには、4つのクラス定義を縦断して確認する必要があります。
各中間クラスがトレイトを使用していたり、マジックメソッドを介して動的に振る舞いを変更している場合、この追跡作業はさらに困難を極めます。
オブジェクトの振る舞いがそのクラスファイル内で完結しないという事実は、コードの局所性の原理に明確に反するのです。

深い継承ツリーの問題を認識することは、より良い設計への第一歩です。
継承は型の分類を表現するための道具であり、コードの再利用を目的とした包含関係の表現には別のメカニズムが求められます。
次節以降では、この構造的課題を解決するコンポジションの原理と、PHPにおける具体的な実装アプローチを論じます。

なぜ深い継承は設計上のアンチパターンと呼ばれるのか

深い継承階層がアンチパターンとされる理由と壊れやすい基底クラス問題の解説用ビジュアル

深い継承階層がアンチパターンと位置づけられる理由は、単なる実装上の不便さではありません。
継承は型階層を表現するための強力な言語機能ですが、その適用範囲を逸脱すると、オブジェクト指向の根本的な利点であるカプセル化と結合の低減を自ら破壊する構造へと変質します。
特に継承階層が深くなるにつれて、クラス間の依存関係は網の目のように錯綜し、局所的な修正が大域的な影響を及ぼすという、まさにアンチパターンの定義に適合する状態が生じます。

壊れやすい基底クラス問題の本質と継承による影響範囲の拡大

いわゆる壊れやすい基底クラス問題は、継承が派生クラスに対して基底クラスの実装詳細を露出させてしまうことに起因します。
基底クラスの作者は、派生クラスがどのように内部メソッドに依存しているかを完全には把握できません。
したがって、基底クラスのリファクタリングや仕様変更は、派生クラスの想定外の破壊を招きます。
これは継承が「白箱再利用」を強制するメカニズムであることの必然的な帰結です。
コンポジションがオブジェクトの公開インターフェースのみに依存するのに対し、継承はprotectedメソッドや内部状態までも派生クラスに開示するため、カプセル化の境界が曖昧になります。
結果として、基底クラスに対する局所的な修正が、派生階層全体に連鎖的な影響を及ぼし、影響範囲の予測可能性が著しく低下するのです。

以下のコードは、基底クラスの変更が派生クラスを脆弱にする典型例です。

class ReportGenerator
{
    protected function formatData(array $data): array
    {
        return array_map('strval', $data);
    }
}

class CsvReportGenerator extends ReportGenerator
{
    public function generate(array $data): string
    {
        $formatted = $this->formatData($data);
        return implode(',', $formatted);
    }
}

基底クラスのformatDataの戻り値の型や要素の並び順が変更された場合、CsvReportGeneratorの動作は暗黙のうちに損なわれます。
派生クラスは基底クラスの内部実装に対して過度の信頼を置いており、契約としてのインターフェースではなく、実装としての慣行に依存しているのです。

継承による強い結合がテスト容易性と保守性を低下させるメカニズム

深い継承は単体テストの設計を困難にします。
派生クラスを隔離して検証しようとしても、基底クラスの複雑な初期化ロジックや外部依存が必ず介在するため、純粋な単体テストの成立が阻害されます。
テストダブルを注入する際も、継承階層に縛られた構造はモックやスタブの導入を複雑化させます。
たとえば、基底クラス内で静的メソッドやグローバルな状態にアクセスしている場合、派生クラスのテストは実質的に結合テストの性質を帯びてしまいます。
これはテストピラミッドの原則に明らかに反し、実行速度の低下と不安定なテスト結果を招きます。
保守性の観点からも、基底クラスの変更のたびに派生クラスのテストを再実行し、回帰の有無を確認するコストが増大するため、コードベースの進化速度は指数関数的に鈍化します。

暗黙の振る舞いを追跡する認知的負荷の増大とコードの予の増大とコードの予測可能性の低下

深い継承ツリーにおいて最も深刻な問題の一つは、コードの意味論が読者の頭内で再構成されなければならない点です。
派生クラスの振る舞いを理解するためには、すべての祖先クラスを縦断して確認する必要があり、各層でオーバーライドやフックメソッドがどのように定義されているかを追跡しなければなりません。
この認知的負荷は継承の深さに比例して増大し、開発者の作業記憶を圧迫します。
結果として、コードのレビュー品質が低下し、バグの混入リスクが高まります。
予測可能性の観点からも、メソッドの実行経路が単一ファイル内で完結しないことは、静的解析の精度を低下させ、IDEによる補完やリファクタリング支援の効果を減じます。
振る舞いが暗黙のうちに蓄積される構造は、ソフトウェアの長期的な健全性を脅かす本質的な欠陥であると言えるでしょう。

コンポジションの基本:継承との違いと「has-a」関係の設計思想

コンポジションの基本とhas-a関係による柔軟なオブジェクト設計の実現方法

継承の濫用がもたらす構造的脆弱性を克服するため、オブジェクト指向設計の文脈で長く提唱されてきた原理がコンポジションです。
コンポジションは、あるオブジェクトが他のオブジェクトをその構成要素として内部に保持し、必要に応じて機能を委譲する設計パターンです。
継承が「is-a」関係に基づく型階層の表現を目的とするのに対し、コンポジションは「has-a」関係に基づく責務の組み立てを実現します。
この本質的な違いを理解することは、柔軟で保守しやすいコードベースを構築するための出発点です。

コンポジションの定義とオブジェクト指向における委譲の位置づけ

コンポジションは、オブジェクト指向における関係性の一種であり、全体と部分のライフタイムが一致する強い所有関係を示す場合も、より緩やかな関連関係を示す場合も含めて、広義に用いられる概念です。
ここで重要なのは、包含されるオブジェクトの公開インターフェースのみを通じて相互作用する点です。
これはいわゆる「黒箱再利用」を実現するメカニズムであり、包含されるオブジェクトの内部実装詳細が隠蔽されたまま機能の統合が行われます。

委譲はコンポジションを実現するための核心的な技法です。
あるオブジェクトが受けた操作を、内部に保持している別のオブジェクトに転送することで、コードの重複を排除しつつ結合を低く保ちます。
継承が暗黙的に全てのprotectedな実装詳細へのアクセスを許容するのに対し、委譲は明示的に定義された契約に基づく相互作用を強制します。
この明示性が、予測可能性とカプセル化の境界を守る鍵となります。

以下は、コンポジションと委譲を用いた基本的な構造の例です。

class Logger
{
    public function log(string $message): void
    {
        error_log($message);
    }
}

class OrderProcessor
{
    public function __construct(private Logger $logger) {}

    public function process(): void
    {
        $this->logger->log('注文を処理しました');
    }
}

この構造では、OrderProcessorはLoggerの実装詳細に依存しておらず、単に公開されているlogメソッドを利用します。
Loggerの内部でファイル出力からデータベース出力へ変更が生じても、OrderProcessorに影響を及ぼさない限界において、結合は最小限に抑えられています。

「is-a」関係と「has-a」関係の使い分け基準と設計判断のフレームワーク

設計時に継承とコンポジションのどちらを選ぶべきかを判断するためには、関係の本質を問う必要があります。
「is-a」関係が成立するかどうかの検証には、リスコフの置換原則を適用することが有効です。
派生クラスが基底クラスに完全に置換可能で、契約の全てを損なわない場合にのみ、継承は設計的に正当化されます。
一方、「has-a」関係は、あるクラスが別のクラスの機能を利用する、あるいは構成要素として包含する関係にあります。

以下の表は、両関係の使い分け基準を整理したものです。

判断基準 is-a(継承) has-a(コンポジション)
関係の本質 型階層と分類の表現 責務の包含と機能の組み立て
置換原則の適合性 派生クラスが基底クラスに完全に置換可能 包含オブジェクトの置換性は関係しない
実装詳細への依存 protectedメソッドと状態へのアクセスを伴う 公開インターフェースのみに依存
変更の波及範囲 基底クラスの変更が派生階層に連鎖 実装の変更が局所的に閉じる

このフレームワークに照らし合わせると、多くの「コードの再利用」を目的とした継承は、実際には「has-a」関係としてモデリングされるべきであることが明らかになります。
特に振る舞いの一部を共有したいだけの場合、継承は過剰な拘束条件を課すことになります。

インターフェースと委譲を組み合わせた結合度の低い構造の実現

コンポジションの真価は、インターフェースと組み合わせた際に最大限に発揮されます。
インターフェースは実装とは独立した契約を定義し、コンポジションはその契約に基づく協働を実現します。
これにより、実装詳細への依存が完全に排除され、依存関係逆転の原則に忠実な構造が得られます。

以下は、インターフェースを活用したコンポジションの例です。

interface CacheInterface
{
    public function get(string $key): ?array;
    public function set(string $key, array $value): void;
}

class FileCache implements CacheInterface
{
    public function get(string $key): ?array { /* ... */ }
    public function set(string $key, array $value): void { /* ... */ }
}

class UserRepository
{
    public function __construct(private CacheInterface $cache) {}

    public function find(int $id): ?array
    {
        if ($cached = $this->cache->get("user:{$id}")) {
            return $cached;
        }
        // データベースからの取得処理
        return null;
    }
}

この設計では、UserRepositoryはCacheInterfaceという抽象に依存しており、FileCacheのような具体的な実装から分離されています。
テスト時にはFileCacheの代わりにインメモリのスタブ実装を注入でき、データベースやファイルシステムに依存しない高速な単体テストが可能になります。
結合度の低減、テスト容易性の向上、実行時の柔軟性という三つの利得を、コンポジションとインターフェースの組み合わせは同時に実現するのです。

PHPでのコンポジション実装:委譲と依存性注入の具体的な書き方

PHPでのコンポジション実装と依存性注入を使った委譲パターンの具体的なコード例

コンポジションの設計思想を理解した上で、次に重要なのはPHPの言語機能を活用した具体的な実装技法です。
PHPは現代的なオブジェクト指向の文法を備えており、コンストラクタによるプロパティ昇格や、強固な型システムを通じて、コンポジションと依存性注入を簡潔かつ型安全に記述できます。
委譲をコードとして落とし込む際には、依存の方向を具体的な実装ではなく抽象に向けることが、結合度を低減するための鉄則です。

コンストラクタインジェクションによるコンポジションの実装方法とメリット

依存性注入のパターンの中で、特にコンポジションと相性が良いのがコンストラクタインジェクションです。
依存オブジェクトをインスタンス生成時に受け取り、クラスの不変条件として保持することで、オブジェクトの完全性がコンストラクタの終了時点で保証されます。
PHP 8.0以降ではコンストラクタプロモーションが利用できるため、ボイラープレートコードを削減しつつ、依存関係を明示的に宣言できます。

以下は、複数の依存をコンストラクタで受け取る実装例です。

class InvoiceService
{
    public function __construct(
        private Mailer $mailer,
        private LoggerInterface $logger
    ) {}

    public function sendInvoice(int $orderId): void
    {
        $this->mailer->send('customer@example.com', "Invoice #{$orderId}");
        $this->logger->info("Invoice sent: {$orderId}");
    }
}

この方式の最大のメリットは、依存関係がクラスの公開インターフェースとして可視化される点です。
テスト時には実装クラスの代わりにモックやスタブを注入でき、外部リソースに依存しない検証が可能になります。
また、依存オブジェクトのライフタイム管理が呼び出し側に委ねられるため、複数のクライアントで同一の依存を共有するか、都度新規インスタンスを生成するかを柔軟に制御できます。

インターフェースを活用した実装詳細の隠蔽と結合度の低減手法

コンポジションの効果を最大化するには、包含されるオブジェクトの型を具象クラスではなくインターフェースに束縛することが不可欠です。
これにより、依存関係逆転の原則が遵守され、上位モジュールが下位モジュールの実装詳細に左右されない構造が得られます。

以下は、支払いゲートウェイの抽象に依存する実装例です。

interface PaymentGatewayInterface
{
    public function charge(int $amount): PaymentResult;
}

class StripeGateway implements PaymentGatewayInterface
{
    public function charge(int $amount): PaymentResult
    {
        // Stripe APIとの通信処理
        return new PaymentResult(true);
    }
}

class SubscriptionService
{
    public function __construct(
        private PaymentGatewayInterface $gateway
    ) {}

    public function renew(int $userId): void
    {
        $result = $this->gateway->charge(1000);
        if (!$result->isSuccess()) {
            throw new PaymentFailedException();
        }
    }
}

この構造では、SubscriptionServiceはStripeGatewayの存在を知る必要がありません。
テスト時にはPaymentGatewayInterfaceのスタブ実装を注入でき、決済プロバイダーのサンドボックス環境に依存しない単体テストが成立します。
実装の切り替えもインターフェースを満たす別クラスを作成するだけで完了し、修正の影響範囲は局所的に閉じます。

継承からコンポジションへ移行するリファクタリングのステップと注意点

既存の深い継承ツリーをコンポジションへ移行する際には、機械的な置き換えではなく、責務の再分配を伴う計画的なリファクタリングが求められます。
まず基底クラスが担っている責務を列挙し、それらが本当に「型階層としての性質」なのか、それとも「再利用したい振る舞い」なのかを峻別します。

以下の表は、移行プロセスを段階的に整理したものです。

ステップ 作業内容 注意点
1 基底クラスの責務を分析し、独立したクラスへ抽出する protectedメソッドの公開範囲を再検討する
2 抽出したクラスに対するインターフェースを定義する 既存の派生クラスへの影響を最小限に抑える
3 派生クラスのコンストラクタに委譲オブジェクトを注入する シグネチャ変更による破壊的変更に留意する
4 継承関係を段階的に除去し、動作検証を行う 回帰テストを十分に実施する

移行前と移行後の構造を対比すると、以下のようになります。

// 移行前:継承による実装共有
class BaseNotificationSender
{
    protected function validate(string $address): bool
    {
        return filter_var($address, FILTER_VALIDATE_EMAIL) !== false;
    }
}

class WelcomeEmailSender extends BaseNotificationSender
{
    public function send(string $to): void
    {
        if (!$this->validate($to)) {
            return;
        }
        // 送信処理
    }
}

// 移行後:コンポジションによる委譲
class EmailValidator
{
    public function isValid(string $address): bool
    {
        return filter_var($address, FILTER_VALIDATE_EMAIL) !== false;
    }
}

class WelcomeEmailSender
{
    public function __construct(
        private EmailValidator $validator,
        private Mailer $mailer
    ) {}

    public function send(string $to): void
    {
        if (!$this->validator->isValid($to)) {
            throw new InvalidArgumentException();
        }
        $this->mailer->send($to, 'Welcome');
    }
}

この移行により、WelcomeEmailSenderはEmailValidatorの実装詳細から解放され、検証ロジックの変更が局所的に留まるようになります。
ただし、移行時には既存のprotectedメソッドが派生クラス内でどのように利用されていたかを綿密に調査し、暗黙の依存を顕在化させる必要があります。
リファクタリングは小さな単位で段階的に進め、各段階でテストスイートを実行して回帰を検出することが、安全な設計進化の条件です。

実践的なリファクタリング:深い継承ツリーをコンポジションで解体する方法

深い継承ツリーをコンポジションで解体する実践的なリファクタリング手法と手順

深い継承ツリーをコンポジションへ移行する際、最も重要なのは機械的なクラスの置き換えではなく、責務の再分配に伴う設計の見直しです。
実際のコードベースでは、基底クラスが認証、ロギング、バリデーション、データ変換といった複数の責務を横断的に抱えていることが少なくありません。
まずは基底クラスの各メソッドがどの責務に属するかを分類し、それぞれを独立したクラスへ抽出することが、解体作業の出発点となります。

基底クラスの責務を分析して独立した委譲オブジェクトへ分離する具体的な手法

リファクタリングの第一歩は、基底クラスのprotectedメソッドとプロパティを列挙し、それらがどの概念領域に属するかを特定することです。
たとえば、API通信、認証ヘッダーの生成、レスポンスのキャッシュという三つの責務が一つの基底クラスに混在している場合、それぞれを専用のクラスへ分離します。

以下は、継承に依存した構造をコンポジションへ移行する前後の対比例です。

// 移行前:深い継承によるAPIクライアント
abstract class AbstractApiClient
{
    protected function sendRequest(string $endpoint): array
    {
        // HTTP通信処理
        return [];
    }
}

abstract class AuthenticatedApiClient extends AbstractApiClient
{
    protected function addAuthHeader(array $headers): array
    {
        $headers['Authorization'] = 'Bearer token';
        return $headers;
    }
}

class UserApiClient extends AuthenticatedApiClient
{
    public function fetchUser(int $id): array
    {
        return $this->sendRequest("/users/{$id}");
    }
}

// 移行後:コンポジションによる責務の分離
class HttpClient
{
    public function get(string $endpoint, array $headers): array
    {
        // HTTP通信処理
        return [];
    }
}

class Authenticator
{
    public function applyAuth(array $headers): array
    {
        $headers['Authorization'] = 'Bearer token';
        return $headers;
    }
}

class UserApiClient
{
    public function __construct(
        private HttpClient $httpClient,
        private Authenticator $authenticator
    ) {}

    public function fetchUser(int $id): array
    {
        $headers = $this->authenticator->applyAuth([]);
        return $this->httpClient->get("/users/{$id}", $headers);
    }
}

この移行により、UserApiClientは親クラスの暗黙的な振る舞いに依存することなく、明示的に宣言された依存を通じて機能を実現します。
各責務は独立したクラスに閉じ込められ、単体テストにおいてそれぞれを隔離して検証できるようになります。

トレイトとコンポジションの使い分けと水平方向のコード再利用の判断基準

PHPにおいては、トレイトも水平方向のコード再利用を実現する手段として提供されています。
しかし、トレイトは言語レベルでメソッドとプロパティをクラスにコピーする仕組みであり、実装への静的な結合を生みます。
包含クラスの内部状態にアクセスできる点も、カプセル化の観点から注意を要します。

以下の表は、トレイトとコンポジションの特性を比較したものです。

観点 トレイト コンポジション
再利用の方向 水平方向の実装注入 包含関係による委譲
結合の強さ 実装詳細への静的結合 インターフェース経由の疎結合
状態へのアクセス 包含クラスのプロパティにアクセス可能 委譲先の内部状態は隠蔽される
テスト容易性 単体テストが困難で結合が強い モック注入により容易に単体テスト可能

トレイトは、複数のクラスにわたって完全に同一の実装を提供したい場合に限って有効です。
しかし、その実装が状態に依存したり、複雑な振る舞いを含む場合はコンポジションを選択すべきです。
特に振る舞いの変更が予想される場合や、実行時に異なる実装を切り替える必要がある場合、コンポジションの優位性は決定的です。
設計判断の基準は常に「結合の強さ」と「変更の予測可能性」に置くべきであり、単なる記述量の短縮を理由にトレイトを選ぶことは避けるのが賢明です。

デザインパターンとの接続:ストラテジーとデコレーターによる振る舞いの拡張

ストラテジーパターンとデコレーターパターンによる振る舞い拡張の実現方法

コンポジションの原理は、単なる実装上の工夫ではなく、オブジェクト指向設計における重要なデザインパターンの基盤となっています。
特に振る舞いの拡張や差し替えを目的とする場面では、継承に代わる構造的アプローチとして、ストラテジーパターンとデコレーターパターンが広く活用されています。
これらのパターンはいずれもコンポジションと委譲を中核に据えており、実行時の柔軟性と型安全性を両立させるための実践的な設計技法です。

ストラテジーパターンを用いた実行時の振る舞い変更とポリモーフィズムの活用

ストラテジーパターンは、アルゴリズムの族を定義し、それぞれをカプセル化して交換可能にするデザインパターンです。
継承によるメソッドのオーバーライドは、コンパイル時に型階層が固定されるため、実行時の振る舞いの動的な変更に対して本質的に不向きです。
これに対し、ストラテジーパターンでは、コンテキストとなるクラスがアルゴリズムをコンポジションとして保持し、共通のインターフェースを通じて委譲することで、ポリモーフィズムを維持しつつ実行時の切り替えを実現します。

以下は、ソートアルゴリズムをストラテジーとして切り替える実装例です。

interface SortStrategy
{
    public function sort(array $data): array;
}

class QuickSortStrategy implements SortStrategy
{
    public function sort(array $data): array
    {
        sort($data);
        return $data;
    }
}

class MergeSortStrategy implements SortStrategy
{
    public function sort(array $data): array
    {
        usort($data, fn($a, $b) => $a <=> $b);
        return $data;
    }
}

class DataAnalyzer
{
    public function __construct(private SortStrategy $strategy) {}

    public function setStrategy(SortStrategy $strategy): void
    {
        $this->strategy = $strategy;
    }

    public function process(array $records): array
    {
        return $this->strategy->sort($records);
    }
}

この構造では、DataAnalyzerはソートの実装詳細から完全に分離されており、データの規模や特性に応じてsetStrategyによって最適なアルゴリズムを動的に選択できます。
継承による固定的な型束縛を回避しつつ、型安全なポリモーフィズムを享受できる点が、ストラテジーパターンの本質的な利点です。

デコレーターパターンによる機能の重ね合わせと継承に代わる柔軟な構造

デコレーターパターンは、オブジェクトに対して動的に責務を追加するためのパターンであり、継承によるサブクラス化の代替として特に強力です。
継承では機能の組み合わせを表現するために、組み合わせの数だけサブクラスを定義する必要が生じる危険性があります。
これはいわゆる「クラス爆発問題」を招きます。
デコレーターパターンでは、包含と委譲によってオブジェクトをラップし、機能を重ね合わせることで、この問題を根本的に回避します。

以下は、レスポンスのレンダリング処理にキャッシュ機能と最小化機能を動的に重ね合わせる例です。

interface ResponseRenderer
{
    public function render(string $content): string;
}

class HtmlRenderer implements ResponseRenderer
{
    public function render(string $content): string
    {
        return "<html><body>{$content}</body></html>";
    }
}

class MinifyDecorator implements ResponseRenderer
{
    public function __construct(private ResponseRenderer $renderer) {}

    public function render(string $content): string
    {
        $html = $this->renderer->render($content);
        return preg_replace('/>\s+</', '><', $html) ?? $html;
    }
}

class CacheDecorator implements ResponseRenderer
{
    private array $storage = [];

    public function __construct(private ResponseRenderer $renderer) {}

    public function render(string $content): string
    {
        if (!isset($this->storage[$content])) {
            $this->storage[$content] = $this->renderer->render($content);
        }
        return $this->storage[$content];
    }
}

この実装では、new CacheDecorator(new MinifyDecorator(new HtmlRenderer()))のように、実行時に機能の重ね合わせ順序を自由に構成できます。
継承では表現が困難だった、動的かつ柔軟な機能の合成が、コンポジションによって簡潔に実現されています。

以下の表は、両パターンの役割と継承との対比を整理したものです。

パターン 主な目的 継承との本質的な違い
ストラテジー アルゴリズムの実行時切り替え 委譲による差し替えで静的な型階層を不要にする
デコレーター 機能の動的な重ね合わせ ラップ構造により継承の固定拡張を回避する

これらのパターンは、コンポジションが単なる「継承の代替」ではなく、より表現力豊かな設計の基盤であることを示しています。
継承が型の分類を表現するための道具であるならば、コンポジションは責務の合成と振る舞いの進化を支えるための原理と言えるでしょう。

継承を完全に排除すべきか?適切に使い分けるための設計判断のフレームワーク

継承を完全に排除すべきでない理由と適切に使い分けるための設計判断のフレームワーク

これまでの論考を総括すると、継承は設計上のアンチパターンとして位置づけられることが多いように見えます。
しかし、継承という言語機能そのものが有害なわけではありません。
問題となるのは、継承の適用範囲を型階層の表現から逸脱させ、コードの再利用や振る舞いの共有を目的として濫用することです。
オブジェクト指向の設計判断において重要なのは、継承とコンポジションのいずれがその関係性の本質により適合するかを、厳密に峻別することです。

型階層の表現とポリモーフィズムにおいて継承が妥当なケースと条件

継承が設計的に正当化されるのは、対象となる概念が本質的な型階層を形成し、リスコフの置換原則に完全に従う場合に限られます。
つまり、派生クラスが基底クラスの契約を全て継承し、文脈を損なわずに基底クラスに置換可能であることが必要条件です。
典型的な例として、ドメイン駆動設計における値オブジェクトやドメインイベントの階層、あるいは数学的な概念の分類などが挙げられます。

以下は、ドメインイベントの型階層において継承が妥当な実装例です。

abstract class DomainEvent
{
    public function __construct(private DateTimeImmutable $occurredOn) {}

    public function occurredOn(): DateTimeImmutable
    {
        return $this->occurredOn;
    }
}

final class OrderPlaced extends DomainEvent
{
    public function __construct(
        DateTimeImmutable $occurredOn,
        private int $orderId,
        private int $amount
    ) {
        parent::__construct($occurredOn);
    }

    public function orderId(): int
    {
        return $this->orderId;
    }

    public function amount(): int
    {
        return $this->amount;
    }
}

この構造では、OrderPlacedは明確に「is-a」関係に基づき、DomainEventという型階層の一員として定義されています。
発生時刻という共通の不変条件を基底クラスで共有しつつ、各イベント固有のペイロードを派生クラスで定義するという責務の分離が、型安全性と多態性の両立を実現しています。
このような場合、継承は型システムの表現力を活用した正当な設計選択と言えるでしょう。

コンポジションを選ぶべきシグナルと継承濫用を防ぐためのチェックリスト

一方で、実装の共有や振る舞いの一部の借用を目的として継承に手を伸ばそうとした場合、まず立ち止まって設計上の問いを投げかける必要があります。
以下の表は、継承とコンポジションの使い分けを判断するためのチェックリストです。

設計上の問い 継承を選ぶ場合 コンポジションを選ぶ場合
関係は本質的な型階層か is-aが成立し置換原則を満たす has-aまたはuses-aに近い
目的は状態や実装の共有か 型階層としての性質の継承のみ 責務の委譲と振る舞いの合成
実行時の差し替えが必要か 不要で固定的でよい 必要で柔軟性が求められる
基底クラスの変更の影響範囲 派生クラス全体で許容できる 局所的に閉じたい

このチェックリストのいずれかで継承濫用のシグナルが検出された場合、コンポジションへの再検討が設計上の賢明な判断となります。
特にprotectedメソッドへのアクセスが必要になった時点で、それは結合が強まる兆候であり、委譲による解決を模索すべきです。
継承は型の分類を表現するための鋭利な道具ですが、その鋭利さゆえに、責務の合成や振る舞いの再利用という目的には向いていません。
設計判断の最終基準は、常にコードの結合度と変更への耐性に置かれるべきです。

まとめ:PHP開発者が実践すべきコンポジション指向の設計原則

PHP開発者が実践すべきコンポジション指向の設計原則と継承アンチパターンの回避策

本記事を通じて、深い継承ツリーがもたらす構造的脆弱性と、コンポジションによるその解決策を体系的に論じてきました。
継承は型階層を表現するための強力な言語機能ですが、コードの再利用や振る舞いの共有を目的とした濫用は、壊れやすい基底クラス問題を引き起こし、コードベースの柔軟性と保守性を著しく損ないます。
PHPの開発現場において、この認識を設計の前提として置くことは、長期的なコードの健全性を担保するための必要条件です。

コンポジションの本質は、オブジェクト間の関係をhas-aとしてモデリングし、明示的な委譲によって責務を合成する点にあります。
継承が暗黙的な実装詳細への依存を強制するのに対し、コンポジションは公開インターフェースに基づく相互作用を促進し、カプセル化の境界を守ります。
これは単なる実装上の好みではなく、結合度を低減し、変更の影響範囲を局所的に閉じるための、ソフトウェア工学において確立された設計原理です。

PHP開発者が日常の設計判断において実践すべき原則を整理すると、以下のようになります。

設計原則 実践のポイント 期待される効果 適用のタイミング
責務の分離 基底クラスに蓄積した責務を独立したクラスへ抽出する 結合度の低減と単体テストの容易化 継承階層が3層を超えた場合
抽象への依存 インターフェースを定義し、委譲先はその実装として分離する 実装詳細の隠蔽と実行時の差し替え 外部サービスや入出力処理の統合時
継承の限定 is-a関係とリスコフの置換原則を厳密に適用する 型階層の正当性とコードの予測可能性の確保 ドメインイベントや値オブジェクトの定義時
委譲の活用 トレイトではなくコンポジションを優先して機能を合成する 状態の隠蔽と柔軟な構造の実現 横断的な関心事の共有が必要な場合

これらの原則は独立して機能するものではなく、相互に補完し合うことで、保守しやすくテスト可能なコードベースを形成します。
特にPHPの現代的なフレームワークは、コンストラクタインジェクションを中心とした依存性注入コンテナを標準として採用しており、コンポジション指向の設計を言語機能とインフラストラクチャの両面から支援しています。
このエコシステムの特性を最大限に活用し、継承に対する過度の依存から脱却することが、PHP開発者にとっての現代的な設計の姿勢です。

最終的に、優れた設計とは、未来の変更に対して抵抗ではなく適応を示す構造を指します。
コンポジションはその適応性を支える中心的なメカニズムであり、継承は型の本質的な分類に限定して適用されるべきです。
深い継承ツリーを見直し、責務を適切に委譲する設計への移行は、短期的には工数を要する決断かもしれません。
しかし、長期的な観点から見れば、それは技術的負債の返済ではなく、ソフトウェアの進化を持続可能にするための最も効果的な投資です。
コードの関係性を再定義し、コンポジションを設計の中心に据えること。
それがPHPにおけるオブジェクト指向の実践的な深化であり、成熟した開発者の判断と言えるでしょう。

コメント

タイトルとURLをコピーしました