关注最近的Ruby 1.8.7预览版的Rails开发者,很快注意到了1.8.7 Preview 1有点不对劲:它破坏了Rails。原因是增加了方法Symbol#to_proc
,该方法是从Ruby 1.9移植回来的。它能让我们可以用更为紧凑的方式来写一些代码(参见details about Symbol#to_proc
)。
那有什么问题呢?Rails已经将to_proc
方法加入Symbol了。但是……Ruby 1.8.7 Preview 1增加的方法与Rails增加的方法略微有些不同。幸运的是,Rails有很多用户,因此问题很快得以提交,也使得Ruby 1.8.7最终版本能够保证Symbol#to_proc
具有良好的兼容性。
Ruby的开放类是一个非常有用的特性,允许向已加载的类增加方法,举个简单的例子:
class String
def foo
"foo"
end
end
puts "".foo # prints "foo"
开放类的问题在于对外过于暴露和开放,我们可以使用一个传统的众所周知的软件设计方法:模块化。针对软件模块化,这些年来也提出了很多的概念,比如,本地变量(vs.全局变量)、词法作用域(vs. 动态作用域),以及众多的命名空间系统等等。软件模块化一个不断前进的过程──比如软件开发中“面向组件”的思想以及软件类似于工业产品的可组装性。因此,正如我们所见,模块化是软件的一个非常重要的特性。
软件模块化的特性与开放类以及自由的Monkeypatching(指在运行时动态地修改类,这个特性也是很通用的,尤其是在Python社区)是相悖的。所有的库开发者在打开一个现有的类时都必须回答一个问题:这个新增的方法是不是绝对必要的,值不值得让我去破坏软件的模块化。接下来,让我们重新说明下的问题:
Symbol#to_proc
方法──库增加这个方法来支持一些操作的特殊而简单的语法。在1.9中,它被增加到Ruby stdlib的Symbol类中。这是产生命名冲突的另一个隐患:如果命名十分普通,那么未来的Ruby版本可能也包含这个方法。尽管当方法实现完全相同的功能的时候还好──但是如果不是这样就会产生问题。这种情况下,重新定义方法可能会破坏系统,也就是Ruby stdlib以及所有依赖标准 Ruby库的程序。公开一个类的原因在于使得一个类对象支持某个协议或接口,比如一系列消息/方法(参看上下文中对词汇协议更详细的解释 )。这里有一些可以替代的方法也可以实现这个目的。
适配器(Adapter)模式基本的思想是,给定一个对象X,找到另一个支持某种协议的对象,可以跟对象X表现的一样。
一个非常通用的适配器模式的例子是Eclipse,它使用这种模式实现了平台的扩展性和模块化。一个使用适配器的例子:获取 Editor的Outline GUI:
OutlinePage p = editor.getAdapter(OutlinePage.class);
类editor的对象要么直接返回一个OutlinePage对象(其知道如何显示一个编辑器内容的摘要)。如果这个特殊的editor没有实现 Outline功能,那么getAdapter方法协议会指示将这个调用重定向到一个查找系统,其将进一步的使用扩展/模块部分:即使其本身没有提供Outline GUI,还有一个Eclipse插件也可以提供这个功能。适配器模式的优点在于:不需要修改类来增加功能──适配器逻辑包含所有的逻辑,将期望的接口适配到初始类上。允许对原来接口进行正交变更,不影响原有的方法。不需要全局的修改。关于Eclipse的适配器模式,参阅Alex Blewitt的“什么是IAdaptable?”。
在动态语言中使用适配器的一个例子来自ZOPE。在其讲稿“使用Grok来学鸭子走路”中,Brandon Craig Rhodes介绍了这些年创建ZOPE的一些经验,并总结了“让一个不是鸭子的对象表现地像只鸭子”的不同方法的优劣。解决方案包括了很多定义和提供适配器的方法。
这些适配器实现方法对一些小应用程序来说好像有点小题大作,但这可以保证应用程序的模块化。与开放类有一些不同,因为返回的适配器不需要与适配对象相同──而开放类(或者单例类──见下文),其可能将行为增加到一个特定类型的所有对象上。其是否是一个至关重要的特性取决于你的应用程序。
Ruby允许修改单个特定对象的类。方法是由对象最初的类创建一个新的单例类来加以实现。示例代码如下:
a = "Hello"
def a.foo
"foo"
end
a.foo # returns "foo"
这个修改的作用在于保证对象的本地化──没有其他的类或对象受影响。更多的关于单例类使用的信息和例子参见InfoQ文章 “使用单例类来处理对象元信息”。
如果你确实需要开放一个类的话,这里有一些减少风险的提示。Jay Fields列出了给类增加方法的不同方式。解决方法是:
每种方法的详细描述参见Jay的文章。
最后,将类的扩展置于一个地方,比如全部放到一个文件extensions.rb
中。通过这种约定,所有的扩展很容易看到,而不需要特殊的IDE或者类浏览器来显示。其就像是一个文档包含了类的作用域。
扩展已存在的类的思想并不是Ruby所特有的。其他的语言也支持类似的特性,有一些解决方法不会影响全局命名空间。
其中的一个概念叫做Classboxes。Squeak Smalltalk、Java和.NET都已实现. 基本思想是:
传统的模块可以良好的支持应用程序模块化的开发,但是缺少增加和替换不在该模块中定义的类的方法。支持方法增加和替换的语言没有提供应用程序模块化的视图,同时修改将会对全局产生影响。这样产生结果是,一方面模块系统和面向对象语言之间出现阻碍,另一方面又非常希望具有增加和替换方法的特性。
为了解决这些问题,我们提出了classboxes,即一个针对面向对象语言的模块化的系统,其可以增加和替换方法。而且,classbox的修改只对本classbox(或者导入它的 classbox)是可见的,这个特性我们称之为本地重绑定(local rebinding)。
C#的扩展方法提供了另一种方式来解决这个问题。不像Ruby中的开放类,扩展方法并不会改变实际的类。相反,其只对定义扩展方法的源才是可见的──简单的说:它是真正在编译器中加以实现的。举个例子(来自于链接的文章):
public static int WordCount(this String str)
正如你所见,方法获得其作用的对象的this指针。为了使扩展可见:
using ExtensionMethods;
好了,现在你就可以使用新的方法了:
string s = "Hello Extension Methods";
int i = s.WordCount();
这种方法的好处在于: 扩展方法仅仅在其显式导入的代码中才是可见的。
关于Monkeypatching/开放类的争论已经促使了工作区的一些相关实验的开展。这个coderr的工作区允许将代码封装为内容,保证了扩展的局部性。这种解决办法的一个问题在于,需要使用Ruby的本地扩展与Ruby的解释器相关联(其使用RubyInline,来查找内联函数调用进而找到其作用的C代码)。
另一种不同的方法是Reginald Braithwaite的gem包Rewrite。其使用ParseTree来从Ruby代码中获得AST,并使用其来使得增加的方法在特定的内容中是可见的。InfoQ在以前详细讨论过Rewrite包。gem包Rewrite也依赖本地扩展(在这里是 ParseTree)来完成相应的工作。
我们分析了开放类特性在粗心地使用时会导致的问题──循环,动态内存分配以及更多语言的特性。我们强调的重点是“粗心”。本文分析了实际使用中的问题,比如命名冲突。在此基础上,分析了尝试使用一些其他类似的解决方法来修改当前的类(使用Adapter)──还有如果不用些方法的话怎样尽可能安全地使用开放类。最后,分析了如何减少对开放类法的修改以及在未来 Ruby版本中的使用──并借鉴了其他语言解决方法 (Classboxes、C#扩展方法,等等)。
查看英文原文:Ruby's Open Classes - Or: How Not To Patch Like A Monkey。
参与InfoQ中文站内容建设,请邮件至[email protected]。也欢迎大家到InfoQ中文站用户讨论组参与我们的线上讨论。