Min tredje state machine DSL


lørdag 6. november 2010 Clojure DSL

I juni 2009 blogget jeg hvordan man kan implementere en generisk tilstandsmaskin i C#, inspirert av Robert C. Martin's overgangstabeller fra boken Agile Principles, Patterns, and Practises in C#. Jeg designet også et flytende interface for å konfigurere tilstandsmaskiner – her er et tilbakeblikk på hvordan det så ut:

10   _stateMachine = new StateMachine<State, Event>(State.LOCKED);
11
12   _stateMachine.Configure()
13       .Given(State.LOCKED)
14           .When(Event.COIN)
15               .ThenSetState(State.UNLOCKED).AndRun(unlock)
16           .When(Event.PASS)
17               .ThenSetState(State.LOCKED).AndRun(alarm)
18       .Given(State.UNLOCKED)
19           .When(Event.COIN)
20               .ThenSetState(State.UNLOCKED).AndRun(thankYou)
21           .When(Event.PASS)
22               .ThenSetState(State.LOCKED).AndRun(lockAction);
23

Et halvt år senere presenterte jeg en implementasjon i Ruby, og i en separat blogpost implementerte jeg en forbedret DSL som så ut som dette:

10   @state_machine.transition :if_state_is => :locked
11     :when_event => :coin, :then_set_state => :unlocked do
12     @unlock_called = true
13   end
14   @state_machine.transition :if_state_is => :locked
15     :when_event => :pass, :then_set_state => :locked do
16     @alarm_called = true
17   end
18   @state_machine.transition :if_state_is => :unlocked
19     :when_event => :coin, :then_set_state => :unlocked do
20     @thank_you_called = true
21   end
22   @state_machine.transition :if_state_is => :unlocked
23     :when_event => :pass, :then_set_state => :locked do
24     @lock_action_called = true
25   end

Og nå som jeg har begynt å leke med Clojure er det på tide å se hva jeg kan få til med det språket. Clojure, og Lisp generelt, har nesten ingen begrensninger i forhold til hva man kan få til når man designer en DSL. Jeg har forsøkt å lage en variant som er mere lesbar enn C# og Ruby-variantene, men uten at jeg har gjort noe spesielt avansert.

10 (ns fsm.sample (:use fsm.core))
11
12 (def turnstile (statemachine :locked '(      
13
14      Given state :locked, event :coin
15         set state :unlocked, (println " > Unlocking, you are free to pass.")
16
17      Given state :locked, event :pass
18         set state :locked, (println " > You haven't paid yet. Alarm!!!")
19       
20      Given state :unlocked, event :coin
21         set state :unlocked, (println " > Thank you for that extra coin!")
22
23      Given state :unlocked, event :pass
24         set state :locked, (println " > Have a nice day! Locking up."))))

Som du forhåpentlig vis ser er reglene strukturert likt i de tre variantene, og inneholder den samme informasjonsmengden. Men Clojure-varianten skiller seg fra de andre gjennom langt mindre "syntaks". Hvordan jeg valgte å strukturere setningene er ganske tilfeldig.

Her er et lite eksempel hvor jeg bruker tilstandsmaskinen i en REPL:

CropperCapture[90]

Så hva er det som gjemmer seg bak denne turnstile? Det kan nesten virke som om det er et objekt, men det er det ikke. Det er en funksjon, men denne funksjonen har også en tilstand – den husker hvilken state tilstandsmaskinen den representerer er i. Og da skjønner du kanskje at dette er en closure (les Lispy C# (og hva er en closure) om du ikke vet eller husker hva det er for noe).

statemachine i linje 12 i kodeeksempelet over er altså et kall til en funskjon som tar alle reglene og genererer en closure-funksjon basert på dem. Her følger den komplette funksjonen. Det er nok mye her som vil være vanskelig å forstå om du ikke kan språket, men jeg skal trekke frem noen "høydepunkter" etterpå.

10 (ns fsm.core)
11
12 (defn statemachine [initial-state dsl]
13   {:pre [(> (-> dsl count) 0)
14          (= (-> dsl count (mod 9)) 0)]}
15   (letfn
16     [
17      (new-transition [g e ns a]
18        { :given g, :event e, :new-state ns, :action a })
19
20      (find-transition [state event transitions]                          
21       (first (filter #(and (= (:given %) state)
22                            (= (:event %) event)) transitions)))
23    
24      (perform-transition [transition state]
25        {:pre [(not-empty transition)]}
26        (reset! state (:new-state transition))
27        (eval (:action transition)))]
28
29     (loop [transitions '(), spec dsl]
30       (if (seq spec)
31         (let [[_ _ state _ event _ _ new-state action & more] spec]
32           (recur (cons (new-transition state event new-state action)
33                        transitions)
34                  more))
35
36         (let [current-state (atom initial-state)]
37           (fn [event]
38               (perform-transition (find-transition @current-state
39                                                    event
40                                                    transitions) 
41                                   current-state)))))))

Noe av det første du ser her er Clojure's støtte for Design by Contract (DbC), eller mer presist preconditions. Linje 13 og 14 sier noe om hva som må være tilfredstilt for at et kall til statemachine-funksjonen skal være gyldig. Linje 13 sier at dsl-argumentet skal inneholde en liste med mer enn ett element. Linje 14 følger opp med å si at antall elementer skal være en multippel av 9. Og hvorfor det? Jo, fordi en regel i syntaksen jeg definerte består av nøyaktig 9 ord/elementer.

For å øke lesbarheten på koden inneholder definisjonen av statemachine-funksjonen tre "interne" funksjoner (linje 17, 20 og 24). Den siste av disse har også en precondition, og den slo faktisk til i REPL-sesjonen du så tidligere. Da jeg sendte eventet :foobar klarte ikke tilstandsmaskinen å finne en overgang, og kastet et assert exception. Ved å bruke preconditions på denne måten slipper jeg å rote til resten av koden med unntakshåndtering.

statemachine-funksjonen starter med å eksekvere en loop i linje 29. Koden plukker de 9 neste uttrykkene fra dsl-spesifikasjonen, og looper sålenge det finnes flere regler. Overgangene samles i verdien transitions. I linje 31 bruker jeg noe som heter destructuring for å plukke ut det som er interessant fra uttrykkene i DSL-listen. Ønsker jeg å endre hvordan jeg skriver reglene er det denne linjen jeg vil måtte endre på.

Når det ikke er flere regler å prosessere opprettes det en verdi for maskinens tilstand (linje 36). Og det eneste som gjenstår da er å opprette og returnere en anonym funksjon (linje 37-41) som representerer tilstandsmaskinen. Denne funksjonen benytter seg av overgangstabellen (transitions) og tilstanden (current-state), og blir dermed en closure. Og ingen andre kan aksessere disse verdiene utenfra, noe som også er ganske kult.

Dette har bare vært en liten smakebit av hva som er mulig å få til av interne DSL'er i Clojure. Og jeg behøvde ikke engang ty til makroer for å få det til. Jeg håper det gav mersmak.


comments powered by Disqus