🦁

[c#, unity] interface のススメ

2025/02/28に公開

interface はクラスの継承に疲れてきた人にオススメです。
まだ、クラスの継承と付き合いはじめたばかりの楽しいころであれば、本記事は不要かもしれません。
そういうのもあるんだ、程度に流してください。

使いどころ その1:クラスの継承を置き換える

EnemyA の親クラス、Enemy

たとえば敵(Enemy)をA、B、C……と用意した時、まず考えられるのは Enemy の親クラスを用意し、それを継承した EnemyA、EnemyB、EnemyC を作る方法です。

そうすることで、このような敵リストを作り、管理することができます。

public class Main
{
  List<Enemy> enemies = new List<Enemy>()
  {
    new EnemyA(),
    new EnemyB(),
    new EnemyA(),
    new EnemyC(),
  };

  void Awake()
  {
    // リストの敵をすべて初期化
    foreach (var enemy in enemies)
    {
      enemy.Initialize();
    }
  }
}

実装が Enemy にあるのか、EnemyA にあるのか?

この手法は使い慣れてくると、「実装(主体)が Enemy にあるのか、EnemyA にあるのか?」とこんがらがってくるところが問題です。例えば次のようなコードがあったとします。

Enemy.cs
public class Enemy
{
  // 未初期化だと -1
  internal int hp = -1;
  internal int attack;

  public virtual void Initialize()
  {
    hp = 0;
    attack = 0;
  }
}
EnemyA.cs
public class EnemyA : Enemy
{
  public override void Initialize()
  {
    attack = 100;
  }
}

EnemyA.cs: Initialize() に base.Initialize() を書き忘れてしまいました。
hp は初期化されませんが、EnemyA を見たり、EnemyA にブレークを置いても原因はわかりません。

Enemy のコードを見ればわかりますが、見ないとわからない、というのは結構厄介です。
ここまで単純なコードであれば抵抗はないでしょうが、Enemy のコードが複雑化していた場合、理由を突き止めるのに結構時間がかかるかもしれません。

今度は次のようなケース。経験ないでしょうか。

Enemy.cs
public class Enemy
{
  // 未初期化だと -1
  internal int hp = -1;
  internal int attack;

  public virtual void Initialize()
  {
    hp = 0;
    attack = 0;
  }
}
EnemyA.cs
public class EnemyA : Enemy
{
  public override void Initialize()
  {
    attack = 100;
    hp = 40;

    base.Initialize();
  }
}

一見問題なさそうです。base.Initialize() も入っています。
問題は base.Initialize を呼ぶ場所です。そのせいで hp も attack も、後から必ず 0 クリアされてしまいます

Enemy にちゃんとした設計思想があればこのようなことは起こらないかもしれません。
でも、往々にして Enemy と EnemyA、何をどっちが引き受けるかグレーゾーンになるものです。
今回は、Enemy を上司が作ったので変えられない、ということで次のようにします。

EnemyA.cs
  public override void Initialize()
  {
    // 場所を上にしてみた
    base.Initialize();

    attack = 100;
    hp = 40;
  }

さて、この Initialize() がもっと複雑化し、上司のおかげで base.Initialize() を後に呼ぶ必要も出てきたらどうなってしまうのでしょうか?

virtual と override の呪い

固有部分は override、共通部分は virtual に書けるクラスの継承はたしかに便利ですが、共通部分は時間が経つほど歪んでいき、崩壊していくものです。
いったん崩壊すると子クラス、親クラスともに絶えずお互いの動向をビクビクと怯えながらコード変更することになります。

なのでもう、いっその事「virtual では何もしない」という運用を考えるようになります。
少し不便になりますが、将来起こり得る面倒を経験し、メリットとデメリットを秤にかけると、段々そうなりがちです。

Enemy.cs
public class Enemy
{
  // 未初期化だと -1
  internal int hp = -1;
  internal int attack;

  public virtual void Initialize()
  {
    // nothing
  }
}
EnemyA.cs
public class EnemyA : Enemy
{
  public override void Initialize()
  {
    attack = 100;
    hp = 40;
  }
}

これでもう base.Initialize() を呼ぶ必要もなくなりました。
ただ、このままだと将来 virtual void Initialize() に何かを記述する不届き者が出ないとは限りません。
それを防ぐために interface を使います。

EnemyA と interface IEnemy

interface はメソッドの宣言のみです。その interface を継承したクラスでは、必ずメソッドを用意する必要があります(ないとエラーになります)。

IEnemy.cs
public interface IEnemy
{
  public void Initialize();
}
EnemyA.cs
public class EnemyA : IEnemy
{
  int attack;
  int hp = -1;
  
  public void Initialize()
  {
    attack = 100;
    hp = 40;
  }
}

変数の宣言は出来ませんので、敵のデータは EnemyData というクラスにまとめてみましょうか。
プロパティにした方がいいですが、それは今回の趣旨ではないので置いといて。

IEnemy.cs
public interface IEnemy
{
  public void Initialize();
}
EnemyData.cs
public class EnemyData
{
    // 未初期化は -1
    public int Hp = -1;
    public int Attack;
}
EnemyA.cs
public class EnemyA : IEnemy
{
    EnemyData data;

    public void Initialize()
    {
        data = new EnemyData()
        {
            Hp = 100,
            Attack = 40
        };
    }
}

これで、Enemy を書き換える人と、EnemyA を書き換える人の戦いが防げるようになりました。
IEnemy を勝手に変えると、それを使っている全ての Enemy? クラスでエラーになるので、不用意に変更することもないでしょう。

interface でメソッドは共通化されているので、Main の宣言も可能です。
Enemy が IEnemy になっていることに注意してください。

Main.cs
public class Main
{
  List<IEnemy> enemies = new List<IEnemy>()
  {
    new EnemyA(),
    new EnemyB(),
    new EnemyA(),
    new EnemyC(),
  };

  void Awake()
  {
    // リストの敵をすべて初期化
    foreach (var enemy in enemies)
    {
      enemy.Initialize();
    }
  }
}

interface って、不便?

メソッドしか宣言できないので、共通で持つ変数は EnemyData のように別に持つ必要もある。
interface で宣言したメソッドは必ず実装する必要がある。

「これって、単に不便になっただけじゃない?」と思うかもしれませんが、この不便さが開発後半でいらぬ問題を引き起こさずに済む理由にもなっています。良薬、口に苦し。

もちろん、クラスの継承で管理する方がいいケースもあります。
可能な限り interface を念頭に置きつつ、どうしても無理なら継承、というのが個人的にはいいバランスだと感じます。

使いどころ その2:機能をハッキリさせる

次のようなコードがあります。

public class Data
{
    public readonly string Name = "Alice";
    public List<string> Names { get; private set; } = new List<string> { "Alice", "Bob", "Charlie" };
}

Name は読み取り専用です。変更するとエラーになります。

Name = "Lonely";    // error

Names も読み取り専用にしたいのですが、プロパティに readonly は使えません。
ですので、get; private set; と頑張って定義してみました。

testData.Names[2] = "the 3rd";

エラーにならず、普通に書き換えできてしまいました……。

List<string> プロパティを readonly にするにはどうすればいいのでしょうか。
これを可能にするのが次のインターフェイスです。

public IReadOnlyList<string> Names { get; } = new List<string> { "Alice", "Bob", "Charlie" };

IReadOnlyList<string> は「読み取り専用にするインターフェイス」として List の中で宣言されています。

このように、「用途を制限するためにインターフェイスを使う」のもアリです。
Enemy で、例えば回復できる敵は IHeal インターフェイスを持つ、なんてのもアリでしょう。

IHeal.cs
public interface IHeal
{
    public void Heal(int amount);
}
EnemyA.cs
using UnityEngine;

public class EnemyA : IEnemy, IHeal
{
    const int maxhp = 15;
    int hp;

    public void Initialize() => hp = 10;

    public void Heal(int amount)
    {
        hp = Mathf.Clamp(hp + amount, 0, maxhp);
    }
}
EnemyB.cs
using UnityEngine;

public class EnemyB : IEnemy, IHeal
{
    const int maxhp = 30;
    int hp;

    public void Initialize() => hp = 10;

    public void Heal(int amount)
    {
        // こっちはかけ算
        hp = Mathf.Clamp(hp * amount, 0, maxhp);
    }
}
EnemyC.cs
public class EnemyC : IEnemy
{
}
Main.cs
using System.Collections.Generic;

public class Main
{
  List<IEnemy> enemies = new List<IEnemy>()
  {
    new EnemyA(),
    new EnemyB(),
    new EnemyC(),
  };

  void Awake()
  {
    // リストの敵をすべて初期化
    foreach (var enemy in enemies)
    {
      // ヒール可能な敵のみ処理
      if (enemy is IHeal)
      {
        var heal = enemy as IHeal;
        heal.Heal(10);
      }
    }
  }
}

unity のインスペクターでは interface が使えない……

Serializeable Inspector というパッケージを使うと可能になります。
別の記事にて公開していますので、よろしければご覧ください。

https://zenn.dev/catsnipe/articles/65f379bbfafb4a

終わりに

インターフェイスは「クラス継承」にまつわる親と子の骨肉の争いをなくすことが出来ます。
また、必ずメソッドを宣言するという制約のおかげでインターフェイスを多重継承したり、「機能をあえて制限」するために使うこともできます。

反面、正直プログラム初心者だと、この有難みは感じにくいです。
ゲームを1つ2つ完成させるころには、interface の有用さに気づいてくるのではないでしょうか。

クラスの継承は便利だけど、なんかしんどいんだよな……と思うようになったら、interface のことを思い出してあげてください。

Discussion