思考面向对象
二十年前我对OO(Object-Orientation,面向对象)兴致正浓,看了不少OO的书,有外文书(例如Grady Booch、Bertrand Meyer的书),也有中文书。其中,中文书为了帮助读者理解,都会用现实生活中的对象做比拟,比方说:哺乳动物、交通工具,我记得我读过的一个书上范例说:「斑马」继承自「马」。
当时我在工研院(台湾的政府科研单位)实习,老板要我报告OO,我于是拿了书上的例子当解说,当老板听到我宣称「斑马继承自马」时,他开玩笑地说:「那么马子(台湾俚语,女朋友的意思)应该也是继承自马」。我当时深受羞辱,感觉被IT中文书荼毒了。
【学习OO的重点】
OO的三大基础是封装、继承、多态。用现实生活的对象做OO解说上的比拟,通常不太恰当,因为只能解释封装和继承,却无法解释多态。而多态却是OO真正的重点,也是学习OO的门槛。没有解释多态,就等于小学而大遗。
对于OO,比较恰当的例子是「形状」,一来容易理解,二来适合同时解说封装、继承、多态。
我认为OO的书不用看太多,只要看Bertrand Meyer的名著OOSC第二版就够了,但这本书可不薄。
前面提到,面向对象的三大基础是封装、继承、多态。你会在特定OO语言上看到一些其他机制,例如Template,RTTI(Run-Time Type Information),但这些都不是重点。学习OO的时候,焦点应该放在封装、继承、多态这三方面。
这三者是有次序性的,没有封装就不可能有继承、没有继承就不可能有多态。只支持封装的语言称为Object-Based语言(例如传统的Visual Basic),同时支持封装、继承、多态的语言才能称为OO语言(例如.NET时代的Visual Basic)。
有没有可能,存在某个语言只支持封装和继承,却不支持多态?不会有语言这么无聊,基本上继承往往只是一个中间过程,真正的目的是多态。既然支持了继承,却不支持多态,这是没有意义的。
【封装】
封装(encapsulation)的目的是要将代码切割成许多模块(module),每个模块之间的关连性降到最低,这么一来比较不会产生「牵一发而动全身」的状况,降低相互依赖的程度,也等于是降低复杂度,可以让开发与维护更容易。
事实上,没有人用「模块」一词来称呼封装的结果,而是称为「类」,把模块一词做更高阶的包装用途。因此我们现在应该将「类」视为封装的结果,把「模块」视为整个程序切割出来的许多片段。而在OO的世界,一般来说,一个程序有多个模块,一个模块内包含多个类。
模块的概念不是OO独具的,许多非OO语言也具有模块,但是OO的语言几乎都具备模块,例如Java的Package;D语言的模块;而.NET更是细分成组件(assembly )和模块,其实.NET的组件与模块都具备一般模块的概念,但程度有别(组件包含模块)。
封装是以数据为核心,将相关的数据放在一起,将会用到这些数据的函数也放进来。封装等于是将数据和函数放在一起。尽管有的语言还有其它的东西,例如event、property,但是从内部来看,这些都是函数的变形。
为了和非OO的世界做出区隔,OO也做了一些名词上的改变,将Function(函数)改称为Method(方法)、将Call(呼叫)改称为Invoke(调用)。但是新旧词汇基本上还是通用的。
【能见度】
封装的目的既然是要「降低互相依赖的程度」,就牵涉到能见度的问题:这个「类/方法/栏位」该不该暴露给别的模块、同一个模块的不同类、自己的「次类」、友伴类(Friend Class)、内部类(Inner Class)?这就是所谓的「能见度」(visibility)。
我们当然希望尽可能降低能见度,这才能「降低互相依赖的程度」。也就是,别人不需要知道的,就不要让它知道,这就是所谓的「信息隐藏」(information hiding)。
最该被隐藏的是数据。极致的封装主义者,主张所有的数据一定都不可以直接被外部(包括次类)访问。
上面提到,封装将相关的数据和使用到这些数据的方法包成类。最理想的状况是,让数据的能见度为最低,外面完全看不见。留下的对外接口(Interface)只剩下method。换句话说,每个对象的Interface是一些方法的集合,完全没有数据。
设定能见度不是一件容易的事,往往需要深思熟虑。特别是对于设计「框架」(framework)的人来说,能见度设定得太宽,造成信息隐藏效果不佳,可能会带来相当多负面的效果(例如复杂度提高、程序容易出错、非thread- safe…等);能见度设定得太紧,造成效率变差、扩充程度变差(有些设计因而做不出来)。
【继承】
被继承的对象称为基底类(base)或超类(super)或亲类(parent),继承者称为衍生类(derived)或次类(sub-)或子类(child)。
继承的目的,是要达到「代码复用」(Code Reuse)或「接口复用」。而继承的手段,就是「扩充」或「修改」。这是继承的重点,请务必牢记。
继承所导致的代码复用,是指次类能自动沿袭超类的所有代码,好让你可以不用写太多代码,只需要稍微扩充或修改,就能符合你的需求。 「扩充」指的是定义新的方法(Method),修改指的是「针对超类中的某方法重新定义其行为」。
请注意,继承所产生的次类,和其超类之间,两者在记忆体内是独立的。继承所做的扩充与修改,并不会影响到超类。在Windows程序设计中,有所谓的SubClassing技巧,其实并不是继承的概念,因为它会修改到原本类的记忆体。
继承所导致的接口复用,是在为OO的下一个阶段(也就是多态)作准备。接口复用,搭配方法的修改,就形成了多态。
如果你不想复用代码,也不想复用接口,或者说你不进行扩充、也不进行修改,那么透过继承产生次类,几乎是没有意义的。
唯一的一个小小的意义是,次类和超类两者是不同的类,你可以在程序中依据这一点做判断,做不同的行为。但是这是一种琐细的程序技巧,和OO无关,而且OO也不鼓励你这么做。对OO来说,透过多态的机制造成行为的差异,才是正确的作法。但即使是为了此目的,我们也会使用空的Interface当作特殊标签(Mark),而不会使用类当作标签,因为Interface当标签的副作用小,成本低,且不是垂直的关系。
将许多类之间的继承关系,绘制出一张关系图,如果绘制的时候依循「超类在上,次类在下」,或者「超类在左,次类在右」,就可以形成一个类阶层(Class Hierarchy)。由于大多数的类阶层设计都是采用单一继承(Single Inheritance),而非多重继承(MultipleInheritance),所以阶层图往往是树状结构,符合树状结构的阶层图,也称为继承树、类树。
【多重继承与接口】
单一继承指的是,只有一个超类;多重继承指的是,具有多个超类。应用框架设计几乎都是采用单一继承(例如MFC、.NET Framework、Borland VCL、Adobe AIR),只有极少数以前的设计会采用多重继承(例如Borland OWL、Eiffel)。
不只是如此,连语言本身的设计上,也往往禁止多重继承(例如Java、Delphi、C#、VB.NET),只剩下极少数语言允许多重继承(例如C++、Eiffel)。这个趋势似乎会延续下去,主要是,多重继承「可能」会造成「不知继承的方法是来自那个超类或祖先类的困扰」。 C++要求编程员要主动指明继承的方法来自何处,但Eiffel的作法则更巧妙(请参考http://www.eiffel.com/)。
姑且不论多重继承的缺点,多重继承显然表达能力比单一继承更佳,至少,有不少原本在单一继承时必须透过AOP(Aspect-Oriented Programming)解决的问题,在多重继承之下可以轻易解决,不需要AOP。
从Java开始,多数的语言使用Interface来解决多重继承的问题,它们号称『利用Interface可以享用多重继承的优点,又没有多重继承的困扰』。但事实根本不是如此!
Interface只能让你继承到Interface,无法继承到代码(Interface不带代码)。因此,如果你在Java中继承多个Interface,你必须亲自定义所有Interface的每个方法,也就是说,你必须写许多代码。但如果是在C++/Eiffel中,你可以继承许多类,不需要再定义这些方法。
所以接口是在「舍弃多重继承缺点的同时,也舍弃了多重继承的优点」。也就是说,接口舍弃了「代码复用」,保留了「接口复用」。从这个角度来看,「接口复用」比「代码复用」更重要。这是因为多态的缘故,多态才是OO的终极目的。
【其他和继承相关的问题】
继承某些程度上破坏了一部份的封装,造成次类和超类的相依程度提高。超类如果改变,且次类没有跟着做出改变,可能会造成次类出问题。类似DLL Hell的观念。
(台湾)法律上有所谓的「限定继承」与「抛弃继承」,目前的编程语言似乎都没有这样的概念,就算有,权力也是放在超类上,由超类所控制,而不是在次类上。
设计继承时,必须先考虑接口是否共享,再考虑代码是否共享,再考虑分类。但是经验不足的程序员,反倒会先考虑分类和代码复用,而忽略了「接口复用」是其中最重要的事。
【多态与虚拟】
Polymorphism中文一般称为「多态」,早期也有人称为「同名异式」。我比叫喜欢前者,不喜欢后者。 「多态」让人觉得对象可以以「多种面貌」出现,同名异式则太强调「不同的函数」。其实,「型态的不同」是因,而「函数的不同」是果。当一个对象具有不同的型态,就有可能会引发多态机制。
一个对象为何为有不同的型态?这是因为继承而来,对象可以扮演所有祖先类的角色。例如当某对象的类是Sub,当此对象被「转型」成超类Super之后,此对象就具有两种不同的类,「实际类」是Sub,「形式类」是Super,此时呼叫此对象的方法m,会执行到的是Super定义的方法m?还是Sub定义(修改)的方法m?
答案是实际类的方法,也就是Sub定义的方法m。所以所谓的多态就是:不管形式类是什么,一定会执行到实际类的方法。
你可能会觉得疑惑,为何当初要将对象转型为祖先类,导致「形式类」(宣告类)和「实际类」(定义类)不一样?这个问题留待下一节时再回答。
如果你的程序中,大量使用switch/case语法,很有可能是你的设计不良,而没有好好地使用多态。你最好能「重构」(Refactoring)你的程序。
类的方法,可以分成虚拟(Virtual)与非虚拟两种。只有虚拟方法才能搭配多态机制使用。如果是非虚拟方法,则会执行到形式类(而非实际类)的方法,因为多态没有发挥作用。
关于虚拟,每个语言有不同的作法。 Java强调动态,所以默认是虚拟;C++注重效率,所以默认不是虚拟。
【应用框架】
为了方便软件的开发,许多软件厂商都会提供应用框架(ApplicationFramework),现今流行的框架相当多,例如:.NET Framework、BorlandVCL、Java Class Library。 1980年代OO开始兴起,1990年代框架开始兴起。有了框架,我们终于可以享受到OO的好处,重复利用别人写好的代码,不用一切自己重头写。
框架厂商先将一大部分的程序先写好,编程员只需要「利用继承来做修改」,就能套用整个框架,为了要让你修改的部分能够确实被执行到(而不是执行到框架本身的方法),所以这些允许修改的方法都是定义成虚拟的。
因为编程员「利用继承来做修改」所以产生了次类和重新定义的方法。框架比这个次类更早被定义,当然不认识这个次类,所以框架内都是以此次类的祖先类为「形式上」的处理对象(处理接口)。当此次类对象被传入框架中,就会被自动转型成为祖先类,因此产生「形式类」和「实际类」的差异。正因为这样的类差异,加上次类有重新定义方法,所以多态机制出现了。
单一继承架构中,良好的框架设计(例如Java Swing)会将代码不需要被修改的部分,设计成类。至于需要被继承修改的部分,设计成Interface(Interface的方法全都是虚拟的),以及实践这些Interface的类。框架内的类尽量只使用到这些接口。
学习框架往往需要付出相当多心力,以JavaSwing来说,就是一个相当复杂,不好学习的框架。
框架设计上,近年来比较比较不一样的是,阶层有变深的趋势(例如AIR和WPF的框架);也就是说,继承树的叶节点到根节点之间的距离变大了。这样的好处是代码重复利用度增加(所以框架档案的体积变小),接口重复利用度增加(学习速度可以加快)。
【OO是生产力的最终解答? 】
和面向对象程序设计关系紧密的是前一个阶段「设计」和下一个阶段「测试」。 「设计模式」(Design Pattern)将许多好的设计整理出来,让我们设计功力大增。如果既有的设计不太好,你可以利用「重构」(Refactoring)的技巧来重新整理你的程序。现在讲求TDD(Test-DrivenDevelopment),对OO来说,「单元测试」(Unit Test)正是以类为最小单元的。
别忘了UML!设计OO系统的时候,UML可以整理你的想法,方便大家沟通,甚至当MDA(Model Driven Architecture)成熟之后,号称可以用UML把架构设计图画出来,用OCL(Object Constraint Language)描述一些规范,然后就可以产生出代码了。
OO太美好了! OO是软件开发的极致灵丹! OO真棒!我爱OO。 … 你醒醒吧! … 尽管OO主宰现今的主流语言,OO对我们的开发效率似乎有一些提升,但我可没看过什么人用了OO之后就若有神助。更不用说OO还有学习门槛、各种OO框架的学习曲线、设计模式的学习曲线、过度工程化的问题。
最近,我觉得真正可以达到更高生产力的关键在于更高阶的抽象,也就是DSL(Domain SpecificLanguage)。尽管有的技术号称有支持DSL,依然有程度上的差异。
至于OO,在我认识到DSL的威力之后,已经被我打入冷宫了。因为当DSL发挥到极致的时候,OO似乎是派不上用场的。