interface
が理解しずらい大きな要因は、道具のような側面 を持つからではないでしょうか。interface
の使い方を覚える事で理解が進みますので5つの使い方を紹介したいと思います。
[使い方1] 依存性の逆転
抽象に依存させることで大切なデータや処理を外部から守る事ができます。
下記のコードではPlayer
オブジェクトはセーブシステムの事を知りません。セーブシステムが変更になってもその影響を受けません。結果、大切なオブジェクトPlayer
の安定性が向上します。確認、修正の必要が無いコードが多ければ多いほどバグは少なくなります。
サンプルコード
Player
とセーブシステムを切り離す為のinterface
public interface IPlayerStatusRepository
{
PlayerStatus Get();
void Set(PlayerStatus status);
}
Repository
セーブデータの保存/ロードを担当、下記はダミーなので何も保存しない。
/// <summary>
/// 何も保存しないダミー
/// </summary>
public sealed class DummyPlayerStatusRepository : IPlayerStatusRepository
{
public PlayerStatus Get() => new (10);
public void Set(PlayerStatus status){}
}
Player
Repositoryが変更になってもPlayer
に変更が発生しないのがコードから分かる
using UnityEngine;
public sealed class Player : MonoBehaviour
{
PlayerStatus _status;
IPlayerStatusRepository _repository;
/// <summary>
/// 依存の解決
/// </summary>
public void Inject(IPlayerStatusRepository repository)
{
_repository = repository;
_status = _repository.Get();
}
/// <summary>
/// ダメージを受けた
/// </summary>
public void TakeDamage(int value)
{
// Hpを減らす
_status.RemoveHealth(value);
// PlayerStatusに変更が発生したのでRepositoryに設定
_repository.Set(_status);
}
}
Resolve 依存の解決
DIを使った方がいいですがここでは適当にAwake()
で実行
本番用のRepositoryが完成したらDummyPlayerStatusRepository
を本番用に書き換える想定
void Awake()
{
// 依存の解決
_player.Inject(new DummyPlayerStatusRepository());
}
[使い方2] 実装の宣言
「私は〇〇ができるよ」と外部に宣言できます。interface
を実装していれば処理を実行するという用途に使えます。
下記のコードでは、Enemy
とTreasureChest
(宝箱)に「私はダメージを受ける事ができるよ」と外部に宣言し外部からダメージを与える処理を実行する例です。
サンプルコード
ダメージを受ける事を宣言するinterface
public interface IDamageable
{
int Health { get; }
void TakeDamage(int value);
}
敵はダメージを受けさせたいので実装
using UnityEngine;
/// <summary>
/// 敵
/// </summary>
public sealed class Enemy : MonoBehaviour, IDamageable
{
public int Health => _health;
int _health = 10;
public void TakeDamage(int value)
{
_health -= value;
if (_health <= 0)
{
// Healthが0になった場合の処理
}
}
}
宝箱もダメージを受けさせたいので実装
using UnityEngine;
/// <summary>
/// 宝箱
/// </summary>
public sealed class TreasureChest : MonoBehaviour, IDamageable
{
public int Health => _health;
int _health = 1;
public void TakeDamage(int value)
{
_health -= value;
if (_health <= 0)
{
// Healthが0になった場合の処理
}
}
}
Player
に衝突したオブジェクトにIDamageable
が実装されていれば殴る
using UnityEngine;
public sealed class Player : MonoBehaviour
{
/// <summary>
/// 衝突
/// </summary>
void OnCollisionEnter(Collision collision)
{
// IDamageableを実装していたら
if (collision.gameObject.TryGetComponent<IDamageable>(out var damageable))
{
// 殴る
damageable.TakeDamage(1);
}
}
}
[使い方3] 設計の明示
後から実装を追加したい場合や、複数人で開発する際、interface
にする事で必要な機能が分かりやすくなり便利です。
下記コードはシェア機能の例です。例えばiOSはSwiftが書けるエンジニアに、AndroidはJavaが書けるエンジニアにお願いするとします。各エンジニアはinterface
内にある必要な機能を実装するだけで良いので伝える事も最小限で済みます。
サンプルコード
シェアに必要な機能を表すinterface
interface
を見るだけで「テキストのシェア」と「画像+テキストのシェア」機能が必要な事が分かります。
public interface IShareHandler
{
/// <summary>
/// テキストのシェア
/// </summary>
void ShareText(string text);
/// <summary>
/// 画像とテキストのシェア
/// </summary>
void ShareImageAndText(byte[] image, string text);
}
iOSでシェア機能の実装
Swiftが書けるエンジニアにネイティブプラグインを書いてもらう
using UnityEngine;
/// <summary>
/// iOSでのシェア
/// </summary>
public sealed class iOSShare : MonoBehaviour, IShareHandler
{
public void ShareText(string text)
{
// iOSでテキストをシェア
}
public void ShareImageAndText(byte[] image, string text)
{
// iOSで画像とテキストをシェア
}
}
Androidでシェア機能の実装
Javaが書けるエンジニアにネイティブプラグインを書いてもらう
using UnityEngine;
/// <summary>
/// Androidでのシェア
/// </summary>
public sealed class AndroidShare : MonoBehaviour, IShareHandler
{
public void ShareText(string text)
{
// Androidでテキストをシェア
}
public void ShareImageAndText(byte[] image, string text)
{
// Androidで画像とテキストをシェア
}
}
Editorでシェア機能の実装
エディタは実行を確認できれば良いのでコメントを表示するだけ
using UnityEngine;
/// <summary>
/// エディタでのシェア
/// </summary>
public sealed class EditorShare : IShareHandler
{
public void ShareText(string text) => Debug.Log($"ShareText : {text}");
public void ShareImageAndText(byte[] image, string text) => Debug.Log($"ShareImageAndText : {text}");
}
シェアを管理/実行するスクリプト
プラットフォームによって使うスクリプトを変更する
using UnityEngine;
public static class Share
{
static IShareHandler s_handle;
/// <summary>
/// 初期化 ※対応プラットフォームが増えたらここに追加
/// </summary>
public static void Initialize()
{
if (s_handle != default)
{
return;
}
#if UNITY_EDITOR
// Editorの場合
s_handle = new EditorShare();
#elif UNITY_ANDROID
// Android端末の場合
var obj = new GameObject().AddComponent<AndroidShare>();
Object.DontDestroyOnLoad(obj.gameObject);
s_handle = obj;
#elif UNITY_IOS
// iOS端末の場合
var obj = new GameObject().AddComponent<iOSShare>();
Object.DontDestroyOnLoad(obj.gameObject);
s_handle = obj;
#endif
}
/// <summary>
/// テキストのシェア
/// </summary>
public static void ShareText(string text) => s_handle.ShareText(text);
/// <summary>
/// 画像とテキストのシェア
/// </summary>
public static void ShareImageAndText(byte[] image, string text) => s_handle.ShareImageAndText(image, text);
}
利用側は、プラットフォームに左右されず同じメソッドを呼び出す
Share.ShareText("テキストのシェア");
[使い方4] グループ化
まったく異なる機能を持つスクリプトにinterface
を実装する事で管理側でグループに対して処理を実行する事ができます。
下記コードは、Androidのバックボタン対応のスクリプトです。Dialog
が開いている時にバックボタンを押された場合、ダイアログを閉じる。StoryPlayer
が再生中にバックボタンを押された場合ストーリーを終了するなど、色々なオブジェクトにバックボタン処理を追加する事ができます。
サンプルコード
Androidバックボタン対応を可能にするinterface
/// <summary>
/// Androidバックボタンを利用したいスクリプトに実装
/// </summary>
public interface IAndroidBackButtonHandler
{
void Close();
}
Dialog
ダイアログを開いたタイミングで管理スクリプトにAdd
する
ダイアログを閉じるタイミングで管理スクリプトにRemove
する
using UnityEngine;
/// <summary>
/// ダイアログ
/// </summary>
public sealed class Dialog : MonoBehaviour, IAndroidBackButtonHandler
{
/// <summary>
/// ダイアログを開く
/// </summary>
public void OpenDialog()
{
AndroidBackButtonInvoker.Add(this);
// (省略)
}
/// <summary>
/// ダイアログを閉じる
/// </summary>
public void CloseDialog()
{
AndroidBackButtonInvoker.Remove(this);
// (省略)
}
/// <summary>
/// Addした場合、AndroidBackButtonInvokerから呼ばれる
/// </summary>
public void Close() => CloseDialog();
/// <summary>
/// このオブジェクトがDestroyされた場合もRemoveする
/// </summary>
void OnDestroy() => AndroidBackButtonInvoker.Remove(this);
}
StoryPlayer
ストーリー再生のタイミングで管理スクリプトにAdd
する
ストーリー停止のタイミングで管理スクリプトにRemove
する
using UnityEngine;
/// <summary>
/// ストーリープレイヤー
/// </summary>
public sealed class StoryPlayer : MonoBehaviour, IAndroidBackButtonHandler
{
/// <summary>
/// ストーリーの再生
/// </summary>
public void Play()
{
AndroidBackButtonInvoker.Add(this);
// (省略)
}
/// <summary>
/// ストーリーの停止
/// </summary>
public void Stop()
{
AndroidBackButtonInvoker.Remove(this);
// (省略)
}
/// <summary>
/// Addした場合、AndroidBackButtonInvokerから呼ばれる
/// </summary>
public void Close() => Stop();
/// <summary>
/// このオブジェクトがDestroyされた場合もRemoveする
/// </summary>
void OnDestroy() => AndroidBackButtonInvoker.Remove(this);
}
Invoker 管理スクリプト
スクリプトがAdd
されている状態でAndroidのバックボタンが押されたらAdd
されたオブジェクトに対してClose()
を呼ぶ
using System.Diagnostics;
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// Androidバックボタンの管理/実行
/// </summary>
public class AndroidBackButtonInvoker : MonoBehaviour
{
static AndroidBackButtonInvoker s_instance;
readonly List<IAndroidBackButtonHandler> _objs = new ();
// エディタとAndroidでのみ初期化可能にする
[Conditional("UNITY_EDITOR"), Conditional("UNITY_ANDROID")]
public static void Initialize()
{
if (s_instance == default)
{
s_instance = new GameObject().AddComponent<AndroidBackButtonInvoker>();
DontDestroyOnLoad(s_instance.gameObject);
}
}
public static void Add(IAndroidBackButtonHandler androidBackButton)
{
if(s_instance != default)
{
s_instance._objs.Add(androidBackButton);
}
}
public static void Remove(IAndroidBackButtonHandler androidBackButton)
{
if (s_instance != default)
{
s_instance._objs.Remove(androidBackButton);
}
}
void Update()
{
// Android端末でバックボタンが押されたらCloseを実行する
if (Input.GetKeyUp(KeyCode.Escape)
&& _objs.Count > 0)
{
var lastIdx = _objs.Count - 1;
var obj = _objs[lastIdx];
obj.Close();
// Close後に破棄処理が実行されていない場合、Removeする
if (_objs.Count - 1 == lastIdx)
{
_objs.RemoveAt(lastIdx);
}
}
}
}
[使い方5] 公開の限定
読み取り専用のinterface
を実装する事で外部からのアクセスを遮断できます。
下記コードではPlayerStatus
のHp追加、Hp削除メソッドを使用できるのはPlayer
に限定し、外部からはステータスの内容しか確認できないようにする例です。
サンプルコード
読み取り専用を示すinterface
/// <summary>
/// プレイヤーのステータス(読み取り専用)
/// </summary>
public interface IReadOnlyPlayerStatus
{
int Health { get; }
}
Status
interface
を実装
/// <summary>
/// プレイヤーのステータス
/// </summary>
public sealed class PlayerStatus : IReadOnlyPlayerStatus
{
public int Health { get; private set; }
public PlayerStatus(int health) => Health = health;
public void AddHealth(int value) => Health += value;
public void RemoveHealth(int value) => Health -= value;
}
Player
外部からAddHealth
やRemoveHealth
を呼んで欲しく無いので、読み取り専用のinterface
を外部に公開
using UnityEngine;
public sealed class Player : MonoBehaviour
{
/// <summary>
/// ステータス (読み取り専用)
/// </summary>
public IReadOnlyPlayerStatus Status => _status;
PlayerStatus _status;
public void SetStatus(PlayerStatus status) => _status = status;
void Start()
{
// 内部からは呼べる
_status.AddHealth(5);
_status.RemoveHealth(5);
}
}
利用側
外部からはステータスを変更するAddHealth
やRemoveHealth
を普通には呼べない
// 呼べない コンパイルエラーでエディタを再生できない
_player.Status.AddHealth(5);
// キャストすれば呼べるけどReadOnlyで隠蔽しているものを相談も無しにキャストして使う人は怒っても良し!
((PlayerStatus) _player.Status).AddHealth(5);
今回は5つの使い方を通してinterface
の使い方を説明しました。少しでも理解が進みますと幸いです。ここからは、interface
を使う際の注意点を説明したいと思います。とても大切な内容なのでもう少しお付き合いください。
interfaceを使う際の注意点
必要以上にinterfaceを使わない
interface
を使い始めると、とりあえずinterface
にしとくかと思う事がありますがそれは間違いです。interface
は必要以上に柔軟性を与えたり、変更を難しくしたりと欠点もあります。特に設計をせず実装しながら考える場合、一旦クラスで実装し必要になったタイミングでinterface
に切り出すのをおすすめします。
※ 複数人開発の場合は、理由があってすべてinterface
で受け渡すのをルールにしている場合もあるので、そこはプロジェクトに従いましょう。
1つのinterfaceに対して1つの機能
interface
を作る際、複数の機能に関わるメソッドを追加してはいけません。1つのinterface
に対して1つの機能を徹底します。もし色々な機能がどうしても必要な場合はinterface
である必要が無い可能性が高いです。その場合は継承が適しているかもしれません。
モバイル向けゲームはパフォーマンスに注意
interface
はキャストして使うのが前提になります。モバイルゲームならキャストは最小限に留めるようにした方が良いです。特にUpdate()
内でのキャストは使わない方が良いです。
個人的には、ゲーム起動時のキャストはあまりコストを考えず保守性や柔軟性を優先して使う。ゲーム起動時移行は極力キャストが発生しないように実装します。
Monobehaiviorに実装したinterfaceのnullチェックに注意
Unityの == 演算子はオーバーロードされています。下記のコードを見てください。
interface
の場合オブジェクトがDestroyされても == null
はfalse
を返す事が分かります。
// interfaceをキャッシュ
Iinterface playerInterface = _player; // _playerはMonoBehaviourを継承しています
// オブジェクトを削除
Destroy(_player.gameObject);
// 削除直後は判定できないので1フレーム待つ
yield return null;
Debug.Log(_player == null); // [true] gameObjectはちゃんとnullになる
Debug.Log(playerInterface == null); // [false] interfaceはnullにならない
特にinterface
のメソッド内でtransform
などにアクセスしている場合は、Null Reference Exceptionが発生するので注意が必要です。
個人的にはUnityの== null
は高価なので極力使いません。Update()
内に関しては1度も使いません。その代わり管理側でオブジェクトがDestroyされた場合、対象から外すように実装したりします。どうしても使いたい場合は、下記のようなメソッドを使う事で対応できます。
public static class UnityExtensions
{
public static bool IsDestroyed(this object obj) => obj == null || obj.Equals(null);
}
// interfaceをキャッシュ
Iinterface playerInterface = _player; // _playerはMonoBehaviourを継承しています
// オブジェクトを削除
Destroy(_player.gameObject);
// 削除直後は判定できないので1フレーム待つ
yield return null;
Debug.Log(playerInterface.IsDestroyed()); // [true] IsDestroyedメソッドならinterfaceでもnullになる
[おまけ] Unityの == オーバーロードの中身
Unityは == を下記のメソッドでオーバーロードしています。
最大でキャストが4回、内部でのオブジェクトの確認が2回と重い処理なのがコードから分かります。
private static bool CompareBaseObjects(Object lhs, Object rhs)
{
// 左右のオブジェクトをSystemのobject型にキャストしnullチェック
bool flag1 = (object) lhs == null;
bool flag2 = (object) rhs == null;
// 両方ともnullの場合trueとする
if (flag2 & flag1)
return true;
// 右のオブジェクトがnullで左のオブジェクトがnullでない場合、左のネイティブオブジェクトが死んでいる時trueとする
if (flag2)
return !Object.IsNativeObjectAlive(lhs);
// 左のオブジェクトがnullで右のオブジェクトがnullで無い場合、右のネイティブオブジェクトが死んでいる時trueとする。
// 左のオブジェクトも右のオブジェクトもnullでは無い場合、インスタンスIDが同じ時trueとする。
return flag1 ? !Object.IsNativeObjectAlive(rhs) : lhs.m_InstanceID == rhs.m_InstanceID;
}
interfaceを理解するまでの道のり
プログラムを始めた頃interface
の勉強をしては、いまいち理解できず諦めるという事を何度か繰り返してきました。ある程度理解してからも思うように使えるまで時間が掛かりました。そして今もまだ勉強中です。
理解できなかった理由を今考えてみると、interface
が実装されたコードを見ても何処か間接的で似たようなコードにしか見えなかったのが大きいです。冒頭で説明した通りinterface
は道具のようなもので、ある理由があってinterface
にしているというのがコードだけでは読み取り辛いのが理解が進まない原因だと思いました。
また、使い始めて思うのはinterface
は設計があってこそ力を発揮します。 設計ができればできるほど、interface
をうまく使えるようになります。設計しないで書く場合は、まず普通に書いて必要であればinterface
に切り出していくようにするとうまく実装できるのでおすすめです。
以上となります。
最後まで読んでいただきありがとうございました!