Allen Lee's Magic

这里没有答案,顶多给你几个值得一试的猜想。

2010年1月27日 #

Ruby 101:行为驱动

Ruby 101:行为驱动

 

Written by Allen Lee

 

写下你的期望

      在上一篇文章里,我们创建了一个简易的插件系统,还为它写了一个YAML导出器,这次,我们将会尝试写个SQLite导出器,并探讨开发过程中的遇到的问题。

      首先,我们的插件系统会自动装载插件,这意味着,当应用程序启动好后,插件就应该准备就绪了,这既是我们期望的效果,也是将来测试的时候需要覆盖到的内容,其重要性犹如航标灯塔,指引着正确的开发方向,既然如此,何不把它正式地记录下来:

当应用程序启动好后,SQLite导出器就应该准备就绪了。

不难预料,类似的描述还有很多很多,如果我们把这些描述收集起来,我们将会得到一份规范文档,如果这份规范文档还可以执行的话,我们就可以随时随地验证插件的行为了。噢,别误会,我不是在开玩笑,下面,我们来看看如何实现一份可执行的规范文档。

      如果你和我一样都是使用NetBeans,那么你只需在Project窗口里右击RSpec Files文件夹,然后选择New\RSpec File菜单项:

图 1

随后,你将会看到下面这个对话框:

图 2

一般而言,每份规范文件都对应一个目标类,这点从上图也可以看出来,但是,我们要开发的SQLite导出器不是一个类,而是AddIn类的一个实例,那么,Tested Class文本框应该怎么填呢?随便填,只要规范文件的名字和路径没有问题就行了(规范文件的名字通常以spec结尾)。单击Finish按钮,NetBeans将会为你创建如下代码:

代码 1

从上面代码可以看到,NetBeans假定我们的目标类(我们随便填的Whatever类)是在whatever.rb文件里的,于是为我们加载这个文件,还为我们创建一个Whatever类的实例。当然,我们心知肚明,这些假设对于我们的情况并不适用,那么,我们应该如何修改这份规范文档呢?首先,插件是通过插件系统来访问的,为了访问插件系统,我们需要加载addins.rb这个文件;其次,我们要描述的不是一个类,而是一个实例,我们可以为describe方法提供一个字符串,用于标识我们的SQLite导出器;最后,我们不需要实例化什么,因为插件系统已经帮我们处理了,所以我们可以去掉before这块代码了。根据这些建议,我们可以把规范文件修改如下:

代码 2

接下来,我们可以实现前面提到那个描述了:

代码 3

我相信,这份代码已经很直观地阐明它要做的事了。如果我们现在执行这份规范文档,我们将会得到如下结果:

图 3

失败是正常的,毕竟我们还没有实现SQLite导出器嘛。现在,我们创建一个sqlite_exp.rb文件,并在里面写下这些代码:

代码 4

然后在addins.rb文件里注册sqlite_exp.rb文件:

代码 5

再次执行规范文档:

图 4

结果正如我们预料的那样,通过了!

      回顾上面整个过程,有没有觉得它和TDD很像?如果有,那么你的感觉是对的,但它和TDD还是有一点儿不一样的,它也有自己的名字,叫做BDD,全名是行为驱动开发(Behavior-Driven Development),而RSpec则是Ruby的BDD框架。如果你有兴趣进一步了解BDD,可以读一读behaviour-driven.org上的介绍,这篇介绍也提到了TDD和BDD的关系。

      噢,忘记说个事儿了,如果你发现规范文档无法执行,那么你很可能还没安装RSpec,你可以通过如下方式进行确认:

图 5

如果确认没有安装,可以通过gem install rspec命令进行安装(Linux平台需要在前面加上sudo)。

 

期望2:把数据写入数据库

      毫无疑问,这个期望是最重要的,试想一下,如果SQLite输出器无法保存数据,我还要它来干什么呢?事不宜迟了,赶快在规范文档里写下这个期望吧:

代码 6

现在的问题是,里面的逻辑怎么办?别担心,我们可以先界定整体方向,然后逐步深入细节。

      一般而言,每个期望都包含两个要点:执行预设操作和验证预期效果。在这里,预设操作包括查找并获取SQLite输出器、提供完整有效的测试数据以及执行输出器的主体逻辑,这些都不难实现:

代码 7

接下来是验证预期效果,执行上述操作肯定会产生一个数据库文件,我们可以先验证这个文件是否存在:

代码 8

我相信上面这句一点都不难理解,但你可能会好奇它是如何工作的。当RSpec执行规范文档时,它会在每个对象上定义shouldshould_not方法,这些方法接受Matcher对象作为参数,代码2的exist方法会返回一个使用File对象的exist?方法进行判断的Matcher对象,而我们传给exist方法的参数也会传给File对象的exist?方法。那么,如何验证数据是否写进去了呢?最直接的办法就是查一下数据库:

代码 9

我们首先创建一个Database对象,然后通过它的execute方法执行一条SQL语句,这将会返回cart表的所有数据。由于前面写入的是两本书,我们期望现在查到的也是两本书,需要说明的是,第三句最后的books是RSpec提供的语法糖,你可以把它换成itemselements或者其它你认为更加合适的单词,这样,当你把这句话的符号都去掉后,就会得到一个标准的英语句子了:cart should have 2 books,这句话也充分表达了我们的期望。最后是关闭数据库对象。当然,要让这些代码工作,你还需要加上require 'sqlite3'

      为了避免多个测试之间出现数据干扰,我们应该在完成每个测试之后删除产生的数据库文件,这项任务可以在after方法的代码块里完成:

代码 10

此外,由于每个测试都需要使用SQLite输出器,我们不妨把查找并获取SQLite输出器的工作放到before方法的代码块里:

代码 11

当然,代码3和代码7都要做相应的调整,首先,它们不需要查找并获取SQLite输出器了,其次,对本地变量sqlite_exporter的引用要改为对实例变量@sqlite_exporter的引用。嗯?实例变量?谁的实例变量?事实上,describe方法会创建一个ExampleGroup对象,而我们在before方法的代码块里创建的实例变量则隶属于这个ExampleGroup对象。还有一点需要说明的,默认情况下,before方法的代码块会在每个测试执行之前执行,如果你想让它在所有测试执行之前执行,而且就执行一次,你可以通过参数来指定:

代码 12

这种做法也适用于于after方法。

      噢,又忘记说了,在使用SQLite之前请先安装SQLite3数据库及其Ruby Gem。SQLite3数据库的安装极其简单,到官网下载sqlite-3_6_22.zipsqlitedll-3_6_22.zip,解压至任意目录(比如C:\SQLite),然后在PATH环境变量里添加这个路径就行了;SQLite3的Ruby Gem也很容易安装,只需在命令行输入gem install sqlite3-ruby就行了。

 

实现2:把数据写入数据库

      SQLite输出器的工作其实很简单,就三个事儿:创建数据库文件、创建表和把数据写入表。

      第一个事儿好办,只需下面这句就搞定了:

代码 13

嗯?这句好像哪里见过?是的,前面打开数据库文件时用的也是这句,事实上,当你执行这句时,它会试图打开指定的数据库文件,如果这个文件不存在,它就会创建一个新的。在继续之前,请允许我开个小差,试想一下,每次访问SQLite数据库时,我们要创建一个Database对象,访问完后我们要调用close方法释放相关的资源,如果Ruby有像C#的using语法就好了。天啊,难道这个想法只能是个想法吗?慢着!我记得F#就有using,但F#的using并不是语法的一部分,而是一个函数,我们可以这样使用using函数:

图 6

显然,这个做法完全可以借鉴过来:

代码 14

事实上,实现这个using方法一点都不难:

代码 15

这里的begin … ensure … end相当于C#的try … finally,由于begin … ensure … end以外没有其它代码,我们可以把using方法的实现进一步简化成:

代码 16

嗯,不错,但如果我随便拿个对象来用呢,比如这样:

代码 17

毫无疑问,ensure部分会抛出异常,因为没有close方法可以调用,对于这种情况,我期望using方法先看看有没有close方法调用,有就调用,没就拉倒,怎么做到呢?还记得respond_to?方法吗,我们可以通过它来检测close方法是否存在:

代码 18

前几天ruby-talk上也有人问Ruby是否有像C#的using语法,于是我把代码放上去,随即有人提出对象可以调用的方法和respond_to?方法了解的可能不同:

代码 19

并建议我们换用如下代码:

代码 20

我们可以这样理解ensure里面的那句:尝试调用close方法,忽略任何抛出的异常。相比之下,这种做法简洁有效,但是,它带来的风险也是不容忽视的,它可能会把close方法抛出的其它异常也吃掉,最保险的做法应该是只处理NoMethodError异常,但由于上面这种简化写法不能指定待处理的异常类型,于是我们只好换用完整的异常处理语法了:

代码 21

那么,如果目标资源是通过其它方法来释放的呢?这样的话,我们可以考虑让调用方配置用于释放资源的方法:

代码 22

这个不难做到,只需给using增加一个参数就行了:

代码 23

默认情况下,我们使用close方法。此外,在使用using方法时,也不是一定要通过代码块的参数把资源传递给代码块的,事实上,我们完全可以写成这样:

代码 24

嗯,看起来和C#的几乎一样了,但有一点需要注意的,就是一定要把{using放在同一行,否则不符合代码块的语法,毕竟这不是真货啊,哈哈~

      回到正题,SQLite输出器要做的第二件事儿是创建一个cart表,这其实也是一句话的事儿:

代码 25

嗯?奇怪了,类型呢?不如我们试一下存点东西,看看存进去的是什么:

图 7

噢!我的类型啊!毫无疑问,SQLite理解我的期望,并按我期望的方式工作着。那么,如果我插入不同类型的数据呢,比如这样:

图 8

33.99是浮点数,并被正确地解析为REAL了,而35是整数,它会被"正确地"解析为整数还是被转换为REAL呢?我们查查看:

图 9

噢!数据的类型得到正确的解析了,但是,不可思议的事情也发生了,同一列存在不同类型的数据!由此可见,SQLite对列的处理更像动态语言对变量的处理,其类型取决于实际数据,那么,如果我希望把35处理成REAL呢?我们可以向SQLite建议,由于SQLite的ALTER TABLE只支持重命名和加字段,要添加类型建议只好重新建一个表了:

图 10

很好!如果我插入的既不是REAL也不是INTEGER,而是TEXT呢?我们试试看:

图 11

毫无疑问,我们得到了期望的结果,但是,如果SQLite发现你给的字符串无法转换,比如ABC,它会原样保留,而不是禁止你插入,这就是为什么我刚才说"向SQLite建议"而不是"命令SQLite"。现在,我想建议SQLite把titleauthors处理成TEXT,排序时不区分大小写,并且组合起来是唯一的(即书名和作者都一样的书看作同一本书),此外,三个字段都不允许为NULL,那么,我们可以把代码24改成这样:

代码 26

你可能会奇怪为什么没有主键,事实上是有的,无论你是否创建你自己的主键,SQLite都会为你创建一个,你可以通过ROWID_ROWID_或者OID来访问(注意,.width命令用于调整各列的显式宽度):

图 12

如果你创建了一个"类型"正好为INTEGER PRIMARY KEY的字段,SQLite将会把它看作ROWID的"分身";如果你创建的主键是其它类型甚至是组合主键,那么PRIMARY KEY的效果只是相当于UNIQUE,真正参与到B-Tree实现的是ROWID

      最后一个事儿是往表里插数据,最直接的做法是迭代shopping_cart,使用里面的数据拼接SQL语句,然后交给execute方法执行:

代码 27

此外,Database对象还提供了一个prepare方法,可以预先处理带有占位符的SQL语句,并返回一个Statement对象,你可以通过它的bind_param方法或者bind_params方法把数据绑定到占位符上(前者用于绑定单个数据,后者用于绑定多个数据),然后调用它的execute方法执行完整的SQL语句,你也可以直接调用它的execute方法,并提供待绑定的参数:

代码 28

那么,这两种做法的效率分别如何呢?我们可以用Ruby自带的Benchmark模块做个测试(需要添加require 'benchmark'):

代码 29

为了避免数据库文件的干扰,我分别为它们配置了独立的数据库文件(db1和db2),以下是它们分别插入1000条记录的测试结果:

图 13

注意,这里的测试结果仅起示意作用,你不能把它看作判断基准,你应该使用这里介绍的办法在真实环境或仿真环境里实地测试一下才能下结论。值得提醒的是,参数是按顺序绑定的,你也可以使用命名绑定:

代码 30

命名绑定可以打乱顺序,只要Hash对象的键能和SQL语句的命名占位符对应起来就行了。

      好了,不知不觉紧张时刻又到了!干什么?当然是执行规范文档啦:

图 14

通过啦!哎,每当看到此番景象,我的心就会感到很踏实,不知道你会不会也是这样呢?

 

其它期望

      我们知道,规范文档本身也是代码,里面的期望也是一个一个地完成的,对于那些还没完成的期望,我们希望它们能出现在规范文档而不是我们的脑子里,但要和已经完成的期望区分开来,怎么才能做到呢?非常简单,比如说我现在对SQLite输出器有两个期望:

  • 如果shopping_cartnil,SQLite输出器应该抛异常。
  • 如果shopping_cart里面有重复的(即书名和作者都是一样的,但价钱可能不一样),那么后面的应该自动覆盖前面的。

我只需在规范文档里这样做就行了:

代码 31

如果我们此时执行规范文档,将会看到如下结果:

图 15

这意味着我们的规范文档还没完成,但不会妨碍已经完成的正常工作。

      第三个期望很容易实现:

代码 32

我们把待执行的代码放在一个lambda里,然后断言执行这个lambda会抛异常,注意,我们既没直接执行目标代码,也没执行包含目标代码的lambda,这一切都是交给RSpec来完成的。现在转到SQLite输出器的代码,在logic do下面加上这句:

代码 33

好了,我们执行一下规范文档看看结果如何:

图 16

哎哟,奇怪了,我明明还没提供数据啊,SQLite输出器应该抛异常才对的呀,为什么会这样呢?一开始shopping_cartnil的,现在我还没给它赋值就有数据了,显然是前面某个测试的遗留影响,为了排除这些影响,我们可以在after方法的代码块里把它清空:

代码 34

现在,再次执行规范文档:

图 17

成功了!

      第四个期望也很容易实现,它几乎和第二个期望一样,都包含了提供测试数据、执行输出器、验证数据库文件和验证插入数据的数目等几个步骤,唯一不同的是这里提供的数据包含重复:

代码 35

在第二个期望的实现里,我们通过SELECT * FROM cart把所有数据取出,然后验证插入数据的数目,显然,我们不必这样做,我们可以通过SELECT COUNT(*) FROM cart直接获取插入数据的数目,然后验证这个数目。Database对象提供了一个get_first_value方法,用于获取查询结果的第一行的第一个值,正好满足我们的需求:

代码 36

值得一提的是should eql "2"这部分代码,eql是一个方法,如果你有FP背景,你可能会想,Ruby啥时候支持把方法当做值传递啦,事实上,不是这么一回事儿的,对于任意的x y z,Ruby会把它理解成zy方法的参数,y方法的返回值才是x方法的参数,即x(y(z)),换句话说,should eql "2"其实是should(eql("2"))

      现在的问题是,这部分功能如何实现?两个显而易见的做法是,要么先SELECT一下,没就INSERT,有就UPDATE,要么先SELECT一下,有就DELETE,然后INSERT,然而,无论哪种做法都不见得有多方便,怎么办?别担心,SQLite提供的冲突处理(conflict resolution)正好派上用场,INSERT的冲突处理语法是:

INSERT OR resolution INTO table (…) VALUES (…);

其中,resolution可以是ABORTFAILIGNOREREPLACEROLLBACK中的任何一个,不难猜测,REPLACE正是我们想要的,于是,我们只需把插入数据的SQL语句改成INSERT OR REPLACE INTO cart VALUES (?, ?, ?)就行了。噢,不知不觉紧张时刻又到了!最后的结果是:

图 18

嗯,非常好!此外,值得一提的是,SQLite还特意为INSERT OR REPLACE提供了一个"外号"——REPLACE,使用这个"外号",上面那句SQL语句可以简化成REPLACE INTO cart VALUES (?, ?, ?)

      最后,我在想,有没有办法把这个结果输出成一份报告呢,比如说HTML格式的?可以的,这个时候就轮到RSpec的命令行工具出场了,打开cmd,切换到规范文档所在目录,然后执行下面这条命令:

spec sqlite_exp_spec.rb --format html:sqlite_exp_spec.html

完成之后你将会得到这样一份报告:

图 19

如果执行结果包含未通过和未完成的呢?我们不妨修改一下规范文档,让第三个失败,去掉第四个的实现,然后再执行上面那条命令,我们将会得到这样一份报告:

图 20

哎哟,不错哦,这份报告除了告诉你失败的原因,还把导致失败的那行代码给挑出来了。

      还有啥事儿没做的吗,噢,差点忘记了,还有一个期望呢:

  • it "应该能让你学到点儿什么 :)"

 

新的旅程

      你喜欢向别人推荐自己喜欢的技术吗?第一次接触BDD的时候,第一个冲进我脑子的问题是:它和TDD有什么不同,它要取代TDD吗?你还记得,有过几次,你真诚地向别人推荐某个技术,最后却怎么也想不通局势为何会发展成你在强迫别人接受你的好意,而别人则厌恶地反驳你的建议。更糟糕的是,你甚至会把拒绝你的好意和拒绝你本人等同起来,而别人则把你向他推荐新技术的做法理解成你在暗示他现有的技术应该被淘汰,误会也因此产生了。你的出发点其实很简单,而且是善意的,你希望把自己认为最好的推荐给别人,并期望得到别人的认同,但是很不幸,意外常常悄然来袭,随后而来的可能是你的心灰意冷以及你和别人之间的紧张关系。

      前一阵子,我和我表弟经常讨论电影,我们相互推荐各自喜欢的电影,与此同时,我们也发现双方的喜好竟然如此不同,我看过的很多电影他都没看过,而他看过的很多电影我也没看过,每到此时,气氛都会突然变得兴奋和激动,我们会和对方说:哇,这么多好看的电影你都没看,真希望自己也像你那样都没看过啊。一方面,我们为对方没有看过这些好电影感到惋惜,另一方面,我们也为对方能及早发现这些好电影感到高兴。那段时间我们经常一起看电影,有时找大家都喜欢看的,有时由他决定,有时由我决定,有时会找大家都未曾想过会看的,我们偶尔会发现,那些曾被认为不太可能会看的电影原来也别有一番风味,这不但开阔了我们的视野,也增进了我们的体验。

      每个人的经历都不一样,有些经历甚至是相互矛盾的,你会把这种矛盾理解成非此即彼还是在某种程度上相互补充,很大程度上取决于你能否带着理解和包容来看待它。如果你把学习的过程看作从别人的经历或者你的新经历中吸取知识,那么理解和包容就是更好地学习的前提了。

      《Ruby 101》从最初发布到现在已经4个月了,本文将会是这个系列的最后一篇,为了方便查阅,特意把前面文章的链接整理出来:

虽然《Ruby 101》要告一段落了,但Ruby的学习是不会停止的,往后我可能会关注的东西包括Silverlight + IronRubyGoogle SketchUp + Ruby以及很久以前我就想玩的Ruby on Rails,这些内容都可能会出现在将来的文章里(也可能不)。

posted @ 2010-01-27 08:25 Allen Lee 阅读(939) | 评论 (1)编辑

2009年12月14日 #

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 阅读(1314) | 评论 (4)编辑

2009年11月30日 #

Ruby 101:方法对象

Ruby 101:方法对象

 

Written by Allen Lee

 

从方法调用说起

      在上一篇文章里,我们看到调用对象的方法实质上是向对象发送消息,下面,我们再来看一个有趣的应用。在Ruby里,字典可以通过 {key => value} 来创建,如果你使用的版本是1.9或以上,当key的类型是Symbol时,创建字典的语法可以进一步简化为 {key: value} (注意,冒号要紧贴在key后面),这使得我们可以创建这样的对象:

代码 1

有没有觉得这个写法很面熟?有些同学可能已经看出来了,这个写法很像JSON,事实上,这个代码正是仿照Wikipedia上的JSON示例代码写出来的,然而,由于它本身是一个字典对象,在访问里面的内容时需要使用字典的语法:

代码 2

现在,请思考一下,有没有办法使它接受这样的做法呢:

代码 3

有些同学可能已经反应过来了——打开Hash类,重写method_missing方法:

代码 4

当我们调用first_namephone_numbers等不存在的方法时,就会触发method_missing方法,它首先检查字典是否包含这个方法名,若是,返回对应的值,否则,转交super处理。下面,我们执行一下代码:

图 1

很好,基本上达到我们的预期了,但是,这种做法至少存在两个弊病,第一,它强制所有字典使用这种重定向逻辑,第二,如果字典的现有方法和它的键出现重名,重定向逻辑将被忽略,因为现有方法将被优先调用,不会触发method_missing方法,那么,怎么解决这两个问题?嗯……很抱歉,这些问题不是今天的主角,还是待到它们的主戏上演之时再行分解吧……

      现在,请思考一个问题,当我们调用一个方法时,我们只需"直呼其名",像代码3的first_namecity等方法那样(当然,加上"()"也是可以的,即person1.first_name()),这意味着,当我们引用一个方法的名字时,我们实际上在引用它的返回值,那么,如果我想把一个方法本身而不是它的返回值作为参数传给另一个方法呢?考虑这样一个情景,我有一个购物车,内有图书若干:

代码 5

图书通过calc_preferential_price方法计算优惠价格:

代码 6

现在,我想创建一个check_out方法,用来计算货款:

代码 7

那么,我该如何把calc_preferential_price方法传给check_out方法?

      直接把方法的名字传给它肯定不行,因为"直呼其名"意味着调用它,得到的是它的返回值而不是它本身,再说,calc_preferential_price方法是有参数的,仅仅"直呼其名"会引发ArgumentError异常,那么,怎样才能得到calc_preferential_price方法本身呢?在回答这个问题之前,我们得先搞清楚,"方法本身"是什么,它是以什么形式存在的?有些同学可能猜出来了,是对象。在Ruby里,所有对象都有一个method方法(这个说法其实不够准确,但就目前而言,你大可放心这样理解),你可以通过它获取对象的实例方法的对象:

图 2

从上图可以看到,m1实质上是Method类的实例,你可以通过它的call方法调用method1方法:

图 3

然而,calc_preferential_price方法并不在任何类里啊,我们应该通过哪个对象的method方法来获取它的对象?在上一篇文章里,我曾经说过,在Ruby里,任意一个时刻都有一个默认对象,那么,此时的默认对象又是什么呢?我们可以通过self来获知:

图 4

从上图可以看到,此时的默认对象是main对象,它是Object类的实例,事实上,我们通常把这些"游离"方法称作顶层方法(top-level method),它们是以Object类的私有实例方法的形式存在的,换句话说,你可以在任何对象内部调用它们(这个说法其实不够准确,但就目前而言,你大可放心这样理解):

图 5

有了这些准备知识,我们可以把代码7补充完整了:

代码 8

值得提醒的是,由于默认对象正是calc_preferential_price方法的所属对象,于是我们可以省掉method方法前面的"消息接收者",当然,你也可以自行加上。下面,我们看看运行结果:

图 6

      对于实例方法,我们可以通过method方法来获取它的对象,那么,类方法的对象又该如何获取呢?比如说,下面的Class1类的method3方法:

图 7

我们知道,Class1类是一个对象,所以它也有method方法,而method3是它的单例方法,所以我们可以通过它的method方法获取method3方法的对象:

图 8

现在,请思考一下,如果method3是一个顶层方法呢?比如下面的top_lv_method2

图 9

它和没有self.前缀的顶层方法有没有什么区别?它是Object类的私有实例方法还是私有类方法?在回答这些问题之前,我们得先搞清楚self是什么,从图4可以看到,selfmain对象,所以它是main对象的单例方法,它和没有self.前缀的顶层方法之间的区别是,它是main对象的公有方法,并且只能通过main对象调用。说到这里,获取top_lv_method2方法的对象对你来说应该不难了:

图 10

值得提醒的是,method方法无法获取代码1的first_namecity等方法的对象,因为它们并非真实方法。

      说了这么多,也只不过想传递一段逻辑而已,而上面的做法似乎比较繁冗,有没有更好的选择?当然有,这也正是下一节要讲的内容。

 

传递逻辑

      说到传递逻辑,有些同学马上想到.NET的委托,在Ruby里,类似的机制是通过Proc对象展开的,比如说,我们想把calc_preferential_price方法里面的逻辑传给check_out方法,那么我们可以这样做:

代码 9

我们通过Proc类的new方法创建一个Proc对象,同时,传递一块代码,这块代码用{}包围,里面包含两个部分,|book|是参数部分,多个参数可以通过逗号分隔,而book.price * 0.67则是主体逻辑,也是这块代码的执行结果,我们通常把这块代码称作代码块(code block)。那么,Proc对象包含的代码块如何才能执行呢,参数是如何传递的呢,执行结果又是如何获取的呢?答案非常简单,运行一下代码9就知道了。有些同学可能感到疑惑,难道check_out方法不用修改?是的,而且运行结果和图6的一样,这意味着Proc对象的使用方式和Method对象的一样,都是通过call方法来执行与之关联的代码逻辑,我们传给call方法的参数将会传给Proc对象包含的代码块,而代码块的执行结果将会通过call方法返回给我们。如果你使用的版本是1.9或以上,那么你可以proc方法来创建Proc对象,效果和通过Proc类的new方法来创建是一样的:

代码 10

当然,我相信没有人会认为把calc_preferential_price方法的主体代码复制到Proc对象的代码块里是个好主意,这样会增加维护成本,即使代码量很少,于是,对于现有的方法,像calc_preferential_price方法,我们可以在代码块里直接调用它:

代码 11

乍看之下,这种写法比代码8的更加繁冗,然而,它却能带来一些特别的好处:

图 11

我们首先定义一个repeat方法,它的工作非常简单,把text文本输出n次。接着,我们创建一个text变量,并把一个字符串对象赋给它。然后,我们创建一个Proc对象,调用repeat方法输出text文本n次。最后,我们通过call方法执行Proc对象包含的代码块,向控制台输出3次"Hi!"。仔细观察创建Proc对象的那行代码,我们在代码块里引用外面的text变量,并用它"固定"repeat方法的第一个参数,有些同学可能已经看出来了,这正是闭包(closure)的应用。

      不过,这种显式创建Proc对象并传给方法的做法比较少见,更常见的做法是把代码块附加到方法调用后面:

代码 12

上面代码创建/打开books.txt文件,并把shopping_cart里的书名写进去,完成之后文件流会自动关闭。如果你依样画葫芦,把代码11改成这样:

代码 13

当你运行代码时,你将被告知少了一个参数,显然,Ruby不把代码块看作参数列表的一部分,如果你希望Ruby把check_out方法的calc参数和你提供的代码块关联起来,那么你需要在calc参数前面加上&

代码 14

再次运行代码,你就会得到图6的输出了。那么,在参数前面加上&之后究竟发生什么事呢?事情是这样的,当你运行代码13时,Ruby发现check_out方法的calc参数前面有个&,而你只提供了一个参数和一个代码块,于是,Ruby假定这个代码块是和calc参数对应的,然后用你提供的代码块创建一个Proc对象,并把它绑定到calc参数上,你可以把这个过程看作从代码块到Proc对象的隐式转换。那么,这种带有&前缀的参数可不可以有多个呢?我们可以试一下:

图 12

噢,出错了!Ruby不允许定义这样的方法,这意味着一个方法只能有一个带有&前缀的参数,此外,Ruby期望跟在block1参数后面的是)而不是,,这意味着带有&前缀的参数应该放在参数列表的末尾,换句话说,你不能把其它类型的参数放在带有&前缀的参数后面:

图 13

      既然Ruby不把代码块看作参数列表的一部分,那么Ruby是否支持某种不通过参数来使用代码块的语法呢?当然有啦!Ruby提供了yield关键字:

代码 15

正如你所看到的,yield关键字的使用很像方法调用,你可以把参数传给它,你也可以使用它的返回值,实际上,参数最终是传给代码块的,而返回值也是来自代码块的。此外,我们看到check_out方法的签名也变了,calc参数已经不再需要了。如果我们只需在方法里面即时执行代码块,那么使用yield关键字就足够了,同时我们也可以得到更简洁的代码;如果我们需要延迟代码块的执行,比如说在将来某个条件满足的时刻才执行,那么我们还是需要通过代码14的写法来捕获代码块,把它保存到某个实例变量,以备后用。如果你现在运行代码11,你将被告知多了一个参数,如果你把第二个参数去掉:

代码 16

你将被告知缺少代码块,显然,你需要告诉Ruby把你提供的Proc对象当做代码块来处理,怎么才能做到这样呢?答案是在Proc对象前面加上&

代码 17

再次运行代码,你就会得到图6的输出了。现在,请思考一下,如果我们希望check_out方法在有代码块的时候计算优惠价,没有的时候按原价计算,那么我们应该如何做呢?实现这个功能的关键在于如何检测代码块是否存在,这可以通过block_given?方法做到,于是,我们可以把check_out方法修改如下:

代码 18

下面我们来看看运行结果吧:

图 14

      有些同学可能已经注意到了,有些代码块用{}来包围,有些则用do/end来包围,那么它们是否一样的呢,如果不是,又有哪些区别呢?我们试一下就知道了:

图 15

看来没什么区别嘛,难道只是代码风格不同而已?我们知道,Ruby允许我们在调用方法的时候不用括号来包围参数列表,如果我们把包围puts方法参数的括号去掉又会怎样呢?试一下就知道了:

图 16

哈哈,这次看出区别了!把这个运行结果和图14的对比一下,不难发现,第二次计算结果和没有提供代码块的一样,这意味着代码块根本没有传给check_out方法! 这个结果正是{}do/end两种写法的不同所致,用{}包围的代码块会优先和check_out方法的调用结合,整个东西的执行结果会成为puts方法的参数,而用do/end包围的代码块则和puts check_out(shopping_cart)的执行结果结合,换句话说,Ruby把图16的代码解释成:

图 17

显然,用do/end包围的代码块传给puts方法了,由于puts方法不需要它,于是它被忽略了,就像不曾存在一样。当然,如果你在调用方法是总是用括号包围参数列表,那么你就不会受到这个问题的困扰了。

      前面我们提到,如果你使用的版本是1.9或以上,那么你可以proc方法来创建Proc对象,然而,proc方法在1.9版本之前就有了,只是创建出来的是一种特殊的Proc对象,通常称为lambda。除了proc方法,Ruby也提供了lambda方法,在1.9版本之前,proc方法和lambda方法创建出来的都是lambda,到了1.9版,proc方法创建出来的是普通的Proc对象,而lambda方法创建出来的则是lambda。那么,lambda和普通的Proc对象有什么区别呢?主要有3个,第一个区别是我们必定会遇到的,普通Proc对象和代码块之间可以隐式转换,但lambda一定要显式创建,否则你得到的就是普通Proc对象:

图 18

第二个区别是我认为最重要的,假设我们有这样两个方法:

图 19

除了第一个方法里面使用lambda,第二个里面使用普通Proc对象之外,它们没有任何区别,那么,这两个方法的执行结果会否一样呢?试一下就知道了:

图 20

看到区别了吗?meth3方法的"after calling…"没有输出!这意味着meth3方法在执行puts "after calling…"这句之前就返回了,而导致meth3方法非预期返回的正是普通Proc对象里面包含的return关键字,换句话说,普通Proc对象里面包含的return关键字会改变方法的执行路径。说到这里,你可能会担心,如果别人传过来的代码块包含了return关键字,是否也会导致我们的方法出现非预期返回?我们不妨试试看:

图 21

噢,抛异常了!这下你多少可以放心了吧。最后一个区别是关于参数的,比如times方法的使用:

图 22

我们既可以传给它一个不带参数的代码块,也可以传给它一个带参数的,即使参数的数目多过它提供的也不会出问题,如果换用lambda,情况就不一样了:

图 23

从这里可以看到,lambda对待参数是很严格的,数目必须刚好,多一个或者少一个都不行,从这个角度来看,lambda更接近方法。

      Ruby 1.9引入了一种新的lambda写法:

图 24

上面这两种写法是等效的,此外,新写法的()是可以省略的:

图 25

为什么要引入新的写法?据说是为了使lambda支持参数默认值:

图 26

好,那么我很想知道,新的语法是否也支持可变参数以及代码块?我们来试试看:

图 27

沃,真的支持耶!那么,yield关键字也支持吗?

图 28

这次不行了。现在,我的脑子里蹦出一个念头,用旧的语法改写图27的lambda会怎样呢?我们来试试看:

图 29

额,有点无语哦,不是说这种写法对于现有解析器来说是不可能的吗,难道换了解析器?不管了,我们再来试试yield关键字:

图 30

呵,情况一样。现在,我还想知道,普通的Proc对象对参数的支持是否像lambda一样?我们来试试看:

图 31

一样的!说到这里,你可能会问,既然新旧lambda写法毫无二致,为何还要引入新的写法?嗯,我也不知道,不过这让我想起一句话:

In Ruby, there's always more than one way to solve a given problem.

就像lambda的调用方式也有3种一样(这3种方式也同样适用于普通Proc对象和Method对象):

图 32

现在,这把"大刀"也砍向lambda了。至于这些风格迥异的写法孰优孰劣就见仁见智了,而这种允许多样性存在的想法是好是坏则更是见仁见智了。

      既然谈到lambda了,接下来我想谈谈两个和它密切相关的主题:高阶方法和分部应用(partial application)。Ruby不是函数式编程语言,方法本身不是值,所以我们无法直接传递/返回方法,但借助lambda,创建高阶方法将不再是难事:

代码 19

那么,分部应用呢?比如下面这个代码:

代码 20

这样真的可以吗?仔细想想就知道了,lambda对待参数非常严格,不提供足够参数是不行的,那么,普通Proc对象对待参数比较宽松,是否能成呢?我们不妨试一下:

图 33

我们确实避开参数问题了,却被主体代码挡住了,当我们调用Proc对象并提供一个参数时,另一个参数的值会默认为nil,接着,执行1 + nil就引发异常了。如果我们确实想使用分部应用,一个办法是使用"原装"lambda写法,根据Wikipedia,"在 lambda 演算中,每个表达式都代表一个只有单独参数的函数,这个函数的参数本身也是一个只有单一参数的函数,同时,函数的值是又一个只有单一参数的函数",于是,我们可以把代码20改成这样:

代码 21

这下就没问题了。值得提醒的是,把lam4从代码20变成代码21的过程叫做柯里化(currying),函数式编程语言的函数默认支持柯里化,然而,Ruby从1.9版开始也对此提供了一定程度的支持:

代码 22

柯里化是在Proc对象层面提供支持的,这意味着普通Proc对象和lambda都适用。

 

操作集合

      现在,让我们把注意力集中在代码19,请思考一下,要让for循环正常运转,shopping_cart对象是否需要满足一些条件呢?我们不妨随便找个对象来试一下:

图 34

显然不行,然而,错误信息已经向我们透露了for循环需要一个each方法,那么,如何定义这个each方法呢?想想看,each方法应该是如何使用的呢?按照字面意思理解,它会把集合里面的东西一个一个地传给你,由你来决定如何处理这些东西,换句话说,你应该传给它一段处理逻辑,说到这里,有些同学可能已经反应过来了,我们可以传给它一个代码块,比如下面这段代码会把购物车里的所有书名输出到控制台:

代码 23

使用上一节学到的知识,我们很容易就把这样的方法实现出来了:

图 35

我们把each方法定义为单例方法,仅对obj对象有效,它会依次把1、2、3传给你。现在,我们可以再试一下for循环了:

图 36

由于for循环最终也是调用each方法的,我们何不直接使用each方法?

图 37

使用each方法和使用for循环在效果上是一样的,然而,each方法可以有返回值,那么,for循环呢?这个问题听起来有点怪异,但是,在Ruby里,for循环本身也是一个表达式,既然是一个表达式,它也应该有一个运算结果,事实上,图36和图37已经分别告诉你for循环的运算结果和each方法的返回值了,都是nil,那么,如果我改变each方法的返回值会怎样呢?我们不妨试试看:

图 38

嗯,上图已经充分说明for循环的运算结果是来自each方法的返回值了,事实上,像数组和字典等集合的each方法都是返回集合本身的,这意味着for循环的运算结果也是集合本身:

图 39

值得提醒的是,当我们把for循环写到一行时,中间要用do(或者;)分隔。另外,字典的each方法传给我们的不是两个参数,而是一个长度为2的数组,第一个元素是键,第二个是值,我们只需把each方法的调用稍稍修改一下就看出来了:

图 40

在Ruby里,数组可以进行类似模式匹配的解构:

图 41

当变量的个数少于或者等于数组的长度时,每个变量都会得到对应位置的数组元素,当变量的个数大于数组的长度时,超出的那些变量将会是nil。事实上,右值不必是数组,把包围数组元素的[]去掉也是可以的:

图 42

在Ruby里,这种赋值方式有个正式的名字,叫做并行赋值(parallel assignment),当左值只有一个,而右值却有多个时,右值将被转换成数组并赋给左值。

      当你在你的集合类里实现each方法并包含Enumerable模块时,你将会得到一大堆很有用的方法,比如说,selectsort_bymapreducetake_while等等,这些方法都是建立在each方法之上的,你不必自己实现它们,只要你实现了each方法,它们就可以使用了,这极大地简化了集合类的创建过程,如果现有算法无法令你感到满意,你也可以重写它们。一般而言,除非你打算创建自己的集合类,否则你更感兴趣的应该是如何使用现有的集合类以及Enumerable模块提供的方法。接下来,我们将会通过一个简单的示例学习如何使用Enumerable模块提供的几个常用的方法。

      我们的任务非常简单,从购物车里选出6本(或以内)共计金额不超过150元(以优惠价为准)且标题包含"Ruby"字眼的图书,输出它们的书名,计算总价(打折以后)、节省金额以及节省百分比。我相信这个任务对于你来说一点都不难,你甚至可以轻易想出多种不同的解决方案,下面,我们来看看其中一个可能的做法吧。

      首先,我们通过select方法从购物车里选出标题包含"Ruby"字眼的图书:

代码 24

它会把购物车里的图书一本一本地传给我们提供的代码块,并根据代码块的返回值来决定是否选择某本书,此外,你也可以添加其它条件,比如说,你还希望选出的图书都是4星以上,前提是你拥有可以操作的评级数据。

      接着,我们通过map方法从上面的结果里提取出每本书的标题和原价,并计算优惠价:

代码 25

上面的代码看起来非常直观,就像数据从一组匿名对象按照特定的规则映射到另一组匿名对象似的,此外,我们还要感谢代码4,由于它的存在,字典能以类似匿名对象的方式使用,极大地提高了代码的可读性。

      然后,我们通过sort_by按照优惠价对上面的结果进行排序(默认是从小到大):

代码 26

它会把集合里的对象一个一个地传给我们提供的代码块,并根据代码块的返回值来对集合进行排序。此外,我们也可以通过sort方法进行排序,但代码块的写法会有所不同:

代码 27

      接下来,我们要从上面的结果里选出6本(或以内)共计金额不超过150元的图书,对于前一项任务,我们可以通过take方法来处理:

代码 28

至于后一项任务,我们可以通过take_while方法来处理:

代码 29

它和take方法的区别在于,take方法取多少是由一个整数来决定的,而它取多少则由我们提供的代码块是否返回true来决定的,当代码块返回false时,它就会停下来并返回已经取出的对象。

      最后,我们要把所有数据整合起来,能够胜任这项任务的非reduce方法莫属了:

代码 30

我们期望最后的结果包含一组书名、折后总价以及节省金额,这个结果可以看作一个包含3个元素的数组,第一个元素是一个数组,后两个元素都是整数,于是,我们把[[], 0, 0]作为初始值传给reduce方法。代码块的第一个参数是累计变量,第二个参数则是集合里的对象,而代码块的任务非常简单,把书名加到累计变量的第一个元素,把图书的折后单价加到累计变量的第二个元素,把图书的原价和折后单价之间的差价加到累计变量的第三个元素,然后返回累计变量。

      现在,我们可以提取并输出结果了:

图 43

我们甚至可以把所有代码整合起来,放在一个方法里:

代码 31

值得注意的是process方法的第二个参数,我们使用了字典,并为它设置了默认值,这使得我们在调用process方法时既可以使用默认行为也可以使用定制行为,此外,当字典是最后一个参数时,Ruby允许我们把{}去掉,这样,字典参数用起来就像一组命名参数一样,而且这些命名参数还可以打乱顺序,非常直观和方便。

      到目前为止,我们操作的集合都是包含了实际元素的,那么,对于那些无法包含实际元素的集合又该如何处理呢?举个例子,自然数集合,这是一个概念集合,你无法在一个具体集合里包含全体自然数,因为自然数有无穷多个,就算你愿意尝试,你的计算机也不会首肯的,不过,我们却对自然数的产生机制了如指掌,随便给你一个自然数,你都能够轻而易举地说出下一个自然数,换句话说,我们无法穷举所有自然数,但我们可以给出产生自然数的逻辑,那么,我们是否能够创建这样一个集合呢,如果可以,又该如何创建呢?

      这个时侯就轮到Enumerator类出场了,它允许我们通过一段产生逻辑来描述一个集合,下面,我们来看看如何使用Enumerator类创建自然数集合:

代码 32

如你所见,整个过程非常简单,我们通过Enumerator类的new方法创建一个Enumerator对象,并通过代码块把产生自然数的逻辑传给它,这个代码块有一个参数,它是用来收集这段逻辑产生的自然数的,这段逻辑也没有难度,我们设置了一个初始值,然后在一个无限循环里把后续自然数输出到收集器。

      那么,我们又该如何使用这个自然数集合呢?最直接的做法是,你想要多少个自然数,你就调用多少次next方法,如果你想重新来过,你可以通过rewind方法重设一下:

代码 33

由于Enumerator类实现了each方法并包含了Enumerable模块,我们也可以通过for循环来使用这个自然书集合:

代码 34

当然,更简单的做法是通过take方法:

代码 35

上面的代码分别获取头6个自然数、头6个自然数里的偶数以及头6个自然数里的偶数的平方,到目前为止,一切正常,但如果你把select方法的调用和take方法的调用换一下位置,你将会发现你的Ruby程序卡在那里了,为什么?因为这些方法返回的不是Enumerator对象而是数组,这意味着要对整个自然数集合进行求值,噢,买瓜!毫无疑问,这个问题将会极大地限制Enumerator类的应用,怎么办?

      最直接的解决方案是打开Enumerator类并重写这些方法,使它们返回Enumerator对象而不是数组:

代码 36

现在,我们可以求头6个正偶数的平方的和了:

代码 37

那么,这几个方法原本的实现呢?被我们覆盖了,如果你希望保留原本的实现,可以在重写它们之前分别给它们取个"外号":

代码 38

这样,当你想使用原来的实现时,这些"外号"就派上用场了。

      如果你不想修改Enumerator类,你也可以选用第三方的库,这里给大家介绍一个开源的惰性集合——LazyList。首先,在命令行输入gem install lazylist安装LazyList,接着,在代码里输入require 'lazylist'引用它,现在,我们来看看如何使用它实现前面的需求:

代码 39

毫无疑问,这对于LazyList来说只不过是小菜一碟,那么,如果我想创建下面这个集合呢?

图 44

没有问题:

代码 40

如果我想创建一个集合,里面每个元素都是自然数集合和seq1集合对应位置的元素之和呢?也没问题:

代码 41

这里的介绍只能算作一个引子,LazyList的更多潜能还有待你去发现,此外,LazyList的实现还使用了一些高级技术,比如特制的Proc对象和延迟机制等,如果你有意深潜Ruby,那么LazyList的代码绝对是一个不错的学习资源。

 

新的旅程

      Ruby是什么?它能做什么?它又有哪些优势?这些问题让我不禁想起why the lucky stiff《Beginning Ruby: From Novice to Professional, Second Edition》绘制的前言(节选):

事实上,这些问题几乎出现在每次我们遇到陌生语言的时候,随着我们对一门语言有了更多的了解,我们对这些问题的回答也会有所变化,我们最终可能会发现,这些最基本的问题往往是最难回答的问题,正如斯蒂芬·吉利根在《艾瑞克森催眠治疗理论》里探讨催眠本质时引用的艾瑞克森之言所说的:

"不论我说它是什么……都将会扰乱我对其诸多可能性的认识和利用。我们必须认识到,不论一种描述是多么精确或完整,都不能取代实际体验,也不适用于所有受试者。"

不论我在这里讲得多么详细,我所讲的都是Ruby的一些片段,没有你的实际体验,这些片段就无法连成一体,而我对上面这些问题的回答也就什么都不是了。

      下一次,我想把注意力集中在Ruby的动态性上,希望到时能为你带来更多的惊喜!

 

P.S. why the lucky stiff已经"失踪"超过3个月了,希望他快点回来吧~

posted @ 2009-11-30 08:19 Allen Lee 阅读(991) | 评论 (1)编辑

2009年11月3日 #

Ruby 101:对象和方法

Ruby 101:对象和方法

 

Written by Allen Lee

 

从静态方法说起

      在上一篇文章末尾,我们提到了受保护的静态方法……受保护的静态方法??Ruby的protected不是用来向相同类型的不同实例开放受限方法的访问的吗(忘记protected的用法了?不要紧,回去上一篇文章复习一下吧。),如果把它用于静态方法,那么我该向参数传入什么?

      在回答这些问题之前,我们先来看看最简单的不带任何参数的静态方法,假设我有一个空的Class1类,如果我试图调用它的method1静态方法,那么我将会被告知没有这个方法:

图 1

在Ruby里,所有类最终都会继承自Object类(这个说法其实不够准确,但就目前而言,你大可放心这样理解),如果我试图调用它的method1静态方法,那么显然,我也将会被告知没有这个方法:

图 2

还记得Ruby允许我们重新打开并修改一个类吗,如果忘记了,不要紧,回去第一篇文章复习一下吧。下面,我们将会通过这种方式向Class类添加method1方法:

图 3

接着,我们再试一次Class1类及其基类的method1方法:

图 4

噢,买瓜!这到底是怎么一回事?

      先别急,解释留到后面,现在让我们把注意力集中到我们的目标上——受保护的静态方法,把上面的发现和上一篇文章的访问控制知识结合起来,就得到创建受保护的静态方法的办法了:

图 5

下面,我们来试一下这个方法:

图 6

显然,我们成功了,接下来,我们使用上面的发现创建一个odd_equals方法,根据length_of_name方法的返回值判断两个类型是否相等:

图 7

最后,我们来试一下这个奇怪的判等方法:

图 8

哇,实在是太不可思议了!我在Class类里创建的实例方法,到了Class1类和Object类就变成静态方法了,难不成……?

      我想你已经猜到了,Class1类和Object类是Class类的实例:

图 9

Class1类和Object类的静态方法则是Class类的实例方法,事实上,Ruby没有静态方法这种说法,这种类似静态方法的东西其实叫做类方法。换句话说,类也是对象……

 

类也是对象

      类也是对象?如果类也是对象,那么Class类是谁的实例?答案是Class类自己:

图 10

换句话说,它是一个Class对象。还记得Object类吗?嗯,Object是一个类,而类又是对象,于是, Class是一个类,Object是一个类,Object是一个对象,Class是一个对象……从类的角度来看,Class类应该先于Object类而存在;而从对象的角度来看,Object对象应该先于Class对象而存在,哈哈,我们陷入先有鸡还是先有蛋的悖论了……嘿嘿,你可别真的陷进去了哟,如果你感到混乱,那就忽略它吧,就目前而言,你只需要记住类也是对象就行了。

      如果类也是对象,那么它也应该可以拥有实例变量,但这种实例变量有什么用呢,它和我们之前看到的实例变量有什么不同呢,它和类变量又有什么联系呢?我们知道,实例变量是和实例绑定的,创建实例变量最直接的做法是在实例方法里执行赋值操作,假设我们有一个Class1类,如果我们想为它创建实例变量,那么我们就要回到Class1类的类里,嗯,读起来有点新鲜和拗口,但操作起来却非常简单:

图 11

@value1变量是Class类的实例变量,目前只能通过set_value1方法设值,而set_value1方法又是Class类的实例方法,只能通过Class类的实例访问,于是,若要调用set_value1方法,就得先有Class类的实例,那么,谁是Class类的实例?就上图来而言,Class1类和Class类自己都是Class类的实例,为了避免思维过于纠结,我们还是选择Class1类吧:

图 12

如果我想获取或者输出@value1变量的值呢?最直接的做法当然是在Class类里创建一个方法,然而,仔细观察set_value1方法的调用,你会发现它和我们在前两篇文章里看到的类方法没啥两样,这是否意味着我们在Class1类里创建的类方法也能访问@value1变量呢?试一下就知道了:

图 13

噢耶,成了!现在,请思考一下:我们在Class1类里创建的实例方法能否访问@value1变量?为什么?对于第一个问题,我们试一下就知道了:

图 14

从上图可以看到,@value1变量对于Class1类的print_value1实例方法是不可见的,这是否意味着,我们在Class类里创建的@value1变量和我在Class1类里创建的@value1变量是互不相干的呢?嗯,试一下就知道了:

图 15

答案已经很明显了,但是,为什么呢?它们的名字是一样的,使用的时候又没有附加任何标识,Ruby究竟是如何区分它们的呢?秘密就在self上!还记得Ruby如何限制私有方法的调用吗(如果忘记了,不要紧,回去上一篇文章复习一下吧),在Ruby里,任意一个时刻都有一个默认对象,用self来表示,上面两个@value1变量之所以相互独立,是因为在Ruby碰到它们的时候,self分别指向两个不同的对象,在类方法里,self指向类,而在实例方法里,self指向类的实例:

图 16

换句话说,上面两个@value1变量隶属于两个不同的对象,自然不会混到一起,事实上,我们通常会把这种和类绑定的实例变量叫做类实例变量(class instance variable),以便和一般的实例变量区分开来。

      现在,请思考一下:在类的定义以内、任何方法的定义以外的地方,比如说,上图的第一行和第二行之间,self指向什么?嗯?这种夹缝之地也有默认对象?当然有:

图 17

从上图可以看到,此处的self和类方法里的self一样,都是指向类的,这是否意味着,我们也可以在里创建类实例变量?我们不妨试试看:

图 18

从上图可以看到,这里也可以创建类实例变量,那么,这种做法和前面的有什么不同呢?你可能会说,前面创建的@value1变量在Class1类和Class2类里都可用,而这里创建的@value2变量只能在Class2类里可用,到底是不是这样呢,我们试一下就知道了,为了避免干扰,我们使用一个干净的Class3类来做试验:

图 19

从上图可以看到,在我们调用在Class类里创建的set_value1实例方法之前,@value1变量根本就不存在!为什么?因为Ruby的实例变量是按需创建的,在你对它们进行赋值操作之前,它们是不存在的(如果你忘记这部分内容了,不要紧,回去上一篇文章复习一下吧),换句话说,Class3类并没有从第一种做法那里占到什么便宜,事实上,同一类型的不同实例在某个时刻也可能有着完全不同的实例变量。

      说到这里,你可能会问,类变量和类实例变量都属于类,那么它们之间有什么区别?第一,类实例变量只能被类方法直接访问,而类变量则可以被类方法和实例方法直接访问;第二,类实例变量的值和单个类挂钩,而类变量的值则和某个继承体系挂钩。来,猜猜下面代码的输出是什么:

代码 1

答案是3、4、4。什么?最后一个竟然是4?没错,因为类变量的值是和整个继承体系而不是单个类挂钩的,当我们创建Rectangle类的实例时,我们改变了@@sides变量的值,这将影响整个继承体系,我们期望变量的值在类的各个实例中保持一致,但在不同的类中保持独立,此时就需要类实例变量了:

代码 2

这样输出就没问题了。值得注意的是,类实例变量不能被实例方法直接访问,为了能在实例方法里访问它,我们得先为它创建一个类方法,用于返回它的值,然后通过self.class获得当前实例的类,最后通过这个类访问刚才创建的类方法,从而获取类实例变量的值,道路似乎非常曲折,不过,如果你把类和它的实例看作两个独立的对象,那么理解起来应该畅顺很多的。

      朋友们,请系好安全带,因为接下来的旅程将会更加惊险!

 

对象的方法

      既然类是Class类的实例,这是否意味着,我们可以通过Class类的new方法创建一个类,并通过这个类的new方法创建它的实例?我们试试看吧:

图 20

正如你看到的,这条路行得通。如果我想为Triangle类提供一个sides类方法,用于返回三角形的边数,我该怎么做呢?既然Triangle类是Class类的实例,你可能会说,在Class类里创建不就行啦?好,我们试试看:

图 21

看起来似乎没问题,但是,如果我再创建一个Rectangle类,然后调用它的sides方法,那么问题就来了:

图 22

很显然,这不是我们想要的,怎么办?有人可能会建议,既然Triangle类和Rectangle类都是Class类的实例,不妨考虑使用@sides变量来保存边数,然后在sides方法里返回它,@sides变量可以通过Class类的initialize方法初始化。好,我们试试看,但为了避免干扰,我们打开一个新的irb:

图 23

看起来似乎没问题,如果我再创建一个Person类呢?

图 24

噢,出错了!怎么回事?看看错误信息,原来是参数的个数不匹配,看到这里,你可以会说,为initialize方法提供一个重载吧。好,我们试试看:

图 25

看起来似乎没问题了,如果我再创建一个Circle类呢?

图 26

又出错了!为什么会这样?原来,Ruby不支持方法重载,我们在图25里创建的无参initialize方法将会覆盖前面那个带参的!显然,我们不能通过initialize方法来初始化@sides变量了,一个可能的做法是为@sides变量提供一个写访问器:

图 27

这下应该没问题了吧?且慢!Person类根本不需要sides方法!

      毫无疑问,我们陷进共性和个性的问题了,只有部分类需要sides方法,而这些类的sides方法又可能返回不同的值。嗯?这有什么好奇怪的,我们平时就是这样理解的呀,反而特意在这提出来才奇怪呢!是的,如果你用我们过往的经验来理解,这里似乎没有什么问题,但如果你换个角度,就会看到我们现在面临的问题了。想想看,类是什么?Class类的实例。相同的类型,部分实例拥有某些方法,部分没有,这意味着什么?噢!反应过来了吗?从另一个角度来看,对象的行为取决于先天和后天两个因素,先天是指我们在类(或模块)里创建的实例方法,它们适用于类的所有实例,那么后天呢?后天是指我们为单个对象创建的方法,它们只适用于单个对象,Ruby把这种方法叫做单例方法(singleton method)。

      那么,如何创建单例方法呢?非常简单,就像你创建普通的实例方法那样,不同的是,你要在方法名字前面加上对象名字,两个名字之间用.分隔。为了避免干扰,我们再开一个新的irb:

图 28

不会吧?又来一种新的写法??嘿嘿,先别急嘛,有没有觉得这个写法似曾相识?细心的你可能已经发现,它和我们在第一篇文章里看到的第一种创建类方法的写法是一样的,除了那个是写在类的定义里,而这个是写在外面的。下面,我们把那篇文章介绍的头两种写法和这里介绍的放在一起看看:

代码 3

当你看到上面这个代码时,有没有这样一种感觉,它们其实是一样的,如果有,那么恭喜你,你的感觉是对的。我们知道,在Ruby里,self指向默认对象,而在类的定义里,默认对象就是类自己,所以上面的def self.boiling_pointdef Mercury.boiling_point是一样的。那么,剩下的两种写法呢?我们还是把它们和这里介绍的写法放在一起看看吧:

代码 4

我们知道,第三种写法的self其实就是Mercury类,换句话说,它和第四种写法的区别仅仅在于一个在类的定义里,一个在外面,但是,这只是表面上的区别,实际上它们是一样的,第四种写法也可以写在类的定义里,你可以自己试试看。那么,第四种写法和第五种又有什么区别呢?这要从单例方法的容身之所说起,我们知道,实例方法存在于类或模块里,能够调用它们的对象可以在这里找到它们,那么单例方法呢,如果它们只能被单个对象调用,对象又该到哪里才能找到它们?现在,细心观察一下第四种写法,你觉得它想什么?是不是很像类的定义?对了,它其实就是一个类,专门用来存放单例方法,Ruby把这种类叫做单例类(singleton class)。每个对象都有两个类,一个是用来创建实例的类,另一个则是单例类,单例类是自动创建的匿名类,但你可以通过第四种写法重新打开它,并往里面添加单例方法。换句话说,第四种写法和第五种都是用来创建单例方法的,前者直接在对象上创建,后者在对象的单例类里创建,它们之间有一个小区别,但就目前而言,你可以把它们看作一样的。

      数一数,我们现在有多少种创建类方法的写法?5种(如果把第四种写法放在类的定义里也算上的话就是6种了),而这些写法最终都可以统一为为类创建单例方法,从另一个角度来看,类方法其实是单例方法的一个具体应用。既然(作为对象的)类可以创建单例方法,那么普通对象也肯定可以:

图 29

现在,请思考一下:类的单例方法和普通对象的是否有什么不同?想想看,类方法能被谁调用?包含该方法的类及其子类。普通对象没有子类的概念,这意味着,类的单例方法是和一个继承体系挂钩的,而普通对象的单例方法只和单个对象挂钩。

      前面我们提到,单例类是匿名类,那么我们是否可以自己创建匿名类?可以的,在Ruby里,类名遵循常量的命名规则,一般建议使用Pascal命名方式而不是全部大写,如果你通过Class类的new方法创建一个类,并把它赋给一个常量,那么它就是命名类,如果你把它赋给一个变量,那么它就是一个匿名类:

图 30

你也可以在创建匿名类的同时为它创建实例方法:

图 31

这里,我们向Class类的new方法传递一个代码块(code block),如果你对它没有了解,不用担心,这个东西将在下次详述,现在你只需知道它可以用来传递一份代码就行了。值得提醒的是,calc_result是一个匿名类而不是匿名对象,这意味着如果你要使用它的实例方法,你得先创建一个实例:

图 32

当然,这不是问题,因为你可以把匿名类的创建和实例化放在一起,毕竟,使用匿名类的潜台词是你不打算重用它的定义:

图 33

那么,如果两个匿名类的结构和内容都一样,它们的实例也相等吗?我们试一下就知道了:

图 34

显然不相等,那么,是否有办法让它们相等?试想一下,person1 == person2是什么?表面上,我们似乎在使用==运算符,但实质上,我们其实在调用==方法,换句话说,person1 == person2将会被解析成person1.==(person2) ,于是,若要person1 == person2的结果为true,只需为person1对象重写==方法就行了:

图 35

值得提醒的是,我们不必为==右边的对象重写==方法,除非你需要想在==上实现对称性。此外,如果你还想对它们使用其它比较运算符,如<>等,你可以把Comparable模块包含进来,然后创建<=>方法就可以了:

图 36

然而,这并不意味着book2 > book1也能执行,因为这些运算符最终都被解析为方法调用,如果你想实现这种等价效果,book2对象也要包含Comparable模块和创建<=>方法。

 

向对象发送消息

      我们知道,面向对象强调通过对象之间的协作来完成任务,那么,对象之间是如何通信的呢?回想一下,我们平时是如何让对象执行操作的?调用对象的方法。就上面的图29而言,我们想让唐老鸭游泳,就调用它的swim方法,从另一个角度来说,我们向它发送swim这个消息,请求它执行对应的操作,在Ruby里,我们可以通过send方法显式向对象发送消息:

图 37

如果你是第一次看到这种做法,可能会觉得它没有直接调用方法来得直观,然而,它却拥有一些后者没有的特殊好处,比如说,如果你想让对象执行的操作是由用户或者配置文件来指定的,那么,你要么通过一个庞大的条件语句来判断要调用的是哪个方法,要么通过一个字典来维护用户或者配置文件指定的操作名字和对应方法之间的映射,如果换用send方法,那么你只需把表示操作名字的字符串传给它就行了。

      现在,请思考一下,如果我们可以向send方法传递任意字符串,并且可以重写它目前的实现,这意味着什么?这意味着我们可以为对象实现一个消息代理!如果某个功能有新旧两个实现,那么我们可以通过这个消息代理把请求从旧版本重定向到新版本,或者根据一定条件来决定应该使用哪个实现。假设我们为唐老鸭创建了一个swim_at_speed方法:

图 38

我们希望重用swim这个消息,并根据用户是否提供速度来决定应该使用哪个实现,那么我们可以这样重写send方法:

图 39

因为send方法的第一个参数可以是符号类型(:swim)或者字符串类型("swim"),而符号类型的比较效率比字符串类型的高,所以我们统一通过to_sym方法把方法的名字转成符号类型再行比较。而第二个参数则是可变参数的写法,它实质上是一个数组。细心的你可能已经注意到代码中的super关键字,在Ruby里,当你调用一个方法时,Ruby会遵循一定的规则查找这个方法,查找的工作会沿着满足这些规则的某条路径展开,在这条路径上可能会遇到多个可用的实现,但只有最先遇到的幸运儿才会被调用,而super关键字则指向当前方法在这条路径上的下一个实现。下面,我们来试试这个消息代理:

图 40

既然我们可以拦截消息,这意味着我们还可以在调用目标方法之前和/或之后执行某些代码,嗯,有点AOP的味道。

      说到这里,你可能会问,如果某个消息没有与之对应的方法呢?这个好办,你可以为对象提供一个类似"404页面"的实现,把没有方法与之对应的消息重定向到这里,但是,这种做法仅对通过send方法发送消息才有效,如果是直接调用方法的,就要通过重写method_missing方法来处理了:

图 41

此外,method_missing方法有一些很有趣的应用,比如说,Builder,它通过重写method_missing方法把这样的方法调用:

代码 5

转换成这样的XML输出:

图 42

既然我们可以把方法调用重定向到XML输出,我们也可以把方法调用重定向到其它对象上,比如下面的BookStore类:

代码 6

除了读取/保存数据这部分逻辑之外,其它诸如添加新项等功能就直接通过send方法转给内部的数组,想想看,如果数组的每个方法都要在BookStore类里创建一个对应的方法,这不写死人吗?当然,你也可以设定规则,只把部分功能转给内部的数组。细心的你可能已经发现上面代码出现了两个新的面孔,一个是&block,这是传递代码块的语法,另一个是bs <<右边的那片代码,这是字典的语法,如果你对它们感到陌生,不要紧,它们都会成为将来某篇文章的主角,但现在不能喧宾夺主。

 

新的旅程

      今晚,听着陈奕迅的《床头灯》,突然,一股莫名的激动涌上心头,我仿佛从这首歌里听到了自己,于是,我特意打开歌词,一个字一个字地听……"我庆幸我走在一条不完美的道路,认清我们多渺小多么脆弱"……"我庆幸我身在这场没脚本的演出,领悟这个姓名该起的作用"……"作过的梦还倒背如流,只是有了不同的感受"……

      有一次,怪怪和我聊到写博有何好处,嗯,我喜欢不断地学习,学习我喜欢的东西,如果你把人比作杯子,把学习比作往杯子里倒水,那么把学到的东西困起来就相当于把水留在杯子里,久而久之,杯子就无法装进新的水了,对我来说,写博就像把杯子倒空,这样我就可以轻装上阵,学习更多新的东西了。事实上,如果你拿我曾经写过的东西来考我,很多时候都会把我考倒,因为写下之后我就会把细节忘掉,所以我总是把东西写得尽量详细。换句话说,学习和写博共同组成了一条让知识经我而过的通道,说到这里,我想起曾经在一篇文章里读到现代舞大师玛莎·格雷厄姆(Marthe Graham)的一段精彩描述:

有股活力、生命力、能量由你而实现,从古至今只有一个你,这份表达独一无二。如果你卡住了,它便失去了,再也无法以其他方式存在。世界会失掉它。它有多好或与他人比起来如何,与你无关。保持通道开放才是你的事。

或许,正如陈奕迅所唱的,我也正在试图"领悟这个名字该起的作用"……

      Ruby宣称一切皆对象,到目前为止,我们已经看过很多很多对象了,甚至连类和模块本身也是对象,但有一个东西我们还没有从对象的角度研究过的,那就是方法,下一次,我们将会围绕这个话题,探索更多有趣的秘密……

posted @ 2009-11-03 20:01 Allen Lee 阅读(1180) | 评论 (6)编辑

2009年10月15日 #

Ruby 101:重用、隐藏和多态

Ruby 101:重用、隐藏和多态

 

Written by Allen Lee

 

什么?你不想安装Ruby?

      在我决定把Ruby装到我的机子里之前,我想先试一下;在我试用Ruby之前,我得先把它装到我的机子里;在我决定……哎哟,死锁了……

      没问题,今天我带上"钥匙"了!现在,请用你喜欢的浏览器打开这个网址:http://tryruby.sophrinix.com/,你将会看到一个网页版的irb:

图 1

现在,它已经支持到Ruby 1.9.0了,而且还有自动缩进。虽然说你可以使用你喜欢的浏览器来打开它,但实际上能用的浏览器并不多,我的机子里同时安装了IE8、FireFox 3.5、Google Chrome 3.0、Opera 10.0、Safari 4.0和Maxthon 2.5,全部更新到最新,但使用正常的只有IE8、FireFox 3.5和Maxthon 2.5,在Google Chrome 3.0和Safari 4.0上,方向键失灵,(同一行)无法回头修改,而在Opera 10.0上,按下左右2个方向键居然分别输出%'……

      本来,IronRuby也有一个用Silverlight做的iirb,以前我试过能用的,可现在打开之后就空白一片了(我的机子里安装的是Silverlight 3.0),我不知道为什么,有兴趣的话你也可以试一下,地址是:http://ironruby.codeplex.com/Wiki/View.aspx?title=SilverlightInteractiveSession

      虽然现在你不用安装Ruby也能体验一把了,可你不要对它抱有不切实际的幻想哟,毕竟它不是装在本机的irb,如果你要在里面读/写文件、访问SQLite或者MySQL,那么你注定要失望了,然而,对于这个系列的头几篇文章,我相信它还是胜任有余的,接下来就让我们进入今天的主题吧!

 

我不喜欢重复劳动!

      在上一篇文章里,我们了解到Ruby允许你"重新打开"一个类,并对里面的内容进行修改,我们还通过这种办法扩展了由Struct类创建的Book类(参见《Ruby 101:类和对象》的代码12),此外,我还提到可以通过继承来扩展Book类,下面,我们来看看如何在Ruby里实现继承:

代码 1

Ruby通过<符号来表达继承关系,上面代码创建了一个Book1类,它继承自由Struct类创建的Book类,此外,这两部分代码还可以合二为一:

代码 2

这相当于你把创建属性的工作交给Struct类,你自己则专注于包含业务逻辑的方法。

      无论你以什么方式看待继承,为它赋予何种层次的意义,你肯定不会否认它的重用功效,一般而言,面向对象语言都支持继承和组合这两种重用方式,而Ruby还支持第三种——Mixin。那么,什么是Mixin呢?设想一下,我要为Book类增加计算折扣的功能,毫无疑问,最简单的办法就是把代码直接写到Book类里:

代码 3

显然,这部分代码还可以用到其它地方,把它直接写到Book类里会使它丧失重用性,我们可以考虑把它放到辅助类里,然后通过组合在Book类里重用它,但这需要额外的代码来重定向相关的方法,我们也可以考虑把它放到基类里,然后通过继承在Book类里重用它,可是,Ruby不支持多重继承,如果将来我们发现另一个类更合适成为Book类的基类,或者像代码2的Book类那样已经有基类了,我们就会陷入困境。那么,我们该如何解决这个问题?聪明的你肯定猜到我想说什么了,嗯,现在轮到Mixin出场了,下面,我们来看看它是如何协助我们应对这种情况的。

      首先,把代码抽出来,放在一个单独的模块里:

代码 4

接着通过includeBook类里把它包含进来:

代码 5

至此,我们已经通过Mixin完成了代码重用,那么,模块里的代码该如何用呢?非常简单,你把它们想象成通过代码3的方式创建,然后该怎么用就怎么用:

代码 6

噢,这简直就是,看,你无法分辨这些方法究竟是直接在Book类里创建的还是通过Mixin混进去的,太神奇了!

      在上一篇文章里,我们看到模块可以用作命名空间,而在本文里,我们看到模块可以用作Mixin,这是模块的两个典型用途,它们均向我们展示了模块作为代码容器这个特征,到目前为止,我们已经看到模块可以包含类(参见《Ruby 101:类和对象》的代码4)和实例方法(参见本文代码4),此外,模块还能包含什么呢?

      首先,我们来试试静态方法,还记得如何在类里创建静态方法吗,如果忘记了,不要紧,回去上一篇文章复习一下吧。在上一篇文章里,我们看到在类里创建静态方法的做法有4种,下面,我们尝试照搬头两种,也是最有可能行得通的两种:

图 2

实践证明,这两种做法都行得通的,另外,我们还看到,模块的静态方法和类的静态方法在调用上也是一样的,.::都可以用来连接模块名/类名和方法名,至于使用哪一种,基本上是个偏好问题。那么,剩下的两种呢?嗯,我觉得不太可能,但尽管试一下吧:

图 3

噢,出错了!在类里面使用class << self,那么换了模块不是应该变成module << self吗?可实践证明这是行不通的,难道在模块里创建静态方法只有上面两种方式?突然,我的脑子蹦出一个怪主意,直接在模块里使用class << self会怎样呢?来,我们试一下:

图 4

这……够呛!现在,我的脑子里只有一个疑惑:最后一种做法是不是也会这样?我们试一下吧:

图 5

哎哟,不错嘛!这样看来,创建静态方法的做法对于模块和类来说是通用的。

      如果我把一个同时带有实例方法和静态方法的模块通过include包含到一个类里又会怎样呢?我是否可以通过这个类及其实例分别访问这个模块的静态方法和实例方法?想知道答案吗?我们做个试验吧:

图 6

从上图可以看到,模块的静态方法只能通过模块来访问,而模块的实例方法则可以通过类的实例来访问,事实上,这也是访问模块的实例方法的唯一办法,因为模块是没有实例的。换言之,模块的实例方法是用于Mixin的,而静态方法则可以看作辅助方法。

      接下来,我们试试常量字段和实例字段:

图 7

在Ruby里,常量字段以大写字母开头,一般建议全部大写,单词之间用下划线(_)分隔,比如说,MY_CONSTANT;实例字段则和类的一样。还记得吗,实例字段在使用之前无需事先声明,于是我创建一个方法来使用@var1这个实例字段。下面,我们创建一个类来包含这个模块:

图 8

由于实例字段是私有的,我们需要创建一个方法来设置它,此外,set_var1方法和show_var1方法的执行结果也将会告诉我们它们是否使用着同一个实例字段。现在,我们来看看常量字段和实例字段能否如我们期望的运作:

图 9

从上图可以看到, set_var1方法里的@var1show_var1方法里的确实是同一个实例字段,此外,我们还看到,模块里的常量字段既可以通过模块名来引用,也可以通过类名来引用,但模块名/类名和常量字段之间必须使用::来连接。

      现在,请思考一个问题:@var1这个实例字段是在Module1里创建,然后Mixin到Class1里的吗?还是说,@var1这个变量的"出生"与set_var1方法和show_var1方法的调用顺序有关?下面,我们做个试验看看:

图 10

这个试验的设计思路是这样的,我分别在Module1Class1里创建一个实例方法,在调用这两个实例方法时,首先通过instance_variable_defined?方法检查@var1这个实例字段是否存在,如果不存在,就用相应的标识字符串来初始化它,然后把@var1的内容打印出来。现在,我们来看看调用顺序会否影响@var1的创建:

图 11

执行结果已经非常直白地把结论告诉我们了,这意味着单纯地看图7和图8的代码是无法确定@var1这个实例字段最终是来自谁的,对于图9来说,我们可以说@var1是来自Class1的,如果我们在c.set_var1(Module1::CONSTANT1)之前加上一句c.show_var1,会不会改变@var1的"出生"?我们不妨试试看:

图 12

从上图可以看到,show_var1的调用并未导致@var1的创建,换言之,在c.set_var1(Module1::CONSTANT1)之前加上一句c.show_var1不会改变@var1的"出生"。此外,我们还发现一个有趣的现象,对于一个刚刚创建出来的Class1实例,它没有任何实例字段,这是因为我们没有使用initialize方法在创建Class1实例时初始化相关的实例字段,这意味着实例字段是可以按需创建的,而不必在一开始就固定下来,但同时也意味着你需要掌握好实例字段的创建时机以及管理好它们的创建顺序,当然,你也可以放弃按需创建的好处,统一通过initialize方法来固定所有实例字段,这样你就不必担心实例字段的创建问题了。

      最后,我们来看看模块中的模块,为了体现效果,我嵌套了3层:

代码 7

那么,我们该如何"到达"Module3show方法呢?有3种途径:

代码 8

第1种是直接通过模块名一层一层走进去,各层模块名之间通过::连接;第2种通过includeModule1包含进来,这样Module1里面的Module2就可以在当前上下文直接引用了;第3种则通过includeModule2包含进来,这样Module2里面的Module3就可以直接引用了。

      那么,是不是说,有多少层就得创建多少个(嵌套)模块呢?是,也不是。嗯?怎么理解?我们不妨做个试验:

图 13

显然,这是行不通的,但是,请别就此放弃,细读错误信息,它告诉我们Module1未被初始化,这是否意味着我们得先把Module1初始化了呢?如果是的话,我们就先初始化Module1Module2吧:

图 14

成功了!从上图可以看到,Ruby支持在创建模块时把各层的名字通过::连起来,条件是外层模块得先存在。

 

嘘,别让他们知道……

      到目前为止,我们创建的方法都是公有的,这显然是不够的,我们很多时候都需要限制外界对方法的访问,那么,如何创建私有方法?最简单的做法就是在你想使之变成私有的方法上标上private,比如说,我现在有1个Class1类,里面有4个方法:

代码 9

我想把method2方法变成私有的,那么我可以在它上面标上private

代码 10

但是,这将会导致method3方法和method4方法都变成私有的,在这里,private就像一个开关,会把它下面的方法都变成私有的,如果我只想把method2方法和method3方法变成私有的,那么我需要在method4方法上面标上public

代码 11

至于method1,由于它上面没有任何访问控制标识,将会默认为公有的。当然,你也可以在每个方法上面显式标上privatepublic。如果方法比较多的话,你可能会发现privatepublic交错地穿插在方法之间。如果你觉得这样比较乱,可以按照访问级别重新组织这些方法:

代码 12

此外,我们也可以这样做:

代码 13

代码12和代码13是等效的,你可以根据个人喜好选择任意一种做法。事实上,private是一个方法,如果你不向它传递任何参数,它将会把它和类结束标记(end)或者另一个访问控制标识(比如说,public)之间的所有方法变成私有的,代码12就是这种情况;它也可以接受参数,当你把方法名以Symbol(或者字符串)的方式传给它时,它将会把对应的方法变成私有的,代码13就是这种情况。

      我们知道,私有方法只能在内部调用,但你可能不知道在调用私有方法时有一个规则需要遵守的,我们来看看下面的代码,这里,method1方法里的self可以看作C#的this,凭直觉说,你觉得调用method1方法时会怎样?

代码 14

现在,我们执行一下,看看你是否猜对了:

图 15

从上图可以看到,method1方法的头两行代码正常执行了,唯独是第三行——self.method2,此外,我们还看到,执行这行代码所报的错和在外面直接调用method2方法所报的错是一样的,为什么会这样?这要从Ruby如何限制私有方法的调用说起,在Ruby里,调用一个方法其实就是向某个对象发送消息,一般情况下,这是通过obj.method来完成的,如果我们把前面的obj.省略,那么这个消息就会发向默认对象(又称当前对象),用self来表示。调用自己的私有方法意味着向自身发送消息,当我们在内部调用私有方法时,默认对象恰好就是私有方法的所属对象,在这种情况下,把obj.省略会使消息发向默认对象,即私有方法的所属对象,因而不会影响消息的传递;当我们在外部调用私有方法时,默认对象并非私有方法的所属对象,这意味着消息的发送者并非自身。Ruby正是通过强制把obj.省略来确保私有方法得到恰当的调用,因为任何你可以调用私有方法的地方都是可以把obj.省略掉的,这意味着你也不能像代码14那样通过self.来调用私有方法。

      然而,这个规则有一个例外,我们来看看下面代码:

图 16

在上一篇文章里,我们了解到,属性的写访问器实际上会被解析为方法调用,即当Ruby碰到c.var1 = "What's var1?"时会把它解析成c.var1=("What's var1?"),而var1=正是attr_accessorvar1属性创建的写访问器的名字。既然是方法调用,而且发生在内部,理应无需指明消息接受者(即省略obj.),那么,当我调用method1方法之后,var1属性的值是什么?我们来试一下吧:

图 17

从上图可以看到,method1方法里的那句根本就没有被解析为调用var1属性的写访问器,那么,method1方法里的那个var1是什么?你觉得呢?你怎么知道我是想调用var1属性的写访问器而不是对var1本地变量进行赋值?事实上,你无法确定!Ruby把这句解析为后面那种情况,这就是为什么我们调用method1方法之后var1属性的值是nil。如果你是使用NetBeans来编写Ruby代码的,NetBeans将会检测到这个问题,并通过相关的标识来提醒你:

图 18

那么,我们应该如何告诉Ruby我们期望的是前面那种情况?答案是通过self

图 19

好了,看到矛盾了吗?调用写访问器必须在前面加上self.,而调用私有方法不能在前面加上self.,那么,私有写访问器该如何调用?正如本段开始时所说的,这是一个例外,换句话说,调用私有写访问器不必遵守调用私有方法那个规则。

      除了publicprivate,Ruby还提供了protected,但这个protected和我们通常认识的那个不太一样,哪里不一样呢?我们来看看下面代码:

图 20

这些代码不难理解,根据value1方法的返回值判断两个Class1类的实例是否相等,判等的工作交由equals方法来负责,这意味着我们需要在一个实例里调用另一个实例的value1方法,这本来不是什么问题,但如果我不想value1方法被外部调用呢?你可能想到把它变成私有的,那么,我们来试试看:

图 21

我们重新打开Class1类,更改value1方法的访问级别,然后重新调用equals方法,噢,虽然外部不能调用value1方法了,但equals方法也不能正常执行了。这个时候就轮到protected出场了:

图 22

再次重新打开Class1类,把value1方法的访问级别改为protected,然后重新调用equals方法,这次,equals方法正常执行,而value1方法的访问也受到限制了。事实上,这就是protectedprivate的唯一区别。

      说到这里,你可能想问,基类的私有方法在派生类里能访问吗?答案是可以的,基类的方法及其访问级别都会被派生类继承下来,并且派生类可以根据需要更改继承过来的方法的访问级别:

图 23

从上图还可以看到,在派生类更改从基类继承过来的方法的访问级别不会对基类的对应方法的访问级别造成影响。

      说了这么多,有没有发现我们一直都在绕着实例方法转?那么,静态方法又该如何处理呢?是不是照搬实例方法的做法?我们试一下就知道了:

图 24

显然……不行!这个时候我们就需要private_class_method方法了,只需把上面的private换成private_class_method就行了,但是把它放在方法上面就不会生效了:

图 25

此外,我们还可以把private方法和其中两种创建静态方法的做法结合起来运用:

代码 15

由于这里使用的是private方法,这意味着我们可以把private标在方法上面,从而衍生出对应的两种做法。

 

请问你能干什么?

      有没有想过怎样在Ruby里玩多态?说起多态,就不能不说继承体系,我们先来创建一个吧:

代码 16

上面的代码很容易理解,我们创建了一个Pet类,它有一个name只读属性和一个play方法, 其中,@name实例字段是通过initialize方法初始化的,接着,我们创建了一个Cat类和一个Dog类,它们都继承了Pet类,并重写了play方法。需要说明的是,#@name是字符串插值(string interpolation),由于@name是实例字段,我们可以把#{@name}简写成#@name

      接下来,我们要创建一个call_play方法,它的职责很简单,就是放宠物去玩,应该怎么写呢?非常简单,直接调用通过参数传过来的Pet对象的play方法就行了:

代码 17

这个方法使用起来也是很简单的:

代码 18

代码的执行也相当顺利:

图 1

然而,你有否想过,在这平静水面之下可能是暗流汹涌?想想看,call_play方法的参数是没有指定类型的,这意味着我甚至可以传一张桌子进去:

代码 19

这显然是call_play方法无福消受的,怎么办?有人建议,在call_play方法里添加类型检查,确保传过来的对象是一个宠物:

代码 20

很好!这下桌子就没法钻空子了。可是,这就好了吗?显然,你绝对不希望别人传一个"宠物"进来:

代码 21

虽然这行代码能够正常执行,但这绝对不是你想要的。我们希望通过Pet类强制派生类实现play方法,但我们不想别人直接调用Pet类的play方法,怎么办?Ruby没有接口和抽象类,如果你想强制派生类实现某个方法,一个可能的做法就是在基类的对应方法里抛出一个NotImplementedError异常:

代码 22

让我们把思路理一理吧,call_play方法在调用play方法时会确保目标对象的类型是Pet类或其派生类,而Pet类的play方法会抛出一个异常,这意味着我们不能直接调用它,并且派生类也需要重写它,嗯,看起来我们已经安全了。真的没问题了吗?能不能创建一个类,它继承了Pet了,但没有play方法?这听起来很荒谬,而且可能性几乎为零,但事实上,我们的确可以创建一个这样的类:

代码 23

MagicDesk类继承了Pet类,却去掉了继承过来的play方法,你可能会问,谁会创建这样一个怪胎呢?我不知道,但至少它告诉我们,从静态语言借鉴过来的强力保护规则不再适用了。事实上,我们把静态语言的思想强加在Ruby之上的同时,我们的视野也被限制了,看不到动态语言的某些好处。

      有没有看过迪士尼的《星际宝贝》?莉萝(Lilo)意外地得到了一只外星怪物,她以为这是一只狗,并为他取名"史迪奇"(Stitch),但这显然不是一只宠物,只是在人们看来像一只宠物而已。如果史迪奇也有play方法,但不是Pet的派生类:

代码 24

莉萝就没办法通过call_play放他去玩了。怎么办?先别急着解决问题,让我们想一想,在这里进行类型检查对于我们来说究竟意味着什么?这个问题的答案就隐藏在代码5里,我们检查pet参数和Pet类型是否兼容,主要为了确保我们可以对pet参数调用play方法,换句话说,我们对类型的关注背后隐藏着对行为的关注,既然如此,何不直接询问pet参数是否支持play方法?

代码 25

这样的话,即使史迪奇不是Pet类的派生类,莉萝也可以通过call_play放他出去玩了:

代码 26

如果call_play方法改成代码25那样的话,我们就没有必要在一开始时建立继承体系了,事实上,继承体系对于动态语言的多态不是必须的。当然,你可能会说,继承体系在这里是必须的,因为派生类需要通过继承重用基类的代码,嗯,这确实是一个理由,然而,我们也可以通过Mixin来实现代码重用,这种做法可能更加合适,试想一下,宠物可以有名字,人也可以,甚至布娃娃也有自己的名字,但你肯定不希望他们都成为Pet类的派生类!当然,你也可能反驳说,每个宠物类型都可能需要通过Mixin重用多个相同的模块,这样的话,与其分别在每个宠物类型里添加多个相同的include语句,不如把这些语句放到一个类里,然后让所有宠物类型继承它,免得每次发生变化时都要同时修改多个地方。嗯,这个理由不错,好,就这么办吧:

代码 27

测试call_play方法的代码就是前面用过的,我把它们集中起来:

代码 28

下面,我们运行一下测试代码:

图 2

非常好!此外,如果你希望为Pet类的派生类提供play方法的默认实现,你也可以在Pet类里创建一个play方法,但这样将会导致代码28的第3行产生输出,这不是我们希望看到的,如果你不想Pet类被实例化,你可以把它创建成一个模块而不是一个类,这样的话,你既可以为包含它的类提供play方法的默认实现,又不必担心代码28的第3行会产生输出,因为执行这行会引发异常。

      现在,让我们重温一下代码25,在调用play方法之前,我们先看看它是否存在,Python的开发者把这种编码风格称为LBYL(Look before you leap),与此相对的编码风格是EAFP(Easier to ask for forgiveness than permission):

代码 29

由于begin前面和第一个end后面没有其它代码,我们甚至可以把这个方法简写成这样:

代码 30

无论你偏爱LBYL还是EAFP,你都会发觉这里和你曾经熟悉的静态类型世界有所不同,不管怎样,欢迎来到动态类型世界!

 

新的旅程

      有人问我,为什么要学Ruby,它能干什么,和其它语言相比,它又有什么优势,我不知道,当初决定学Ruby是因为它和我喜欢的人有一个共通点,仅此而已,这个决策听起来很不理性,不过有feel对我来说很重要,再说,在学习Ruby的过程中,我得到了很多乐趣,而且惊喜还陆续有来,我想这已值回票价。

      还有什么遗漏的吗?噢,静态方法能和protected结合起来吗?你觉得呢?看起来没这个必要,因为静态方法(似乎)没有protected要解决的问题,但事实上你确实可以创建protected的静态方法,可这种静态方法又有什么用?嗯,这个问题,将会引出一系列更有趣的东西,我们将会在以后的文章里逐一探讨。

posted @ 2009-10-15 08:13 Allen Lee 阅读(1703) | 评论 (8)编辑