Events i Ruby


tirsdag 9. mars 2010 Ruby

Ruby har ikke events slik som vi er vandt med fra .Net. Det er derimot ikke særlig vanskelig å implementere noe som ligner. Jeg begynte å eksperimentere litt, og her følger en slags log over hva jeg har forsøkt. Jeg har ikke landet på noen "best practice", og det er heller ikke noe rocket science her - men jeg tror følgende kodesnutter kan være insteressante, særlig om man ikke er så veldig erfaren med Ruby enda.

Iterasjon 1

Jeg ønsker å implementere en Counter-klasse. Den skal ha en metode som heter increment som jeg kan kalle x antall ganger, hvor x er en limit jeg setter. Når limiten er nådd vil jeg at Counter-objektet informerer meg om dette ved å fyre av et event. Her er koden for klassen, samt litt kode som viser hvordan den brukes:

1 class Counter
2   def initialize limit
3     @limit = limit
4     @count = 0
5   end
6   def on_limit_reached= delegate
7     @on_limit_reached_delegate = delegate
8   end
9   def increment
10     @count += 1
11     @on_limit_reached_delegate.call if @count == @limit
12   end
13 end
14
15 c = Counter.new(5) # create a new counter
16 c.on_limit_reached = lambda{ puts 'Limit reached' }
17 4.times { c.increment } # count up to limit -1
18 puts 'Limit not reached yet'
19 c.increment # one more time, limit is now reached
Output:
Limit not reached yet
Limit reached

Løsninger er altså at vi lager en closure i linje 16 (også kalt lambda, kodeblokk, anonym metode, etc) som man sender til on_limit_reached= metoden. Counter-klassen tar vare på en referanse til denne closuren. Når limiten er nådd eksekveres den så i linje 11.

Merk at jeg glemte å legge til en test for om delegaten er satt, så koden i linje 11 vil feile om den ikke har noen lyttere (lover å skjerpe meg).

Iterasjon 2

Løsningen i iterasjon 1 støtter bare én lytter – hvis flere legges til vil den bare erstatte den første. Jeg ønsker derfor å utvide Counter for dette, og denne gangen husket jeg å legge til en test for om det er noen som lytter før jeg "fyrer av eventet":

1 class Counter
2   def initialize limit
3     @limit = limit
4     @count = 0
5   end
6   def on_limit_reached= delegate
7     @limit_reached_delegates ||= []
8     @limit_reached_delegates << delegate
9   end
10   def increment
11     @count += 1
12     if @count == @limit
13       @limit_reached_delegates.each {|d| d.call } if @limit_reached_delegates
14     end
15   end
16 end
17
18 c = Counter.new(5) # create a new counter
19 c.on_limit_reached = lambda{ puts 'I was informed about limit reached' }
20 c.on_limit_reached = lambda{ puts 'I was also informed about limit reached' }
21 4.times { c.increment } # count up to limit -1
22 puts 'Limit not reached yet'
23 c.increment # one more time, limit is now reached
Output:
Limit not reached yet
I was informed about limit reached
I was also informed about limit reached

Counter har nå et array av delegater: @limit_reached_delegates. Når limiten er nådd kaller jeg alle sammen. Jeg fikk desverre ikke til å bruke += for å legge til eventer, noe som ville ha virket riktigere for C#-utviklere.

Iterasjon 3

Det er på tide å legge opp støtte for flere eventer; jeg ønsker nå å bli fortalt hver gang telleren inkrementeres, og legger derfor opp en on_increment= metode. Jeg vil også sende med verdien på counteren i eventet.

1 class Counter
2   def initialize limit
3     @count, @limit = 0, limit
4     @event_handlers = {}
5   end
6   def on_limit_reached= delegate
7     (@event_handlers[:limit_reached] ||= []) << delegate
8   end
9   def on_increment= delegate
10     (@event_handlers[:increment] ||= []) << delegate
11   end
12   def increment
13     @count += 1
14     @event_handlers[:increment].each {|d| d.call(@count) } if @event_handlers[:increment]
15     if @count == @limit
16       @event_handlers[:limit_reached].each {|d| d.call } if @event_handlers[:limit_reached]
17     end
18   end
19 end
20
21 c = Counter.new(5) # create a new counter
22 c.on_limit_reached = lambda{ puts 'Limit reached' }
23 c.on_increment = lambda{|count| puts "Counter was incremented to #{count}" }
24 5.times { c.increment }
Output:
Counter was incremented to 1
Counter was incremented to 2
Counter was incremented to 3
Counter was incremented to 4
Counter was incremented to 5
Limit reached

Jeg har nå brukt en Hash(-tabell) til å holde rede på alle handlerne – denne opprettes i linje 4. Den litt hårete syntaksen på linje 7 og 10 legger inn en ny array for en gitt event-nøkkel om arrayet ikke finnes enda, før den legger delegaten til arrayet. Deretter kan jeg trigge increment-eventet hver gang increment kalles (linje 14). Legg merke til at jeg sender inn @count når jeg kaller delegaten, og kan derfor bruke den i closuren i linje 23.

Iterasjon 4

Det ble litt mye "bråk" i Counter-klassen for å holde rede på event-handlerene i iterasjon 3, og jeg forsøker derfor å trekke ut denne logikken (Single Responsibility Principle). Jeg lager en Ruby-modul som jeg kan mikse inn i Counter (vi kaller det en mixin, som er Ruby's løsning på multippel arv, noe vi ikke har i .Net). Modulen har nå handler-hashen, og brukes også til å trigge eventene:

1 module Events
2   def add_handler event, delegate
3     @event_handlers ||= {}
4     (@event_handlers[event] ||= []) << delegate
5   end
6   def raise_event event, *args
7     @event_handlers[event].each {|d| d.call(*args) } if @event_handlers[event]
8   end
9 end
10
11 class Counter 
12   include Events
13   attr_reader :limit
14   def initialize limit
15     @count, @limit = 0, limit
16   end
17   def on_limit_reached= delegate
18     add_handler(:limit_reached, delegate)
19   end
20   def on_increment &delegate
21     add_handler(:increment, delegate)
22   end
23   def increment
24     @count += 1
25     raise_event(:increment, self, @count)
26     raise_event(:limit_reached) if @count == @limit  
27   end
28 end
29
30 c = Counter.new(5) # create a new counter
31 c.on_limit_reached = lambda{ puts 'Limit reached' }
32 c.on_increment do |sender, count|
33   puts "Counter was incremented to #{count}"
34   puts "#{sender.limit - count} left.."
35 end
36 5.times { c.increment }
Output:
Counter was incremented to 1
4 left..
Counter was incremented to 2
3 left..
Counter was incremented to 3
2 left..
Counter was incremented to 4
1 left..
Counter was incremented to 5
0 left..
Limit reached

Jeg valgte også å endre litt på on_increment for å illustrere en annen måte å lage closures på, som nok er mere vanlig i Ruby. I stedet for å bruke lambda-metoden kan jeg nå lage en kodeblokk ved hjelp av 'do' og 'end' (do og end kan byttes ut med { og } om man foretrekker det). Jeg sender også med selve counter-objektet som et argument til handleren ('self' i linje 25 tilsvarer 'this' i C#), som jeg så kan bruke til å beregne hvor mange increments som gjenstår – fordi jeg har definert en reader for limit-variabelen (linje 13).

Iterasjon 5

En annen approch jeg ville teste ut var å opprette en generisk Event-klasse. Jeg droppet da Events-modulen, selv om jeg kunne ha brukt dem i kombinasjon. I dette eksempelet har jeg sneket inn litt dynamisk evaluering også – se om du skjønner hva jeg gjør på linje 17 og 32.

1 class Event
2   def initialize
3     @handlers = []
4   end
5   def raise *args
6     @handlers.each {|h| h.call(*args)}
7   end
8   def << handler
9     @handlers << handler
10   end
11 end
12
13 class Counter 
14   attr_reader :limit
15   def initialize limit
16     @count, @limit = 0, limit
17     events :limit_reached, :increment
18   end
19   def on_limit_reached= delegate
20     @limit_reached << delegate
21   end
22   def on_increment &delegate
23     @increment << delegate
24   end
25   def increment
26     @count += 1
27     @increment.raise(self, @count)
28     @limit_reached.raise if @count == @limit   
29   end
30   private
31   def events *attr
32     attr.each {|e| eval("@#{e} = Event.new")}
33   end
34 end
35
36 c = Counter.new(5) # create a new counter
37 c.on_limit_reached = lambda{ puts 'Limit reached' }
38 c.on_increment do |sender, count|
39   puts "Counter was incremented to #{count}"
40   puts "#{sender.limit - count} left.."
41 end
42 5.times { c.increment }
(Output som i iterasjon 4)
Jeg håper dette fungerte som eksempler på hvordan man kan kode med events i Ruby, og at du lærte litt underveis. Spørsmål til koden mottas og besvares med største fornøyelse.

comments powered by Disqus