Episode #027: Macros and Modules

Upgrade to download episode video.

Episode Script

Our client, the colonial fleet command, has asked us for a Ruby library to help them use the Observer pattern in tactical software. Here's how they'd like it to work: given a class Dradis which includes the module Eventful, they'd like to be able to declare an #event named :new_contact. They should then be able to instantiate an instance of the Dradis class and add listener objects by calling #add_listener. When they then call the #new_contact method, corresponding to the :new_contact event they declared earlier, every listener object should be sent the #on_new_contact message.

class Dradis
  include Eventful

  event :new_contact
end
class ConsoleListener
  def on_new_contact(direction, range)
    puts "DRADIS contact! #{range} kilometers, bearing #{direction}"
  end
end

dradis = Dradis.new
dradis.add_listener(ConsoleListener.new)
dradis.new_contact(120, 23000)

Our first crack at the library looks like this. We define the Eventful module. We define the included callback to extend the including class with another module called Macros, which we'll define momentarily. We give the Eventful module a method for adding listeners. We also give it a method to notify all listeners of a given event.

Now we turn our attention to the Macros module. This module contains the event macro, which uses module_eval to define a new instance method named for the given event. This method simply sends the notify_listeners message we defined earlier.

module Eventful
  def self.included(other)
    other.extend(Macros)
  end

  def add_listener(listener)
    (@listeners ||= []) << listener
  end

  def notify_listeners(event, *args)
    (@listeners || []).each do |listener|
      listener.public_send("on_#{event}", *args)
    end
  end

  module Macros
    def event(name)
      module_eval(%Q{
        def #{name}(*args)
          notify_listeners(:#{name}, *args)
        end
      })
    end
  end
end

A quick note on terminology: Ruby doesn't have macros in the sense that Lisp and C do. Nonetheless, it's common to refer to class-level methods that generate other methods, modules, or classes as “macros”. Don't let the term fool you, however: there's nothing special about these methods. They are just ordinary Ruby methods that happen to generate code.

Getting back to our story: Fleet command accepts this library and at first it works well. But one day a consultant, we'll call him “Dr. Baltar”, reports a problem. Baltar was trying to add a count of the total number of new contacts reported to the Dradis class. At first, he tried an implementation that looked like this:

class Dradis
  include Eventful

  event :new_contact

  attr_reader :contact_count

  def initialize
    @contact_count = 0
  end

  def new_contact(*args)
    @contact_count += 1
    new_contact(*args)
  end
end

…but of course this didn't work, because the method calls itself, this creating an infinite recursion and overflowing the stack.

Next up, he tried aliasing the new_contact method that Eventful generates to another name, and then calling the original version from within the new version of the method.

class Dradis
  include Eventful

  event :new_contact

  attr_reader :contact_count

  def initialize
    @contact_count = 0
  end

  alias_method :new_contact_without_count, :new_contact
  def new_contact(*args)
    @contact_count += 1
    new_contact_without_count(*args)
  end
end

This worked, but he found it awfully verbose for such a simple change. He noted that this solution would make sense if he still needed access to the non-counted version of the method in other contexts; but that in this case it was just extra noise in the class's method list. Finally, he pointed out that this version was sensitive to how statements were ordered in the class definition: if the event was declared at the end of the class instead of at the beginning, it no longer worked.

He tried one more approach. In this one, he saved a reference to the original version of the #new_contact method in a local variable inside the class definition. Then he used define_method to define the counted version of the method, calling the method object he'd saved earlier in order to trigger the original behavior.

class Dradis
  include Eventful

  event :new_contact

  def initialize
    @contact_count = 0
  end

  original_new_contact = instance_method(:new_contact)
  define_method(:new_contact) do |*args|
    @contact_count += 1
    original_new_contact.bind(self).call(*args)
  end
end

Baltar's only comment on this version was that it was absurdly complicated, and we had to agree with him.

We think about the problem for a while, and then we set about rewriting our little library. In the new version, we change the #event macro to first create a blank anonymous module. Then it generates the new event method within the context of that module. Finally, it includes the new module into the current class.

module Eventful
  def self.included(other)
    other.extend(Macros)
  end

  def add_listener(listener)
    (@listeners ||= []) << listener
  end

  def notify_listeners(event, *args)
    (@listeners || []).each do |listener|
      listener.public_send("on_#{event}", *args)
    end
  end

  module Macros
    def event(name)
      mod = Module.new
      mod.module_eval(%Q{
        def #{name}(*args)
          notify_listeners(:#{name}, *args)
        end
      })
      include mod
    end
  end
end

By inserting the generated method into the class's ancestor chain, we've now made it possible for Dr. Baltar to implement his addition to the #new_contact method by simply redefining it, incrementing the call count, and then using super to defer to the original version of the method.

class Dradis
  include Eventful

  event :new_contact

  attr_reader :contact_count

  def initialize
    @contact_count = 0
  end

  def new_contact(*)
    @contact_count += 1
    super
  end
end

Baltar is delighted with this solution, and promptly takes all the credit for it. For our part, we're just thankful to the Lords of Kobol to have found a way to leverage Ruby's method lookup chain in such an elegant fashion.

Now, that's not quite the end of the story, but it's all we have time for today. Until next episode, happy hacking!