2012年7月26日木曜日

Rubyでフック処理

ここ2日ほどフック処理をするためのモジュールを作ろうとして四苦八苦していた。あちこち検索していろんなソースを参考にしつつ、とりあえずこんな感じになった。

module Hooker
  def self.included(klass)
    klass.extend(ClassMethods)
    klass.__send__ :include, InstanceMethods
  end
  module ClassMethods
    def define_hook(name, &hook)
      define_method(name) do |*args, &block|
        singleton_class =(class << self; self; end)
        singleton_class.module_eval{define_method(:__instance_exec, &hook)}
        orig = proc{|*args, &block| super(*args, &block)}
        begin
          return send(:__instance_exec, orig, *args, &block)
        ensure
          singleton_class.module_eval{remove_method(:__instance_exec)} rescue nil
        end
      end
    end
  end
  module InstanceMethods
  end
end

どういうときに使うかというと、例えばArrayクラスを継承したMyArrayクラスを定義して、MyArray#mapの戻り値をArrayではなくMyArrayで受け取りたい、というような場合に使う。普通にクラスを継承させただけでは、MyArray#mapの戻り値はArrayオブジェクトになる。

class MyArray < Array
end

ary = MyArray.new([1,2,3])
new_ary = ary.map{|n| n + 1}
puts new_ary.class #=> Array

Hookerモジュールを使うと、次のように#mapの戻り値をMyArrayオブジェクトで返すMyArrayクラスを定義することができる。

class MyArray < Array
  include Hooker
  define_hook(:map){|orig, &block| self.class.new(orig.call(&block))}
end

ary = MyArray.new([1,2,3])
new_ary = ary.map{|n| n + 1}
puts new_ary.class #=> MyArray

おおまかな使い方としては、define_hookメソッドでフックしたいメソッドをオーバーライドする。define_hookの引数はフックしたいメソッドの名前で、ブロックはフック処理である。ブロックの第一引数origはsuperをProc化したものである(定義ブロック内ではsuperが使えないのでこのような方法を取っている)。ブロックの第二引数以降では*argsや&blockなど、メソッドに渡したい引数を与えることができる。引数を加工した上で元メソッドに引き渡したい時は、argsやblockを加工した上でorig.call(*args, &block)などとする。メソッドの戻り値を加工した上で取得したい時は、orig.callの戻り値を加工する。上記では、mapの戻り値を加工してArrayからMyArrayに書き換えている。

別の使い方としては、eachメソッドをフックして#eachのブロックの引数を加工することもできる。

class MyArray < Array
  include Hooker
  define_hook(:each){|orig, &block| orig.call{|x| block.call(self.class.new(x))}}
end

ary = Bar.new([[1,2,3], [4,5,6], [7,8,9]])
ary.each{|x| puts "#{x}:#{x.class}"}

#=>
[1, 2, 3]:MyArray
[4, 5, 6]:MyArray
[7, 8, 9]:MyArray

手こずったポイントの一つは、define_hookに渡す定義ブロックが、クラスのコンテキストで生成されてしまうということ。このため、定義ブロック内でselfを使うとインスタンス(=ary)ではなくクラス(=MyArray)を参照してしまうという問題が生じた。そこでinstance_execを使って定義ブロック(=&hook)を強制的にインスタンスのコンテキストで実行することを考えたが、instance_execは&hookに引数を引き渡すことはできるものの、ブロックの引渡し方がよく分からない。このため、eachなどブロックを取るメソッドをフックしたいときに、このブロックをどうやって&hookに引き渡せばいいかで悩むことになった。最終的に、instance_execのソースを探してきて、似たようなコードを自前で書くことによって問題を解決した。あとはsuperが使えるようになれば、もっと直感的な使い勝手になると思うのだが・・・。

0 件のコメント:

コメントを投稿