継承の次に学びたい「コンポジション」― 継承より組み合わせを考える

広告

C#でオブジェクト指向を学ぶと、クラス、インスタンス、カプセル化と進み、その後に継承を学ぶことが多いでしょう。

継承は、既存のクラスの性質や機能を引き継いで、新しいクラスを作る仕組みです。

しかし、

共通する機能があるなら、継承すればよい

とは限りません。

オブジェクト指向設計では、古くから次の考え方があります。

Favor object composition over class inheritance.
クラス継承より、オブジェクトのコンポジションを優先する

これはGoFの『Design Patterns』でも示されている有名な設計原則です。

この記事では、継承を学んだ次の段階として、コンポジションについて見ていきます。

継承を確認しよう

まず、継承を簡単に確認しておきましょう。

class Animal
{
    public void Eat()
    {
        Console.WriteLine("食べる");
    }
}

class Dog : Animal
{
}

Dog クラスは Animal クラスを継承しています。

そのため、Dog 自身に Eat() を書いていなくても利用できます。

Dog dog = new Dog();
dog.Eat();

この関係は、

Dog is an Animal.
犬は動物の一種である

と表現できます。

このような関係を、is-a の関係と呼びます。

すべてを継承で表せるわけではない

では、車とエンジンはどうでしょうか。

Car is an Engine.
車はエンジンの一種である

これは不自然です。

車はエンジンそのものではありません。

むしろ、

Car has an Engine.
車はエンジンを持っている

と考えるほうが自然です。

このような関係を、has-a の関係と呼びます。

そこで使えるのがコンポジションです。

コンポジションとは

コンポジションでは、あるクラスが別のクラスのオブジェクトを持ちます。

class Engine
{
    public void Start()
    {
        Console.WriteLine("エンジン始動");
    }
}

CarEngine を持つようにします。

class Car
{
    private readonly Engine engine = new Engine();

    public void Start()
    {
        engine.Start();
    }
}

使う側は、

Car car = new Car();
car.Start();

となります。

CarEngine を継承しているわけではありません。

Car
 └ Engine

という構造になっています。

ただ持つだけでは、まだ差し替えられない

先ほどの Car には、少し注意点があります。

private readonly Engine engine = new Engine();

と、Car の中で Engine を直接作っています。

そのため、このままでは別の種類のエンジンに簡単に差し替えることはできません。

つまり、

オブジェクトを持つだけでもコンポジションだが、
外から部品を渡せるようにすると、さらに柔軟になる

ということです。

次は、その形をゲームのキャラクターで見てみましょう。

部品を外から渡せるようにする

まず、「移動する」という役割をインターフェースで表します。

なぜインターフェースにするのかは、少しあとで説明します。

interface IMovement
{
    void Move();
}

歩く移動を作ります。

class WalkMovement : IMovement
{
    public void Move()
    {
        Console.WriteLine("歩く");
    }
}

空を飛ぶ移動も作れます。

class FlyMovement : IMovement
{
    public void Move()
    {
        Console.WriteLine("飛ぶ");
    }
}

そして Enemy は、具体的な移動方法を自分で作りません。

class Enemy
{
    private readonly IMovement movement;

    public Enemy(IMovement movement)
    {
        this.movement = movement;
    }

    public void Move()
    {
        movement.Move();
    }
}

使う側で、どの移動方法を使うか決めます。

Enemy walker = new Enemy(new WalkMovement());
Enemy flyer = new Enemy(new FlyMovement());

walker.Move();
flyer.Move();

実行結果は、

歩く
飛ぶ

となります。

同じ Enemy クラスなのに、渡す部品を変えるだけで動きが変わりました。

ここが、コンポジションの大きな利点です。

Carとの違い

先ほどの Car は、

private readonly Engine engine = new Engine();

と、内部で部品を作っていました。

一方、Enemy は、

public Enemy(IMovement movement)
{
    this.movement = movement;
}

と、部品を外から受け取っています。

この違いによって、

Enemy + WalkMovement
Enemy + FlyMovement

のように、組み合わせを変えられるようになります。

このように、必要な部品を外から渡す考え方は、依存性の注入(Dependency Injection:DI)と呼ばれます。

また、

移動方法という振る舞いを部品として切り替える

という設計は、Strategyパターンにつながる考え方です。

ここでは名前だけ覚えておけば十分です。

差し替えられる理由は「インターフェース型で受け取る」から

ここで大事なことがあります。

Enemy が差し替え可能なのは、外から渡しているからだけではありません。受け取る型が WalkMovement ではなく、IMovement になっているからでもあります。

// WalkMovement しか渡せない → 差し替えられない
public Enemy(WalkMovement movement) { ... }

// IMovement なら WalkMovement も FlyMovement も渡せる
public Enemy(IMovement movement) { ... }

具体的なクラスで受け取ると、その型のものしか渡せません。

Car も同じです。Car(Engine engine) と書き換えても、渡せるのは Engine 型のものだけです。本当に差し替えたいなら、IEngine のようなインターフェースを作り、それを受け取る形にします。

つまり、

「外から渡す」ことと「インターフェース型で受け取る」ことがセットになって、はじめて差し替えができる

ということです。

処理を別のオブジェクトに任せる「委譲」

EnemyMove() を見てみましょう。

public void Move()
{
    movement.Move();
}

Enemy 自身が歩き方や飛び方を実装しているわけではありません。

実際の処理を、

WalkMovement
または
FlyMovement

に任せています。

このように、

自分で処理せず、別のオブジェクトに処理を任せる

ことを委譲(delegation)と呼びます。

コンポジションでは、この委譲がよく使われます。

コラム:UMLの「コンポジション」とは少し意味が違う

※ このコラムは、読み飛ばしても本編は分かります。

この記事では、GoFのいう object composition のように、

オブジェクトを部品として組み合わせる

という広い意味で「コンポジション」という言葉を使っています。

一方、UMLでは、

  • 集約
  • コンポジション

をより厳密に区別します。

たとえば、Car のように内部で部品を生成し、その寿命も強く管理する関係は、UMLではコンポジションに近い表現になります。

一方、Enemy のように外部から部品を受け取る関係は、集約に近いと説明されることがあります。

この記事では、初学者向けにオブジェクトを組み合わせて設計するという広い意味でコンポジションを扱っています。

継承だけで作ると何が困るのか

ゲームに次のようなキャラクターがいるとします。

歩くキャラクター
飛ぶキャラクター
攻撃できるキャラクター

これを継承だけで表そうとすると、

Character
 ├ WalkingCharacter
 ├ FlyingCharacter
 └ AttackingCharacter

のようにしたくなるかもしれません。

ところが、

飛べて、しかも攻撃できるキャラクター

が必要になったらどうでしょうか。

C#では、クラスの多重継承はできません。

そのため、次のようには書けません。

// C#ではこのようなクラスの多重継承はできない
class FlyingAttacker : FlyingCharacter, AttackingCharacter
{
}

継承だけで対応しようとすると、組み合わせごとにクラスを増やしたくなります。

インターフェースを複数実装すればよいのでは?

C#ではクラスの多重継承はできませんが、インターフェースは複数実装できます。

たとえば、

interface ICanFly
{
    void Fly();
}

interface ICanAttack
{
    void Attack();
}

として、

class FlyingEnemy : ICanFly, ICanAttack
{
    public void Fly()
    {
        // 飛ぶ処理
    }

    public void Attack()
    {
        // 攻撃処理
    }
}

と書くことはできます。

では、これで十分なのでしょうか。

インターフェースは、

どのような操作を持つか

という約束を決めるために使えます。

しかし、Fly()Attack() の具体的な処理は、それぞれのクラス側で用意する必要があります。

C# 8以降ではインターフェースにデフォルト実装を書くこともできますが、通常のクラスのようにインスタンスフィールドを持って状態を保持することはできません。

そこでコンポジションを使うと、

FlyMovement

のように、具体的な処理や状態を持った部品そのものを、さまざまなクラスで再利用できます。

大切なのは、

インターフェースとコンポジションは対立するものではない

ということです。

この記事の Enemy では、両方を一緒に使っています。

IMovement
   ↓
部品の差し込み口・約束

WalkMovement / FlyMovement
   ↓
実際の部品

Enemy
   ↓
その部品を組み合わせて使う

つまり、

インターフェースは部品の差し込み口を決め、コンポジションはそこに実際の部品を組み合わせる

と考えると分かりやすいでしょう。

継承では「掛け算」、コンポジションでは「足し算」になりやすい

では、能力の組み合わせが増えるとどうなるでしょうか。

たとえば、

移動方法
・歩く
・飛ぶ
・泳ぐ

攻撃方法
・近接攻撃
・遠距離攻撃

があるとします。

継承によって組み合わせごとのクラスを作ると、

移動 3種類 × 攻撃 2種類
= 6種類

になります。

たとえば、

WalkingMeleeEnemy
WalkingRangedEnemy
FlyingMeleeEnemy
FlyingRangedEnemy
SwimmingMeleeEnemy
SwimmingRangedEnemy

です。

一方、コンポジションなら、

移動の部品
WalkMovement
FlyMovement
SwimMovement

攻撃の部品
MeleeAttack
RangedAttack

と分けられます。

つまり、

継承
移動 × 攻撃
3 × 2 = 6種類

コンポジション
移動 + 攻撃
3 + 2 = 5部品

です。

小さな例では差が小さく見えます。

しかし、移動方法が10種類、攻撃方法も10種類になったとすると、

継承
10 × 10 = 100種類

コンポジション
10 + 10 = 20部品

となります。

さらに防御方法や特殊能力まで増えると、組み合わせの差はもっと大きくなります。

これが、コンポジションが大規模な設計で力を発揮する理由の一つです。

移動と攻撃を両方組み合わせる

では、実際に組み合わせてみましょう。

攻撃についても、移動と同じようにインターフェースを作ります。

interface IAttack
{
    void Attack();
}

近接攻撃を実装します。

class MeleeAttack : IAttack
{
    public void Attack()
    {
        Console.WriteLine("近接攻撃");
    }
}

遠距離攻撃も作れます。

class RangedAttack : IAttack
{
    public void Attack()
    {
        Console.WriteLine("遠距離攻撃");
    }
}

そして、先ほどの Enemy を、移動方法と攻撃方法の両方を受け取るように拡張します。

class Enemy
{
    private readonly IMovement movement;
    private readonly IAttack attack;

    public Enemy(IMovement movement, IAttack attack)
    {
        this.movement = movement;
        this.attack = attack;
    }

    public void Move()
    {
        movement.Move();
    }

    public void Attack()
    {
        attack.Attack();
    }
}

すると、

Enemy enemy1 =
    new Enemy(new WalkMovement(), new MeleeAttack());

Enemy enemy2 =
    new Enemy(new FlyMovement(), new RangedAttack());

のように作れます。

構造としては、

Enemy
 ├ IMovement
 └ IAttack

です。

そして実際には、

Enemy
 ├ FlyMovement
 └ RangedAttack

のように、必要な機能を組み合わせられます。

新しい移動方法を1つ追加したいときも、IMovement を実装した部品を1つ書くだけです。攻撃側のクラスには手を入れる必要がありません。

継承とコンポジションは使い分ける

ここまでを見ると、

Favor object composition over class inheritance.

という考え方が具体的に見えてきます。

ただし、

継承を使ってはいけない

という意味ではありません。

たとえば、

class Animal
{
}

class Dog : Animal
{
}

なら、

Dog is an Animal.

という関係が自然です。

このような場合には継承が適しています。

また、フレームワークの仕組みに参加するために継承する場合もあります。

Windows Formsでは、

public partial class Form1 : Form
{
}

と書きます。

Unityでは、

public class PlayerController : MonoBehaviour
{
}

と書きます。

これらは、

単に機能を再利用するためだけの継承

とは少し意味が違います。

コンポジションにも手間はある

コンポジションには柔軟性がありますが、無料で得られるわけではありません。

たとえば、

Enemy enemy =
    new Enemy(new FlyMovement(), new RangedAttack());

のように、どの部品を組み合わせるかをどこかで決める必要があります。

また、

public void Move()
{
    movement.Move();
}

のような中継用のメソッドを書くこともあります。

つまり、

コンポジションなら常に正解

というわけではありません。

小さく単純な設計では、継承や直接実装のほうが分かりやすい場合もあります。

Unityはコンポジションを理解しやすい

UnityのGameObjectを考えてみましょう。

PlayerというGameObjectに、

Player
 ├ Transform
 ├ Rigidbody
 ├ Collider
 └ PlayerController

のようにComponentを追加していきます。

Playerが巨大なクラスを継承してすべての機能を手に入れるのではありません。

必要なComponentを追加することで機能を構成します。

たとえば音を出したければ、

AudioSource

を追加できます。

物理演算が必要なら、

Rigidbody

を追加できます。

このようにUnityの従来のGameObject / Component方式は、コンポジションを理解するよい例です。

Unityでは「外から渡す」方法が少し違う

ここまでの Enemy は通常のC#クラスです。

Enemy enemy = new Enemy(new FlyMovement(), new RangedAttack());

のように、自分で new してコンストラクタへ部品を渡しています。

しかし、Unityの MonoBehaviour は通常、自分で new して作りません。

UnityがGameObjectのComponentとして生成します。

そのため、MonoBehaviour では、先ほどとまったく同じ形のコンストラクタ渡しは通常使えません。

Unityでは、たとえばInspectorからComponentを渡す方法があります。

public class PlayerController : MonoBehaviour
{
    [SerializeField]
    private Rigidbody rb;
}

Inspectorで Rigidbody を設定すれば、

必要な部品を外から渡す

という考え方になります。

これは、考え方としてDIに近いものです。

また、同じGameObjectに付いているComponentなら、

private Rigidbody rb;

void Awake()
{
    rb = GetComponent<Rigidbody>();
}

のように取得することもできます。

そして、

rb.AddForce(...);

のように処理を任せれば、PlayerControllerRigidbody に委譲していることになります。

初学者の段階では、

通常のC#クラス
→ コンストラクタで渡す

UnityのMonoBehaviour
→ Inspectorから渡す
  またはGetComponentで取得する

くらいに理解しておけば十分です。

MonoBehaviourの継承とコンポジションは両立する

Unityでは、

public class PlayerController : MonoBehaviour

と継承します。

しかし、その PlayerController 自体はGameObjectに付けるComponentです。

つまり、

MonoBehaviourを継承する
        ↓
UnityのComponentとして使える

GameObject
        ↓
複数のComponentを組み合わせる

という構造です。

ここでは、継承とコンポジションが同時に使われています。

MonoBehaviour の継承は、主にUnityの仕組みに参加してGameObjectへComponentとして付けられるようにするためのものです。

一方、GameObjectに複数のComponentを付ける部分はコンポジションです。

コラム(発展):Unity ECSとの関係

※ このコラムは、コンポジションを理解したあとに学ぶ発展的な内容です。読み飛ばしても本編は分かります。

Unityには、従来のGameObject / MonoBehaviour方式とは別に、Entitiesパッケージを中心としたECS(DOTS)があります。

ECSでは、概念的に、

Entity
= 存在を識別するもの

Component
= データ

System
= 処理

という役割に分けます。

UnityのGameObject / Component方式と似て見える部分もありますが、

× 継承
  ↓
  コンポジション
  ↓
  Unity Component
  ↓
  ECS

という単純な進化の流れではありません。

ECSには、大量のデータを効率よく処理することや、メモリ配置、CPUキャッシュなどを意識したデータ指向設計という別の目的があります。

まとめ

継承では、

Dog is an Animal.

という is-a の関係を表せます。

コンポジションでは、

Car has an Engine.

という has-a の関係を表せます。

さらに、部品を外から渡し、インターフェース型で受け取るようにすると、

Enemy
 + WalkMovement
 + MeleeAttack

や、

Enemy
 + FlyMovement
 + RangedAttack

のように、機能を組み合わせたり差し替えたりできます。

特に覚えておきたいのは、

継承は「〜の一種」という種類の整理が得意で、コンポジションは「能力の組み合わせ」が得意

という違いです。

また、組み合わせが増えたときには、

継承
→ 掛け算になりやすい

コンポジション
→ 足し算になりやすい

という違いも現れます。

だからといって、継承が悪いわけでも、コンポジションが常に正解なわけでもありません。

クラスを設計するときには、

本当に継承が自然だろうか?
別のオブジェクトを部品として持たせたほうが柔軟ではないだろうか?

と考えてみましょう。

継承が自然なら継承を使う。

組み合わせたほうが柔軟ならコンポジションを使う。

その判断ができるようになることが、継承を学んだ次のステップです。

補足:サンプルコードを動かすときの注意

この記事のコードは、説明のために断片に分けています。実際に動かすときは、次の点に注意してください。

  • クラス名の重複Enemy は「差し替え」の例(IMovement だけを受け取る版)と、「移動と攻撃を組み合わせる」例(IMovementIAttack を受け取る版)の2回出てきます。後者は前者を拡張したものなので、1つのファイルに両方を貼ると重複エラーになります。どちらか一方だけを使ってください。
  • トップレベルステートメント:.NET 6以降のコンソールアプリでは、new Enemy(...) などの実行コードを、クラス定義より前に書く必要があります。クラス定義は、実行コードの後ろにまとめてください。
  • using System;Console.WriteLine を使うので、環境によっては using System; が必要です。
訪問数 4 回, 今日の訪問数 4回

広告