Ruby 101:动态编程

Ruby 101:动态编程

 

Written by Allen Lee

 

当method_missing的魔法失效时……

      在上一篇文章里,我们通过重写Hash类的method_missing方法把Hash对象模拟成匿名对象,但是,这种做法有时会产生一些莫名其妙的问题,举个例子吧,假如我把process方法(完整实现参见上一篇文章的代码31)的options参数从这样:

代码 1

改成这样:

代码 2

我们将会发现,不论options参数的count取什么值,我们总是得到两本书,为什么?想想看,method_missing方法的触发条件是什么?仅当我们调用的方法不存在时,method_missing方法才有机会出场,但是,Hash类本身就有count方法:

图 1

这打破了method_missing方法的触发条件,换句话说,method_missing方法被count方法"截胡"(麻将术语)了。Hash类的count方法返回键/值对的个数,而options参数默认就有两个键/值对,如果我们没有添加额外的键/值对,count方法的返回值将会总是2,这就是为什么修改options参数之后我们总是得到两本书。那么,如何解决这个问题?

      显然,method_missing方法和count方法无法同时存在,否则转发消息的逻辑总是被忽略的,但我们不能移除count方法,因为这样会导致依赖它的代码不能正常工作,在这种情况下,我们只好把转发消息的逻辑移至别处了,那么,放哪呢?还记得《Ruby 101:对象和方法》最后那个BookStore类吗?我们可以仿效它的做法,为Hash类创建一个代理类,然后把转发消息的逻辑放到代理类的method_missing方法里:

代码 3

这样就不怕count方法的干扰了:

图 2

当然,如果你想要的只是这些,那就没有必要另起炉灶了,因为使用Ruby自带的OpenStruct类也可以达到相同的效果:

图 3

但是,OpenStruct类不支持嵌套的Hash对象,你只能通过person1.address[:city]来访问下面这个对象的城市信息:

代码 4

如果你希望通过person1.address.city来访问这个对象的城市信息,要么显式地把内嵌的Hash对象创建为OpenStruct对象:

代码 5

要么扩展AnonymousObject类,使它支持嵌套的Hash对象,如果你有兴趣的话,不妨把握这个机会练习一下吧!

 

消息代理

      既然我们可以通过代理类截获并转发消息,何不在此基础上加点想象力?假设我有这样一个类:

代码 6

我想在调用method1方法之前做一些事情,在调用method2方法之后做另一些事情,忽略method3方法的调用,把method4方法的调用转到method3方法上,你有什么建议?我可以直接在代码里表达这些需求吗?如果可以,我希望像下面这样表达:

代码 7

当我通过代理类依次调用Class1类的4个方法时,我期望这样的输出结果:

代码 8

现在的问题是,我真的可以这样吗?不知道呢,试一下吧,看看能够走到什么程度。

      首先,我们需要一个Proxy类,它提供相关的方法收集并保存我们的"需求":

代码 9

接着,我们需要重写method_missing方法:

代码 10

处理消息的逻辑并不复杂,除非@ignore包含这个方法的名字,否则将会依次进行前期处理、消息转发和后期处理。如果我们就此撒手不干,那么Proxy类就只能这样用了:

代码 11

这显然不是我想要的。仔细观察代码7,不难发现,proxy是一个方法,而object1对象则是它的参数,proxy方法接受一个代码块,用来配置proxy方法创建的代理对象,要创建这样的方法并不难:

代码 12

下面,我们来看看如何使用这个方法:

代码 13

嗯,我们离目标非常近了,但是,before等方法前面那个"p."可以去掉吗?想想看,调用对象的方法实质上就是向对象发送消息,如果去掉before等方法前面那个"p.",那么这些消息将会发往默认对象,而此时的默认对象是main,一来没有before等方法,二来亦非接收这些消息的正确对象,我们期望接收这些消息的对象是由proxy方法创建的代理对象,如果有办法把代码块执行时的默认对象改为代理对象,代码7就可以实现了,那么,能否改变代码块执行时的默认对象?当然可以,现在正是instance_eval方法大展拳脚的时候:

代码 14

然而,改变代码块执行时的默认对象有时会产生一些莫名其妙的问题,比如下面这个代理工厂:

代码 15

猜猜看,在调用method1方法和method2方法之前将会分别输出什么?由于代码块执行时的默认对象是代理对象,既没有@msg_before_meth1实例变量,又没有msg_before_meth2方法,于是,在调用method1方法之前会输出一个空行,这是puts nil的行为,而在调用method2方法之前会抛出异常,告知找不到msg_before_meth2方法。怎么办?解决方法其实很简单,由于代码块可以访问create_proxy方法的本地变量,我们可以把@msg_before_meth1实例变量和msg_before_meth2方法的值绑定到本地变量,然后在代码块里使用这些本地变量就行了:

代码 16

现在,我们来看看输出结果:

图 4

非常好!接下来,我们看看还有什么需要完善的。

      首先是before方法,目前,它只允许一个目标方法对应一个代码块,试想一下,如果我想在调用某个方法之前分别进行安全检查和资源调配,我就必须把它们混到一个代码块里,这显然不是一个好主意,我希望before方法支持一个目标方法对应多个代码块,这将有助于合理分割代码逻辑。此外,我还希望before方法允许这些代码块访问目标对象,当然,代码块有权选择是否访问。为此,我需要把before方法以及method_missing方法的相关部分修改如下:

代码 17

代码 18

需要说明的是,@before[m] ||= []相当于@before[m] || @before[m] = [],在这里的效果相当于@before[m] = [] if not @before[m]或者@before[m] = [] unless @before[m]。下面,我们来看看运行结果:

图 5

非常好!至于after方法,依样画葫芦就可以了。

      接着轮到ignore方法,目前,它只接受一个参数,这意味着我一次只能忽略一条消息,如果我要忽略多条消息,就不得不重复调用ignore方法了,如果我可以像下面这样忽略多条消息该多好啊:

代码 19

可以吗?当然可以,我们可以使ignore方法接受可变参数:

代码 20

为了避免出现重项,我使用uniq!方法把@ignore处理了一下。值得注意的是,如果你使用的是uniq方法而不是uniq!方法,@ignore将不会受到任何影响,uniq方法在不影响原来的集合的情况下返回一个新的集合,而uniq!则就地修改原来的集合。

      最后是forward方法,它的情况和ignore方法类似,目前,它只接受两个参数,这意味着我一次只能转发一条消息,如果我要转发多条消息,就不得不重复调用forward方法了,如果我可以像下面这样转发多条消息该多好啊:

代码 21

能行吗?想想看,:method4 => :method3是什么?有些同学已经反应过来了,是Hash对象,当它是最后一个参数时,Ruby允许我们把{}去掉,于是看起来就像一组消息映射关系。由于这些映射关系本来就是保存在Hash对象里的,我们只需修改forward方法,把参数指定的映射关系添加到@forward就行了:

代码 22

      有意思的是,我们还可以利用这些方法之间的微妙关系,描绘一些实用的处理逻辑,比如下面这个:

代码 23

首先,我屏蔽外界直接调用method4方法,接着,我把method3方法和method6方法的调用转到method4方法上,并设置在调用它们之前分别进行不同的资源配置,其中,object1是代码7里创建的那个目标对象。这个处理逻辑可能来源于这样的业务需求,系统原本通过method3方法来执行某些任务,由于业务的发展,method3方法的实现出现了僵化,于是催生了method4方法,我希望保留method3方法的请求途径,但背后通过method4方法来执行相应任务,同时增加一个"虚拟"的请求途径(之所以说"虚拟"是因为目标对象并不包含method6方法的定义),这两个请求途径将会针对不同的资源配置展开。

      虽然代码21的proxy2是个代理对象,但我希望它用起来就像object1一样,由于Proxy类和Class1类都是Object类的子类,根据上一节的结论,如果我在proxy2上调用从Object类继承过来的方法,那么我将会得到的proxy2对象的信息而不是object1对象的,这显然不够透明,为了获得object1对象的信息,我将不得不在Proxy类里重写这些方法,但是……Object类的实例方法有52个啊!怎么办?如果你使用的版本是1.9或以上,那么你只需让Proxy类继承自BasicObject类就行了,那么,BasicObject类是何方神圣?我想下图应该能够说明问题了:

图 6

正如你所看到的,BasicObject类是Object类的基类,同时也是整个继承体系的根类。如果你一直关注这个系列,你应该不止一次碰到"这个说法其实不够准确,但就目前而言,你大可放心这样理解"这句话了,事实上,BasicObject类就是那些时候的例外。BasicObject类的实例方法比Object类的少很多,只保留了最基本的7个:

图 7

没有多余方法的烦扰,使得BasicObject类非常适合成为代理类的基类,但是,如果没有指明基类,默认将会是Object类。如果你使用的版本是1.8或以下,那么你需要的是Jim Weirich的BlankSlate,它的用法和BasicObject类似,但额外提供了一些有趣的功能,比如隐藏/重现某些方法,如果你有兴趣的话,不妨看看它是怎么做到的。

 

事件回调

      正如你所看到的,method_missing方法的力量非常强大,以至于我的多篇文章都对它有所涉及,事实上,每当我们需要实现动态接口(dynamic interface)时,method_missing方法和send方法都会无可避免地牵涉进来。回顾method_missing方法的整个作用过程,首先,我们定义这样一个方法,然后,当特定条件满足时,这个方法将被调用,想想看,这像什么?有些同学可能已经看出来了,这就像为特定事件创建回调方法,事实上,我们通常把method_missing方法称作钩子方法(hook method),类似的还有method_added方法、included方法、extended方法和inherited方法等等,如果我们实现了这些方法,当它们对应的事件发生时,Ruby将会调用它们。这种回调机制是内置的,并且由解析器负责执行的,那么,Ruby有否提供现成的机制帮助我们创建自定义的事件呢?很抱歉,Ruby没有正式的事件概念,但它为我们提供了基本材料——Proc对象和Method对象,下面,我们来看看如何实现自定义的事件。

      假如我有一个Button类,我想为它实现一个click事件,我希望它用起来就像……嗯……IronRuby的那样

代码 24

以上是IronRuby支持的三种常见的做法,从Ruby的角度来看,click显然是一个方法,它接受一个代码块,那么,当我们提供代码块时,它的返回值是什么,如果我们没有提供代码块,它会否抛出异常?为了回答这些问题,我们需要借助IronRuby的irb(这里使用的是IronRuby 1.0 RC1):

图 8

从上图可以看到,IronRuby的事件是一个RubyEvent对象,当我们没有提供代码块时,click方法将会返回RubyEvent对象,而当我们提供代码块时,这个代码块会被隐式转换成Proc对象,click方法则返回这个Proc对象。那么,如何实现这个RubyEvent类?当我们感到无从下手时,不妨想像一下完成之时的使用情景,根据图8的试验结果,我们可能会这样使用它:

代码 25

从上面代码可以看到,RubyEvent类至少包含两个实例方法——add方法和call方法,它们分别负责添加单个Proc对象和调用所有Proc对象,实现这样一个类一点都不难:

代码 26

值得注意的是,add方法在添加之前会先进行重复检查。现在,请思考一个问题,如何移除现有的Proc对象?还是让我们先看看IronRuby是如何反应的吧:

图 9

我们可以通过click方法获取RubyEvent对象,然后通过RubyEvent对象的remove方法移除现有的Proc对象,这个remove方法实现起来也不难:

代码 27

至此,我们实现了一个简单的RubyEvent类,并用它为Button类创建了一个简单的click事件,如果你想创建其它事件,那么只需把代码25的"click"替换成对应的事件名字就行了,但是,如果我们想创建N个事件呢,毫无疑问,我们需要重复这样的代码N次!我相信,这绝对不是一个好主意,至少不是一份好差事,解决之道?

      还记得我们是如何创建属性的吗?比如说,我们要为Button类创建heightwidth两个属性,如果手动创建的话,我们将无可避免要写很多代码:

代码 28

但我们通常不必写这么多代码,因为我们有attr_accessor方法:

代码 29

我们知道,attr_accessor方法最终会为我们生成代码28,所以二者的效果是完全一样的,那么,我们能否仿效attr_accessor方法,为RubyEvent类创建这样一个event方法呢:

代码 30

当然可以,现在正是class_eval方法大展拳脚的时候:

代码 31

为了支持长度可变的参数列表,我们使用*event_names来表示event方法的参数,event_names数组的每个元素都会嵌入代码模板,然后传给class_eval方法处理,在这里,你可以把class_eval方法理解成把我们传给它的字符串"嵌入"类的定义里,就像我们在类的定义里写下这些代码一样。接下来,我们要使EventHandling模块的event方法"变成"Button类的类方法,怎么变呢?想想看,如果event方法不是定义在EventHandling模块里,我们又会如何定义呢?我们可能会这样定义:

代码 32

现在,仔细观察一下代码32和代码31的两个event方法的签名,是否发现了什么?有些同学可能已经反应过来了,它们都是实例方法,要把一个模块的实例方法变成一个类的实例方法,我们只需在类里调用include方法就行了:

代码 33

非常好!事实上,我们刚才是把EventHandling模块包含到Button类的单例类里,我们知道,类都是Class类的实例,类方法要么是Class类的实例方法,要么是所属类的单例方法,而这些单例方法正是存放在所属类的单例类里,于是,要使一个模块的实例方法变成一个类的类方法,只需把这个模块包含到这个类的单例类里。然而,这并非实现我们的目标的唯一途径,我们还可以通过extend方法扩展某个对象,因为Button类也是对象,于是我们可以用EventHandling模块扩展它:

代码 34

本质上,这两种方法都是把EventHandling模块嵌入Button类的单例类,使前者的实例方法变成后者的单例方法,但效果上它们会有一个小小的差别,如果我们EventHandling模块里分别定义了included方法和extended方法,那么使用include方法会触发included方法,而使用extend方法则触发extended方法。

      现在,有了RubyEvent类和EventHandling模块,我们就可以轻松实现事件回调了:

图 10

不难预料,红框里的代码也将以相同的模式一再出现,不过,有了上面这些知识,处理这个问题已经不再是难事,那么,除了使用class_eval方法之外,还有没有别的解决方案呢?当然有啦!在Ruby里,类的定义并不限于通常意义的对象的模板,而是一组可以执行的代码,你可以在里面创建本地变量、调用方法,它甚至可以拥有返回值:

图 11

需要说明的是,类的定义的返回值不是类,而是最后一条表达式的值。从这个角度来看,它和方法没啥两样,换句话说,图10的Book类和下面这个应该是等效的:

代码 35

如果可以一般化price_notifier方法,使之根据参数执行这些代码,我们的目的就达到了,但是,我们遇到问题了:首先,我不希望每次调用方法时都创建property_changed事件,其次,我要动态获取/设置实例变量的值,最后,也是最难的,写访问器的名字如何动态修改?

      对于第一个问题,就目前而言,我们无法直接判断某个事件是否已被创建,但是,我们不妨换个角度来看,如果property_changed事件已被创建,Book类会有什么变化,比如说,多了一些什么?有些同学可能已经反应过来了,多了一个property_changed方法,很好,我们可以通过method_defined?方法判断property_changed方法是否已被创建,从而推断property_changed事件是否已被创建。虽然创建property_changed事件还会创建on_property_changed方法,但由于此方法是私有的,而method_defined?方法只对公有和受保护方法有效,所以我们不能借助判断on_property_changed方法是否已被创建来推断property_changed事件是否已被创建。当然,更好的做法是修改event方法,在创建事件的时候登记一下,然后提供event_defined?方法判断事件是否已被创建。对于第二个问题,我们可以通过instance_variable_get方法和instance_variable_set方法做到。至于第三个问题,显然,我们无法继续使用def … end定义方式了,一方面,它不支持动态指定方法名字,除非你回到代码31的做法,另一方面,即使方法名字是固定的,由于def … end会打开一个新的作用域,外面的变量将会无法穿透,致使我们无法向内传递变量的名字。这个时候,我们就要改用define_method方法了,它接受一个参数和一个代码块,参数用于指定目标方法的名字,代码块的参数将会成为目标方法的参数,而代码块的逻辑则成为目标方法的逻辑,由于闭包的作用,我们可以在代码块里使用外面的变量。有了这些知识,我们就可以着手实现attr_notifier方法了:

代码 36

毫无疑问,这个方法不应该被Book类独享,我们可以把它提取到一个模块里,并使它支持多个参数:

代码 37

值得注意的是,attr_notifier方法变成实例方法了,这和event方法一样,此外,为了使用event方法,我们需要把EventHandling模块包含进来,这样,我们只需使用NotifyPropertyChanged模块扩展Book类,Book类就能同时使用EventHandling模块和NotifyPropertyChanged模块的功能了。现在,有了NotifyPropertyChanged模块,创建带有变更通知的属性将会非常便捷:

代码 38

在创建attr_notifier方法的过程中,正如你所看到的,我们不知不觉地使用了反射,Ruby把反射的功能融入现有的对象模型而不是单独提供一套对象模型,这样便于我们随时随地创建更具动态的对象。而提到反射,第一个出现在我脑海里的就是插件系统,下一节,我们将会尝试使用Ruby实现一个简单的插件系统。

 

插件扩展

      以前开发插件系统总是首先定义介于宿主和插件之间的接口,现在稍稍有点不同,我会首先考虑插件是如何"描述"的,举个例子吧,假如我想为下面这个购物车提供导出插件:

代码 39

那么,我可能会这样"描述"其中一个插件:

代码 40

其中,meta部分提供插件的基本信息,包括插件的名字、作者、版本以及一些附加信息等,params部分定义插件所需的参数以及参数的默认值,最后,logic部分包含了插件的主体逻辑。插件的"描述"将会放在一个单独的代码文件里,比如说,我们可以把代码40保存到yaml_exp.rb文件,然后在某个地方统一登记这些文件:

代码 41

假设这些插件是通过AddInStore模块来管理的,那么我可能会这样使用YAML Exporter插件:

代码 42

上面这些好像天方夜谭,但是,运用我们前面学到的知识,你会发现这些效果只是小菜一碟,就像魔术的秘密被揭开之后,里面只有一些基本的东西,外加小小想象力。

      首先是插件的"描述",这种效果我们在前面实现代理对象时已经玩过了,无非就是把一个代码块传给addin方法,由addin方法负责创建一个插件对象,然后通过这个插件对象的instance_eval方法执行这个代码块,而代码块里的metaparamslogic三个部分也只不过是插件对象的三个实例方法,前两个方法的参数采用了Ruby 1.9引入的Hash对象的新写法,最后那个方法只是简单地保存代码块隐式转换成的Proc对象,以备后用。是不是觉得很简单呢?事实也是这么简单:

代码 43

需要说明的是,meta方法和params方法是"两用"方法,在"描述"插件时负责写入数据,在使用插件时负责读出数据,这两种情况对参数分别有着不同的要求,前者需要参数,后者则刚好相反,为了使得meta方法和params方法同时支持这两种不同的参数要求,我给参数设置了默认值——nil,在使用插件时,由于我们没有提供参数,参数将会维持默认值,即nil,此时,meta方法和params方法只需返回相关的数据就行了,否则,保存参数的数据,此外,为了把Hash对象模拟成匿名对象,在保存时我用OpenStruct对象包装了一下。

      接着,我们来看看AddInStore模块,

代码 44

目前,我只是简单地为它提供插件注册和搜索功能,稍后,我们将会为它添加更多枚举功能。

      最后,我们来看看addin方法和addins方法:

代码 45

从上面可以看到,addin方法在创建插件之后还会注册插件,而addins方法则负责加载插件的"描述",换句话说,当我们调用addins方法时,插件就可用了。

      至此,我们已经实现了一个简单的插件系统,我把代码43、代码44和代码45保存到addin.rb文件,把代码41保存到addins.rb文件,把代码40保存到yaml_exp.rb文件,把代码39和代码42保存到main.rb文件,并在开头添加require 'addin'require 'addins',把所有文件放在同一个文件夹里,然后运行main.rb文件……嗯,什么也没有,因为YAML Exporter的逻辑还是空的呢!我们把它补充完整吧:

代码 46

然后再次运行main.rb文件:

图 12

你可以在main.rb文件所在的文件夹里找到cart.yaml:

图 13

什么?没听过YAML?YAML是一个数据序列化标准,支持多种语言,官方提到的有C/C++、Java、Python、Ruby、Perl、C#、PHP、OCaml、Javascript、Actionscript和Haskell。此外,根据Wikipedia上面的说法,JSON语法是YAML 1.2的子集,而且多数JSON文档都可以被YAML解析器解析。

      现在,回到AddInStore模块,毫无疑问,只有一个find方法是远远不够的,我希望AddInStore模块支持Enumerable模块的其他方法,最简单的做法就是使用Enumerable模块扩展AddInStore模块,并为AddInStore模块提供一个each方法:

代码 47

这样,假如我想输出所有导出插件的名字,我可以这样:

代码 48

我们又回到熟悉的集合操作了,当然,AddInStore模块应该具备的功能绝对不止这些,如果你有兴趣的话,不妨试试扩展它。

 

新的旅程

      第一次接触动态语言时,第一个出现在我脑海里的问题是IDE如何提供智能感知?随着Visual Studio的日益完善,我无法想象没有智能感知的日子,它不但减少我的输入失误、提高我的编码效率,还弥补我日益模糊的记忆,基本上可以说,没有智能感知就会处处不便。情况开始有所改变是在后来学习F#的时候,那时F#还只是个研究项目,虽然也提供了Visual Studio的插件,但功能极其有限,加上大部分时间都是通过命令行使用F#的,一开始很不习惯,慢慢地,我发现没有智能感知的日子并没有想象中的那么糟糕,逐渐地,情况变成使用C#时依然很依赖智能感知,而换用F#时则极少依赖智能感知了,这段经历可以说是为我后来不依赖智能感知使用Ruby铺平了道路。当初选择NetBeans作为Ruby的IDE主要是因为它的智能感知做得比较好(个人感觉),但由于它的提示速度很多时候都不及我的脑子来得快,加上等待它的提示窗口出来常常阻塞我的思路,于是不得不放弃使用智能感知,随后的体会是,输入失误并没有想象中的那么多,出现了一些新的途径提高编码效率,还有的就是,我的记忆似乎变得比以前更清晰了,嗯,相比之前担心智能感知的不完善会妨碍我使用动态语言,现在此番感受真是不同啊,有时我在想,究竟智能感知是弥补我的记忆模糊还是促进我的记忆模糊呢?

      智能感知的问题绝对不是我接触动态语言时的唯一担心,然而,随着深入的了解,我发现很多担心都没有想象中的那么糟糕,有时我觉得,或许这些担心只是我用来维持现状的借口罢了。当然,正如我们都知道的,做出改变是有风险的,就其对于有限的生命来说,面对如何改变这个问题还是需要审慎而行,不过,正如李子勋在《心灵飞舞》里说的:

看不懂没有关系,知道它存在,你就会变得丰富多彩!

每个人都曾经历那个充满好奇、渴望知识的人生阶段,如果因为某些原因丢弃了这些宝贵的特质未免有点可惜,或许我所学的知识未能成就一番伟业,但却能拓宽我的思维视野、丰富我的人生体验,还可能成为别人进入某个领域的响导,就像why the lucky stiff当初把我领进Ruby的世界一样。

      下一次,我们将会看看如何处理使用Ruby进行开发的时候出现的错误。

 

P.S. 祝伟杰生日快乐~

posted @ 2009-12-14 08:26 Allen Lee 阅读(...) 评论(...) 编辑 收藏