アプリ開発における継承 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 をプレイヤーに注入 |
機能拡張 | 拡張メソッド・Decorator | Stream + GZipStream |
実装隠蔽 | 委譲 (Delegation) | FileLogger → 内部に StreamWriter |
依存解決 | DI コンテナ | ASP.NET Core / Zenject など |
それでも継承がハマるケース
- Template Method を用意したい
- 例:StateMachineBehaviour, Control などフレームワーク側の「差し込みポイント」
- 純粋 is-a 関係が厳密に成り立つ
- 例:IOException is-a Exception
- 過去 API との互換を保ちたい
- フレームワークが継承前提
- 例: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 を実装してインスペクター経由で切替可能
現場での落とし所
- まずコンポジションで書いてみる
- 冗長なら abstract クラスを“一段だけ” 検討
- 公開面には sealed を付け、派生禁止をデフォルトに
- コードレビューで “is-a?” クイズを実施
まとめ
- 原則:アプリ側はコンポジション優先
- 継承は ライブラリ内部か純粋 is-a の 例外的手段
- 判断基準 は保守性・テスト容易性・依存方向・将来拡張性
- 深い階層や派生の拡散が見えたらリファクタリング警報
継承は剃刀、コンポジションはハサミ。
- 剃刀は鋭いけれど取り扱い注意。
- 日常作業は安全な道具でこなすのが得策です。
訪問数 4 回, 今日の訪問数 4回
ディスカッション
コメント一覧
まだ、コメントがありません