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)