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.
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.