Runner in the High

技術のことをかくこころみ

ElmでPhantom TypeとExtensible Recordを用いて型安全な状態遷移パターンを実装する

このDiscourseスレッドがかなり面白かった。

OPは「幽霊型(Phantom Type)を使うと特定の順序でしか型安全に状態遷移できないように実装できると思うんだけど、どうしたらいいかな?」と質問している。

discourse.elm-lang.org

実装してみる

回答者からのアイデアによると、Phantom TypeとExtensible Recordを組み合わせて実装することで型安全な状態遷移が作れる。

たとえば、以下のようなゲーム上での状態遷移のパターンが仕様としてあるとしよう。

f:id:IzumiSy:20200104151513p:plain

これを実際に今回のパターンで表現すると、このようになる。

type Transition a =
    Transition


type Allowed
    = Allowed


type Game
    = Loading (Transition { ready : Allowed }) -- ロード中
    | Ready (Transition { playing : Allowed }) -- プレイ可能
    | Playing (Transition { paused : Allowed, gameOver : Allowed }) -- プレイ中
    | Paused (Transition { playing : Allowed }) -- 一時停止
    | GameOver (Transition { ready : Allowed }) -- ゲーム終了


toReady : Transition { a | ready : Allowed } -> Game
toReady _ =
    Ready Transition


toPlaying : Transition { a | playing : Allowed } -> Game
toPlaying _ =
    Playing Transition


toPaused : Transition { a | paused : Allowed } -> Game
toPaused _ =
    Paused Transition


toGameOver : Transition { a | gameOver : Allowed } -> Game
toGameOver _ =
    GameOver Transition

最も重要な部分は、Transition型が幽霊型として次に遷移する状態の許可レコードのようなものを保持している箇所で、遷移の際に呼び出すtoReadytoPlayingなどの関数が引数としてTransitionの保持しているレコードをチェックするようになっている。

この実装表現の優れている点は

  • 型で遷移のパターンが限定されている
  • Game型を見るだけで「次にどういう状態への遷移が許されているか」がすぐに分かる。
  • 状態と遷移のパターンが増えたとしてもGame型を修正するだけでよいので修正範囲が少ない。

などが挙げられ、正直イイことしかない。コードとしても、ある程度Elmに習熟していればさほど難しいものでもなく理解しやすいだろう。

あとはGame型をOpaque Typeなモジュールにでもしてやれば、明示的にLoadingReadyなどのバリアントを利用できなくなるのでより安全性が増すだろう。

型の恩恵を確かめてみる。

上の型を操作するにあたって、遷移の関数ではモデルの状態と遷移の組み合わせがほぼ強制される。

update : Game -> Game
update game =
    case game of
        Loading transition ->
            { state = toReady transition }
            
        Ready transition ->
            { state = toPlaying transition }
            
        Paused transition ->
            { state = toPlaying transition }
            
        Playing transition ->
            { state = toGameOver transition }
            
        GameOver transition ->
            { state = toReady transition }

試しにLoadingからGameOverに遷移するようなコードに書き換えてみる

これはあってはいけない遷移だ。

update : Game -> Game
update game =
    case game of
        Loading transition ->
+           { state = toGameOver transition }

        Ready transition ->
            { state = toPlaying transition }

コンパイルしてみると、以下のようなエラーになる。すばらしい!

The 1st argument to `toGameOver` is not what I expect:

72|             { state = toGameOver transition }
                                     ^^^^^^^^^^
This `transition` value is a:

    Transition { ready : Allowed }

But `toGameOver` needs the 1st argument to be:

    Transition { a | gameOver : Allowed }

Hint: Seems like a record field typo. Maybe gameOver should be ready?

Hint: Can more type annotations be added? Type annotations always help me give
more specific messages, and I think they could help a lot in this case!

ellieで実際のコードも用意したので、興味があればどうぞ。