En Generisk State Machine i Ruby


tirsdag 12. januar 2010 Ruby

Den siste tiden har jeg holdt på å lære meg Ruby, og det er en fantastisk opplevelse. Å lære et så elegant, dynamisk språk åpner dører som jeg ikke ante fantes. Underveis skriver jeg selvsagt endel kode, og tenkte det var på sin plass å dele litt av galskapen. Merk at det er en viss overgang å gå fra C# til Ruby, så hvis de som allerede behersker dette spåket har noe å utsette på koden min så er det forståelig – og kommmentarer mottas med glede.

For et halvt års tid siden blogget jeg hvordan jeg lagde en generisk state machine i C# basert på en overgangstabell. Jeg tenkte at det kunne være en fin øvelse å gjøre det samme i Ruby.

Jeg startet med å sette opp en testfixture som definerer tilstandsmaskinen og alle overgangene. I C#-løsningen brukte jeg 30 linjer på dette – i Ruby klarer jeg meg med 13 (en ganske typisk reduksjon i antall kodelinjer når man går fra et statisk typet språk til et som er dynamisk typet). Bortsett fra det har jeg beholdt samme fremgangsmåte som sist:

   1 require 'test/unit'

   2 

   3 class GenericStateMachineTests < Test::Unit::TestCase

   4     def setup

   5         @unlock_called, @alarm_called, @thank_you_called, @lock_action_called = false

   6   

   7         @state_machine = GenericStateMachine.new :locked

   8       

   9         @state_machine.add_transition(:locked, :coin, :unlocked) { @unlock_called = true }

   10         @state_machine.add_transition(:locked, :pass, :locked) { @alarm_called = true }

   11         @state_machine.add_transition(:unlocked, :coin, :unlocked) { @thank_you_called = true }

   12         @state_machine.add_transition(:unlocked, :pass, :locked) { @lock_action_called = true }

   13     end

Jeg "kopierte" også testene fra C# løsningen:

   15     def test_initial_conditions

   16         assert_equal :locked, @state_machine.state

   17     end

   18     def test_coin_in_locked_state

   19         @state_machine.state = :locked

   20         @state_machine.handle_event :coin

   21         assert_transition :unlocked, @unlock_called

   22     end

   23     def test_coin_in_unlocked_state

   24         @state_machine.state = :unlocked

   25         @state_machine.handle_event :coin

   26         assert_transition :unlocked, @thank_you_called

   27     end

   28     def test_pass_in_locked_state

   29         @state_machine.state = :locked

   30         @state_machine.handle_event :pass

   31         assert_transition :locked, @alarm_called

   32     end

   33     def test_pass_in_unlocked_state

   34         @state_machine.state = :unlocked

   35         @state_machine.handle_event :pass

   36         assert_transition :locked, @lock_action_called

   37     end

   38   

   39     def assert_transition expected_state, indicator_should_be_true

   40         assert_equal expected_state, @state_machine.state

   41         assert indicator_should_be_true

   42     end

   43 end

Det burde ikke være så vanskelig å kjønne hva som skjer i de testene, selv om man ikke har vært borti Ruby før. I Ruby's innebygde testrammeverk blir alle metoder i en testklasse (en klasse som arver fra Test::Unit::TestCase) som starter med "test" oppfattet som en test.

Og her følger implementasjonen jeg etter hvert kom frem til av GenericStateMachine. C#-varianten, som eksponerer det samme interfacet, var på ca  50 linjer, mens Ruby-klassen ender på 20. Ta en rask titt på koden, så kommer jeg med litt kommentarer og forklaringer etterpå..

   45 class GenericStateMachine

   46     Struct.new "Transition", :start_state, :trigger, :end_state, :action

   47     attr_accessor :state

   48     def initialize initial_state

   49         @state = initial_state

   50         @transitions = []

   51     end

   52     def add_transition start_state, trigger, end_state, &action

   53         @transitions << Struct::Transition.new(start_state, trigger, end_state, action)

   54     end

   55     def handle_event event

   56         @transitions.each do |t|

   57             if t.start_state == @state and t.trigger == event

   58                 @state = t.end_state

   59                 t.action.call

   60                 break

   61             end

   62         end

   63     end

   64 end

Linje 46 illustrerer hvordan man veldig enkelt kan definere en klasse i Ruby som bare består av "properties" (i Ruby kaller vi properties for accessor methods). I linje 53 kan du se hvordan jeg oppretter instanser av denne klassen, og legger dem til et array.

På linje 47 deklareres tilstandsmaskinens eneste accessor (dvs property), som eksponerer tilstanden. Linje 48 til 51 er konstruktøren (eller gjør i alle fall samme nytten), hvor initiell tilstand settes, og en tom tabell med overganger opprettes.

Og på linje 55 begynner metoden som skal håndtere events. Jeg itererer over alle overgangene, og hvis start-tilstand og event er riktig så gjennomfører jeg overgangen ved å sette ny tilstand og kjøre kodeblokken (action) som er definert. Referer til linje 9 til 12 i test-oppsettet for å se hvordan overgangene ble definert.

Det fine med tilstandsmaskinen jeg lagde i C# var at den var generisk – på den måten at jeg kunne bruke ulike enums eller klasser til å definere både tilstander og events. Siden Ruby er dynamisk typet fungerer dette like bra i denne løsningen, og det helt uten at jeg har gjort noe spesielt for å få det til. I koden min bruker jeg Ruby-symboler (de merkelige tingene som begynner med kolon), men jeg kunne f.eks. brukt tall, strenger, konstanter, eller noen helt andre objekter.

Flytende konfigurering

Jeg lagde også et "flytende interface" (eller intern DSL om du vil) for tilstandsmaskinen min i C#, og tenkte jeg skulle forsøke det samme i Ruby. Her er jeg litt usikker på om jeg har gjort det "the Ruby way", men jeg har brukt endel av det jeg har lært meg i det siste, og er foreløpig fornøyd med løsningen. Her er hvordan jeg har valgt å gjøre det flytende konfigureringen (erstatter linje 9 til 12 i test-oppsettet).

   72         @state_machine.configure do |sm|

   73             sm.given(:locked).when(:coin).then_set_state(:unlocked).and_run { @unlock_called = true }

   74             sm.given(:locked).when(:pass).then_set_state(:locked).and_run { @alarm_called = true }

   75             sm.given(:unlocked).when(:coin).then_set_state(:unlocked).and_run { @thank_you_called = true }

   76             sm.given(:unlocked).when(:pass).then_set_state(:locked).and_run { @lock_action_called = true }

   77         end

Jeg vet ærlig talt ikke om dette er bedre, men det lar seg i alle fall lese som en setning (kind of): Gitt tilstand LOCKED, når COIN, set tilstand UNLOCKED og kjør "unlocked_called = true".

For å implementere muligheten for denne konfigurasjonen gjenåpner jeg klasse-definisjonen og legger til et sett med nye metoder; du kan nemlig se på alle klasser i Ruby som "partial", og ved å spre definisjonen av GenericStateMachine til flere filer kan jeg f.eks. velge om jeg vil inkludere det flytende API'et eller ikke.

   110 class GenericStateMachine #open class definition to add fluent dsl

   111     def configure  

   112         yield self

   113     end

   114     def given start_state

   115         @temp = [start_state]

   116         self

   117     end

   118     def when event

   119         @temp << event

   120         self

   121     end

   122     def then_set_state end_state

   123         @temp << end_state

   124         self

   125     end

   126     def and_run &block      

   127         add_transition *@temp, &block

   128     end

   129 end

Det er litt vanskelig å forklare hvordan dette fungerer, men la meg forsøke: Metoden configure kjører blokken som er sendt til den (blokken er linje 73 til 76) med seg selv (self) som argument. Variabelen sm i 72-76 er derfor tilstandsmaskinen. De tre metodene starte_state, event og then_set_state tar bare vare på argumentet som sendes inn til dem, og returnerer self igjen, slik at metodekallene kan kjedes sammen. I and_run-metoden tar jeg imot det siste argumentet – en kodeblokk – og legger til en ny overgang ved å kalle den eksisterende metoden add_transition med de tre argumentene pluss kodeblokken.

Merk at dette grensesnittet forutsetter at det brukes riktig – her er det absolutt ingen feilhåndtering.

Jeg håper dette kan være med på å spre interessen for å lære seg flere programmeringsspråk, spesielt blant .Net-utviklerne, og vil fortsette å blogge ting og tang jeg finner på etterhvert som jeg bruker Ruby mer og mer.


comments powered by Disqus