Skip to content

2. 依存・結合とは

クラス設計において基礎的な概念である依存と結合について改めて確認します。

依存

クラスAがクラスBの機能を使わないと仕事ができないとき、AはBに依存しているといいます。コードで言えば、AがBの型を参照している、Bのメソッドを呼んでいる、Bのインスタンスを持っている、これらは全てAがBに依存している状態です。

csharp
// PlayerはSwordが無いと攻撃できない → PlayerはSwordに依存している(Player → Sword)
public class Player
{
    Sword sword = new();

    public void Attack()
    {
        sword.Slash();
    }
}

// SwordはPlayerのことを知らない → SwordはPlayerに依存していない
public class Sword
{
    public void Slash() { /* 斬撃 */ }
}

依存の怖いところは、変更の波及です。Bに変更が入ると、Bに依存しているAも影響を受け、修正を余儀なくされる可能性があります。依存先が多いほど、また依存が強いほど、一つの変更が広範囲に波及して修正コストが膨れ上がります。

結合

どの程度依存しているのかという関係性を表すのが結合です。依存先のクラスの内部事情、すなわちフィールドやメソッドの中身などを知っており、その知識ありきでコードを書いていればそれだけ結合は強くなります。結合が強い状態を密結合といい、逆に結合が弱い状態を疎結合といいます。

csharp
// ❌ Bad: 密結合 — PlayerがSwordの内部事情をガッツリ知っている
public class Player
{
    Sword sword = new();

    public void Attack(Enemy enemy)
    {
        // Swordの「攻撃力は基礎値×強化レベル」という内部計算を外からやっている
        int damage = sword.basePower * sword.enhanceLevel;
        // Swordの「耐久度が0以下だと壊れる」という内部ルールも外から管理
        sword.durability--;
        if (sword.durability <= 0)
        {
            sword.isBroken = true;
        }
        enemy.TakeDamage(damage);
    }
}

public class Sword
{
    public int basePower = 10;
    public int enhanceLevel = 1;
    public int durability = 100;
    public bool isBroken = false;
}
// → Swordのフィールド名を1つ変えるだけでPlayerも修正が必要
// → Swordのダメージ計算式を変えたらPlayerも修正が必要
// → Swordの耐久ルールを変えたらPlayerも修正が必要
// → 全部Swordの話なのに、なぜPlayerを修正しなくてはいけないのか?

一般に、結合は弱ければ弱いほど良いとされています。結合が強ければそれだけ依存先のクラスの内部事情に左右され、依存先のクラスが少し変更されただけでも、その影響を受けコードを修正しなくてはいけなくなります。

csharp
// ✅ Good: 疎結合 — PlayerはSwordの内部事情を知らない
public class Player
{
    Sword sword = new();

    public void Attack(Enemy enemy)
    {
        // 「ダメージを計算する」「耐久度を減らす」という詳細はSwordに任せる
        int damage = sword.CalculateDamage();
        sword.Use();
        enemy.TakeDamage(damage);
    }
}

public class Sword
{
    int basePower = 10;
    int enhanceLevel = 1;
    int durability = 100;

    public int CalculateDamage() => basePower * enhanceLevel;

    public void Use()
    {
        durability--;
    }

    public bool IsBroken => durability <= 0;
}
// → ダメージ計算式が変わってもSwordだけ修正すれば済む
// → 耐久度のルールが変わってもSwordだけ修正すれば済む
// → PlayerはSwordの「何ができるか」だけ知っていればいい

より疎結合な状態にするには、間にインターフェースなどの抽象を挟み、その抽象に依存するようにすることです。こうすれば、依存先の具体的なクラスを差し替えても、呼び出し側は一切変更する必要がありません。

csharp
// ✅ Better: インターフェースを挟んでさらに疎結合に
public interface IWeapon
{
    int CalculateDamage();
    void Use();
    bool IsBroken { get; }
}

public class Player
{
    IWeapon weapon;

    public Player(IWeapon weapon)
    {
        this.weapon = weapon;
    }

    public void Attack(Enemy enemy)
    {
        int damage = weapon.CalculateDamage();
        weapon.Use();
        enemy.TakeDamage(damage);
    }
}

// SwordもBowもIWeaponを実装するだけでPlayerにそのまま渡せる
public class Sword : IWeapon
{
    int basePower = 10;
    int enhanceLevel = 1;
    int durability = 100;

    public int CalculateDamage() => basePower * enhanceLevel;
    public void Use() => durability--;
    public bool IsBroken => durability <= 0;
}

public class Bow : IWeapon
{
    int power = 8;
    int arrowCount = 30;

    public int CalculateDamage() => power;
    public void Use() => arrowCount--;
    public bool IsBroken => arrowCount <= 0;
}
// → 新しい武器を追加したい? IWeaponを実装するクラスを作るだけ
// → Playerのコードは一行も変更しなくていい
// → PlayerはSwordやBowの存在すら知らない

ただし、何でもかんでもインターフェースを挟んで疎結合にするのは現実的ではありません。過度な抽象化は冗長になり、かえって保守性が下がってしまいます。疎結合化によるメリットとそれによって生じるコストとのバランスを保つことが重要です。

相互依存

依存の中でも特に危険なのが相互依存です。相互依存とは、クラスAがクラスBに依存し、同時にクラスBもクラスAに依存している状態のことです。これは絶対に避けましょう。相互依存が厄介な理由は、変更の影響が双方向に波及するということです。AをいじればBが壊れ、Bを直せばAが壊れ…と、まるで無限ループに陥る可能性があります。ロジックとしても下手で、動作がとても追いにくいです。依存の方向は絶対に一方通行にしましょう。どうしても相互依存が直せないなら、クラスに統合しましょう。

csharp
// ❌ Bad: 相互依存 — PlayerとEnemyがお互いを参照し合っている
public class Player : MonoBehaviour
{
    [SerializeField] Enemy enemy; // インスペクターから注入

    public int hp = 100;

    void Update()
    {
        // 敵が近くにいたら攻撃
        if (Vector3.Distance(transform.position, enemy.transform.position) < 2f)
        {
            enemy.TakeDamage(10);
        }
    }

    public void TakeDamage(int damage)
    {
        hp -= damage;
        if (hp <= 0)
        {
            // 死亡時、敵にも通知
            enemy.OnPlayerDied();
            Destroy(gameObject);
        }
    }
}

public class Enemy : MonoBehaviour
{
    [SerializeField] Player player; // インスペクターから注入

    public int hp = 50;

    void Update()
    {
        // プレイヤーを追いかける
        transform.position = Vector3.MoveTowards(
            transform.position,
            player.transform.position,
            2f * Time.deltaTime
        );

        // プレイヤーが近くにいたら攻撃
        if (Vector3.Distance(transform.position, player.transform.position) < 1.5f)
        {
            player.TakeDamage(5);
        }
    }

    public void TakeDamage(int damage)
    {
        hp -= damage;
        if (hp <= 0)
        {
            Destroy(gameObject);
        }
    }

    public void OnPlayerDied()
    {
        // プレイヤーが死んだら追跡をやめる(ここでは簡略化のため何もしない)
    }
}
// → PlayerもEnemyも、お互いのインスタンスをインスペクターから注入しないといけない
// → Playerを修正するとEnemyが壊れ、Enemyを修正するとPlayerが壊れる
// → どちらか一方だけをテストすることが不可能
// → 循環参照で初期化順序の問題も起きやすい

Unity設計講習会 資料公開ページ