aliasによるメソッドの再定義は危険なのでUnboundMethodかextendを使おう

Jay Fields' Thoughts: Alternatives for redefining methods

メソッドの再定義の技法はいろいろあるが、どれも欠点があるというお話。状況に応じて使い分けるべき。

aliasで再定義

メソッドを再定義するときにこんな感じでaliasで元のメソッドをコピーするのは常套手段だ。

class Gateway
  def process(document)
    p "gateway processed document: #{document}"
  end
end

class Gateway
  alias old_process process
  def process(document)
    p "do something else"
    old_process(document)
  end
end

Gateway.new.process("hello world")
# >> "do something else"
# >> "gateway processed document: hello world"

しかし、これは危険だ。なぜなら、そのスクリプトを再度読み込むと、無限ループに陥ってしまうからだ!だいたい、old_*などというメソッドを作成してるあたりがカッコ悪い。

以下のスクリプトを実行してみるとよくわかる。無限ループになってしまうため、「do something else」が延々と表示されてしまう。

class Gateway
  def process(document)
    p "gateway processed document: #{document}"
  end
end

class Gateway
  alias old_process process
  def process(document)
    p "do something else"
    old_process(document)
  end
end

class Gateway
  alias old_process process
  def process(document)
    p "do something else"
    old_process(document)
  end
end

Gateway.new.process("hello world")
# >> "do something else"
# >> "do something else"
# >> "do something else"
# >> "do something else"
# 略

UnboundMethodを使う

UnboundMethodとdefine_methodのコンボも結構有名なテクニック。

Module#instance_methodでUnboundMethodが返る。ぶっちゃけレシーバがないメソッド。それを変数に代入しておく。古い定義のUnboundMethod版だ。そして、Module#define_methodでメソッドを再定義する。define_methodはクロージャーなので外側の変数にもアクセスできる。だから古い定義を参照できる。UnboundMethodを呼ぶにはUnboundMethod#bindでレシーバを設定してMethod#callで呼ぶ。

UnboundMethodはアンパンマンの顔の部分で、UnboundMethod#bindはアンパンマンに新しい顔をつけてもらうようなイメージだと思う?

class Gateway
  def process(document)
    p "gateway processed document: #{document}"
  end
end

class Gateway
  process_method = instance_method(:process)
  undef :process                # warningがうざいので黙らせる
  define_method :process do |document|
    p "do something else"
    process_method.bind(self).call(document)
  end
end

Gateway.new.process("hello world")
# >> "do something else"
# >> "gateway processed document: hello world"

これもスクリプトの再ロードで問題がおきるけど、無限ループよりはましで、「do something else」がだぶる。

class Gateway
  def process(document)
    p "gateway processed document: #{document}"
  end
end

class Gateway
  process_method = instance_method(:process)
  undef :process                # warningがうざいので黙らせる
  define_method :process do |document|
    p "do something else"
    process_method.bind(self).call(document)
  end
end

class Gateway
  process_method = instance_method(:process)
  undef :process                # warningがうざいので黙らせる
  define_method :process do |document|
    p "do something else"
    process_method.bind(self).call(document)
  end
end

Gateway.new.process("hello world")
# >> "do something else"
# >> "do something else"
# >> "gateway processed document: hello world"

UnboundMethod(再ロード対応版)

onceメソッドを定義して、ブロック内のコードを一度しか実行させないようにしてみれば再ロード問題が解決する。

class Gateway
  def process(document)
    p "gateway processed document: #{document}"
  end
end

def once
  unless instance_variable_defined? :@__once_executed__
    yield
    @__once_executed__ = true
  end
end

class Gateway
  once do 
    process_method = instance_method(:process)
    undef :process                # warningがうざいので黙らせる
    define_method :process do |document|
      p "do something else"
      process_method.bind(self).call(document)
    end
  end
end

class Gateway
  once do 
    process_method = instance_method(:process)
    undef :process                # warningがうざいので黙らせる
    define_method :process do |document|
      p "do something else"
      process_method.bind(self).call(document)
    end
  end
end

Gateway.new.process("hello world")
# >> "do something else"
# >> "gateway processed document: hello world"

しかし、それでも完璧な解決方法ではなく、クロージャーなのでdefによるメソッド定義よりも多少遅くなるという問題がある。メモリリークの可能性もでてくる。

extendとsuper

再定義といったら、やっぱりextendとsuperを使うのが一番rubyらしくてカッコイイと思う。
しかも再ロードしても問題ない。

唯一の欠点といえば、extendしなければならないということなのだが、大したことはないんじゃないかな。

class Gateway
  def process(document)
    p "gateway processed document: #{document}"
  end
end

module ProcessLogging
  def process(document)
    p "do something else"
    super
  end
end

Gateway.new.extend(ProcessLogging).process("hello world")
# >> "do something else"
# >> "gateway processed document: hello world"