Clojure 状態遷移まとめ

ClojureにおけるFinite State Machineの実現方法まとめ.

状態遷移は手続き的に処理するのならばloopのなかにifとswitchのおばけをかけばいいのだけれどもClojureを書いていてそれはあまりにも悲しいのでもっと宣言的な方法を探す.

trampolineというClojureの関数で状態遷移を表現することができる.

もしくはフレームワーク活用. メンテがおわっている有名なライブラリは reduce-fsm, 代替は tilakoneだけど2022年これも終わってそう… 自作がいいのかな… パット見, 昔に開発されたけどもう使われてないものが多い気がする.

📝lucywang000/clj-statechats

ClojureのFSMライブラリ.


📝Statechartsの概念をCloujureで実装している.

clj-statechartsの情報が少ないものの, 📝XStateにインスパイアされて開発されており, そしてその背後にあるStatechartsのコンセプトは昔からあるものなので, XStateの情報を漁ってみるのは理解を助ける.

Immuatble API

APIは3つのみ.

  • fsm/machine: ステートチャートを作成(PersistentArrayMap).
  • fsm/initialize: はじめのstateを返す.
  • fsm/transition: ステートチャートと現時点の状態をinputにして次のstateを返す.

結局stateしろステートマシンにしろMapにすぎない(ステートマシンはMapに関数がassocされてぶら下がっているだけ). service APIに比べてより直感的に状態遷移を理解できる. 🔖参照透過性によりテストやデバックがしやすい.

stateのシンタックスまとめ

  • :fooと書くのは[:foo]のsyntax suger.
    • さらにこれは同一階層のstateを指す(relative path).
    • [:. :parent.foo]と同義.
  • hierarchical statesにおいて :> をつかうと絶対パスで指定できる.

clj-statecharts Tips

💡entry/exit actionにguard/targetが設定できない(Eventlss transision)

どこかに書いてないけど結構ハマったメモ.

entry/exit actionにguard/targetが設定できない. 状態遷移の前後で条件付きでactionを発動するにはalways & guardをするしかない.

これはstatechartsにおけるEventless Transitionという機能であり, alwaysで実現するという決まりごと.

💡contextのupdateのassignでstateを返す

どこかに書いてないけど結構ハマったメモ.

statecharts.core/assignでcontextをupdateするときに戻り値にstateを返さないとstate自体が消滅してシステム停止する.

actionの中で情報を取得してcontextにキャッシュするような処理で, 仮に外部の通信が失敗したときにもassignをつかうならばstateを返す. これはかなりバグの原因.


このclj-statechartsに依存する状態管理がめんどうならば, contextに自分で定義したatomをassocするといい. そうするとassignのシンタックスを無視して状態更新ができる.

💡サブステータス(parallel/serial)の扱い

1つのステータスとのサブステータスを定義することができる. サブステータスに同時になにかを処理させるときは, type=:parallelと:resionsを指定する必要がある.

{:type :parallel
 :regions {:sell entry-sell-fsm
           :buy  entry-buy-fsm}
:exit [(assign exit-trade!)]}

パラレルに実行したくない場合も定義は出来る. ここで大事なのは, サブステータスを表す記法として親ステータスのあとにドットを書いて子を定義すること. {:s1 {:states {:s1.1… :s1.2}}}. これをしないと事前チェックみたいなものでエラーして実行できない.

また, サブステータスで定義したものは, :_stateでは[:s1 {:s1.1 :idle :s1.2 :idle}]のようになぜかVectorとMapが混じったデータ構造で保存されているので, fsm/valueで取得した値のsecondを見る必要がある.

Parallel States | clj-statecharts

serialなサブステータスは応用すればついでにできるみたいな感じなので片手落ちな気がしている. 本来の使い方ではないかもしれないが, 入れ子構造にステータスを定義する方法がいまいちわからない. そもそもFSMにおいてサブステータスはアンチパターンかもしれない.

状態に関わらずあるeventに対して何かをする

いわゆる Compound States というコンセプト.

  • ある状態はサブの状態を定義できる.
  • サブの状態がそのイベントをハンドリングできなければ親がハンドリングする.

ref. Hierarchical States | clj-statecharts

guard/actionsの中で自己ステータス参照

:_stateを参照する.

(defn- valid-status? [{:keys [_state]} {:keys [status]}]
  (= _state status))

actions入っている時, :_stateの値は:targetで指定されたものとなっている. 遷移前を参照するには, :_prev_stateをみる.

immutable API vs service API

service APIは内部でvolatile-mutableという機能をつかって状態を保持しているが, これはJavaのクラスの機能らしい.

https://github.com/lucywang000/clj-statecharts/blob/master/src/statecharts/service.cljc

re-freame integrationで両者の違いは説明されている.

Re-frame Integration | clj-statecharts

実装レベルの違いは, 自前でatomを用意してfsm/transitionで状態遷移して戻ってきたstateでswap!をかけるか, fsm/sendで状態遷移してそのまま何もしないかの違い.


別の説明. state recordがatomに保存されているに過ぎない. ただしdelay transitionの制御で内部にstateをもつことが必要だった.

;; https://lucywang000.github.io/clj-statecharts/docs/concepts/#state-and-service

How are machine/state connected to the higher level services?

A service is stateful, and we need it for two reasons:

We need a container of state to represent the state of system, which could transition over time. Actually it’s just an atom in the state record.

For delayed transitions, we need someone to keep track of these scheduling information. In its essence a delayed transition is just a timer: the timer is scheduled when entering the state if the machine transitions out of the state before the timer is fired, the timer shall be canceled.

あるstateのactionsの延長で次のstateへのtransitionをキックしたい

actionsの関数の入力stateを元に遷移する. このとき, actionの関数をfsm/assignで囲まないと, stackoverflowになる.

{:states
 {:a
  {:on {:foo {:actions (fsm/assign some-fn)
              :target  :b}}}}
 {:b
  {:on {:bar {}}}}}
 
(defn some-fn [state event]
  (do-something)
  (fsm/transition machine state {:type :bar})

immutable api特有か? service apiでは未検証.

状態遷移でのaction抑止(immutable api)

fsm/transition の on event actionsを抑止するには {:exec false} のoptionsをつける. または, fsm/initialize の entry actionsを抑止するためにも有効.

これは主にテストのための機能.

(fsm/initialize machine {:exec false})
(fsm/transition machine state event {:exec false})

fsm/transitionの前に引数で渡すmachineのMapからactionsをassoc-inでピンポイントで削除する方法もある.

clj-statecharts Insights

🤔つかってみた感想

そこそこガチでつかった.

悪くないんだけどexampleがネット上にないので(integrantみたいに)コードよんであとはいろいろ試しながら使い方を覚えるしかない.

このライブラリをけっこう使い倒しつつあるものの, どうも使っている人もいなくて不安ではある. おそらくこのライブラリの背後にある状態遷移の理論がよく出来ていてそれをシンプルに実装しているだけのものなのだけど, このライブラリが良いものなのかはalternativeを試してないのでわからない.

はたまた📝Clojure Bot 開発の問題解決でFSMが適切なのかもよくわからない..

contextという概念

contextという概念はstatechartsで見当たらないのでclj-statechartsの独自概念だろうか? ただしこれはFSMの外部から見えない状態変数の集合にみえる.

Actions & Context | clj-statecharts


updated: Extended Stateという概念かな? extended states.

ステートマシンの全ての状態で参照, 更新できる値.

actionは副作用なので!をつける

actionの概念はFSMの外に対する副作用を示すため, Clojureの慣習に従って関数名に!をつけるのがいい.

FSM: Actorの基礎概念

状態定義はデータでしかなくMapの操作で編集できる

fsm/machineに渡す前のFSMの定義はMapでしかないのでassocなりupdateで修正できたり, 中をinspectすることが可能.

テストのときに初期化前にcontextの内容を変えたい場合は, fsm/machineの手前でデータを修正すればいい.

🤔fsmはinitializeするとactorになる

より抽象的な概念と紐付ける.

fsm/machineはステートマシンを生成する. これは, クラスを定義するようなものである.

fsm/initializeはクラスからインスタンスを生成する. これはThreadにbindingされてstreamに読み書きをするactorになる.

fulcrologic/statecharts

clj-statechartsより後発のライブラリ. 企業がメンテナンスしている.

よりSCXMLの記法に忠実に従うことがコンセプト.

現時点ではclj-statechartsのほうが人気だけど, それは逆転する可能性もある. ドキュメントがしっかりしてるので基礎概念が学べる.

Topics

💡状態は keywordで表現する

いろいろな例をみた感想なので一般的ではない.

状態はkeywordで定義されていることが多い, とくにライブラリ系は.

はじめは文字列で定義して大文字の変数にbindするのかと思った. Clojureにおいてはdefaultが定数なため, 定数のための特別な表記はしない.

🔗References

[Clojure]trampolineを理解する - Qiita