アプリ開発における継承 vs コンポジション

― “継承は例外” と考える現代的設計指針


TL;DR

  • 基本方針:アプリ側コードはコンポジション(委譲・Strategy パターン・DI など)を優先し、継承はライブラリ内部や純粋 is-a 関係に限定する。
  • 理由:保守コスト・テスト難度・依存の拡散・メモリ負荷など、継承の副作用が大きいため。
  • 例外:テンプレートメソッドなど「差し込みポイント」が必要なフレームワーク設計、または厳密な is-a 関係で親を子に入れ替えても意味が変わらない場合のみ。
  • チェックリスト:sealed/internal 化、契約の明示、派生階層の深さ、将来の再利用可能性を確認しよう。

はじめに

オブジェクト指向といえば継承、というイメージは今も根強いものの、「安易な継承はプロジェクトを壊す」という教訓も広く共有されています。

とくに アプリケーション側のコード では、次のようなトラブルを避けるため “継承禁止” ではなく “継承は例外” と考える流れが主流です。


継承を避ける 5 つの理由

#リスク具体例
1脆弱な基底クラス親クラスの仕様変更 → 子クラスが崩壊
2隠れ依存の伝播protected フィールドがスパゲッティ化
3名前衝突・メソッド隠蔽new と override の取り違えによるバグ
4静的拘束 & テスト困難DI で差し替え・モック化しにくい
5メモリ・初期化コスト不要メンバーを丸ごと継承してしまう

Unity で MonoBehaviour を多重継承チェーンにすると、インスペクター上の依存関係が読めなくなるのも典型的な落とし穴です。


コンポジションで実現するプラクティス

目的手段.NET/Unity 例
共通 APIインターフェースIDamageable, IDisposable
振る舞い差し替えStrategy パターンIMoveBehavior をプレイヤーに注入
機能拡張拡張メソッド・DecoratorStream + GZipStream
実装隠蔽委譲 (Delegation)FileLogger → 内部に StreamWriter
依存解決DI コンテナASP.NET Core / Zenject など

それでも継承がハマるケース

  1. Template Method を用意したい
    • 例:StateMachineBehaviour, Control などフレームワーク側の「差し込みポイント」
  2. 純粋 is-a 関係が厳密に成り立つ
    • 例:IOException is-a Exception
  3. 過去 API との互換を保ちたい
  4. フレームワークが継承前提
    • 例:WPF の MarkupExtension

採用前のチェックリスト

  • 外部に派生クラスを公開しない (sealed / internal 化できるか)
  • 不変条件をコメント・ドキュメントで厳密に記述したか
  • “とりあえず共通” で親に押し込めていないか
  • 階層が 2 段以上深くなりそうなら再設計を検討
  • 将来ドメインが変わっても同じ継承が必要か?

コードで比較:継承 vs Strategy+コンポジション

❶ 継承版

abstract class Monster
{
    public abstract void Act();
}

class FlyingMonster : Monster
{
    public override void Act() => Console.WriteLine("空を飛ぶ");
}

class GroundMonster : Monster
{
    public override void Act() => Console.WriteLine("地面を走る");
}

課題

行動を増やすたびに派生クラスが増殖し、State パターンなどに置き換えづらい。

❷ Strategy+コンポジション版

interface IActStrategy { void Act(); }

class Fly : IActStrategy
{
    public void Act() => Console.WriteLine("空を飛ぶ");
}

class Run : IActStrategy
{
    public void Act() => Console.WriteLine("地面を走る");
}

class Monster
{
    public IActStrategy Behavior { get; set; }
    public void Act() => Behavior.Act();
}

// 利用側
var dragon = new Monster { Behavior = new Fly() };
var ogre   = new Monster { Behavior = new Run() };

メリット

  • 行動追加はクラス新設のみ、既存コードは無改変
  • テスト用に MockAct を差し替えやすい
  • Unity なら ScriptableObject に IActStrategy を実装してインスペクター経由で切替可能

現場での落とし所

  1. まずコンポジションで書いてみる
  2. 冗長なら abstract クラスを“一段だけ” 検討
  3. 公開面には sealed を付け、派生禁止をデフォルトに
  4. コードレビューで “is-a?” クイズを実施

まとめ

  • 原則:アプリ側はコンポジション優先
  • 継承は ライブラリ内部か純粋 is-a の 例外的手段
  • 判断基準 は保守性・テスト容易性・依存方向・将来拡張性
  • 深い階層や派生の拡散が見えたらリファクタリング警報

継承は剃刀、コンポジションはハサミ。

  • 剃刀は鋭いけれど取り扱い注意。
  • 日常作業は安全な道具でこなすのが得策です。

訪問数 4 回, 今日の訪問数 4回