OO设计的三宝
在讲具体的原则之前,我想先明确一下面向对象语言的三个特性。所有的面向对象语言都首先必须支持这三个特性,才能称之为OO语言。也只有这三个特性的支持,才有了后面的各种原则和模式之说。所以这是OO设计的“三宝”。
首先是封装,封装的本质的是将行为寓于数据之中。注意到这是面向对象语言与面向过程语言最大的区别,不用赘述。其次是继承,继承对于老的教学方式,总是强调拓展属性,即具体到更具体。这是欠妥当的。相应的,我们应该更多的认为继承是一种桥梁,把抽象和具体连接起来。
多态是三宝中最重要的一个。封装和继承都是为了多态做铺垫。正因为有了多态,才有了面向对象设计的那些原则和模式,才有可能产生高内聚低耦合的软件系统。所以说,对于软件开发,多态就像是普罗米修斯带给人类的圣火。这种评价是毫不夸张的,越懂OO的设计,越能理解多态的重要性。Java成为OO语言的翘楚,我个人认为与其天然的支持多态是有一定关系的。
我用一个形象的例子总结一下OO语言的这三个特性。假设我们有一个异质链表,类型为OfficeTool,这个抽象类对象代表一种Office工具。它会有很多的方法,例如有一个方法叫getYourBestOutput,意即“返回自己最好的输出”。(方法寓于对象之中,这就是封装。)这个链表中有不同的对象,它们都是OfficeTool这种对象的子类,其中三个就是Word,Excel和PowerPoint。(子对象拥有父对象的方法,可以以父对象的名字进行引用,这就是继承。)如果遍历这个异质链表,访问刚才提到的方法时,我们知道,这三个工具各有所长,所以Word会输出一部精心排版的书稿,Excel会输出一份内容详实的财报,而PowerPoint会输出一个制作精美的演示文稿。(不同子类的相同方法,表现出不同的结果或输出,这就是多态!)
迪米特法则
迪米特法则有多种表述:
Each unit should have only limited knowledge about other units: only units "closely" related to the current unit.(每一个软件单位对其他的单位都只有最少的知识,而且局限于那些与本单位密切相关的软件单位。)
Each unit should only talk to its friends; don't talk to strangers.(每个软件单位都只与自己的朋友对话,不要和陌生人说话。)
Only talk to your immediate friends.(只与你最亲近的朋友通信。)
作为法律,法则,它强调了一种对软件系统普世的原则,即“高内聚,低耦合”。尽量减少通信,保持内部高度统一。实际上你去网上搜,该法则也不是完美的,但是它传递了一种思想,我认为OO设计原则的源头在此。因为你要遵循迪米特法则,你就要考虑整个软件系统哪些元素应该聚合在一起,能够产生什么行为才是高内聚的,如何进行交互才是低耦合的。这本身不就是设计的过程么?而且我认为如果能考虑这些问题,这还很有可能是一个优秀的设计。
S.O.L.I.D.原则
Robert Martin有一本非常著名的书,《敏捷软件开发:原则、模式与实践》。他在这里提到SOLID是最初的五个原则,我感觉这就像是说亚当和夏娃是最初的2个人一样。其实还是强调原则重于模式。我下面会谈谈单一职责和接口隔离,因为它们有一定的相似性,也容易掌握。后三个是OO设计的精髓,体现了延迟实现和针对接口编程的核心思想。
单一职责原则
Every context (class, function, variable, etc.) should have a single responsibility, and that responsibility should be entirely encapsulated by the context.(每个实体都应该只有一种职责,且这种职责被完全的包裹在该实体内。)
这个原则相对简单,只要你多想想是不是把2个以上无关的事情放到了一个单位里,就可以避免过大而冗余的类。记住,10000行代码的类,不是你的荣誉,而是你的耻辱。如果10000行的类需要复用,请问有复用的可能和切实可行的办法么?就一个类而言,应该只有一个引起它变化的原因。我们经常会遇到User一改需求,就要改同一个类,即使需求之间没多大关联,这就说明我们违背了单一职责原则,赋予了一个类太多的职责。
接口隔离原则
Once an interface has become too 'fat' it needs to be split into smaller and more specific interfaces so that any clients of the interface will only know about the methods that pertain to them.(一旦一个接口过于“臃肿”,需要把它拆分成更小和更专一的接口,为的是实现接口的类,只需要知道和自己相关的方法。)
对接口的设计同样要遵循迪米特法则。一旦一个接口过于“臃肿”,需要把它拆分成更小和更专一的接口,为的是实现接口的类,只需要知道和自己相关的方法。最好的例子就是Java中的一些接口定义。比如Java类库中提供的Comparable和Serializable接口。如果你通过compare方法给出了实现Comparable接口的类的两个对象的比较结果,一个int值。你就可以在一些排序的数据结构中很好的承载这些对象,达到你比较他们的目的,比TreeTable;Serializable做法更绝,是一个没有方法的接口,相当于仅仅是一个帽子,是一个标记,说明只要继承这个接口的类才能被序列化,否则就抛出异常。
开/闭原则
Software entities should be open for extension, but closed for modification.(软件实体应该只做扩展,而不做修改。)
开闭原则是最简单的但很难做到的。继承应当被看做是封装变化的方法,而不应当被认为是从一般的对象生成特殊的对象的方法。这是《Java与模式》那本书作者的原话。对于它的解读是,完美的继承是从抽象类到具体类的过程。即具体类通过继承抽象类而封装了不同的方法(方法接口在抽象类说明)。错误的继承是在一般的对象基础上,通过加入特殊的方法,而形成特殊的对象。
抽象化是开/闭原则的关键。在Java、C#等编程语言中,可以为系统定义一个相对稳定的抽象层,而将不同的实现行为移至具体的实现层中完成。这是第一次提出行为的延后实现,稍后会看到这个动作的最后落脚点。
里氏替换原则
In a computer program, if S is a subtype of T, then objects of type T may be replaced with objects of type S (i.e., objects of type S may substitute objects of type T) without altering any of the desirable properties of that program (correctness, task performed, etc.).(如果对每一个类型为T1的对象o1,都有类型为T2的对象o2,使得以T1定义的所有程序P在所有的对象o1都代换成o2时,程序P的行为没有变化,那么类型T2是类型T1的子类型。)
里氏替换原则是实现开/闭原则的重要方式之一,由于使用基类对象的地方都可以使用子类对象,因此在程序中尽量使用基类类型来对对象进行定义,而在运行时再确定其子类类型,用子类对象来替换父类对象。
依赖反转原则
Abstractions should not depend upon details. Details should depend upon abstractions. Program to an interface, not an implementation.(抽象不应该依赖于具体,具体应该依赖于抽象。)
由于有了开/闭原则和里氏替换原则的铺垫,这里提出了最核心的原则,针对接口编程,延缓细节的实现。如果说开/闭原则是目标,里氏替换原则是行为保证,那么按接口编程的依赖反转原则就是有理论保证的,有实际目标的,真正的高质量OO设计。其实有人已经把该原则叫做OO设计的标志,足见其重要性。
这三个重要的OO原则和三个OO语言特性的本质关系是这样的:开/闭原则要求我们尽量在构造软件实体的时候,应该使用扩展,而不是修改原来的对象;那么继承是一个很好的方式,继承在理想化的使用场景中,应该是从抽象到具体(将行为封装到一个具体对象之中),而不是从一种具体到另外一种具体。为什么?因为里氏替换原则要求行为一致,才是继承的关系。这和我们理解的加一个extends就定义了子类和父类的关系是不同的。由于抽象类没有具体行为的实现,所以对抽象类的继承,天然的是符合里氏替换原则的真正的继承。而具体到具体的继承,很难保证里氏替换原则的实现。
在满足开/闭原则和里氏替换原则的基础上,对同一个抽象类的行为,不同的实现了继承的具体类表现了不同的行为,这就是多态。回想到前面我们提到的Office异质链表的例子,我们的遍历操作,是针对抽象类的行为来进行编程的,这就是针对“接口/抽象类”编程的意义,这也是编程从依赖具体类(Word,Excel和PowerPoint)倒转为依赖Office这个抽象类的过程。依赖倒转原则的精髓就在于此。
再论重构
最后我想再絮叨两句重构的话题。重构来源于那本著名的书。那些“坏味道”,也随重构的概念被程序员所熟知。但如果掌握了以上所说的OO设计的原则并应用于设计和实现阶段,那么有些“坏味道”根本就不会发生,那么重构也不会发生了。我把重构分为2种,一种是简单的重构,就像修改文章中的改正错别字,或者调整个别语句的顺序;另一种是结构上的重构,是由于业务逻辑的变化或完善,导致设计方案的进化(注意我并没有说完全推翻),这时的重构才是最有价值的。一个掌握了OO设计精髓和原则的程序员,应该着眼于结构上的重构,而在正常的编码中就要注意避免,数百行的函数,随意定义变量和分配内存,大量的重复代码等问题。贾岛有时间在“推”和“敲”上反复斟酌,而曹植只有七步的时间酝酿自己的诗篇,讲究的就是一气呵成。程序员的能力和效率往往就体现在这些不经意的地方。所以学无止境,以此共勉吧。