Refactoring to Observer Pattern
Scenario
Let’s take a scenario in which we are trying to automate our home. We want to play a welcome message as soon as we get home.
We decide to play a welcome message whenever our phone is connected to the modem at the house. So we create a class Modem and pass an instance of ‘Speaker’ class to it, The Modem object can use this instance to play the welcome message.
class Modem
def initialize(speaker)
@speaker = speaker
@connected_devices = []
end
def connect_device(device)
@connected_devices << device
@speaker.play_welcome_message
end
end
speaker = Speaker.new
modem = Modem.new(speaker)
phone = Phone.new
modem.connect_device(phone)
=> Welcome Home
One disadvantage of our code is that the Modem and Speaker class are tightly coupled. We cannot reuse the Modem class without passing a Speaker object to it. So let’s refactor our code to make our classes reusable and not tightly coupled with each other.
class Modem
def add_listener(listener)
(@listeners ||= []) << listener
end
def notify
@listeners.each { |listener| listener.update }
end
def connect_device(device)
(@connected_devices ||= []) << device
notify
end
end
class Speaker
def update
play_welcome_message
end
end
phone = Phone.new
modem = Modem.new
speaker = Speaker.new
modem.add_listener(speaker)
modem.connect_device(phone)
=> Welcome Home
We have added an instance method called add_listeneres on Modem class, to register an object that is interested in listening for changes on it. Whenever there is a change in the Modem instance like our phone gets connected to the modem, we call the notify method on the Modem which in turn calls update method on each of the objects that have registered as listeners. The listeners should implement this update method to perform logic that needs to get triggered.
Concept
We have refactored our initial code to use Observer design pattern. In this scenario, the Modem is called the subject and the Speaker and Light are the observers. These observers register themselves as listeners on the Subject. When the Subject changes its state, it sends a notification to all the observers that have registered as listeners to it.
When to use
- When an object depends on another object’s changes
- When a change in one object requires changing other objects
- When an object should be able to notify other objects without making assumptions about what these objects are.
Advantages
- Subject only needs to know that observer implements Observer interface and nothing else.
- There is no need to modify subjects to add new listeners.
- We can reuse the subject & observer classes independent of each other
Implementation
Here are a few points to keep in mind while implementing observer pattern:
Pull/Push method
We generally tend to send information to the observers on what triggered the notification on the subject. We can do this in two ways the pull method or the push method.
Pull method
In the pull method, we send very less information to the observer. The observer has to deduce what changed in the subject without the help of the subject.
class Modem
...
def notify
@listeners.each { |listener| listener.update(self) }
end
def disconnect_device(device)
@connected_devices.delete(device)
notify
end
def any_phone_connected?
...
end
end
class Speaker
def update(modem)
if modem.any_phone_connected?
play_welcome_message
else
play_farewell_message
end
end
end
phone = Phone.new
modem = Modem.new
speaker = Speaker.new
modem.add_listener(speaker)
modem.connect_device(phone)
=> Welcome Home
modem.disconnect_device(phone)
=> Have a good day
Push method
The push method, the subject has to know what the observers need and send the required information along with the notification. This makes the observer less usable.
class Modem
...
def notify(event_name)
@listeners.each { |listener| listener.update(event_name) }
end
def connect_device(device)
(@connected_devices ||= []) << device
notify("#{device.class.to_s.downcase}_connected")
end
def disconnect_device(device)
@connected_devices.delete(device)
notify("#{device.class.to_s.downcase}_disconnected")
end
end
class Speaker
def update(event_name)
case event_name
when 'phone_connected'
play_welcome_message
when 'phone_disconnected'
play_farewell_message
end
end
end
modem = Modem.new
speaker = Speaker.new
modem.add_listener(speaker)
modem.connect_device(phone)
=> Welcome Home
modem.disconnect_device(phone)
=> Have a good day
Improved push method
We can improve the `push method` by adding support for listeners to provide a list of events they are interested in at the time of registration. The subject notifies the observers only if the event they are interested in occurs.
class Modem
def add_listener(listener, events)
(@listeners ||= {})[listener] = events
end
def notify(event_name)
@listeners.each do |listener, events|
listener.update(event_name) if events.include?(event_name)
end
end
def connect_device(device)
...
end
def disconnect_device(device)
...
end
end
class Speaker
def update(event_name)
case event_name
when 'phone_connected'
play_welcome_message
when 'phone_disconnected'
play_farewell_message
end
end
end
modem = Modem.new
speaker = Speaker.new
modem.add_listener(speaker, ['phone_connected', 'phone_disconnected'])
modem.connect_device(phone)
=> Welcome Home
modem.disconnect_device(phone)
=> Have a good day
Listening to multiple subjects
If the observer is listening to more than one subject, then it makes sense to let the observer know which subject is sending the notification. We can do this by passing a reference of the subject itself as a parameter when notifying the observers.
class Modem
# We modify our Speaker to play welcome message only when a phone is connected to the modem and the lights are turned on
def add_listener(listener)
(@listeners ||= []) << listener
end
def notify
@listeners.each { |listener| listener.update(self) }
end
def connect_device(device)
(@connected_devices ||= []) << device
notify
end
end
class Light
def notify(event_name)
@listeners.each do |listener|
listener.update(self)
end
end
def turn_on
@state = on
notify
end
end
class Speaker
def update(subject)
if subject.instance_of? Modem
play_welcome_message
elsif subject.instance_of? Light
play_farewell_message
end
end
end
phone = Phone.new
modem = Modem.new
speaker = Speaker.new
light = Light.new
modem.add_listener(speaker)
light.add_listener(speaker)
modem.connect_device(phone)
=> Welcome Home
Modem.light_off
=> Have a good day
Using change manager
If an observer depends on multiple subject changes to perform an action, then it makes sense to have a change manager which acts as a mediator between all the subjects and handles the complex logic.
# We modify our Speaker to play welcome message only when a phone is connected to the modem and the lights are turned on
class Modem
def add_listener(listener)
(@listeners ||= []) << events
end
def notify(event_name)
@listeners.each { |listener| listener.update(event_name) }
end
def connect_device(device)
(@connected_devices ||= []) << device
notify("#{device.class.underscore}_connected")
end
def disconnect_device(device)
@connected_devices.delete(device)
notify("#{device.class.underscore}_disconnected")
end
end
class Light
def turn_on
@state = on
notify('light_on')
end
end
class Hub
def add_device(device)
case device.class
when Light
@light = device
when Speaker
@speaker = device
when Modem
@modem = device
end
end
def update(event_name)
case event_name
when 'phone_connected'
@phone_connected = true
when 'phone_disconnected'
@phone_connected = false
when 'light_on'
@light_on = true
when 'light_off'
@light_on = false
end
case
when @phone_connected && @light_on
@speaker.play_welcome_message
else
@speaker.play_farewell_message
end
end
end
class Speaker
end
phone = Phone.new
modem = Modem.new
speaker = Speaker.new
light = Light.new
hub = Hub.new
hub.add_device(modem)
hub.add_device(light)
hub.add_device(speaker)
modem.add_listener(hub)
light.add_listener(hub)
modem.connect_device(phone)
light.turn_on
=> Welcome Home
modem.disconnect_device(phone)
=> Have a good day
Here the Hub class acts as a mediator between the Modem, Light and Speaker. It encapsulates all the complex logic on when to perform actions on the Speaker based on the events.