Connecting Objects with Observable
- Author: Stephen Ball
- Published:
-
- Permalink: /blog/connecting-objects-with-observable
Using the Observable module from Ruby's standard library
Today let’s dive a bit into the Observable module from Ruby’s standard library.
Rocket Launching
Let’s say we’re launching a rocket.
Fortunately we have a very high level of abstraction that handles all the rocket launchy pieces. We only need to output the countdown to STDOUT. Ok easy!
class Countdown
attr_reader :starting_count
def initialize(starting_count)
@starting_count = starting_count
end
def run
starting_count.downto(0) do |count|
puts count
end
end
end
Countdown.new(5).run
$ ruby countdown.rb
5
4
3
2
1
0
Done!
Blast Off!
But wait, we need to trigger the blast off by emitting a special “BLAST OFF” string after 0. Well ok.
class Countdown
attr_reader :starting_count
def initialize(starting_count)
@starting_count = starting_count
end
def run
starting_count.downto(0) do |count|
puts count
end
puts "BLAST OFF"
end
end
Countdown.new(5).run
$ ruby countdown.rb
5
4
3
2
1
0
BLAST OFF
Ok done!
Ignition
But wait, we need to trigger the ignition sequence when the count is at three.
class Countdown
attr_reader :starting_count
def initialize(starting_count)
@starting_count = starting_count
end
def run
starting_count.downto(0) do |count|
puts count
if count == 3
puts "IGNITION"
end
end
puts "BLAST OFF"
end
end
Countdown.new(5).run
$ ruby countdown.rb
5
4
3
!!! IGNITION !!!
2
1
0
BLAST OFF
Too much responsibility
Well that works, but our poor simple countdown class now has a lot of responsibility. Too much responsibility! Let’s move the ignition and blast off jobs to other classes.
class Countdown
attr_reader :starting_count,
:ignition_control,
:blast_off
def initialize(starting_count)
@starting_count = starting_count
@ignition_control = IgnitionControl.new(3)
@blast_off = BlastOff.new
end
def run
starting_count.downto(0) do |count|
puts count
ignition_control.check(count)
blast_off.check(count)
end
end
end
class IgnitionControl
attr_reader :ignite_at
def initialize(ignite_at=0)
@ignite_at = ignite_at
end
def check(count)
puts "!!! IGNITION !!!" if count == ignite_at
end
end
class BlastOff
def check(count)
puts "BLAST OFF" if count == 0
end
end
Countdown.new(5).run
$ ruby countdown.rb
5
4
3
!!! IGNITION !!!
2
1
0
BLAST OFF
Too much knowledge
That works, but our poor Countdown class still knows way too much about its collaborators. It should only be concerned with counting down, not with setting up the ignition and blast off classes.
Instead of hardcoding the collaborators into Countdown, let’s try and wire them up at runtime.
class Countdown
attr_reader :starting_count, :listeners
def initialize(starting_count)
@starting_count = starting_count
@listeners = []
end
def add_listener(listener)
listeners << listener
end
def run
starting_count.downto(0) do |count|
puts count
listeners.each do |listener|
listener.update(count)
end
end
end
end
class IgnitionControl
attr_reader :ignite_at
def initialize(ignite_at=0)
@ignite_at = ignite_at
end
def update(count)
puts "!!! IGNITION !!!" if count == ignite_at
end
end
class BlastOff
def update(count)
puts "BLAST OFF" if count == 0
end
end
countdown = Countdown.new(5)
ignition = IgnitionControl.new(3)
blastoff = BlastOff.new
countdown.add_listener(ignition)
countdown.add_listener(blastoff)
countdown.run
$ ruby countdown.rb
5
4
3
!!! IGNITION !!!
2
1
0
BLAST OFF
That’s a bit better. We’ve changed the API for our things that react to count and add them at runtime instead of hardcoding them into the Countdown class.
Now the Countdown class starts with an empty list of listeners and it has a method to allow new listening objects to be added. Each listener is expected to have an update
method which they can expect to be called with each number of the countdown. Not bad!
Turns out, with this approach we’ve pretty much implemented the bare bones version of Observable!
Let’s switch to the real thing. While we’re at it, let’s even make the STDOUT of the countdown another observer.
Observable Countdown
require "observer"
class Countdown
include Observable
attr_reader :starting_count
def initialize(starting_count)
@starting_count = starting_count
end
def run
starting_count.downto(0) do |count|
changed
notify_observers count
end
end
end
class TerminalOutput
def update(count)
puts count
end
end
class IgnitionControl
attr_reader :ignite_at
def initialize(ignite_at=0)
@ignite_at = ignite_at
end
def update(count)
puts "!!! IGNITION !!!" if count == ignite_at
end
end
class BlastOff
def update(count)
puts "BLAST OFF" if count == 0
end
end
countdown = Countdown.new(5)
countdown.add_observer(TerminalOutput.new)
countdown.add_observer(IgnitionControl.new(3))
countdown.add_observer(BlastOff.new)
countdown.run
$ ruby countdown.rb
5
4
3
!!! IGNITION !!!
2
1
0
BLAST OFF
Hooray! Let’s look closer at what using Observable looks like.
-
In the class that will be emitting the signals we
include Observable
. That handles adding the data structure that will contain the objects that are observing. -
When we have an update to emit we call
changed
which tells Observable that it should actually call the observers with the notification. A nice feature of Observable is that notifications are only emitted if the class has declared a change. -
We call
notify_observers
with the data
When notify_observers
is called from an observable object that has declared a change
then it calls update
on each listener with whatever arguments it has been given.
# simply call update on the observers
notify_observers
# send :hello to the observers
notify_observers :hello
# send :temperature and the current_temperature variable to the observers
notify_observers :temperature, current_temperature
With Observable we could even easily do cool things like skip outputting the “2” after the IGNITION.
def run
starting_count.downto(0) do |count|
changed unless count == 2
notify_observers count
end
end
$ ruby countdown.rb
5
4
3
!!! IGNITION !!!
1
0
BLAST OFF
Of course then we’re giving more responsibility to the Countdown again. But maybe the countdown should know when specific events are supposed to happen. Who knows? Not me!
Many Signals
With the Observable module we can have a few (or one) observers that get notifications from a lot of different places.
require "observer"
class Sonar
include Observable
attr_reader :label
def initialize(label)
@label = label
end
def ping
changed
notify_observers "ping from SONAR #{label}"
end
end
class Station
attr_reader :label
def initialize(label)
@label = label
end
def update(signal)
puts "Station #{label}: #{signal} detected"
end
end
station = Station.new(1)
sonars = 1.upto(9999).map do |n|
Sonar.new(n).tap { |sonar| sonar.add_observer(station) }
end
sonars.each do |sonar|
sonar.ping
end
$ ruby many_signals.rb | tail
Station 1: ping from SONAR 9990 detected
Station 1: ping from SONAR 9991 detected
Station 1: ping from SONAR 9992 detected
Station 1: ping from SONAR 9993 detected
Station 1: ping from SONAR 9994 detected
Station 1: ping from SONAR 9995 detected
Station 1: ping from SONAR 9996 detected
Station 1: ping from SONAR 9997 detected
Station 1: ping from SONAR 9998 detected
Station 1: ping from SONAR 9999 detected
Many Observers
Or we can have lots of observers all watching one object for notifications.
require "observer"
class Sonar
include Observable
attr_reader :label
def initialize(label)
@label = label
end
def ping
changed
notify_observers "ping from SONAR #{label}"
end
end
class Station
attr_reader :label
def initialize(label)
@label = label
end
def update(signal)
puts "Station #{label}: #{signal} detected"
end
end
sonar = Sonar.new(1)
1.upto(9999) do |n|
sonar.add_observer(Station.new(n))
end
sonar.ping
$ ruby many_stations.rb | tail
Station 9990: ping from SONAR 1 detected
Station 9991: ping from SONAR 1 detected
Station 9992: ping from SONAR 1 detected
Station 9993: ping from SONAR 1 detected
Station 9994: ping from SONAR 1 detected
Station 9995: ping from SONAR 1 detected
Station 9996: ping from SONAR 1 detected
Station 9997: ping from SONAR 1 detected
Station 9998: ping from SONAR 1 detected
Station 9999: ping from SONAR 1 detected
Many Many? Both is good
Or we can have lots of observers watching lots of Observables!
require "observer"
class Sonar
include Observable
attr_reader :label
def initialize(label)
@label = label
end
def ping
changed
notify_observers "ping from SONAR #{label}"
end
end
class Station
attr_reader :label
def initialize(label)
@label = label
end
def update(signal)
puts "Station #{label}: #{signal} detected"
end
end
COUNT = 100
stations = COUNT.times.map { |n| Station.new(n) }
sonars = COUNT.times.map do |n|
Sonar.new(n).tap do |sonar|
stations.each do |station|
sonar.add_observer(station)
end
end
end
sonars.each { |sonar| sonar.ping }
$ ruby many_many.rb | tail
Station 90: ping from SONAR 99 detected
Station 91: ping from SONAR 99 detected
Station 92: ping from SONAR 99 detected
Station 93: ping from SONAR 99 detected
Station 94: ping from SONAR 99 detected
Station 95: ping from SONAR 99 detected
Station 96: ping from SONAR 99 detected
Station 97: ping from SONAR 99 detected
Station 98: ping from SONAR 99 detected
Station 99: ping from SONAR 99 detected
Signals everywhere! Wow!
The Observable module is a great tool in the Ruby standard library. If you ever find yourself writing mechanisms to allow objects to respond to changes in other objects you could well find that Observable is already exactly what you’re looking for.