Episode Script
Back in Episode 11 we went from calling a method object, using the #call
message, to sending a message called #notify
. In this episode we’re going to go the opposite direction, and redeem the #call
message a little bit.
Consider a Contest
class, which has a #choose_winner
method. How it chooses a winner is unimportant for this example. After it chooses a winner, it notifies them. Since notification is part of a different role than choosing a winner, it delegates the task to a collaborator object called @notifier
.
class Contest
def choose_winner
# ...
@notifier.notify(winning_user,
"You're a lucky winner!")
end
end
Now supposing, just for the sake of argument, we change the #notify
method to #call
instead. What does this buy us?
class Contest
def choose_winner
# ...
@notifier.call(winning_user,
"You're a lucky winner!")
end
end
Well, let’s look at how we might initializer the @notifier
instance variable. In the class initializer we’d like to have some kind of bare bones default implementation for the notifier. Here’s a version that uses #fetch
to see if a notifier has been supplied in the options to the new object. If not, it substitutes a simple lambda. The lambda just prints a message to the console.
class Contest
def initialize(options={})
@notifier = options.fetch(:notifier) {
->(user, message) {
puts "Message to #{user}: #{message}"
}
}
end
# ...
end
Since lambdas respond to #call
, we can make a quick-and-dirty default notifier without going to the trouble of defining a separate class.
Later on in development, we transition to notifying the user by email. We add a new method to the Contest
class responsible for sending the notification emails. Then we change our default notifier to be a method object which references the new #notify_user_by_email
method.
class Contest
def initialize(options={})
@notifier = options.fetch(:notifier) {
->(user, message) {
puts "Message to #{user}: #{message}"
}
}
end
# ...
def notify_user_by_email(user, message)
# ...
end
end
If you remember Episode 11, you may be wondering if this is a good idea, since we are tightly binding the notifier to a specific implementation of the #notify_user_by_email
method. In that episode we took a method reference from the public API of one collaborator and passed it into another collaborator. The biggest difference in this case is that the use of the method object is entirely internal to one object. We are setting an internal instance variable to be a reference to an internal method.
def initialize(minutes)
self.ui = StdioUi.new
self.timer = SleepTimer.new(minutes, ui.method(:notify))
init_plugins
end
So far we’ve talked about how the class sets up defaults for this collaborator. What about the collaborators that other code might pass in?
We decide that in production, we want users to be notified by SMS text message. The User
class has a #send_sms
method for just this purpose. To configure a Contest
object to send text messages to winners, we simply take the :send_sms
symbol, convert it to a Proc
, and pass that in as the :notifier
.
class User
def send_sms(message)
puts "Sending SMS: #{message}"
# ...
end
end
# ...
c = Contest.new(notifier: :send_sms.to_proc)
How does this work? The Symbol#to_proc
method takes a Symbol
and converts it into a proc which takes a receiver object and then sends the symbol as a message to that object.
->(receiver, *args) {
receiver.send(:send_sms, *args)
}
So when the #choose_winner
calls the resulting Proc
:
@notifier = :send_sms.to_proc
@notifier.call(user, message)
…it is effectively transformed into this code:
user.send(:send_sms, message)
Time goes by, and we decide to make our contest application social-media-enabled. We decide to notify lucky winners via their Facebook accounts instead of SMS. To do this, we need access to the user’s Facebook authorization information. We add a new FacebookNotifer
class to the system, which is instantiated with the needed auth info. And we give it a #call
method which takes user
and message
arguments.
class FacebookNotifier
def initialize(auth_info)
@auth_info = auth_info
end
def call(user, message)
# ...
end
end
# ...
fb_notifier = FacebookNotifier.new(auth_info)
c = Contest.new(notifier: fb_notifier)
Of course, no class is complete without tests. In testing the Contest
class, we find it convenient to pass in a lambda to collect sent notifications:
before do
# ...
fake_notifier = ->(user, message) {
@sent_notifications << [user, message]
end
@contest = Contest.new(notifier: fake_notifier)
# ...
end
At this point we’ve seen five different kinds of object which respond to the #call
message:
- A lambda
- A method object
- A symbol converted to a Proc
- A class implementing the
#call
method explicitly - A test-specific lambda
#call
is one of those “standard protocols”, like the “shovel” operator or the #each
method, that many different kinds of Ruby object implement. As a result of using this common protocol, you might have noticed that we never once touched the implementation of the #choose_winner
method, despite drastically changing how winners would be notified.
I think of #call
as the standard protocol for objects which are “executable things”. You’ll see this in some Ruby libraries: for instance, Rack middlewares must respond to #call
. When I see code like this, where the objects have a single “main” method which reiterates the name of the object itself, I usually wonder if implementing the #call
protocol would be more appropriate.
notifier.notify
runner.run
That’s all for now. Happy hacking!