このDiscourseスレッドがかなり面白かった。
OPは「幽霊型(Phantom Type)を使うと特定の順序でしか型安全に状態遷移できないように実装できると思うんだけど、どうしたらいいかな?」と質問している。
実装してみる
回答者からのアイデアによると、Phantom TypeとExtensible Recordを組み合わせて実装することで型安全な状態遷移が作れる。
たとえば、以下のようなゲーム上での状態遷移のパターンが仕様としてあるとしよう。
これを実際に今回のパターンで表現すると、このようになる。
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
型が幽霊型として次に遷移する状態の許可レコードのようなものを保持している箇所で、遷移の際に呼び出すtoReady
やtoPlaying
などの関数が引数としてTransition
の保持しているレコードをチェックするようになっている。
この実装表現の優れている点は
- 型で遷移のパターンが限定されている
Game
型を見るだけで「次にどういう状態への遷移が許されているか」がすぐに分かる。- 状態と遷移のパターンが増えたとしても
Game
型を修正するだけでよいので修正範囲が少ない。
などが挙げられ、正直イイことしかない。コードとしても、ある程度Elmに習熟していればさほど難しいものでもなく理解しやすいだろう。
あとはGame
型をOpaque Typeなモジュールにでもしてやれば、明示的にLoading
やReady
などのバリアントを利用できなくなるのでより安全性が増すだろう。
型の恩恵を確かめてみる。
上の型を操作するにあたって、遷移の関数ではモデルの状態と遷移の組み合わせがほぼ強制される。
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で実際のコードも用意したので、興味があればどうぞ。