Tuesday, December 2, 2008

Method-Hooks In Ruby

Method-Hooks provide a way to intercept method calls on objects and can be implemented conveniently using Ruby's meta programming techniques.

Acceptance Test

Starting with an acceptance test helps focusing on the indended usage of the functionality we will implement. Suppose our simple system has the ability to draw content to windows. Windows can be configured via properties in their appearance. Everytime properties are changed the window needs to be redrawn to reflect the changes. In order to ease the handling of windows, we'd like to call redraw automatically when certain properties are changed.

Here's a short implementation of class Window

# Window capable of rendering items
class Window
  # Access background color of window.
  attr_accessor :background
  # Access text messages overlayed to window content
  attr_accessor :text
  # Simple counter that increments when redraw is invoked
  attr_reader :redraw_counter
  
  # Initialize window by drawing content
  def initialize
    @redraw_counter = 0
  end
  
  # Redraw window in case of changes and increment counter
  def redraw
    #...
    @redraw_counter += 1
  end

  # After setters are invoked update window content
  include FollowingHook
  following :background=, :text= do |wnd, args|
    wnd.redraw
  end
end

The most important part about this acceptance test is the syntax we'd like to implement to automatically call redraw when background color or text messages are changed. Here it is again

include FollowingHook
following :background=, :text= do |wnd, args|
  wnd.redraw
end

The keyword following is a method name that takes any number of method symbols and a block. Its semantic is to execute the code block immediaetely after the given methods are called. Block arguments are provided: wnd: the window for which one of the provided methods was called, args: given invokation arguments and return value of method.

Advantages are

  • Simple generic method hook interface
  • Concentrate common code in one place
  • Can use provided attribute accessors

Implementation

Here's an implementation of following inside a module FollowingHook

# Contains methods to hook method calls
module FollowingHook
  
  module ClassMethods
    
    private
    
    # Hook the provided instance methods so that the block 
    # is executed directly after the specified methods have 
    # been invoked.
    #
    def following(*syms, &block)
      syms.each do |sym| # For each symbol
        str_id = "__#{sym}__hooked__"
        unless private_instance_methods.include?(str_id)
          alias_method str_id, sym        # Backup original 
                                          # method
          private str_id                  # Make backup private
          define_method sym do |*args|    # Replace method
            ret = __send__ str_id, *args  # Invoke backup
            block.call(self,              # Invoke hook
              :method => sym, 
              :args => args,
              :return => ret
            )
            ret # Forward return value of method
          end
        end
      end
    end
  end
  
  # On inclusion, we extend the receiver by 
  # the defined class-methods. This is an ruby 
  # idiom for defining class methods within a module.
  def FollowingHook.included(base)
    base.extend(ClassMethods)
  end
end

Basically what happens is that for a given method to hook its original content is backup'ed using an unique alias name. The original method content is 'replaced' by an invokation of the original method and an invokation of the given block with current parameters. Additionally we prohibit hooking a method twice and make the original method private to prevent direct execution.

following is used as a class method. To define class methods from within modules we use an inner module that keeps our class methods. When the module is included by the receiver Module#included is invoked with the class as in-parameter. The receiver is then extended by the class methods using Object#extend which causes the receiver class to add following as a class method.

Usage

With our class Window in place we can write a simple unit test to check the behaviour of following

require 'test/unit'

# Tests for methods in PrivateMethodHooks
class TestFollowingHook < Test::Unit::TestCase
  
  # Test following method hook
  def test_following_hook
    wnd = Window.new
    assert_equal(wnd.redraw_counter, 0)
    wnd.text = "Show me!"
    assert_equal(wnd.redraw_counter, 1)
    wnd.background = [1.0, 1.0, 1.0]
    assert_equal(wnd.redraw_counter, 2)
  end
  
end

Further Examples

Here's a simple demonstration on how to log all invokations to Kernel#system

class Object
  include FollowingHook
  following :system do |receiver, args|
    p "#{args[:method]} called with arguments #{args[:args].join(",")}"
    p "return value was #{args[:return]}"
  end
end

system('ruby --version')
# => ruby 1.8.6 (2008-08-11 patchlevel 287) [i386-mswin32]
# => "system called with arguments ruby --version"
# => "return value was true"
# => true

Grab the code!

0 Kommentare:

Post a Comment