Ruby callbacks

If the Ruby programming language has any single weakness, for me that weakness is how callbacks are defined.

In many programming languages, functions or methods can be declared outside of a specific class, passed around as variables, and invoked just as any other function or method anywhere they’re in scope. For example, Python allows such “top level” functions, and of course in C and C++ functions are just pointers and can be passed around and dereferenced as usual.

Ruby sort of does, and sort of doesn’t have callbacks. For example, it’s easy to define a ruby callback with either a proc or a lambda like so:

foo = -> { 'top level lambda' }
bar = proc { 'top level proc' }

describe 'main' do
  it "prints top level foo" do
    expect(foo.()).to eq 'top level lambda'
  end

  it 'prints top level bar' do
    expect(bar.()).to eq 'top level proc'
  end
end

But this has limited use, as the callback scope has to be controlled explicitly (and only) by file inclusion. Which is fine for C programming, but we’re programming in Ruby.

Also, note that the term “top level” is probably being abused here. For the purpose of the article, “top level” refers to a function-like method defined outside of any enclosing class defined within the script, program or application.

Dispatch table

We can also hash these callbacks to invoke later:

dispatch = {
  'foo' => foo,
  'bar' => bar
}

describe 'main' do
  it 'dereferences dispatch table and invokes 'foo'' do
    foo_from_table = dispatch['foo']
    expect(foo_from_table.()).to eq 'top level lambda'
  end

  it 'dereferences dispatch table and invokes 'foo'' do
    bar_from_table = dispatch['bar']
    expect(bar_from_table.()).to eq 'top level proc'
  end
end

That’s all easy enough, and there is no need to set a variable from the hash dereference, which could be call‘ed directly. On the other hand, variables are handy for passing around.

Controlling scope

We can also define constants in a module:

module Foobar
  Foo = -> { 'module lambda' }
end
dispatch['foom'] = Foobar::Foo # dispatch defined above

describe 'main' do
  it 'dereferences dispatch table and invokes 'Foobar::Foo'' do
    foom_from_table = dispatch['foom']
    expect(foom_from_table.()).to eq 'module lambda'
  end
end

Practical implementation

All the above is well and good, but begs the question “Why would we want to use ruby callbacks?” Here’s one situation, overriding default execution:

#!/usr/bin/env ruby

require 'test/unit'

class CallbackDemo
  Default = -> { 'default response' }

  def foo &bar
    bar ||= Default
    bar.call
  end
end

`

This implementation was inspired by Avdi Grimm’s Ruby Tapas screencast Caller-specified Fallback Handler. A slightly more in-depth treatment can be found in Avdi Grimm’s Confident Ruby. Also find a more recent treatment of caller-specified callbacks.

Naturally, we want to test it:

class CallbackDemoTest < Test::Unit::TestCase
  def test_default_response
    assert_equal 'default response', CallbackDemo.new.foo
  end

  def test_custom_response
    assert_equal 'custom response',
      CallbackDemo.new.foo { 'custom response' }
  end
end

It works with class methods as well:

class CallbackDemo
  class << self
    def bar &foo
      foo ||= Default
      foo.call
    end

    def quux(arg)
      arg ? yield(arg) : Default.call
    end
  end
end

class CallbackDemoTest < Test::Unit::TestCase
  def test_class_default_response
    assert_equal 'default response', CallbackDemo.bar
  end

  def test_class_custom_response
    assert_equal 'custom response',
      CallbackDemo.bar { 'custom response' }
  end

  def test_argument_passing
    # Braced block can be passed to the assertion inline
    assert_equal 'foobar response',
      CallbackDemo.quux('foobar') { |arg| '#{arg} response' }

    # do .. end block cannot be passed inline with the assertion;
    # it will produce a `nil` block:
    response = CallbackDemo.quux('foobar') do |arg|
      '#{arg} response'
    end
    assert_equal 'foobar response', response
    assert_equal 'default response', CallbackDemo.quux(nil)
  end
end

Recap

As usual, if I’ve written something unclear, feel free to leave a comment. (or email if comments are turned off.) Likewise if I’ve gotten something wrong. If you’ve written something you believe others would benefit from, feel free to leave a link as well.