Implementing Callbacks in Ruby with Lambdas

At times, closures allow us to write programs that are otherwise very difficult to express or downright nasty to look at. An example of this is callbacks. Imagine that you’re working with a report-generation tool. The programmer before you hacked together something quick and dirty and has since left for greener pastures. Lucky him. Unfortunately for you, the report-generating method has a couple of bugs, causing crashes to occur 5% of the time.

The code is a complete mess and you have no appetite to go near that monstrosity. Instead, you want to know if a report has been successfully generated and send it to your boss right away. However, when things go awry, you want to be notified personally.

The report generator is straightforward:

 require ​'ostruct'
 
 class​ Generator
 attr_reader​ ​:report
 
 def​ initialize(report)
  @report = report
 end
 
 def​ run
  report.to_csv
 end
 end

The Generator takes in a report. The Generator#run delegates the call to the report’s to_csv method.

For simplicity’s sake, let’s use the OpenStruct class. This is an example of a report without any error:

 good_report = OpenStruct.new(​to_csv: ​​"59.99,Great Success"​)

An erroneous report is represented by a nil value in to_csv like so:

 bad_report = OpenStruct.new(​to_csv: ​​nil​)

Let’s take a step back now and try to sketch out how to implement this. Recall that we need to handle two cases. The first case is when things are rosy and the report is sent to the boss; the other is when things go horribly wrong and we want to know about it.

Here’s how this might look:

 Notifier.new(Generator.new(good_report),
 on_success: ​lambda { |r| puts ​"Send ​​#{​r​}​​ to [email protected]"​ },
 on_failure: ​lambda { puts ​"Send email to [email protected]"​}
  ).tap ​do​ |n|
  n.run
 end

A Notifier takes a Generator object and a Hash that represents the callbacks for the success and failure cases, respectively. Finally, the run method is called to invoke the notifier.

If you’re looking at the previous code listing and thinking to yourself, “Wait a minute, this looks almost like functional programming!,” give yourself a pat on the back. Although Ruby is an object-oriented language, supporting features such as lambdas blur the lines between object-oriented and functional programming.

In particular, the feature that you might be thinking about is passing around functions as first-class values. Understanding what this means is useful especially when you go further into functional programming. So let’s take a short detour and investigate.

First-Class Values

Think about the way you use an integer or a string. You can assign either to a variable. You can also pass them into methods. Finally, integers and strings can be return values of methods. These characteristics make them values.

What about lambdas? Are they values? In order to answer that question, they need to fulfill the same three prerequisites as integers and strings.

Can a lambda be assigned to a variable? Fire up irb and find out:

 >>​ is_even = lambda { |x| x % 2 == 0 }
 =>​ ​#<Proc:0x007fa8a309c448@(irb):1 (lambda)>
 
 >>​ is_even.call(4)
 =>​ ​true
 
 >>​ is_even.call(5)
 =>​ ​false

Check. Next, can a lambda be passed into a method? Define complement. This method takes a predicate lambda and a value, and returns the negated result:

 >>​ ​def​ complement(predicate, value)
 >>​ not predicate.call(value)
 >>​ ​end
 =>​ ​:complement
 
 >>​ complement(is_even, 4)
 =>​ ​false
 
 >>​ complement(is_even, 5)
 =>​ ​true

So yes, a lambda can most definitely be passed into a lambda. Now, for the final hurdle: can a lambda be a return value? Modify complement so that it only takes in one argument:

 >>​ ​def​ complement(predicate)
 >>​ lambda ​do​ |value|
 >>​ not predicate.call(value)
 >>​ ​end
 >>​ ​end
 =>​ ​:complement

What would you expect now if you invoked complement(is_even)? Let’s find out:

 >>​ complement(is_even)
 =>​ ​#<Proc:0x007fa8a31ef4f8@(irb):8 (lambda)>

We get back another lambda: Strike three! For completeness, go ahead and supply some values:

 >>​ complement(is_even).call(4)
 =>​ ​false
 >>​ complement(is_even).call(5)
 =>​ ​true

As you can see, we have been treating lambdas as first-class functions all along. Being able to pass around functions like values means that we can conveniently assign, pass around, and return tiny bits of computation.

It should be noted that Ruby’s methods are not first-class functions. Instead, lambdas, Procs, and blocks step in to fill in that role.

We will circle back now to implementing Notifier and see how first-class functions can lead to decoupled code.

Implementing Notifier

Let’s see how Notifier can be implemented:

 class​ Notifier
 attr_reader​ ​:generator​, ​:callbacks
 
 def​ initialize(generator, callbacks)
  @generator = generator
  @callbacks = callbacks
 end
 
 def​ run
  result = generator.run
 if​ result
  callbacks.fetch(​:on_success​).call(result)
 else
  callbacks.fetch(​:on_failure​).call
 end
 end
 end

The meat of the code lies in the run method. result contains the generated report. If result is non-nil, then the on_success callback is invoked. Otherwise, the on_failure one will be called.

There’s a tiny subtlety that is easy to miss, and that’s where the beauty of this technique lies. Take a closer look at how the on_success callback is defined:

 on_success: ​lambda { |r| puts ​"Send ​​#{​r​}​​ to [email protected]"​ }

What’s the value of r? Well, at the point where this lambda was defined, no one knows. It is only at the point when we know that the generated report is non-nil do we pass the result into the success callback.

Let’s work with some examples. First, create a good report and run the notifier:

 good_report = OpenStruct.new(​to_csv: ​​"59.99,Great Success"​)
 
 Notifier.new(Generator.new(good_report),
 on_success: ​lambda { |r| puts ​"Send ​​#{​r​}​​ to [email protected]"​ },
 on_failure: ​lambda { puts ​"Send email to [email protected]"​}
  ).tap ​do​ |n|
  n.run ​#=> Send 59.99,Great Success to [email protected]
 end

Now, create a bad report and run the notifier again:

 bad_report = OpenStruct.new(​to_csv: ​​nil​)
 Notifier.new(Generator.new(bad_report),
 on_success: ​lambda { |r| puts ​"Report sent to [email protected]: ​​#{​r​}​​"​ },
 on_failure: ​lambda { puts ​"Whoops! Send email to [email protected]"​}
  ).tap ​do​ |n|
  n.run ​#=> Whoops! Send email to [email protected]
 end

This is a very flexible technique. Notice that Notifier doesn’t dictate how you should handle success and failure cases. All it does is invoke the appropriate callbacks. This means that you are free to log errors to a file or send your boss an SMS when a report has been successfully generated—all without modifying the original Notifier class.

Now you should be ready for something slightly more challenging. You will get to implement one of the most useful operations inspired from functional programming—fold left.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset