ruby元编程之猴子补丁

本文章发表在我的个人博客上
http://xuyao.club/blog/2017/05/06/metaprogramming-ruby-of-monkeypatch/

打开类

先看一个例子,

3.times do 
  class Dog
    puts "wang..."
  end
end
=> wang...
   wang...
   wang...

上面的代码并非定义三个同名的类,而是第一次定义一个类,其它两次是重新打开这个类。

class Dog
  def say
    puts "wang wang..."
  end
end

class Dog
  def eat
    puts "bone"
  end
end

dog = Dog.new
dog.say    #=> wang wang...
day.eat     #= bone

从上面的例子可以看出,当Ruby开始着手定义一个类,并定义say()方法,第二次再提及Dog类时,如果它已经存在,Ruby就不用再定义了,只需要重新打开这个已经存在的类,并定义eat()方法。
  像这种你总是可以重新打开一个已经存在的类并对它进行动态修改的技术,可以称之为打开类(open class)

猴子打补丁

你写了一个substitute方法,功能是在一个数组中,把一个指定的元素替换成另一个元素的,代码如下

  def substitute(array, from, to)
    array.each_with_index do |v, i|
      array[i] = to if v == from
    end 
  end    

#=> 
a = ["zh", "usa", "ja", "ck"]
substitute(a, "ja", "kr")
["zh", "usa", "kr", "ck"] 

果然是个好方法,用起来很方便,但是很快你发现其实这个方法可以再重构一下,把它定义成Array中的一个实例方法岂不是更好,于是你又改了一下,代码如下

class Array
  def substitute(from, to)
    self.each_with_index do |v, i|
      self[i] = to if v == from
    end 
  end
end

#=> 
a = ["zh", "usa", "ja", "ck"]
a.substitute("ja", "kr")
["zh", "usa", "kr", "ck"] 

上面的代码是打开Array类,并再类中添加substitute()方法,可以看到,你并没有修改原始的Array类库,而仅仅是重新打开了它,再往里面增加方法,
简直是太完美了,Array类中竟然缺少这么一个好用的方法,还好你把它加上了。

像这种在不改变源码的情况下,对功能进行动态追加、修改的技术叫做猴子补丁(Monkey patch)

猴子补丁引起的问题

如上所说,在Ruby中,可以很轻松地打开一个已经定义的类,并往类中塞方法(包括String,Array类)
 这时你突然发现substitute这个单词太长,影响使用,你已经想到了一个更好的方法名replace,于是你把代码又改了一次,如下:

  class Array
    def replace(from, to)
      self.each_with_index do |v, i|
        self[i] = to if v == from
      end 
    end
  end 

简直太完美了,你轻轻松松就在Ruby自己的类库中添加了方法,这时你旁边的同事一头雾水的在找bug,说自己明明没改过这块代码,怎么现在代码却报错了,你从容地凑过去看,帮他找bug, 代码如下。

a = [ "a", "b", "c", "d", "e" ]
a.replace([ "x", "y", "z" ]) 
#=>[ "x", "y", "z" ]  TODO 你同事预期出来的结果

天啦噜,你同事怎么这么快就用了你刚定义的replace()方法,还SB一样后面只带一个参数,我方法里面明明有两个参数。。。
你同事说,我明明记得Array类中有个replace()方法,是把当前数组内容替换,怎么会报错呢?
这时你好像明白了什么,Array类中本来就有replace()方法,我刚才无意中把这个replace()给覆写了,为了证明你的说法,你把刚才定义的方法去掉,并在Array中的方法找了一遍,看下是否有已经定义过的replace()方法

[].methods.grep(/replace/)
#=> [:replace] 

好了,你的问题找到了,是刚才你意想天开地在Array类中加了一个已经存在的方法(覆写了方法),你悄悄地把代码改回去,并让你同事pull一下代码,再试下,应该不会错,你同事pull了代码,运行一下果然没错,这时更一头雾水,你说没问题就行了,去吃饭。。。
经过上面的事情,你总结出了几条经验

  • 打开一个已定义的类,并往里面添加方法是很危险的,因为你并不知道类中是否已经存在这个方法,
  • 猴子补丁是全局性的,一旦你修改了Array中的replace()方法,则系统中的所有数组都会加载这个方法
  • 猴子补丁是不可见的,如果重定义了Array#replace()方法,则很难发现这个方法被修改了,由于是全局性的,你很难发现问题所在,也很难找出在哪个地方定义了这个方法。

如何避免猴子补丁引起的问题

我们先来看个例子,

module M
  def my_method
    "M#my_method()"
  end
end

class C
  include M
end

class D < C; end

D.new.my_method()  #=>"M#my_method()"
#TODO 查看类D的所的父类
D.ancestors   #=> [D, C, M, Object, Kernel, BasicObject]

从上面列子可以看到,当我们在一个类中包含(include)一个模块时,Ruby创建了一个封装该模块的匿名类,并把这个匿名类插入到祖先链中,其在链中的位置正好包含在它的类的上方,这些封装的类就叫做包含类(include class),或者叫代理类(proxy class)

接下来我们重新打开ruby的irb,试着查看一下Array的祖先链

Array.ancestors
=> [Array, Enumerable, Object, Kernel, BasicObject] 
#TODO可以看到Ruby初始Array类中的祖先一共有5个

然后我们再打开rails环境下的console(命令rails console),其环境加载了包括rails的各种gems源码和你自己的代码,同样的查看Array中的祖先链

Array.ancestors
=> [Array, RQRCode::CoreExtensions::Array::Behavior, V8::Conversion::Array, JSON::Ext::Generator::GeneratorMethods::Array, Enumerable, Object, PP::ObjectMixin, ActiveSupport::Dependencies::Loadable, V8::Conversion::Object, JSON::Ext::Generator::GeneratorMethods::Object, Kernel, BasicObject] 

天呐,Array类怎么多出来这么多父类,为了找出原因,你随便找了个父类ActiveSupport::Dependencies::Loadable研究下,可以看到源码点这里,其中有一个hook!方法

def hook!
  Object.class_eval { include Loadable }
  Module.class_eval { include ModuleConstMissing }
  Exception.class_eval { include Blamable }
end

它的作用就是将所需的各种Meta Programming挂到Object和Module下,而其实这里ActiveSupport::Dependencies::Loadable并不是一个真正的类,看源码我们就可以发现,它其它是一个模块,被include到了Object类里,也就是我们上面讲到的,ActiveSupport::Dependencies::Loadable其实是一个代理类,那为什么要这样做呢,因为使用模块(带了各种namespace的)可以让猴子补丁更明显一些,想对而言比较容易追踪到他们,因为这种方式至少可以在祖先类中看到这个模块。

所以使用命名空间(namespace)可以有效地解决类名冲突,从而避免猴子补丁引起的问题

Rails中如何防止猴子补丁引起的问题

rails在ActviteRecord中,有个instance_method_already_implemented?()方法,点击查看源码,它会在你定义一个动态方法前,首先会检查有没有同名方法存在,如果有的话,方法会抛出一个异常,否则返回false,说明可以定义方法,举个栗子

class Patient < ActiveRecord::Base
  def save
    'already defined by Active Record'
  end
end

Patient.instance_method_already_implemented?(:save)
#=>  ActiveRecord::DangerousAttributeError: save is defined by Active Record. Check to make sure that you don't have an attribute or method with the same name.
Patient.instance_method_already_implemented?(:aaaaaa)
 #=> false 

Rake中如何防止猴子补丁引起的问题

在rake 中有一个名为Module#rake_extension()的方法,这里看源码点击查看

  def rake_extension(method) # :nodoc:
    if method_defined?(method)
      $stderr.puts "WARNING: Possible conflict with Rake extension: " +
        "#{self}##{method} already exists"
    else
      yield
    end
  end

Rake在它想打开类来添加方法时,会使用rake_extension()方法检查它是否已经存在,如果错误地重定义一个已经存在的方法,那么rake_extension()会抛出一个警告信息

require 'rake'
class String
  rake_extension("xyz") do
    def xyz
      "xyz"
    end
  end
end
#=> :xyz

class String
  rake_extension("to_s") do
    def to_s
      "to_s"
    end
  end
end

#=> WARNING: Possible conflict with Rake extension: String#to_s already exists

最后,在使用猴子补丁的时候,千万不要覆写、修改了原类中的方法,不然你会走上一条不归路的。

参考资料

《Ruby元编程》
https://en.wikipedia.org/wiki/Monkey_patch
https://web.archive.org/web/20120730014107/http://wiki.zope.org/zope2/MonkeyPatch
https://github.com/rails/rails
http://thekaiway.com/2013/07/04/code-loading-of-rails/
https://github.com/ruby/rake/blob/master/lib/rake/ext/core.rb

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,937评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,503评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,712评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,668评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,677评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,601评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,975评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,637评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,881评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,621评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,710评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,387评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,971评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,947评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,189评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,805评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,449评论 2 342

推荐阅读更多精彩内容