A Better method_missing

February 28, 2008 – 8:29 pm

method_missing 是 Ruby 用于实现其动态性的一个重要成员。简而言之就是在调用一个对象的某个方法的时候发现这个方法不存在,于是会触发 method_missing ,进而做一些事情,比如转发这个方法,甚至根据需要定义那个方法让下次调用的时候不会产生 method_missing ,另外这也是制作 DSL 的一个重要工具。

下面是一个简单的 DSL 的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#!/usr/bin/ruby
 
class Config
  def initialize
    @h = Hash.new
  end
 
  def method_missing(method, *args)
    if method.to_s =~ /^(.+)=$/
      @h[$1] = args.first
    else
      @h[method.to_s]
    end
  end
end
 
require 'test/unit'
 
class TC_DSL < Test::Unit::TestCase
  def setup
    @conf = Config.new
  end
 
  def test_assign
    @conf.some_key = 'some value'
    assert_equal('some value', @conf.some_key)
  end
 
  def test_fail
    @conf.class = 'BlaBla'
    assert_not_equal('BlaBla', @conf.class)
  end
end

其中 some_keysome_key= 都触发了 method_missing ,这里产生的效果就是把值放在一个 Hash 中,达到类似于配置参数的效果。但是也要注意第 29 行的 test_fail ,其中 class= 触发了 method_missing 然而 class 却没有触发,因为这个方法原来已经有定义了,为了避免这种情况发生,人们做了将大部分方法都 undef 掉的 BlankSlate 专门用于制作 DSL ,Ruby 1.9 也专门有了一个比 Object 更简单的 BasicObject 类可以当作这个用途。不过这些不是今天的重点。

重点在于 method_missingmethod_missing 还有一个很常用的地方就是做转发,把所有自己无法理解的消息转发到代理那里:

def method_missing(method, *args, &block)
  @proxy.send(method, *args, &block)
end

可是这个实现其实是比较有问题的——效率问题。问题就处在 &block 。Ruby 号称是一个完全面向对象的语言,连数字都是对象,但是却又一样东西不是对象:block ,而这一切又都是为了效率。在 RubyTalk 上贴过一个 Benchmark ,对比了匿名 block 和被 bind 到一个名称(例如 &block ,这个时候被转换为了一个真正的对象)时候的效率,结论是匿名的时候要高效许多。

为什么会有这种情况呢?这是通常匿名的情况:

1
2
3
4
5
6
7
8
9
10
def foo
  # do sth.
  yield # call the anomyous block
  # do other thing
end
 
# at some place
foo {
  # body of anomyous block
}

注意在匿名的时候 foo 可以确定在自己退出之前它的 caller (第 8 行)不会事先退出,也就是栈会一直有效,因此 yield 只要记录一个栈的引用,直接跳到那里就可以了。

而非匿名的时候呢?用一个变量来引用这个 block ,一般都是需要做其他事情了,比如传递到其他地方,甚至保存在哪里,总之这个时候无法确定这个 block 的生存期和它诞生的地方的栈帧的生存期哪个比较长,不能直接采用引用的方式,而是必须把它所在的环境复制一遍保存在某个不会因为堆栈弹出而被破坏的地方,复制的时候所有的局部变量都会被引用到而无法被 GC (由于 eval 等邪恶的原因导致无法通过分析代码的方法来优化避免引用没有用到的变量,所以,是“所有的”)。总之,这样的情况既浪费时间又浪费空间。

再回到我们的 method_missing 上来,改成这样的实现

def method_missing(method, *args)
  @array.send(method, *args) { |*block_args| yield(*block_args) if block_given? }
end

就好多了。事实上,在 Rails 里有许多这样“低效”的 method_missing ,Alexander Dymo 在他的文章中就描述了给 Rails 打 method_missing 的补丁来获得性能提升的情况。

Post a Comment