Refining Ruby (译)

ruby

前言:

Ruby2.0 即将在明年2月份发布, 而 refine 是中间一个关键的特性, 所以我正打算详细研究这个特性并发布一个首文. 无奈功力不够, 看到一篇写的极佳的文章, 翻译之, 来自 Charles Nutter , 希望对自己有用之余, 对大家也有所帮助.

下面的代码是干什么的?

class Baz < Quux
  def up_and_add(str1, str2)
    str1.upcase + str2.upcase
  end
end

“把两个字符串加起来,然后返回结果”

你可能是错的。因为Ruby2.0的一项新的特性:refinements

让我们从猴子补丁开始说起。

猴子补丁

在Ruby中,所有的类都是可以修改的。实际上,当你定义一个新的类时,你仅仅是创建了一个空的类,向中间填充了一些方法而已。我们完全可以在Ruby运行时修改类的行为,甚至是Ruby自身的类。这个特性被许多库和框架用来重定义或添加新的行为。例如,你可以添加一个 camelize 方法到 String 中,用来将类似于 under_score_names 转换为 UnderScoreNames,这种特性被Ruby社区称之为“猴子补丁”。

猴子补丁是非常有用的,Ruby中许多特有的设计模式都是通过这种能力完成的。但是,它有着潜在性问题。例如,它可能导致库的使用者莫名的行为修改,甚至两个库会引发冲突(我在之前的工作中,就出现过重定义underscore时与ActiveSupport库冲突导致的bug,很难查)。有时候,其实你并不想全局应用这个特性,好的,正是 refinements 的用武之地。

局部引用的猴子补丁

refinements 这个特性已经被讨论很多年了,以前有时会被称为 selector namespaces。本质上,refinements 就是为了让猴子补丁可以限制在一个作用域内,就像一个库虽然利用猴子补丁修改了Ruby的核心方法,但却不会影响它外部代码一样。举个实际的例子,ActiveSupport只应当服务于Rails的核心一样。

ActiveSupport 提供了许多对Ruby核心类的扩展,例如 String#pluralize, Range#overlaps?, Array#second。里面一些扩展是为了让使用者更加方便地完成可读性强,简洁的代码。但其它的扩展,只是为了给Rails框架本身提供支撑。如果我们有能力避免第二类的扩展影响到我们使用者,那有多完美哪。

refinements

简言之,refinements 提供一种方法让类的修改只影响某个作用域。看下面的代码,我添加一个camelize方法到String类中,但它只能被Foo使用。

module Camelize
  refine String do
    def camelize
      dup.gsub(/_([a-z])/) { $1.upcase }
    end
  end
end

class Foo
  using Camelize

  def camelize_string(str)
    str.camelize
  end
end

Foo中声明了 refinement 时,我们可以看出它可以使用直接camelize了,但在Foo外却无法使用它。

>> Foo.new.camelize_string('blah_blah_blah')
=> "blahBlahBlah"
>> 'blah_blah_blah'.camelize
NoMethodError: undefined method `camelize' for "blah_blah_blah":String
    from (irb):17
    from /usr/local/bin/irb-2.0.0:12:in `<main>'

表面上看,这正是我们所期望的。但不幸的是,这会带来很多隐藏的复杂性问题。

Ruby方法调用的问题

我们知道,Ruby中的方法调用,实际上是通过以下步骤进行的: Ruby解释器根据当前 self 对象的信息,找到所属类的继承关系,自底向上查找它的方法,找到后就调用它。而且,Ruby解释器为了效率,会智能地缓存查找过的方法,以减少每次搜索的时间,因为一般的查找过程都相当的简单。

JRuby的实现中,我们会缓存方法,记录它真正的地址(想想你的C语言),为了简单,我们称之为“调用点”(call site)。为了确定方法在接下来的调用时是有效的,我们在 "调用点" (call site)会做两个检查:第一个是查找当前 self 对象是否和前一个调用时的对象是否相同,另一个是查看方法在被缓存之后继承关系是否被改变过。

到目前为止,Ruby方法的调用还只取决于目标对象的类型。被调用时的上下文在方法查找过程是不重要的,除了在某些时候需要确认方法的可见性(主要是protected methods,私有方法是肯定无法在直接在其他类中直接调用的,所以不用考虑)。这种简单性让Ruby的实现能够有针对性的优化方法的调用,也让Ruby的开发人员能够清晰地理解目标对象所拥的方法.

然而,一切都变了。refinements 来了。

refinements 的基础

让我们再看一下刚才的例子。

module Camelize
  refine String do
    def camelize
      dup.gsub(/_([a-z])/) { $1.upcase }
    end
  end
end

class Foo
  using Camelize

  def camelize_string(str)
    str.camelize
  end
end

显然,refinements 的实现是靠 refineusing 两个方法的。

refine 方法带了两个参数: 一个类名(在这里是String),还有一个代码块。 通过这个块,定义好的方法(在这里是camelize)被添加到一个补丁集合中(当然是猴子补丁),这个集合可以在以后方便地应用在指定的上下文中。这些方法现在还不会被添加到实际的类中(String),只有一旦使用了using方法,它们将会激活并应用到当前的代码中。

using 方法带着 refinements 包裹的模块,应用在当前的上下文中。在当前上下文中,这些被重定义的方法可以使用了,而出了当前上下文,这些方法就自动失效。

事情开始变的奇怪起来, TODO…, 在当前实现的 refinements 中, "using" 影响着以下的作用域:

  • 直接作用域, 比如脚本的顶级域, 类的内部, 或者方法或块的内部
  • 从已经被 refine 的类或模块继承下去的类
  • 还有一种 module_eval 所在的代码块, 因为它或者它兄弟方法们可以将 self 带入代码块.

值得一提的是, refinements 的可以影响的代码, 远远比 "using" 调用的地方多的多. 不用说被 refine 的方法必须需要知道目标对象的类型和当前上下文, 但是没有被 refine 的方法会是什么样子呢?

动态上下文中的方法查找

refinements(当前的特性) 从根本上导致了方法的查找必须是在动态上下文中。为了正确地完成一次 被 refine 的调用,我们必须要知道哪些 refinements 在当前的上下文被激活了,另外要确定当前对象的类型。后者是显然的,但是前者处理起来却十分棘手。

本地应用的 Refinements

刚才的简单例子中,using 调用与被 refine 的方法放在一起的,当前的执行上下文包含了我们需要的所有细节。在这里,方法的查找可以简单的通过找到目标类,方法名称,和上下文的继承环境即可。方法查找的关键是想办法将一个简单的名称展开成一个名字加上一个调用上下文。

继承下的 Refinements

refinements 必须对子类也是生效的。虽然没有在当前上下文没有使用 "using", 我们也必须进行 refine 的方法分发。下面的例子阐述了这个特性。(接着上面一个例子)。

class Bar < Foo
  def camelize_and_join(str_ary)
    str_ary.map {|str| str.camelize}.join(',')
  end
end

这里,我们在 map 这个方法调用中使用了 camelize,显然,refinementsBar 中是生效的,包括在它里面的方法定义,以及方法中任何子域,例如块调用。这个例子可以解决开篇我举的第一个例子的问题了,为什么它可能不是你所期望的 “把两个字符串加起来,然后返回结果” 这样简单的想法了。我贴下第一个例子的完整情况:

module BadRefinement
  refine String do
    def upcase
      reverse
    end
  end
end

class Quux
  using BadRefinement
end

class Baz < Quux
  def up_and_add(str1, str2)
    str1.upcase + str2.upcase
  end
end

Quux 类从 BadRefinement 模块中 refineString#upcase ( 这货居然将 upcase 定义为 reverse ), 问题来了, 你只是去看 Baz 并不能看到任何眉目, 反而, 令人各种疑惑. 可以想像的到, 如果继承级数一复杂, 将会出现各种种样的 "MAGIC" 事件, 作为读者的你, 必须弄清楚每个继承的细节才能看懂代码了.

动态分发的 refinements

动态分发在这里是指 refinements 可以应用到 基于块的 DSL, 这样可以 修改一个块内某些方法的行为却不用影响到它外部的DSL, 举个例子, 来一个 rspec 的用例.

describe MyClass do
  it "is awesome" do
    MyClass.new.should be_awesome
  end
end

我们有几个地方想进行 refine.

  • describe 是顶级域的, 即 Object的单实例对象 main. 我们希望能够多在这一层应用 refinements, 防止污染到 Object. 这样就不用担心所有对象继承了.

  • it 这个方法必须在 describe 中调用, 我们希望它被封在 describe 这个块中, 而不是更新了 self, 否则将会全局受到影响.

  • should 我们必须假定使用者没有在某个类中定义这个方法, 有了 refinements, 我们有更好的选择, 只有在it 块中, 我们才能调用 should 这个方法, 这样更加灵活和自然.

  • 最后, be_awesome(我们知道它会被 RSpec 翻译为 MyClass#awesome?), 不应当增加一个 be_awesome方法 到 self中, 而只应该在 it 块中激活.

为了不在 spec 文件内使用 using, 我们要有一种方法来动态的进行应用 refinements 这个特性. 当前我们确实有一种办法, 即使用 module_eval (或者用它兄弟 module_exec).

一个块传递给 module_eval 或者 instance_eval 时, 它的 self 会指到目标对象, 而不是当前创建它的作用域. 这使得我们可以很方便地在一个类的内部运行我们的代码块. 因此, 在 module_eval中执行的方法定义, 正是我们想要的, 那就是创建方法到目标类, 而不影响当前环境上下文.

我们可以利用这个特性, 将 refinements 应用到系统的任何块上. 因此, 通过 module_eval, 任何一个块被调用时,都可能已经被 refine 了, 而问题是, refine 会导致我们不得不在方法调用时仔细查找当前上下文的类继承关系. 好了, 试着看下面的一个例子, 实际上它可能不是你所看到的那么简单, 就算 String 类没有直接被修改.

def add_all(str_ary)
  str_ary.inject('') do |str, accum|
    accum + str
  end
end

因为 "+" 这个方法是在块中被调用的, 所以问题可能就来了. 传来的 str_ary 参数可能不是一个简单的数组; 它可能是用户定义了 inject 方法的类. 如果是那样, 这个块就有可能被 refine 了. 看一下完整的实例.

module WeirdPlus
  refine String do
    def +(other)
      "#{self} plus #{other}"
    end
  end
end

class MyArray
  def initialize
    @ary = ['foo', 'bar', 'baz']
  end

  def inject(accum, &block)
    @ary.each do |str|
      accum = WeirdPlus.module_exec(str, accum, &block)
    end
    accum
  end
end

def add_all(str_ary)
  str_ary.inject('') do |str, accum|
    accum + str
  end
end

Oh No, 我们如果没有留意过这样简单的例子都可能得到相当诡异的结果.

>> add_all(MyArray.new)
=> " plus foo plus bar plus baz"

到现在, 我们应该完全理解了 refinements 的工作原理, 现在, 我们讨论下这家伙带来的问题.

实现的挑战

大部分使用者其实并不关心新特性有多难实现, 对吧? 所以我不会花太多篇幅讲这节. 我关心的问题是, 从实现层面上做一次 refine 调用带来的复杂性, 以及如何识别哪些方法是 refinements.

当前的 Ruby 实现都是简单依赖于目标对象的类型, 来完成方法的分发(method dispatch). 并且在此基础上运用了各种优化技术如 cache. 但是, 一旦加入了 refinements 这个特性, 我们就必须同时搜索调用者的上下文, 这会让方法调用变的复杂起来. 从理想角度说, 我们应该可以仅仅让被 refine 的方法复杂点, 但是, 由于 using 不仅仅是影响在它被调用的地方, 我们通常没有更好的办法来知道一个调用是否在以后将被 refine. 这实际就是在说 module_eval, 它会导致我们就必须时刻处理这种情况.

不过还是有几种方法可以解决这个问题.

去掉 module_eval 这个特性


目前, 没有任何人知道如何用简单的办法来实现 module_eval 中的 refinements 特性. 比如, 在 MRI 中, 当前的实现手段是相当无厘头的: 每一次执行时刷新全局方法缓存表, 以及每个方法调用时生成一个新的,被 refine 的, 匿名的模块. 显然, 这只是临时方案, 是不可行的路子. 块调用是频繁发生的, 我们不能因为 refine 这个特性导致所有的地方都要做判断, 浪费性能.

出现这个情况的根本的原因是我们想让 module_eval 在这里工作起来, 所以所有的块都必须像被 refine 过的代码一样处理. 也就是说, 即使一个块没有被 refine 过, 它内部的方法调用也必须查找和检查调用者的上下文. 结局就是, 为了抓住极个别"小偷", 所有人都必须忍受着繁锁的检查手续.

到目前为止, 我还见过任何一种高效处理 module_eval 这种情况的例子. 它应当被移除.

限制 "using"


任何新的特性不应该影响整体的性能; 另一个解决问题的方法是想方法让 refinements 在语法解析时就可以确定. 这样的话, 我们就可以轻松地保留原有处理逻辑来处理原来的方法调用, 而将精力放在真正被 refine 的这类复杂方法上.

我们先想一种最简单的方法: 强制 "using" 只影响当前作用域. 这样, 我们必须在每个需要使用 refinements 的作用域切换时使用 "using". 嗯, 超简单但也笨重的很. 我们看一个例子(来自上一个例子的改进).

class Foo
  def camelize_string(str)
    using Camelize
    str.camelize
  end
end

using RSpec
describe MyClass do
  using RSpec
  it "is awesome" do
    using RSpec
    MyClass.new.should be_awesome
  end
end

显然, 这种方法超丑, 不过从实现角度上却简单多了. 每一个作用域我们就找一次 "using", 这样可以简单识别出后面的调用是否使用 refinements. "using" 作用域外的调用可以跟以前的搜索模式一样就可以了.

我们可以进一步改进它: 让 "using" 支持到子作用域. 这样我们仍然可以在语法解析过程中就确定 "using" 的范围.

class Foo
  using Camelize

  def camelize_string(str)
    str.camelize
  end
end

using RSpec
describe MyClass do
  it "is awesome" do
    MyClass.new.should be_awesome
  end
end

更好的一种方法是让 "using" 成为一个关键字, 它用来打开一个 refine 域. 这样的话我们就很清楚知道 refine 过的代码跟普通的区别. 我列两个例子: 第一个打开一个 "类"或者"模块", 另一个例子是在一个"do...end" 块中.

class Foo
  using Camelize
    def camelize_string(str)
      str.camelize
    end
  end
end

using RSpec do
  describe MyClass do
    it "is awesome" do
      MyClass.new.should be_awesome
    end
  end
end

公平的说, 我很关心如何通过 "using" 表达一个清晰的被 refine 的调用. 然而, 我们没有讨论关于在调用时激活 refinements 的问题.

定位 refinements


上述的例子里, 我们仍然必须从调用者的上下文传递一些状态到方法的分发(method dispatch)逻辑. 理想中, 既然正在调用的对象已经做过显式的检查, 我们应该只需要传递被调用的对象即可. 这种方式可以很好的支持继承下的 refine , 但是 Rspec 这个例子这种做法还有问题, 因为调用的对象有时候是一个顶级的对象实例(比如 main, 我们不想 refine 整个 Object.)

这里我们引出 Ruby 的另一个特性: 常量查找(constant lookup). 当 Ruby 代码访问一个常量时, 程序在运行时必须先查找当前上下文, 进而找到该常量的定义位置. 如果没找到, 我们继续遍历 self 所在对象的父类, 父类的父类等等. 这个特性很像我们想要的简单版本的 refinements.

如果我们假定我们已经限制 refinements 只能用在 "using" 中, 那么在语法分析时我们就可以完全确定一个方法调用是否使用了 refine. 一个已经被 refine 的调用必须利用当前上下文和目标类来查找指定的方法. 查找过程如下:

  1. 查找当前上下文, 并向上查找, 看是否有 refinements
  2. 如果有, 搜索目标方法
  3. 如果方法已被 refine, 作为调用对象即可.
  4. 如果方法是普通方法, 使用普通的查找进行即可.

因为语法分析已经可以将 refinements 限定在特定的调用上了, 接下来就只需要将调用者的上下文传递给方法分发过程.

再来看看可用性


上面已经有几种可能 refinements 的方式可以很合理高效的实现了, 或者说至少这种方式不会影响到没有被 refine 的代码. 我认为这是任何新特性所具备的要求: 不要伤害(do no harm). 但是, 有时候伤害来的不是那么直接, 它会导致 Ruby代码更难于阅读. 这里有几点需要关注的.

我们回到 module_eval的例子.

def add_all(str_ary)
  str_ary.inject('') do |str, accum|
    accum + str
  end
end

因为这段代码没有使用 "using", 并且我们没有扩展(extend)其他任何类, 大部分人应该会认为我们只是简单地做字符串连接的操作. 毕竟, 我们从来没预料过 "+" 竟然会干了别的事? 这里的 "+" 应该可以干别的事么?

Ruby 有许多特性被认为是有一点魔法. 在大多数时候, 是因为开发者没有理解它们是如何工作的. 例如, 常量的查找,实际上非常的简单… 但是, 如果你不知道它同时要搜索当前上下文和继承类, 你可能就无法得知那些值是从哪里来的.

module_eval 下的 refinements 的行为太离谱了. 它强迫我们每一个 Ruby 开发者事后猜测每一个传给另一个的库或方法的块的行为. 标准的方法分发的行为也无法保证; 你必须要弄明白你调用的方法是否会改变你的代码的行为. 换句话, 你们完全理解你要使用的方法的内部细节. 对于我们这些 Rubyists, 简直是恐怖再恐怖的事.

同样的问题还出在 refinements 应用在类的继承上, 你再也不可能知道扩展(extend)一个类之后,你调用的方法真正干了什么. 相反, 你必须要知道你的父类们 refine 了哪些方法. 我认为这甚至比直接使用猴子补丁更丑, 至少后者在行为上是一致的.

而且从长期看, 问题更加严重. 一旦你用到的库有变化, 你就必须仔细查看源代码来确保 refinements 没有改变. 你必须要清晰地了解这些 refinements 来确保你自己的代码没有问题. 并且更悲剧的是, 你不得不乞求千万别有两套库使用了两种不同的 refinements, 一旦如此, 你就可要面对你写的一半代码向东,另一半代码是向西. Happy吧?

所以, 我认为现在 refinements 的实现带来的复杂度远远超出了它解决的问题. 但是, 这些绝大多数时候都是缺乏对 "using" 的限制所致. Rubyists 们应当无压力的看懂一份代码, 可以清楚地依靠调用对象的类型就知道它在调什么. 但是, refinements 来了, 一切都变了.

更新: Josh Ballanco 指出了另一个可用性问题: "using" 仅仅影响它后面调用的方法. 比如, 下面的例子只有 barrefine, foo 则不受影响.

class Yummy
  def foo(str)
    str.camelize # will error
  end

  using StringCamelize

  def bar(str)
    str.camelize
  end
end

上面这个问题仅仅是当前实现下的一个特性, 或者它只可能是一个特定的行为. 因为没有任何测试说明(ruby spec)来验证它.(译者: 可以参考 ruby spec) 不管怎样, 它仅仅意味了激活 refinements 的顺序会导致不同的行为, 这又将是另一个坑.

后记

我的分析目标不是打倒 refinements. 我同意, 有很多场合 refinements 都非常有用, 尤其是管理 "野蛮"的猴子补丁的时候. 但是, 当前的实现方式过头了; 它带了几个很不合理的特性, 将会导致性能损失, 并让我们更加难于理解. 希望我们能够跟 Matz 和 ruby核心团队就这个问题聊聊, 限制 refinements… 或者说服他们在 Ruby2.0 中不加入这个特性.

本文是译文, 来自: Charles Nutter

发表于 2012.12.06