継承・委譲・コンポジション(比較ページ)¶
一言で言うと¶
コードを再利用し組み合わせる手段は主に3つあります。
- 継承(inheritance): 「BはAの一種である(is-a)」。Aの実装とインターフェースを丸ごと受け継ぐ。
- コンポジション(composition): 「BはAを部品として持つ(has-a)」。必要な機能だけ持ち物として抱える。
- 委譲(delegation): 「Bは仕事をAに任せる」。コンポジションで持った部品に処理を転送する使い方。
現代の定石は「まずコンポジションを検討し、is-a が本当に成り立つときだけ継承」です。
解決したい問題¶
「敵の種類を増やしたい。共通処理は使い回したい」。このとき手段を誤ると、種類が増えるたびに苦しくなります。
問題のある実装(継承の乱用)¶
// C++20(問題例)
class Enemy { // 歩いて近接攻撃する敵
public: /* Move, MeleeAttack ... */
};
class FlyingEnemy : public Enemy { // 飛ぶ敵: Moveを上書き
};
class ShootingEnemy : public Enemy { // 撃つ敵: 攻撃を上書き
};
class FlyingShootingEnemy : public ??? // 飛んで撃つ敵は…どっちを継ぐ?
何が問題になるか¶
- 組み合わせ爆発: 「飛ぶ×撃つ×分裂する×毒」のような特徴の組み合わせは、継承ツリーでは表現できない(多重継承は別の問題を持ち込む → 多重継承)。
- 基底の変更が全子クラスに波及: Enemyの実装詳細に全子クラスが依存する。継承は最強の結合です。
- 不要な機能まで継承: 撃つだけの砲台が「歩く」機能を持ってしまい、
Move()を空実装で潰すことになる(→ LSP違反の温床)。
改善した実装(コンポジション+委譲)¶
「敵の特徴」を部品にして持たせます。
// C++20(骨格。各部品の実装は省略)
#include <memory>
class IMover { // 「動き方」という部品の抽象
public:
virtual ~IMover() = default;
virtual void Move(struct Enemy& self) = 0;
};
class WalkMover : public IMover { /* 地上移動 */ public: void Move(Enemy&) override; };
class FlyMover : public IMover { /* 飛行 */ public: void Move(Enemy&) override; };
class IAttacker { // 「攻撃の仕方」という部品の抽象
public:
virtual ~IAttacker() = default;
virtual void Attack(Enemy& self) = 0;
};
class MeleeAttacker : public IAttacker { public: void Attack(Enemy&) override; };
class ShootAttacker : public IAttacker { public: void Attack(Enemy&) override; };
struct Enemy {
// has-a: 部品を所有する(unique_ptr = このEnemyだけが所有)
std::unique_ptr<IMover> mover;
std::unique_ptr<IAttacker> attacker;
void Update() {
mover->Move(*this); // 委譲: 仕事を部品に任せる
attacker->Attack(*this);
}
};
// 「飛んで撃つ敵」= 部品の組み合わせで表現。クラスは増えない
Enemy MakeFlyingShooter() {
return Enemy{ std::make_unique<FlyMover>(), std::make_unique<ShootAttacker>() };
}
何が改善されたか¶
- 特徴の組み合わせがクラス定義なしで作れる(データ駆動にもできる: 敵テーブルから部品を選んで組む)。
- 動き方の変更は Mover だけで完結。敵クラス群に波及しない。
- 部品単体でテストできる。
代わりに何を失ったか¶
- 間接化のコスト: 実行時は仮想関数呼び出し(→ vtable)、コード上は「実際どのMoverなのか」を追う手間。
- 暗黙の共有がない: 継承なら基底に足せば全員に反映された。部品方式では組み合わせを管理する場所が必要。
- 敵が2種類しかなく今後も増えないなら、素朴なif文や小さな継承の方が読みやすい。
継承が適切な場面もある¶
継承を全否定するのは誤りです。次の条件が揃うなら継承は自然です。
- 本当に is-a が成り立つ(子は親としていつでも扱える → LSP)
- 親のインターフェースを外部に対してそのまま提供したい
- 差分が「一部の振る舞いの上書き」で表現できる(→ Template Method)
典型例: IMover と WalkMover の関係(インターフェースの実装)は継承の正しい使い方です。「実装の再利用のための深い継承」が危険なのであって、「抽象への準拠を表す浅い継承」は健全です。
比較表¶
| 継承 | コンポジション+委譲 | |
|---|---|---|
| 関係 | is-a | has-a |
| 結合の強さ | 強い(基底の実装詳細に依存) | 弱い(部品のインターフェースに依存) |
| 組み合わせ | ツリー1本。爆発する | 自由。実行時に差し替えも可能 |
| コードの追いやすさ | 型を見れば能力が分かる | 部品の中身を追う必要がある |
| 実行時の変更 | 不可(型は固定) | 可能(moverを差し替えれば飛べる) |
| 向く場面 | 抽象の実装、安定した小さな階層 | 特徴の組み合わせ、変化の多い仕様 |
ゲーム開発での例¶
この「継承ツリーの敵」→「部品の組み合わせ」への移行を突き詰めたものが、Unityの Component システムであり、さらにデータ指向に進めたものが ECS です。詳細な設計比較は ケーススタディ: 敵AI と 武器切り替え で扱います。
Unity/C# との対応¶
- GameObject に MonoBehaviour を付けるのはコンポジションそのもの。
- 一方で MonoBehaviour 自体は継承で書く(
class EnemyAI : MonoBehaviour)。「エンジンの抽象への準拠は継承、ゲームの特徴の組み合わせはコンポーネント」という使い分けが既に行われています。
Unreal Engine との対応¶
- UEはActor継承(ACharacter ← APawn ← AActor)とActorComponentの両方を提供。Gameplay Frameworkは継承が深めの設計で、ゲーム固有機能はComponentに切り出すのが近年の推奨です(→ 第10部)。
よくある誤解¶
- 「継承は禁止」ではありません。実装再利用目的の深い継承が問題で、インターフェース実装の継承は日常的に使います。
- 「コンポジションにすれば設計は正しい」でもありません。部品の切り方(責務)を誤れば、部品同士が密結合した別の地獄ができます。
使う場面 / 使わない場面¶
- コンポジション: 特徴が組み合わさる、実行時に振る舞いが変わる、仕様変更が多い部分。
- 継承: インターフェースの実装。安定していて浅い(1〜2段)階層。フレームワークの規約(MonoBehaviour、AActor)。
- どちらも過剰な場面: バリエーションが2つで固定なら、
enumとswitchの方が誠実なこともあります(→ 判断ガイド)。
関連項目¶
- LSP: リスコフの置換原則
- Strategy — 上のIMoverはStrategyパターンそのもの
- Component と ECS
- 判断ガイド: 継承かコンポジションか
理解度チェック¶
- is-a と has-a の違いを、敵キャラの例で言えますか。
- 継承が「最強の結合」と呼ばれるのはなぜですか。
- 継承が適切な条件を3つ挙げてください。
演習¶
「通常弾・追尾弾・貫通弾・追尾かつ貫通弾」という弾の仕様を、(a) 継承のみ、(b) コンポジションのみ、の2通りで骨格実装し、「爆発弾(着弾時範囲ダメージ)」追加時の変更量を比較してください。
前: 抽象化・カプセル化・情報隠蔽 | カテゴリ目次 | 次: ポリモーフィズムとインターフェース