LoginSignup
6

ステートマシン実装の決定版ImtStateMachineについて語り尽くしたかった

Last updated at Posted at 2022-02-20

加筆 or 修正

  • 2022-02-24
    • ImtStateMachine.csへのリンクを修正
  • 2022-06-28
    • ImtStateMachine.cs(StateMachine.cs)のリンクを修正

前回の記事についてと謝礼

 前回の記事「ステートマシン実装の決定版ImtStateMachineについて語り尽くす」から3年以上も経ち、「書けたら書く」みたいなことを最後に書いてから何も書かない怠慢っぷりを発揮してしまいましたが、ImtStateMachineの利用者もちらほらと見かけたり、前回の記事のリンクをして頂いたり、コメントなどで参考になりましたと書いていただいて、更には前回の記事のおかげでImtStateMachineの存在を知ることが出来たと、言って頂けている方がいて非常に感銘しております。

 ImtStateMachineが着実にC#におけるステートマシンとして流行ってくれて記事を書いて良かったなと感じております。本記事ではImtStateMachineについて前回の記事で書けなかった部分やちょっとした補足を紹介出来ればなと思います。

 2つ目の投稿記事だというのに簡素だなと感じる方がいるかも知れませんが宜しくおねがいします。

最新のImtStateMachine情報

 さて、前回の記事を書いてから月日が経ち流石にImtStateMachine自体も少しバージョンアップされ機能の追加と削除などが行われております。その違いについて書こうと思いましたが、すでに紹介している方がいらっしゃいました(大変感謝!)。内容も特に大きな間違いもなさそうでしたので、リンク先の記事を確認して頂ければ大丈夫です。これからもどんどん情報を発信していく人が増えると助かります!(他力本願)

 ただ、強いてここでも紹介したい物としては、ステートイベントが任意の型に指定することが出来るようになったことでしょうか。旧バージョンImtStateMachineでは、ステートイベントがint型で固定されていましたが、新バージョンのImtStateMachineでは、ジェネリック型を指定することが出来るようになり、かつ旧バージョンとの互換性を維持するための実装も行われていますので、新しいバージョンへマイグレーションする実装はしなくても良いようになっている点です。ここのPRにてその強化が行われているようですね、機能提案してくれた方GJです。

 あと、最近ImtStateMachine作者であるシノア氏のつぶやきに何やら新設計のステートマシンも再稼働しそうな予感を感じさせているので、ImtStateMachineの最新状態を要チェックする必要がありそうですね。

旧ステートイベント実装と、新ステートイベント実装の違い

旧ステートイベント実装のサンプルコード
using IceMilkTea.Core;
using UnityEngine;

public class OldBehaviourScript : MonoBehaviour
{
    // 旧来のステートマシン記述(int型のステートイベントを持つステートマシン)
    private ImtStateMachine<OldBehaviourScript> stateMachine;

    // 状態イベントは enum による列挙型で定義
    public enum StateEvent
    {
        Finish,
        Click,
    }

    private void Awake()
    {
        stateMachine = new ImtStateMachine<OldBehaviourScript>(this);

        // int型の状態イベントなので、列挙型の値をint型にキャストする必要がある
        // ただ、int型なので列挙型ではない指定をされてしまう型の安全性が無い
        stateMachine.AddTransition<IdleState, MainLoopState>((int)StateEvent.Finish);
        stateMachine.AddTransition<MainLoopState, EndState>((int)StateEvent.Click);
        stateMachine.SetStartState<IdleState>();
    }

    // アイドリング状態クラス
    private class IdleState : ImtStateMachine<OldBehaviourScript>.State
    {
        protected override void Enter()
        {
            // ここでもint型を指定するためのキャストをするがenum型を見ているわけではないので型の安全性に難あり
            StateMachine.SendEvent((int)StateEvent.Finish);
        }
    }

    // メインで何かをし続ける状態クラス
    private class MainLoopState : ImtStateMachine<OldBehaviourScript>.State
    {
    }

    // すべてが終了した状態クラス
    private class EndState : ImtStateMachine<OldBehaviourScript>.State
    {
    }
}
新ステートイベント実装のサンプルコード
using IceMilkTea.Core;
using UnityEngine;

public class NewBehaviourScript : MonoBehaviour
{
    // 新しいステートマシンの記述は、コンテキストとなる型の次にステートイベントの型を指示する
    private ImtStateMachine<NewBehaviourScript, StateEvent> stateMachine;


    // 状態イベントは引き続き enum による列挙型で定義
    public enum StateEvent
    {
        Finish,
        Click,
    }

    private void Awake()
    {
        stateMachine = new ImtStateMachine<NewBehaviourScript, StateEvent>(this);

        // 今回はStateEvent型の状態イベントなので、列挙型の値をそのまま遷移イベントの値として指示が可能
        // そのため、安全に型が保証されるので誤入力などの心配が無い
        stateMachine.AddTransition<IdleState, MainLoopState>(StateEvent.Finish);
        stateMachine.AddTransition<MainLoopState, EndState>(StateEvent.Click);
        stateMachine.SetStartState<IdleState>();
    }


    // アイドリング状態クラス
    private class IdleState : ImtStateMachine<NewBehaviourScript, StateEvent>.State
    {
        protected override void Enter()
        {
            // もちろんイベント送信する型も指示が出来るので安全
            StateMachine.SendEvent(StateEvent.Finish);
        }
    }


    // メインで何かをし続ける状態クラス
    private class MainLoopState : ImtStateMachine<NewBehaviourScript, StateEvent>.State
    {
    }


    // すべてが終了した状態クラス
    private class EndState : ImtStateMachine<NewBehaviourScript, StateEvent>.State
    {
    }
}

 この様に、旧ステートイベント実装と新ステートイベント実装を比べて見れば分かる通り、より自然なステートイベントの指定などが可能になったため型安全性が向上し可読性やメンテナンス性が確保されるようになりました。良かった良かった。

ちょっとしたテクニック、少し状態型をスマートにしよう

 今回の新ステートイベント実装を見て誰もが思うこととしては「型名が長い」だと思います。なので、状態クラスは内部で完結するので別の型として再定義してあげてよりスマートにしてしまいましょう。

スマートなサンプルコード
using IceMilkTea.Core;
using UnityEngine;

public class SmartBehaviourScript : MonoBehaviour
{
    // 状態型クラスの型定義を行うことで状態型の記述が楽になる
    private class MyState : ImtStateMachine<SmartBehaviourScript, StateEvent>.State { }

    // 状態イベントの型定義
    private enum StateEvent
    {
        Finish,
        Click,
    }


    // ステートマシンの宣言だけは普段通り
    private ImtStateMachine<SmartBehaviourScript, StateEvent> stateMachine;


    private void Awake()
    {
        stateMachine = new ImtStateMachine<SmartBehaviourScript, StateEvent>(this);
        // ImtStateMachine<SmartBehaviourScript, StateEvent>.State型を継承した型を指定出来るので普段どおりの遷移テーブルが記述出来る
        stateMachine.AddTransition<IdleState, MainLoopState>(StateEvent.Finish);
        stateMachine.AddTransition<MainLoopState, EndState>(StateEvent.Click);
        stateMachine.SetStartState<IdleState>();
    }


    // このクラスの状態クラス型は MyState型 このため非常に状態クラスであることが見やすくなる
    private class IdleState : MyState
    {
    }


    private class MainLoopState : MyState
    {
    }


    private class EndState : MyState
    {
    }
}

状態のエラーハンドリング

 残念ながらプログラムというのは常に必ず想定した通りに動くことはありません、何かしらの理由によりエラーが意図しないタイミングで発生します。そして、発生したエラーが原因でロジックが中断状態あるいは不正な状態になったりすることで、正しい挙動をとることが出来なくなり意図しない結果を招くことになります。

 ImtStateMachineでは、状態処理中に状態の例外が発生しても安全に状態を維持する機構が存在しております。特別な手続きは不要で基本的にはデフォルトの例外ハンドリングにまかせても問題ありません。デフォルトの挙動は状態内で発生した例外は、 'ImtStateMachine.Update' 関数で例外が発生するようになっています。つまり、通常の例外キャッチは 'ImtStateMachine.Update' 関数を見張るようにすれば問題ありません。

 他にもImtStateMachineには、実装目的別に例外ハンドリングする機構が2つあります。例外ハンドリングを切り替える場合は 'ImtStateMachine.UnhandledExceptionMode' プロパティにハンドリングモードをセットします。

 セットする事が可能な例外ハンドリングモードや使い方、ケースなどは後述していますので、それぞれ確認してみてください。

以下の例外ハンドリングモード及び具体的な挙動や推奨されるケースについては、コードを読み取った上で個人的な視点に基づいているため一つの意見としてご参考下さい。

例外送出(デフォルト挙動)

モード値

'ImtStateMachineUnhandledExceptionMode.ThrowException'

具体的な挙動

 ImtStateMachineのデフォルト挙動です。 'ImtStateMachine.Update' 関数によって処理されている状態の状態処理中に発生した例外はすべて 'ImtStateMachine.Update' 関数からスローされます。例外ハンドラが無いため、例外をハンドリングする場合は 'ImtStateMachine.Update' 関数を try-catch するようにしてください。

推奨されるケース

殆ど場合において、このモードを使用することをおすすめします。状態の例外を意図的なハンドリングを行うより、ステートマシンとしての例外としてスローすることで、どのステートマシンのどのステートで問題が発生したかが把握しやすい為です。

実装例

ImtStateMachine<MyContextClass> stateMachine;

// -- 中略 --

private void Update()
{
    try
    {
        // どのステートであってもステート処理中に発生した例外はここまで転送されます
        stateMachine.Update();
    }
    catch (Exception error)
    {
        // 特定の例外ごとにハンドリングを行う場合は対応の型をcatchし行ってください
        Logger.Error(error.Message);
        throw;
    }
}

状態内例外ハンドラ

モード値

'ImtStateMachineUnhandledExceptionMode.CatchStateException'

具体的な挙動

 動作中のステートクラスの処理内にて発生した例外がそのステートクラスの 'Error' 関数に転送されます。ただし、ステートクラスが 'Error' 関数を実装していない場合は 'ThrowException' モードと同じ挙動になります。また、ステートクラスが 'Error' 関数を実装していても動作モードが 'CatchStateException' 以外の場合は 'Error' 関数へ例外が転送されることはありません。さらに、 'Error' 関数が例外をハンドリング出来なかった( 'Error' 関数が false を返した)場合は、 'ThrowException' モードと同じ挙動になります。

推奨されるケース

発生する例外が同じ型であっても、状態によってハンドリングする方法が異なる場合に使用することが可能です。しかし、同じ例外型に対し必要以上に別々のハンドラを実装することは問題解決の複雑化に直結する為、本当にこのモードを動作させる必要があるか{十分に検討}してください。

実装例

// ステートマシンの例外ハンドリングモードをCatchStateExceptionへ切り替える
ImtStateMachine<MyContextClass> stateMachine;
stateMachine.UnhandledExceptionMode = ImtStateMachineUnhandledExceptionMode.CatchStateException;

// -- 中略 --

private void Update()
{
    try
    {
        // 状態クラスの Error 関数にて例外が処理された場合は例外が発生することはありませんが
        // Error 関数が無い、あるいは例外をハンドリングされなかった場合はここで例外が発生します
        stateMachine.Update();
    }
    catch (Exception error)
    {
        // 状態クラスで処理されなかった例外はここで最終的なハンドリングを行いましょう。
        // どんな例外であっても、無視されてはいけません
        Logger.Error(error.Message);
        throw;
    }
}

// -- 中略 --

private class StateA : ImtStateMachine<MyContextClass>.State
{
    protected override bool Error(Exception error)
    {
        // StateAのEnter, Update, Exit関数のいずれかで発生した例外はここに転送されます。
        // 状態例外ハンドラが true を返すことが出来れば、Update関数で例外は発生しません
        return true;
    }
}

private class StateB : ImtStateMachine<MyContextClass>.State
{
    protected override bool Error(Exception error)
    {
        // StateBのEnter, Update, Exit関数のいずれかで発生した例外はここに転送されます。
        // 状態例外ハンドラが false を返すと、Update関数で例外が発生(ThrowExceptionモード相当)します
        return false;
    }
}

状態機械例外イベントハンドラ

モード値

'ImtStateMachineUnhandledExceptionMode.CatchException'

具体的な挙動

 動作中のステートマシン内にて発生した例外がそのステートマシンの 'UnhandledException' イベントに転送されます。ただし、イベントハンドラに何も設定されていない場合は 'ThrowException' モードと同じ挙動になります。また、ステートマシンの 'UnhandledException' イベントに関数を設定していても動作モードが 'CatchException' 以外の場合は 'UnhandledException' イベントへ例外が転送されることはありません。さらに、 'UnhandledException' イベントが例外をハンドリング出来なかった( 'UnhandledException' イベントが false を返した)場合は、 'ThrowException' モードと同じ挙動になります。

推奨されるケース

”ImtStateMachine.Update”関数がtry-catchされていない状況下で発生した例外を、ロギングや大量の状態マシンを動作させる中で個別のエラーハンドラが必要な時に使用することが可能です。ただし、”UnhandledException”イベントが正常に例外を処理した場合、”ImtStateMachine.Update”関数から例外が送出されなくなり、”ImtStateMachine.Update”関数をtry-catchする設計に対して非常に相性の悪い挙動が起きてしまうため、設計方針を事前にしっかりと決定しどちらの挙動を使用するか意識するようにするのが望ましいです。あるいは、ロギングだけを行うケースであれば、ロギングだけ行い例外を処理しないとして”false”返す事が望ましいです。

実装例

// ステートマシンの例外ハンドリングモードをCatchExceptionへ切り替えてMyUnhandledException例外ハンドラを登録する
ImtStateMachine<MyContextClass> stateMachine;
stateMachine.UnhandledExceptionMode = ImtStateMachineUnhandledExceptionMode.CatchException;
stateMachine.UnhandledException = MyUnhandledException;

// -- 中略 --

private void Update()
{
    try
    {
        // 状態マシンの UnhandledException イベントにて例外が処理された場合は例外が発生することはありませんが
        // UnhandledException イベントに関数が登録されていない、あるいは例外をハンドリングされなかった場合はここで例外が発生します
        stateMachine.Update();
    }
    catch (Exception error)
    {
        // 状態クラスで処理されなかった例外はここで最終的なハンドリングを行いましょう。
        // どんな例外であっても、無視されてはいけません
        Logger.Error(error.Message);
        throw;
    }
}

// -- 中略 --

private bool MyUnhandledException(Exception error)
{
    // ロギングを行い未ハンドリングを返すことでステートマシンでハンドリングされなかった例外を確認することが出来る
    Logger.LogError(error);
    return false;
}

今後について

 まずは、前回の記事から補足したい内容の補足とちょっとしたテクニックの追加が出来ましたがImtStateMachineの作者が少し前に興味深いつぶやきをしていたのでその紹介と今後について説明します。

ImtStateMachineの新実装について

 前回の記事では、同期的なロジックを前提としたステートマシン実装でしたがちょうどその時期からUnityのバージョンがどんどん上がり、今ではTaskを使った非同期ロジックを実装することが容易になっています。

 Taskによる非同期ロジックは負荷の高い処理を分散させたり、容易なマルチスレッディングの実装を提供してくれます。もちろんステートマシンにも非同期の概念はありますが、非常に難易度が高く容易に実装出来る代物ではありません。

 そんな非同期型ステートマシンですが、ImtStateMachine作者のシノア氏によると少し古い情報ですが、Taskベースなステートマシンを実装することを検討しているようです。それが次のツイートとなります。

 気になりますね!完成したら是非採用検討をしてみたいところです。最新情報はつぶやきとImtStateMachineのリポジトリ更新を追っかけてみると良いかもしれません。

 また、ImtStateMachine作者がQiitaに興味を示しているようなので、もしIceMilkTeaに関する記事が登場したら早速チェックと言ったところでしょうか。

最後に

 超ハイパー駆け足気味に書いてしまいましたが、ImtStateMachine作者が面白そうなステートマシンを模索しているようなので、今後のUnityによる状態制御がどの様になっていくのか要チェックです。本家様もQiita記事を書くのか書かないのか正確にははっきりしませんが、もし記述されたようであれば前回の記事と今回の記事から参考リンクを貼ろうと思います。

 では、やや不完全燃焼気味ではありますが、これからの状態制御の革新的な発展とImtStateMachineの今後の発展に期待しつつ最後とさせていただきます。最後まで読んで頂きありがとうございました。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6