Skip to content

8. インターフェースを活用する

ここまでに紹介したことをやると、設計としてはだいぶ見通しが良くなってきます。ここで更にインターフェースを上手く設計に活用することで設計は更に洗練されます。ここでは、設計におけるインターフェースの活用例をいくつか紹介します。

Strategyパターンで処理を切り替え

第1回で紹介したStrategyパターンを改めて確認しましょう。Strategyパターンとは、ある処理をインターフェースとして抽象化し、その実装を差し替えることで処理を切り替えるパターンでした。

csharp
// ❌ Bad: switchが多くの場所に散らばる
void Attack(WeaponType type)
{
    switch (type)
    {
        case WeaponType.Sword: /* 剣の攻撃処理 */ break;
        case WeaponType.Bow:   /* 弓の攻撃処理 */ break;
        case WeaponType.Staff: /* 杖の攻撃処理 */ break;
    }
}

void PlayAttackSound(WeaponType type)
{
    switch (type)
    {
        case WeaponType.Sword: /* 剣の攻撃音 */ break;
        case WeaponType.Bow:   /* 弓の攻撃音 */ break;
        case WeaponType.Staff: /* 杖の攻撃音 */ break;
    }
}
// → WeaponTypeに新しい値が追加されたら、全てのswitchを探して修正しなきゃいけない!
csharp
// 各switchの処理をインターフェースで抽象化
public interface IWeapon
{
    void Attack();
    void PlayAttackSound();
}

// 各ケースをクラスとして実装
public class Sword : IWeapon
{
    public void Attack() { /* 剣の攻撃処理 */ }
    public void PlayAttackSound() { /* 剣の攻撃音 */ }
}

public class Bow : IWeapon
{
    public void Attack() { /* 弓の攻撃処理 */ }
    public void PlayAttackSound() { /* 弓の攻撃音 */ }
}

public class Staff : IWeapon
{
    public void Attack() { /* 杖の攻撃処理 */ }
    public void PlayAttackSound() { /* 杖の攻撃音 */ }
}
csharp
// 辞書で条件に応じたインスタンスを取得
Dictionary<WeaponType, IWeapon> weapons = new()
{
    { WeaponType.Sword, new Sword() },
    { WeaponType.Bow,   new Bow() },
    { WeaponType.Staff, new Staff() },
};

// 呼び出す側はswitch不要! 相手が何であるかを知る必要がない
IWeapon weapon = weapons[currentWeaponType];
weapon.Attack();
weapon.PlayAttackSound();
// → 新しい武器を追加するときは、クラスを作って辞書に追加するだけ!

これにより、開放閉鎖の原則や依存性逆転の原則を自然と満たすことができます。

異なるクラスを統一的に扱う

インターフェースを使うと、異なるクラスのインスタンスを共通のインターフェースにキャストして統一的に扱うことができるようになります。

csharp
// ❌ Bad: 型で分岐して個別に処理
public class EnemyManager : MonoBehaviour
{
    List<Slime> slimes = new();
    List<Skeleton> skeletons = new();
    List<Dragon> dragons = new();

    void Update()
    {
        foreach (var slime in slimes) slime.SlimeAct();
        foreach (var skeleton in skeletons) skeleton.SkeletonAct();
        foreach (var dragon in dragons) dragon.DragonAct();
    }

    public void DamageAll(int amount)
    {
        foreach (var slime in slimes) slime.SlimeTakeDamage(amount);
        foreach (var skeleton in skeletons) skeleton.SkeletonTakeDamage(amount);
        foreach (var dragon in dragons) dragon.DragonTakeDamage(amount);
    }
}
// → 新しい敵を追加するたびにリストもforeachも増えていく
// → EnemyManagerが全種類の敵クラスを知っている(密結合)

インターフェースで共通の契約を定義すれば、全ての敵を一つのリストで管理し、同じように扱うことができます。

csharp
// ✅ Good: インターフェースで統一的に扱う
public interface IEnemy
{
    void Act();
    void TakeDamage(int amount);
    bool IsDead { get; }
}

public class Slime : IEnemy
{
    int hp = 30;
    public bool IsDead => hp <= 0;

    public void Act()
    {
        // スライム固有の行動(跳ねて体当たり)
    }

    public void TakeDamage(int amount) => hp -= amount;
}

public class Skeleton : IEnemy
{
    int hp = 50;
    public bool IsDead => hp <= 0;

    public void Act()
    {
        // スケルトン固有の行動(剣で斬りかかる)
    }

    public void TakeDamage(int amount) => hp -= amount;
}

public class Dragon : IEnemy
{
    int hp = 200;
    public bool IsDead => hp <= 0;

    public void Act()
    {
        // ドラゴン固有の行動(ブレスを吐く)
    }

    public void TakeDamage(int amount) => hp -= amount / 2; // ドラゴンは硬い
}
csharp
// 全ての敵を IEnemy のリスト1つで管理できる
public class EnemyManager : MonoBehaviour
{
    List<IEnemy> enemies = new();

    void Update()
    {
        // 全ての敵に対して同じように命令するだけ
        foreach (var enemy in enemies)
        {
            enemy.Act(); // スライムは跳ね、スケルトンは斬り、ドラゴンはブレスを吐く
        }
    }

    public void DamageAll(int amount)
    {
        foreach (var enemy in enemies)
        {
            enemy.TakeDamage(amount);
        }

        // 倒した敵を除外
        enemies.RemoveAll(e => e.IsDead);
    }
}
// → EnemyManagerはSlime, Skeleton, Dragonの存在を知らない
// → 新しい敵を追加したい? IEnemyを実装するクラスを作ってリストに入れるだけ
// → EnemyManagerのコードは一行も変更しなくていい

技術的関心を隠蔽する

例えば、ゲームのセーブデータを保存する機能を考えてみましょう。Unityでデータを保存する方法はいろいろあります。PlayerPrefsを使う方法、JSONファイルに書き出す方法、将来的にはサーバーに送信する方法もあるかもしれません。

セーブの方法はプロジェクトが進むにつれてどんどん変更される可能性があります。しかし、セーブ機能を利用する側としては、どんな方法でセーブするかなどは知ったことではありませんし、そこで使われる技術の関心事も知りたくありません。

csharp
// ❌ Bad: 保存の技術(PlayerPrefs)に直接依存している
public class GameSaveSystem
{
    public void SaveScore(int score)
    {
        PlayerPrefs.SetInt("Score", score);
        PlayerPrefs.Save();
    }

    public int LoadScore()
    {
        return PlayerPrefs.GetInt("Score", 0);
    }
}
// → 「JSONファイルに変えたい」と言われたら? このクラスを丸ごと修正する必要がある
// → PlayerPrefsをどうやって利用するか知っている必要がある

そこで、「データを保存する」「データを読み込む」という操作をインターフェースとして定義し、具体的な保存方法や関心事を実装クラスに隔離します。これにより、セーブ機能を利用する側はどのような技術でそれが実現されてるか知る必要がなく、また、その技術の関心事についても同様に知らずに機能を利用できます。ここにおいて、技術の変更も実装を差し替えるだけで容易です。

csharp
// ✅ Good: 保存方法をインターフェースで抽象化

// 「保存と読み込みができる」という契約だけを定義
public interface IDataStore
{
    void Save(string key, string value);
    string Load(string key);
}

// PlayerPrefsを使った実装
public class PlayerPrefsDataStore : IDataStore
{
    public void Save(string key, string value)
    {
        PlayerPrefs.SetString(key, value);
        PlayerPrefs.Save();
    }

    public string Load(string key)
    {
        return PlayerPrefs.GetString(key, "");
    }
}

// JSONファイルを使った実装
public class JsonFileDataStore : IDataStore
{
    readonly string directoryPath;

    public JsonFileDataStore(string directoryPath)
    {
        this.directoryPath = directoryPath;
    }

    public void Save(string key, string value)
    {
        string path = Path.Combine(directoryPath, $"{key}.json");
        File.WriteAllText(path, value);
    }

    public string Load(string key)
    {
        string path = Path.Combine(directoryPath, $"{key}.json");
        if (!File.Exists(path)) return "";
        return File.ReadAllText(path);
    }
}
csharp
// セーブシステムは IDataStore だけに依存する
public class GameSaveSystem
{
    readonly IDataStore dataStore;

    public GameSaveSystem(IDataStore dataStore)
    {
        this.dataStore = dataStore;
    }

    public void SaveScore(int score)
    {
        dataStore.Save("Score", score.ToString());
    }

    public int LoadScore()
    {
        string value = dataStore.Load("Score");
        return int.TryParse(value, out int score) ? score : 0;
    }
}

// → GameSaveSystemはPlayerPrefsの存在もJSONファイルの存在も知らない
// → 保存方法を変えたい? 注入する実装を変えるだけ。GameSaveSystemは一行も変更しなくていい

これは依存性逆転の原則そのものです。「セーブする」という安定した本質的なルールが、変わりやすい具体的な技術(PlayerPrefsやJSON)に直接依存しない。両者の間に IDataStore という抽象を挟んで、具体的な技術を隠蔽しているのです。

モックを差し込んでテストする

インターフェースで抽象化する大きなメリットの一つが、テストのしやすさです。

先程のセーブシステムを例にします。もし GameSaveSystemPlayerPrefs に直接依存していたら、テストするたびに実際に PlayerPrefs に値が書き込まれてしまいます。テスト終了後にクリーンアップも必要ですし、テストの実行環境に依存してしまいます。しかしインターフェースで抽象化していれば、テスト用の偽物(モック)を簡単に差し込むことができます。

csharp
// テスト用の偽物:実際の保存はせず、辞書にデータを保持するだけ
public class MockDataStore : IDataStore
{
    readonly Dictionary<string, string> data = new();

    public void Save(string key, string value)
    {
        data[key] = value;
    }

    public string Load(string key)
    {
        return data.TryGetValue(key, out string value) ? value : "";
    }
}

// PlayerPrefsを一切使わずにテストできる!

MockDataStoreIDataStore を実装しているので、GameSaveSystem にそのまま渡せます。PlayerPrefsもファイルシステムも一切触らずに、しかもコードを一切修正することなく、GameSaveSystem のロジックだけを純粋にテストできるのです。

このように、差し替えるのが簡単なことを利用して、モックを使用した単体テストが非常にやりやすいのがインターフェースによる疎結合化の利点の一つです。

インターフェースの注意点

インターフェースを使うと疎結合になり基本的に保守性が向上しますが、何でもかんでもインターフェースにするのは誤りです。過度な抽象化は冗長になり、かえって保守性が下がります。インターフェースを導入する価値があるのは、主に以下のような場面です。

  • 実装を差し替えたい場面がある
  • 具体的な技術に依存したくない
  • テスト時にモックを差し込みたい
  • 複数の共通する実装が存在する

逆に、実装が一つしか存在せず今後も増えないのなら、無理にインターフェースを作る必要はありません。YAGNI原則に従い、必要になったときに導入すれば十分です。

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