はじめに
様々な言語で「デザインパターン」の本が世の中にありますが、筆者個人の経験では
いまいちピンとこない例
いまいちピンとこないコード
で説明されてることが多く、
結局これっていつ使うの?
という疑問に答えるには仕事仲間等との議論をしないと
辿り着けないことが多々ありました。
そこで特に「ゲーム開発ではどう使うか?」にフォーカスを当てて、実践的な例を交えて
デザインパターンの説明の需要があると思い記事を作りました。
デザインパターンを学ぶ理由
デザインパターンを学ぶ理由としては
- 車輪の再発明の防止
- 長文で読みにくいコード(可読性の低いコード)を減らす
- コードを疎結合にして変更に強くなる(変更時のコスト・変更箇所を減らす)
- モジュールとして使いまわせるように、コードの再利用性を高める
といった効果を期待できます。
対象読者
Unity 全くの初心者(インストールしただけで触ったことがないような方)はお断りです。
最低限以下のことは理解・経験を積んでおくことが必須になります。
- MonoBehaviour 継承クラスでコードを書いたことがある
- C# のピュアクラスを用いた自作クラスを作ったことがある
- クラスの継承という概念は知っている
そのため、脱・初心者
中級者へのステップアップ
として デザインパターンを学ぶ
のが良いと思います。
デザパタ記事リンク
生成系
構造系
様態・ふるまい系
- Chain of Responsibility パターン
- Command パターン
- Interpreter パターン
- Iterator パターン
- Mediator パターン
- Memento パターン
- Observer パターン
- State パターン
- Strategy パターン
- TemplateMethod パターン(本記事)
- Visitor パターン
TemplateMethod パターンについて
名前の通り ひな形
のメソッドを提供するデザインパターンです。
一言で説明すると 抽象メソッド(abstract method)
のことです!!
以上!!
と、流石にこれだけでは不親切なので、もう少し解説すると、 メソッド定義
は親クラス(=抽象クラス) で行い、実装は派生先のクラスにお任せするというものです。
C# では abstract
キーワードをつけたメソッドは、派生先での実装を 義務
にすることができるため、実装忘れを防止できる効果もあります。この抽象メソッドは、 定義場所のクラスでは関数呼び出しは可能
であることから、 呼び出す場所は予め決める
けど 具体的な実装・処理は派生先にお任せする
といったことができます。つまり、処理フロー自体に手を加えることをせずに、部分的な処理を派生先クラス毎に自由に設計することができます。
この 実装は後
(具象クラスに任せる) という考え方は CleanArchitecture
の最も重要な考え方と言ってもいいほど重要で、この手法により、定義と実装を分離することができます。
Interface vs 抽象クラス, 抽象メソッド
メソッドの定義という意味では C# においては interfaceによる定義
と 抽象クラスにおいて抽象メソッドとして定義
の2種類があります。初心者の頃はメリット・デメリットや使い分けについ、慣れてないと中々難しいところがあります。
せっかくなので、ついでにここで解説しようと思います。
抽象クラス
抽象クラス利用のメリット
- 抽象クラス内部に抽象メソッドを利用した処理を書いておくことが可能
- 抽象メソッドの実装を義務化できる
- 実装わすれ防止
- 抽象メソッドのアクセス修飾子を指定できる
- 多重継承の防止
- C++とかでは可能
- 場合によっては二重に継承する問題があった
- クラスの継承の枠組みで利用可能
抽象クラスのデメリット
- 多重継承ができない
- C#的に意図的に禁止している
- クラスに縛られるため ピュアクラス化・MonoBehabviour派生化という切り替えは難しい
抽象クラス内部で定義されている抽象メソッドに関しては、定義場所でも処理の中に組み込むことが可能なため、メインの処理は override 禁止にして、簡単に改変できないようにするけど、ここの処理は abstract にしておくことで柔軟に変更可能にするというやりかたにすることで、処理フロー変更によるバグの混入を防ぎつつ、派生先毎に微妙にメソッド内部の処理を変更することができます。
using System;
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;
public abstract class BaseInitializer : MonoBehaviour, IDisposable
{
/// <summary>
/// 絶対Private
/// newも禁止
/// </summary>
private void Awake()
{
InitializeAsyncOnAwake(this.GetCancellationTokenOnDestroy()).Forget(e => DebugLogger.LogError(e));
}
/// <summary>
/// Awake時の初期化処理
/// </summary>
/// <param name="token"></param>
/// <returns></returns>
protected abstract UniTask InitializeAsyncOnAwake(CancellationToken token);
// Start is called before the first frame update
private void Start()
{
InitializeAsyncOnStart(this.GetCancellationTokenOnDestroy()).Forget(e => DebugLogger.LogError(e));
}
/// <summary>
/// Awake時の初期化処理
/// </summary>
/// <param name="token"></param>
/// <returns></returns>
protected abstract UniTask InitializeAsyncOnStart(CancellationToken token);
/// <summary>
/// 派生先で書き換えられると面倒なのでprivate限定
/// </summary>
private void OnDestroy()
{
Dispose();
}
/// <summary>
/// Destroy 時処理
/// </summary>
public abstract void Dispose();
}
例えばこのように設計しておけば、派生先のメソッドは必要な箇所のみを実装すれば良くなります。
public class HogeSceneInitializer : BaseInitializer
{
private readonly CompositeDisposable m_disposable = new CompositeDisposable();
protected override UniTask InitializeAsyncOnAwake(CancellationToken token)
{
//Validation
Assert.IsNotNull(someObject, "[HogeSceneInitializer]SomeObject is NULL");
return UniTask.CompletedTask;
}
protected override async UniTask InitializeAsyncOnStart(CancellationToken token)
{
try
{
await someAsyncMethod();
}
catch (OperationCanceledException e)
{
Debug.LogWarning("Start was Canceled.");
}
catch (Exception e)
{
Debug.LogError(e);
}
}
public override void Dispose()
{
if (!m_disposable.IsDisposed)
{
m_disposable.Dispose();
}
}
}
interface
インターフェースのメリット
- 複数のインターフェースを1つのクラスに実装することができる
- メソッド名がぶつかっても
明示的実装
によって区別することが可能
インターフェースのデメリット
- アクセス修飾子は利用できない
- C#8.0 からは利用可能( 参考: https://ufcpp.net/study/csharp/cheatsheet/ap_ver8/#default-imeplementation-of-interface )
- Unity2022.1 現在ではまだ利用不可能
- デフォルト実装ができない
- C#8.0 からは利用可能( 参考: https://ufcpp.net/study/csharp/cheatsheet/ap_ver8/#default-imeplementation-of-interface )
- メソッドを定義したものの、
名前が長すぎる
,概念みたいな命名
等期待する動作が不明な場合、どう実装すべきか?の指針がブレるため、実装先ごとに異なる動作になる可能性がある
- 明示的実装を行わないとメソッド名が衝突した時に回避できない
どういう時にどっちを使えばいいか?
ここからは筆者の肌感になります。
まず、基本的にインターフェースを使う場合は このインターフェースを実装しているObjectに期待する動作・ふるまいが何か?
を意識して設計できる力が必要です。そこに自信がないうちは 抽象クラスで実装
が良いでしょう。
例えば IDisposable
は名前の通り Dispose可能
なので Dispose()
というメソッドを実行できないと困ります。
このように 名前から大まかなふるまい
がわからないようなインターフェースは これは何を目的に実装されているんだ?
という可読性を大きく下げる要因にもなるため、目的・用途が明確なもので無い限りは使用を避けましょう。
また、 interfaceの明示的実装
という方法がありますが、 キャストしないと使えない
だったり書き方がちょっと独特だったりするため、いきなり実践投入すると事故ります(2敗)。
インターフェースは ふるまいを定義するもの
であるため、どんな振る舞いなのか?を設計できない場合は抽象クラスでまずは実装することを強く推奨します。
そこで、抽象メソッドを利用した書き方を学んで 定義
と 実装
の分離を十分に学んだらインターフェースに手を出すのが良いです。
インターフェースに慣れてくれば、 アプリ内で共通のインターフェース
が増えることで、インターフェースを実装したクラスを使いまわせたり、修正に強くなったりします。
ポイントとしては 1つのインターフェースに大量のメソッドは定義しない
ところで、例えば IVisible
というインターフェースを定義するなら 可視化・不可視化
あたりがキーワードになるため
public interface IVisible{
void SetVisible(bool isVisible);
void Show();
void Hide();
bool IsVisible{get;}
のような形で実装すれば、 UIとしての表示切り替え
であったり、3Dオブジェクトの表示切り替え
それぞれ同じインターフェースを実装することで、UIと3DObjectが入り混じったみためでも foreach( Ivisible target in targets)target.Hide();
のようにまとめて処理が可能です。
また、一部テクニカルな使い方として このインターフェースを持っているものだけ
という 権限・認証
的な使い方があります。
public interface IAttackable{}
public abstract class BaseEnemy{
public abstract string GetName();
}
public class Enemy01 : BaseEnemy, IAttackable {
public override string GetName(){return "Enemy01";}
}
public class Enemy02 : BaseEnemy {
public override string GetName(){return "Enemy02";}
}
public class Enemy03 : BaseEnemy, IAttackable {
public override string GetName(){return "Enemy03";}
}
public class Hello{
public static void Main(){
// Your code here!
BaseEnemy[] enemies = new BaseEnemy[]{
new Enemy01(),
new Enemy02(),
new Enemy03(),
};
foreach(var e in enemies)
{
if( e is IAttackable)
{
System.Console.WriteLine( e.GetName()+" can Attack!!");
}
else
{
System.Console.WriteLine( e.GetName()+" cannot Attack!!");
}
}
}
}
これを実行すると
Enemy01 can Attack!!
Enemy02 cannot Attack!!
Enemy03 can Attack!!
という具合になります。
is演算子による比較コストがそこそこ重いため、むやみやたらな利用は推奨しませんが、 命名的に意図がはっきりしている場合
や、 比較回数がそこまで多く無い、限定的である
場合などでは利用しても良いと思います。
まとめ
TemplateMethod はインターフェースか抽象メソッドで実現可能です。
一番重要なものとしては メソッド定義場所
と メソッド実装場所
の分離です。
ここが理解できて初めて、今まで出てきたデザインパターンがなんで重要なのか?が見えてくるため、なるべく最初に習得してほしいデザインパターンではあります。