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
接着通过include在Book类里把它包含进来:
代码 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方法里的@var1和show_var1方法里的确实是同一个实例字段,此外,我们还看到,模块里的常量字段既可以通过模块名来引用,也可以通过类名来引用,但模块名/类名和常量字段之间必须使用::来连接。
现在,请思考一个问题:@var1这个实例字段是在Module1里创建,然后Mixin到Class1里的吗?还是说,@var1这个变量的"出生"与set_var1方法和show_var1方法的调用顺序有关?下面,我们做个试验看看:
图 10
这个试验的设计思路是这样的,我分别在Module1和Class1里创建一个实例方法,在调用这两个实例方法时,首先通过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
那么,我们该如何"到达"Module3的show方法呢?有3种途径:
代码 8
第1种是直接通过模块名一层一层走进去,各层模块名之间通过::连接;第2种通过include把Module1包含进来,这样Module1里面的Module2就可以在当前上下文直接引用了;第3种则通过include把Module2包含进来,这样Module2里面的Module3就可以直接引用了。
那么,是不是说,有多少层就得创建多少个(嵌套)模块呢?是,也不是。嗯?怎么理解?我们不妨做个试验:
图 13
显然,这是行不通的,但是,请别就此放弃,细读错误信息,它告诉我们Module1未被初始化,这是否意味着我们得先把Module1初始化了呢?如果是的话,我们就先初始化Module1和Module2吧:
图 14
成功了!从上图可以看到,Ruby支持在创建模块时把各层的名字通过::连起来,条件是外层模块得先存在。
嘘,别让他们知道……
到目前为止,我们创建的方法都是公有的,这显然是不够的,我们很多时候都需要限制外界对方法的访问,那么,如何创建私有方法?最简单的做法就是在你想使之变成私有的方法上标上private,比如说,我现在有1个Class1类,里面有4个方法:
代码 9
我想把method2方法变成私有的,那么我可以在它上面标上private:
代码 10
但是,这将会导致method3方法和method4方法都变成私有的,在这里,private就像一个开关,会把它下面的方法都变成私有的,如果我只想把method2方法和method3方法变成私有的,那么我需要在method4方法上面标上public:
代码 11
至于method1,由于它上面没有任何访问控制标识,将会默认为公有的。当然,你也可以在每个方法上面显式标上private或public。如果方法比较多的话,你可能会发现private和public交错地穿插在方法之间。如果你觉得这样比较乱,可以按照访问级别重新组织这些方法:
代码 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_accessor为var1属性创建的写访问器的名字。既然是方法调用,而且发生在内部,理应无需指明消息接受者(即省略obj.),那么,当我调用method1方法之后,var1属性的值是什么?我们来试一下吧:
图 17
从上图可以看到,method1方法里的那句根本就没有被解析为调用var1属性的写访问器,那么,method1方法里的那个var1是什么?你觉得呢?你怎么知道我是想调用var1属性的写访问器而不是对var1本地变量进行赋值?事实上,你无法确定!Ruby把这句解析为后面那种情况,这就是为什么我们调用method1方法之后var1属性的值是nil。如果你是使用NetBeans来编写Ruby代码的,NetBeans将会检测到这个问题,并通过相关的标识来提醒你:
图 18
那么,我们应该如何告诉Ruby我们期望的是前面那种情况?答案是通过self:
图 19
好了,看到矛盾了吗?调用写访问器必须在前面加上self.,而调用私有方法不能在前面加上self.,那么,私有写访问器该如何调用?正如本段开始时所说的,这是一个例外,换句话说,调用私有写访问器不必遵守调用私有方法那个规则。
除了public和private,Ruby还提供了protected,但这个protected和我们通常认识的那个不太一样,哪里不一样呢?我们来看看下面代码:
图 20
这些代码不难理解,根据value1方法的返回值判断两个Class1类的实例是否相等,判等的工作交由equals方法来负责,这意味着我们需要在一个实例里调用另一个实例的value1方法,这本来不是什么问题,但如果我不想value1方法被外部调用呢?你可能想到把它变成私有的,那么,我们来试试看:
图 21
我们重新打开Class1类,更改value1方法的访问级别,然后重新调用equals方法,噢,虽然外部不能调用value1方法了,但equals方法也不能正常执行了。这个时候就轮到protected出场了:
图 22
再次重新打开Class1类,把value1方法的访问级别改为protected,然后重新调用equals方法,这次,equals方法正常执行,而value1方法的访问也受到限制了。事实上,这就是protected和private的唯一区别。
说到这里,你可能想问,基类的私有方法在派生类里能访问吗?答案是可以的,基类的方法及其访问级别都会被派生类继承下来,并且派生类可以根据需要更改继承过来的方法的访问级别:
图 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的静态方法,可这种静态方法又有什么用?嗯,这个问题,将会引出一系列更有趣的东西,我们将会在以后的文章里逐一探讨。