Ruby Ruby 的方法查找与 method_missing

molezz · August 30, 2014 · Last by dogstar replied at November 17, 2015 · 9962 hits
Topic has been selected as the excellent topic by the admin.

第一次发帖子,多少有些紧张 ...

在 Ruby 中方法调用都是通过对象。方法的形式。当调用一个方法时,Ruby 会在对象的类中查找那个方法,如果当前类中没有这个方法的定义,Ruby 会搜索祖先链,看看其中是否存在这个方法。再更进一步前,我们得先来了解下什么是“祖先链”。

在 Ruby 的世界里,“祖先链”是一个很重要的概念。祖先链为我们展示了一个完整的类继承路径,同时它也是 Ruby 中方法查找的路径。 先来看一个简单的情况:

class Animal; end

class Dog < Animal

end

def my_ancestors(klass)
  a = []
  a << klass
  sc = klass.superclass
  until sc==nil
    a << sc
    sc = sc.superclass
  end
  a
end

p my_ancestors Dog

首先,我们定义两个类AnimalDog。其中Dog继承Animal类。superclass()方法可以访问到某个类的父类。my_ancestors()方法,会帮助我们输出一个类的所有父类。我们想象中Dog的祖先链应该是这样:

想象的图

但是执行这段代码会输出如下的内容:

[Dog, Animal, Object, BasicObject]

其中多了 Object,BasicObject两个类。可见一个 Ruby 类如果不声明父类,默认继承于Object类。而Object又继承于BasicObject类(Ruby 1.9 版本引入了 BasicObject)。BasicObject是这条继承链的顶端。于是现在的继承链应该是:

实际的图

这就是一个类的祖先链了吗?别忘了还有模块没有试验。下面我们来添加一个Action模块,这个模块可以为类添加一些方法:

module Action

  def eat
    puts 'eat'
  end

end

class Animal; end

class Dog < Animal
  include Action
end


def my_ancestors(klass)
  a = []
  a << klass
  sc = klass.superclass
  until sc == nil
    a << sc
    sc = sc.superclass
  end
  a
end

p my_ancestors Dog

再运行这个脚本,发现输出跟刚才没有什么区别:

[Dog, Animal, Object, BasicObject]

在这个输出中,我们并没有发现添加进来的模块。看来使用superclass()方法并不能访问到添加到类中模块。那么模块被添加到哪里了呢?还好,Ruby 提供了一个更强力的方法ancestors(),可以打印一个类所有的祖先。在脚本文件中加上一行

p Dog.ancestors

然后执行脚本:

[Dog, Animal, Object, BasicObject]
[Dog, Action, Animal, Object, Kernel, BasicObject]

在新的输出中,我们找到了Action模块,它位于Dog类的上方。同时我们还发现了另一个东西 -- kernel,它是 Ruby 在Object类中引入的一个模块。这使得我们可以在任何对象上调用 kernel 中定义的方法。

如果一个类中添加了多个模块呢?再添加一个模块,看看输出:

module Action

  def eat
    puts 'eat'
  end

end

module Status

  def sleeping?
    true
  end

end

class Animal; end

class Dog < Animal
  include Action
  include Status
end


def my_ancestors(klass)
  a = []
  a << klass
  sc = klass.superclass
  until sc == nil
    a << sc
    sc = sc.superclass
  end
  a
end

p my_ancestors Dog
p Dog.ancestors
[Dog, Animal, Object, BasicObject]
[Dog, Status, Action, Animal, Object, Kernel, BasicObject]

可见新的模块被添加到Dog上方,最接近Dog的地方。现在祖先链完整了。它包括了从当前类到BasicObject类的完整路径。

祖先链

好了,明白了什么是祖先链,现在让我们回到主题,看看当在一个对象上调用方法时,Ruby 如何查找这个方法。

1. 先看一个简单的情况,在一个包含模块且有继承关系的类如何查找方法。

module Action

  def eat
    puts 'eat in module Action'
  end

end

module Status

  def sleeping?
    true
  end

end

class Animal; end

class Dog < Animal
  include Action
  include Status
end


def my_ancestors(klass)
  a = []
  a << klass
  sc = klass.superclass
  until sc == nil
    a << sc
    sc = sc.superclass
  end
  a
end

#p my_ancestors Dog
Dog.new.eat
p Dog.ancestors

Dog类本身没有eat()方法,我们在Dog的对象上调用eat()方法后,Ruby 会先查找Dog类中的实例方法,然后沿着祖先链一直向上找,直到找到Action模块中的方法后进行了调用。

find eat

这种查找能到什么程度呢?如祖先链表示的,接下来就是 Object 类,这是 Ruby 中一切对象的开始。其中有一个模块Kernel。也就是说,如果你在 Kernel 中定义了一个方法,那么 Ruby 中的所有对象都可以用这个方法。

2. 如果类和混入的模块中有相同的方法,会发生什么呢?


class Dog < Animal
  include Action
  include Status

  def eat
    puts 'eat from dog'
  end

end

我们在Dog类中添加了新的eat()方法。运行后输出:

eat from dog
[Dog, Status, Action, Animal, Object, Kernel, BasicObject]

可以看出来,Ruby 调用了Dog类中的eat()方法。也就是说,如果存在同名方法,在祖先链中靠前的方法会覆盖靠后的方法。这个规则同样适用于混入的多个模块中存在同名方法的情况。

这是不是 Ruby 方法查找路径的全部呢?我们来看个简单的例子:

class Foo; end

a = Foo.new
b = Foo.new

class << b
  def bar
    puts 'bar'
  end
end

b.bar
a.bar

在 irb 里运行:

2.1.2 :001 > class Foo;end
 => nil
2.1.2 :002 > a = Foo.new
 => #<Foo:0x007fc019952e38>
2.1.2 :003 > b = Foo.new
 => #<Foo:0x007fc01994ae90>
2.1.2 :004 > class << b
2.1.2 :005?>   def bar; puts 'bar'; end
2.1.2 :006?>   end
 => :bar
2.1.2 :007 > b.bar
bar
 => nil
2.1.2 :008 > a.bar
NoMethodError: undefined method `bar' for #<Foo:0x007fc019952e38>
  from (irb):8
  from /Users/molezz/.rvm/rubies/ruby-2.1.2/bin/irb:11:in `<main>'

很奇怪,ab都是Foo类的对象,为什么bar()方法在b对象中存在,而a中却没有呢?看来 Ruby 还有隐藏关卡。

class << b
  def bar
    puts 'bar'
  end
end

这个写法,打开了b对象的单件类。单件类(singleton class/eigenclass)又称,元类(meta class),影子类...。在 Ruby 2.0 之前,官方一直没有一个明确的名字,2.0 以后官方称之为“Singleton class”。在单件类中定义的方法只在当前对象中存在。这就是为什么a对象没有响应bar()方法的原因。Ruby 在进行方法查找的时候会优先查找对象单件类中定义的方法。所以,现在的方法查找路径变成了:

find singleton eat

有一点很有意思:在 Ruby 的世界里,一切皆是对象。我们定义的类也是对象(它们是Class类的对象)。所以每个类也有自己的单件类。我们常见的类方法实际上就定义在这个类的单件类中。模块也有自己的单件类,但模块的单件类并不在祖先链中。单件类不能被继承,它只属于当前的对象。但是单件类可以有父类或子类。一个类的单件类的父类或子类是这个类的父类或子类的单件类。这读起来有点绕口,画个图就明白了:

find singleton class

图中虚线框的类是单件类。虚线箭头是实例方法的查找路径。实线箭头是类方法的查找路径。这就是较为完整的 Ruby 方法查找路径了。

当方法被调用时,Ruby 会沿着这样的路径去查找,一直到顶部。如果方法没有找到,Ruby 会调用一个叫做method_missing()的方法。默认情况下,这个方法会抛出一个NoMethodError的异常。我们可以在自己的类中覆盖这个方法,从而实现各种惊奇的功能。Rails 中使用method_missing()的代码随处可见:

#rails / activesupport / lib / active_support / option_merger.rb

def method_missing(method, *arguments, &block)
  if arguments.first.is_a?(Proc)
    proc = arguments.pop
    arguments << lambda { |*args| @options.deep_merge(proc.call(*args)) }
  else
    arguments << (arguments.last.respond_to?(:to_hash) ? @options.deep_merge(arguments.pop) : @options.dup)
  end

  @context.__send__(method, *arguments, &block)
end

我们在Dog类中也添加一个自己的方法:

class Dog < Animal
  include Action
  include Status

  def eat
    puts 'eat from dog'
  end

  private

  def method_missing(method, *arguments, &block)
    puts "call method: #{method}"
  end

end

然后调用下不存在的wang()方法

Dog.new.wang
eat from dog
[Dog, Status, Action, Animal, Object, Kernel, BasicObject]
call method: wang

奇迹出现了!Ruby 没有报NoMethodError而是打印出了我们调用的方法名字。当你沉浸在method_missing()带来的喜悦中的时候,你可能已经掉进了一个坑中。来看下面一段测试:

require 'benchmark'
require 'rubygems'
require 'sqlite3'
require 'active_record'


ActiveRecord::Base.establish_connection(
  :adapter => 'sqlite3',
  :database => ':memory:'
)

ActiveRecord::Migration.class_eval do
  create_table :as do |table|

    table.column :title, :string
    table.column :performer, :string

  end

  create_table :bs do |table|

    table.column :title, :string
    table.column :performer, :string

  end
end


class A < ActiveRecord::Base

  def test
    true
  end

end

class B < ActiveRecord::Base

  def method_missing(method)
    true
  end

end

a = A.new
b = B.new


Benchmark.bmbm do |x|
  x.report('call method:') { 10000000.times { a.test } }
  x.report('call method_missing:'){ 10000000.times { b.test }  }
end

我们使用 Ruby 提供的Benchmark进行基准测试。为了尽可能减小误差,代码中使用了bmbm()方法,给要测试的代码进行“预热”。输出的结果如下:

# Ruby 2.1 / Activerecord 3.2.16

Rehearsal --------------------------------------------------------
call method:           1.040000   0.000000   1.040000 (  1.047809)
call method_missing:   1.300000   0.010000   1.310000 (  1.295754)
----------------------------------------------- total: 2.350000sec

                           user     system      total        real
call method:           1.050000   0.000000   1.050000 (  1.047334)
call method_missing:   1.280000   0.000000   1.280000 (  1.286637)

method_missing()固然很强大,但是它是有一定性能损失的 (从测试上看,大概 20% 左右)。这个性能损失一部分就是来自于祖先链的查找。祖先链越长,性能损失越大。虽然相比性能损失,method_missing()带来的便捷性和灵活性是我们更看重的,但是在一些高负载的环节,性能问题还是不得不留意。

##讨论

我们在 Rails 的源代码里经常见到这样的写法:

#rails / actionpack / lib / action_dispatch / routing / routes_proxy.rb line 25
def method_missing(method, *args)
  if routes.url_helpers.respond_to?(method)
    self.class.class_eval <<-RUBY, __FILE__, __LINE__ + 1
      def #{method}(*args)
        options = args.extract_options!
        args << url_options.merge((options || {}).symbolize_keys)
        routes.url_helpers.#{method}(*args)
      end
    RUBY
    send(method, *args)
  else
    super
  end
end

在一些文章里也有很多人讨论使用类似的方法提高method_missing()的性能。即在method_missing()中动态创建方法,这样可以省掉后面调用时对祖先链进行查找消耗的时间。我们也把试验代码改动下:

require 'benchmark'
require 'rubygems'
require 'sqlite3'
require 'active_record'


ActiveRecord::Base.establish_connection(
  :adapter => 'sqlite3',
  :database => ':memory:'
)


ActiveRecord::Migration.class_eval do
  create_table :as do |table|

    table.column :title, :string
    table.column :performer, :string

  end

  create_table :bs do |table|

    table.column :title, :string
    table.column :performer, :string

  end

  create_table :cs do |table|

    table.column :title, :string
    table.column :performer, :string

  end
end


class A < ActiveRecord::Base

  def test
    true
  end

end

class B < ActiveRecord::Base

  def method_missing(method)
    true
  end

end

class C < ActiveRecord::Base

  def method_missing(method, *args)

    self.class.send(:define_method, method) do
      true
    end

    # self.class.class_eval <<-RUBY
    #   def #{method}(*args)
    #     true
    #   end
    # RUBY

    send(method, *args)

  end

end

a = A.new
b = B.new
c = C.new



Benchmark.bmbm do |x|
  x.report('call method:') { 1000000.times { a.test } }
  x.report('call method_missing:'){ 1000000.times { b.test }  }
  x.report('call dynamic method:'){ 1000000.times { c.test }  }
end

这次加入C类,并且在method_missing()中动态定义了方法,然后执行测试:

# Ruby 2.1 / Activerecord 3.2.16

Rehearsal --------------------------------------------------------
call method:           0.110000   0.000000   0.110000 (  0.110478)
call method_missing:   0.140000   0.000000   0.140000 (  0.137801)
call dynamic method:   0.160000   0.000000   0.160000 (  0.158153)
----------------------------------------------- total: 0.410000sec

                           user     system      total        real
call method:           0.110000   0.000000   0.110000 (  0.105553)
call method_missing:   0.130000   0.000000   0.130000 (  0.134528)
call dynamic method:   0.150000   0.000000   0.150000 (  0.155159)

很奇怪动态创建方法不仅没有提升性能,反而略有降低。我们把同样的代码在 Ruby 1.9.3 环境下测试一遍:

#Ruby 1.9.3 p547 / Activerecord 3.2.16

Rehearsal --------------------------------------------------------
call method:           0.290000   0.000000   0.290000 (  0.294679)
call method_missing:   0.450000   0.000000   0.450000 (  0.449482)
call dynamic method:   0.410000   0.000000   0.410000 (  0.410275)
----------------------------------------------- total: 1.150000sec

                           user     system      total        real
call method:           0.300000   0.000000   0.300000 (  0.297368)
call method_missing:   0.460000   0.000000   0.460000 (  0.454473)
call dynamic method:   0.410000   0.000000   0.410000 (  0.409693)

这次跟我们的猜测一致了。在method_missing()中动态定义方法确实能提高一些性能。看来 Ruby 2.1 版对method_missing()进行了优化。“今后是否还要在method_missing()中使用动态方法定义来提高性能?”成为一个值得考虑的问题。

清晰易懂~谢谢分享~不过 ruby 世界还是太绕啊~

简单从测试数据看,2.1 比 1.9.3 性能提升很明显啊~~

@molezz

#rails / actionpack / lib / action_dispatch / routing / routes_proxy.rb line 25
def method_missing(method, *args)
  if routes.url_helpers.respond_to?(method)
    self.class.class_eval <<-RUBY, __FILE__, __LINE__ + 1
      def #{method}(*args)
        options = args.extract_options!
        args << url_options.merge((options || {}).symbolize_keys)
        routes.url_helpers.#{method}(*args)
      end
    RUBY
    send(method, *args)
  else
    super
  end
end

其实我多年疑惑的问题一直是 他们为什么喜欢用 eval+字符串的方法来定义方法啊 这种方法往往是代码注入的根源,一开始我还以为是考虑到 Lambda 可能引发的内存泄漏,后来发现 eval+字符串也不是省油的灯啊。。

#2 楼 @iBachue 同疑惑,为啥喜欢用 eval+字符串的方法来定义方法啊?

#2 楼 @iBachue class_eval 我也测试了,比 define_method 还慢些。不过就像@salga分享的,还是有一些好处的。合理 eval 应该不会有安全问题,ruby 有相应的安全防范。

#5 楼 @molezz 已经看到 Rails 曾经 N 次坑在这个东西上了 全是代码注入级的安全漏洞 我反正觉得得不偿失的。。

后面的 benchmark 没看出 require active_record 的必要性。。。

method_missing 不会因为速度必须定义 dynamic method,感觉方便不少

#8 楼 @palytoxin我 require active_record,是为了让祖先链更复杂些。对比更明显。而且更接近实际使用。

清晰明了。

看来在 2.1 中,就没必要在 method_missing 中动态定义方法了,学习到了。

赞一个,比《ruby 元编程》中讲的要好一些

我觉得楼主有两个地方稍微有点小问题,一个地方有比较严重的问题: 严重的问题:如果每个方法执行一百万次的话,动态创建方法的测试数据是不准确的。因为动态方法第一次访问的时候就已经创建了,后边访问,实际已经不会再去动态创建了,是去直接调用已经创建的方法。所以时间消耗上,实际上是只执行一次动态创建。 抛开上边的问题。 小问题第一个:

当方法被调用时,Ruby会沿着这样的路径去查找,一直到顶部。如果方法没有找到,Ruby会调用一个叫做method_missing()的方法。

这个地方应该是在祖先链中某个节点中没有找到方法,都会去查找本节点的 method_missing 方法。 例子:

class Animal
    def method_missing( method, *args, &block)
        puts "function #{method} is missing."
    end
end

class Dog < Animal
    def method_missing( method, *args, &block)
        puts "dog #{method} is missing. "
    end
end

Dog.new.hello

输出的是:

dog hello is missing.

第二个: 个人感觉的楼主在对 method_missing 和动态创建的测试过程,稍显片面。因为当你第一次动态创建的时候,确实是时间消耗比较大,不过,当你运行第二次的时候,时间就会跟直接访问的时候一样,不会再去创建一次方法,而如果要所有方法都写好再去调用的话,显然是不可能的,而有了 method_missing 机制,这就完全不是问题。这就是他比较优秀的一面,而楼主的测试目测也对回复者也产生了一些片面引导,这么好的方法,怎么能不用呢! 当然,以上纯属个人见解,欢迎指正。

#12 楼 @realwol method_missing 这块是没有说清楚。调用 method_missing 也是按照祖先链的顺序进行查找的。Ruby 会先查找位于祖先链最底部的 method_missing,如果没有再往上查找。至于您说的严重问题,我没有看明白。是说 100 万次有些少么?我也测试过 1 亿次的,结果也是一样的。

另外我也是赞成用 method_missing 的。只是提示大家有性能损失。

#13 楼 @molezz 我的意思是动态创建方法在一个运行周期间断之前,对一个方法只运行一次(或者有其他废除动态创建的方法运行之前)。也就是说,我第一次要 new 方法,找不到,那么我现在创建了 new 方法。第二次再来找 new 方法的时候,他就不会再动态创建了,只是去找这个已经创建的方法了。所以其实你的一百万次的时间,是一次动态创建和 999999 次查找动态创建方法的时间,不知道我这么说你明白不。

#13 楼 @molezz 至于 method_missing 的性能损失,这是建立在降低空间复杂度甚至是增加可行性的基础上的。本来就是在空间复杂度和时间复杂度上做平衡而已。

#14 楼 @realwol 对啊。在 method_missing 中动态定义方法就是为了节约那 999999 次祖先链查找的时间啊

#16 楼 @molezz 所以我的意思,你的最后一组测试的时间并不是一百万次创建动态方法的时间,我理解你是想计算这个时间。

#17 楼 @realwol 最后本来就不是想测试一百万次创建动态方法的时间啊。只是想说明 method_missing 中定义方法在 ruby2.1 版本中并不能提高性能

#18 楼 @molezz 动态定义方法这个是有他特定环境的,性能的话,其实已经很接近了,而且 2.1 提升这么多。我的疑问是 1.9 到 2.1 的变化中,为什么会是这样的趋势。

#19 楼 @realwol 嗯,我也想搞明白这个问题。期待大牛来科普

单件类只属于当前对象,不能被继承,为什么很多书中说单件类有超类一说呢?求解

#21 楼 @dorayatou 是有超类,超类是父类的单件类。这块可能说的有歧义。是不能通过class A < B这种方式继承

mark & good

You need to Sign in before reply, if you don't have an account, please Sign up first.