Caller-specified callback in Ruby
Here’s an interesting technique I’d like to see more of in the wild: passing in error handlers using callbacks. For that matter, I’m a fan of callbacks in general, but that’s probably my c programming background showing through.
Note: the basis for the code shown here is one of Avdi Grimm’s free Ruby Tapas screencasts. You should watch it, it’s good.
So let’s take a look at BookFetcher
, which examines our extensive
library of technical books. Because as programmers, so many of us
are addicted to purchasing technical books, we have what many people
might consider a library. And sometimes we need to find books in that
library. Consider the following, simplistic snippet, a wrapper around
Hash.fetch
:
#!/usr/bin/env ruby
class BookFetcher
DEFAULT_FALLBACK = ->(error) { raise }
def fetch hash, &fallback
fallback ||= DEFAULT_FALLBACK
hash.fetch(:book)
rescue => error
fallback.call(error)
end
end
The actual “fetching” is a no-op, we’re only interested in how the
callback interacts with Hash.fetch
.
Also, note that we’re effectively reimplementing the error handling behavior of
Hash.fetch
itself. The example is a bit contrived and sort of meta.
Next, let’s define a couple of custom errors to play with.
require 'rspec'
class LibraryError < StandardError
end
class LibraryFancyError < LibraryError
attr_reader :book
def initialize book
@book = book
super "The book '#{@book}' is not in your library" unless book.nil?
end
end
Now let’s run this code to see what it does. RSpec is a nice way to investigate behavior, starting with a spec I always write before getting started, namely, one that tests for the existence and default instantiation of a new class.
describe BookFetcher do
it "instantiates a new BookFetcher" do
tf = BookFetcher.new
expect(tf).to_not be_nil
end
Ok so that seems dumb, and is redundant given the following specs, but I’m leaving it to show that’s it just fine to write dumb tests when driving out new code. Such tests can always be deleted later.
Almost as dumb, but probably not redundant, is a test to ensure the method actually works as designed. That is, let’s not throw any errors:
it "fetches a 'Confident Ruby' book" do
tf = BookFetcher.new
book = tf.fetch book: 'Confident Ruby'
expect(book).to eq 'Confident Ruby'
end
Once we’re satisfied we have a working implementation, we can get on
with the main event, error handling. First is ensuring we catch the
default error produced by Hash
when it can’t find a key. The fallback
simply passes through the error raised from Hash.
it "raises Hash error on wrong hash key" do
tf = BookFetcher.new
expect {
book = tf.fetch foobar: 'Confident Ruby'
}.to raise_error "key not found: :book"
end
Now let’s customize with a basic LibraryError
:
it "raises an error with default message on wrong key" do
tf = BookFetcher.new
expect {
book = tf.fetch foobar: 'Confident Ruby' do |error|
raise LibraryError, error.message
end
}.to raise_error "key not found: :book"
end
Library
doesn’t do anything more than “tag” the underlying Hash error
and again, pass it along in the same way as the fallback error.
We can check for LibraryError
explicitly by not passing along the
raised error message, like so:
it "raises a LibraryError without message on wrong key" do
tf = BookFetcher.new
expect {
book = tf.fetch foobar: 'Confident Ruby' do |error|
raise LibraryError
end
}.to raise_error "LibraryError"
end
And the message can be explicitly suppressed by passing in nil
:
it "raises a LibraryError with suppressed message on wrong key" do
tf = BookFetcher.new
expect {
book = tf.fetch foobar: 'Confident Ruby' do |error|
raise LibraryError, nil # suppress message
end
}.to raise_error 'LibraryError'
end
Always reassuring to see the code works as advertised. This may or may not be useful, but these specs demonstrate what happens under the given conditions, which I find useful for understanding.
Now let’s get fancier with a LibraryFancyError
.
it "raises a LibraryFancyError with custom message" do
tf = BookFetcher.new
book = 'Confident Ruby'
expect {
book = tf.fetch foobar: 'Confident Ruby' do
raise LibraryFancyError, book
end
}.to raise_error "The book '#{book}' is not in your library"
end
Finally, it’s perfectly legimate in Ruby to define Procs elsewhere,
and pass such Procs as the trailing argument to methods requiring or
allowing blocks. Simply ensure the named Proc is prefixed with an
ampersand &
, as shown below:
it "raises a LibraryFancyError with custom message as Proc" do
tf = BookFetcher.new
book = 'Confident Ruby'
custom_error = Proc.new { raise LibraryFancyError, book }
expect {
book = tf.fetch foobar: 'Confident Ruby', &custom_error
}.to raise_error "The book '#{book}' is not in your library"
end
end
How useful is this?
An obvious criticism at this point: “This technique smacks of
overengineering. The custom error method should be specified
in the rescue
without all this callback Mickey Mouse.”
For this particular snippet, without any further context to an underlying problem, such criticism is probably true. I can’t think of any reason whatsoever to employ such error handling in the current production code at work. However, it could be useful for directing logging statements to appropriate output streams.
For example, I am using this technique to abstract output formatting on
a data processing stream. The data processor (which is in a Ruby gem)
has no business knowing how to format any output. The output formatter
is passed in as a writer
callback. The gem provides a default
callback to output csv, but the writer is just as happy to write to a
postgres database, just pass in the appropriate callback.