DEV Community

Stephen Ball
Stephen Ball

Posted on • Originally published at rakeroutes.com on

Connecting Objects with Observable

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)