1.1设计原则
上面我们讨论了面向对象设计的语言机制,但语言再好也不能保证设计良好。在实际开发中,我们常常面临各种不良设计的困扰。下面我们探讨不良设计有哪些现象,明确什么是良好的设计,以及如何得到良好设计。
Robert C. Martin在其著作《Agile Software Development: Principles, Patterns, and Practices》中总结出不良设计的7种现象:僵硬(Rigidity)、脆弱(Fragility)、低复用(Immobility)、高粘度(Viscosity)、无端复杂性(needless complexity)、无端复制(needless repetition)、晦涩(Opacity)。
-
僵硬:很难加入新功能,新加入的模块会波及其他,而且范围越来越大,最后不得不放弃。
-
脆弱:与僵硬共存,修改某个地方却导致不相关的其他地方发生故障,而且难以预料。
-
低复用:当发现某个模块可复用,但进一步却发现要依赖一大堆不需要的东西,以至于很难把可复用的部分区分开,最后还得重新设计编码。
-
高粘度:对设计的更改违背了原始设计的意图和框架,仅考虑眼前利益,只做权宜之计。这导致开发人员和维护人员缺乏长远打算,最终导致设计方案难以经受长久考验而失败。
-
无端复杂性:即过度设计。设计人员试图预测将来可能的变化,而设计太多的当前无用的元素,而这些偶然性的元素却导致不必要的复杂性,使软件难以维护。
-
无端复制:团队中多人出于自己的目的复制相同的代码,而后各自修改,使大量代码有重复但略有差别,这导致一个地方发现bug而其它地方也存在相同bug,但却难以集中修复。
-
晦涩:即难以理解,不能明确表达其意图。开始的设计往往清晰易懂,但随着工程进行,不断添加新功能,导致设计越来越难以理解,最终使软件难以维护。
那么良好的设计是什么?Peter Coad提出良好设计具有3个性质:可扩展性(Extensibility)、灵活性(Flexibility)和可插入性(Pluggability)。
-
可扩展性:新功能容易加入,而且不会影响已有功能,即不“僵硬”。
-
灵活性:修改一个地方,不会影响其他,即不“脆弱”。
-
可插入性:用一个类容易替换另一个类,只要它们实现相同的接口即可,即低“黏度”。
如何得到良好的设计?可维护性和可复用性是关键。Robert C.Martin总结出面向对象设计的五个原则SOLID:
-
SRP:The SingleResponsibility Principle,单一职责原则,一个类应有且仅有一个改变的理由。
-
OCP:TheOpen/Closed Principle,开闭原则,扩展一个类的行为而不更改原有的类。
-
LSP:The LiskovSubstitution Principle,里氏替换原则,派生类对象随时能替换其基类对象。
-
ISP: The Interface SegregationPrinciple,接口分离原则,客户端程序只需关注自己所需要的接口。
-
DIP:The Dependency InversionPrinciple,依赖倒置原则,依赖于抽象而不依赖于细节。
1.1.1 SRP单一职责原则
单一职责原则SRP要求“一个设计元素只做一件事”。当你要改变一个类时,只能有一个理由。我们的设计人员往往倾向于使一个类“多功能化”,这显然违背了SRP原则。而有时简单地模拟客观对象,也可能违背SRP原则。例如,设计一种调制解调器,如图一中的Modem类。
类Modem实现了调制解调器的基本功能。但事实上完成了两个职责:连接的建立和中断、数据的发送和接收。如此设计会有潜在的问题:当仅需要改变数据连接方式时,必须修改Modem类,而修改Modem类的结果就使得所有依赖Modem类的其他元素都必须重新编译,而不管它是否涉及到数据连接功能。故此它违反了SRP原则。一种解决方法是重构Modem类,从中抽出两个接口,一个Connection专门负责连接、另一个DataChannel专门负责数据发送。依赖Modem类的元素也要做相应细化,根据职责的不同分别依赖不同的接口。最后由ModemImplementation类实现这两个接口。
对于何时应遵循SRP有以下考虑:
1.如果应用程序的变化影响到类中某一种职责,那么就应将它与其他职责分开,这样可避免客户端应用程序与类中的这两种职责耦合在一起。
2.如果应用程序的变化总会导致两种职责同时变化,那么就没有必要去分离。
不仅在类和接口设计中需要坚持SRP原则,而且在软件架构设计中也要注意。例如在软件开发中,持久化(Persistence)技术用来解决对象状态保存的问题。业务规则会频繁改变,而持久化方式却不会如此频繁变化,并且即使有变化,其原因也完全不同。如果把业务规则与持久化方式绑定在一起,就会为以后的开发、维护造成很大麻烦。解决的办法是运用分层(layer)架构模式或TDD(Test-Driven Development测试驱动开发)提早分离这两种职责。特殊情况下,还可使用Facade或者Proxy模式对设计进行重构,以分离这两种职责。
1.1.2 OCP开闭原则
| |
Bertrand Meyer提出此原则:“模块应对扩展开放,对更改关闭”。
遵循开/闭原则的模块设计有两个主要特征:1.“对扩展开放”。这意味着模块的行为可扩展。当应用需求改变时,我们可对模块进行扩展,使其具有满足那些改变的新行为,使软件具有适应性和灵活性。2.“对更改关闭”。对模块行为进行扩展时,不应改动已有模块,使软件有一定的稳定性和延续性。总之,当我们需要扩展新功能时,应编写新的代码,而不应修改已有的代码。
这里所讲的模块包括模型、源代码、可执行代码等。
对于OCP原则,抽象是关键。如图二,如果多个Client依赖于一个具体的Server,当Server需要扩展新功能时,将导致所有的Client都可能改动,起码要重新编译。解决方法是抽象出一个Server,使Client依赖这个抽象的Server,仅说明哪些不易改变的一组服务,由其子类提供具体的实现和扩展,使实现的改变和扩展不会影响到Client。
对于OCP原则的另一种解释,就是所谓的“封装可变性原则”(Principleof Encapsulation of Variation,简称EVP)。其核心思想是“找到系统中的可变因素,将其封装起来”。EVP有两个要点:
-
一种可变性不应散落在代码的很多角落,而应封装到一个对象中。
-
一种可变性与另一种可变性不应混合在一起。
对于设计改变有两种思路,传统的思路是“什么会导致设计改变”。《设计模式》作者GoF指出另一种思路:“你允许什么发生改变,而不能让这一改变导致重新设计”。
在许多方面,OCP都是面向对象设计的核心所在。但实际应用中,滥用OCP原则也是错误的。正确的做法是仅对程序中呈现出频繁变化的部分进行抽象和封装。
1.1.3 LSP里氏替换原则
LSP原则是BarbaraLiskov在1988年首次指出的,常被称为“Liskov替换原则”:“派生类通过其基类的接口必须可用,而用户无需知其差别”。换言之,子类型(subtype)必须能替换其基类型(base type)。
我们有一个结论:一个模型,如果孤立地看,并不具有真正的有效性。模型的有效性只能通过它的客户程序来体现。在考虑一个设计是否恰当时,不能完全孤立地来看,而应根据该设计的使用者所做出的合理假设来看。
那么谁能知道设计的使用者会做出什么样的合理假设呢?大多数这样的假设都很难预测。事实上,如果试图去预测所有这些假设,我们所得到的系统很可能会充满不必要的复杂性的臭味(Martin Fowler在《Refactoring: Improving the Design of Existing Code》一书中总结出22种代码臭味,臭味成了不良设计的代名词,消除臭味的方法是重构refactoring)。因此,通常只预测那些最明显的违背LSP原则的情形。直到出现相关的脆弱性臭味时,再去处理。
子类型与超类型之间的Is-A关系是关于行为的。LSP原则明确指出,Is-A关系是针对行为方式而言的,行为方式是可进行合理假设的,这些假设是客户程序所依赖的约定。
Bertrand Meyer提出基于约定的设计DBC(Design By Contract)。类的设计者能显式地规定针对该类的某些约定。客户代码的设计者可通过该约定获悉可依赖的行为方式。约定是通过为每个操作或方法说明的前置条件、后置条件和不变式来明确的。要使一个操作得以执行,前置条件必须为真。执行完后,要保证其后置条件为真。
考虑矩形Rectangle和正方形Square两个类之间的关系。大多数人会认为Square应该是Rectangle的子类,理由是正方形是矩形的长等宽的一种特殊情形,如图8所示。但如果这样的话,Square类对于继承而来的两个操作setWidth和setHeight就必须改写,以保持“长等宽”。只需改写这两个方法就能提供看似合理的一种实现。
但是我们考虑矩形Rectangle的用户对于操作setWidth的假设是什么?一种合理的假设是当改变其宽度时,不会改变其长度。用后置条件来表示,应该是“width=new width; height= old height”。而Square类则改写为“width=newwidth;height= new width”,这显然背离了用户的假设和Rectangle类的约定,实际上这导致了Square对象不能替换Rectangle对象。将Square作为Rectangle的子类的设计方案违背了LSP原则。类似的设计还有圆与椭圆之间的关系。
单元测试中可对约定进行明确说明。可通过编写单元测试的方式来明确约定。客户程序的设计者会去查看这些单元测试,使他们知道对于要使用的类,应该做哪些合理的假设。
遇到下面情形时应特别注意LSP原则:
1.子类中对操作的改写,实现类对操作的实现。子类中的改写并不一定违反LSP,但当存在这种情况时,还是值得注意的。
2.子类操作中引发异常。C++和Java都支持异常处理。Java的异常处理比较完善,子类改写方法时,不能引发超类中未说明的异常类型。但C++没有这种限制,可在其在子类的改写函数中添加其超类不会引发的异常。如果超类的使用者不期望这些异常,那么这种改写就会导致不可替换性。如要遵循LSP,要么改变使用者的期望,要么子类就不应引发这些异常。
1.1.4 ISP接口分离原则
此原则建议为客户端程序提供尽可能“小”的单独的接口,而不是提供一个“大”接口。在设计中,一个类或一个接口中的公共方法往往数量比较多,而且根据不同的用途有不同的交叉。从客户角度看,并非所有的客户程序都依赖所有的方法。这种缺乏内聚而数量众多的方法可能导致严重的依赖性问题,解决此问题通常是重构。
过于简单的设计往往导致“大”接口。如图9左面,多个客户程序直接依赖一个类,但每个客户程序仅调用其中一部分操作,例如,客户Client1仅调用了c1function()。这导致更改一个客户的需求,就需要改动Server类,而这可能影响到其它客户所依赖的功能。解决此问题的方法是为每个客户建立自己的接口。这种接口分离的原则可分离不同客户的需求,尽可能地减少相互间的影响。
依据接口分离原则,应把一个功能过多的接口分离为功能较少的几个接口。
1.1.5 DIP依赖倒置原则
传统的结构化设计方法倾向于使高层的模块依赖于低层次的模块,使抽象层次依赖于具体层次。依赖倒置原则就是要把这个错误的依赖关系倒转过来。
在面向对象系统中,两个类之间通常有以下三种依赖关系:
1.零耦合:两个类之间没有耦合关系。
2.具体耦合:在两个具体类之间,由一个类调用或引用另一个类形成依赖关系。
3.抽象耦合:在一个具体类与一个抽象类或接口之间,使两者之间存在最大的灵活性。
| |
该原则要求我们在设计中,细节设计应依赖于抽象设计,而抽象不应依赖于细节。针对接口进行编程,而不要针对实现细节进行。也就是说,我们应尽可能使用接口或抽象类进行变量的类型说明、操作的返回类型说明,以及数据类型的转换等。要做到这一点,一个具体类应只实现接口或抽象类中说明过的操作,而不应给出多余的操作。
一切依赖抽象。换言之,避免与具体类发生关联;避免与具体类发生聚集关系;避免依赖具体构件。
一个被引用的对象只要存在抽象类型,就应当在所有使用此对象的地方都使用抽象类型,包括参量的类型声明、操作的返回类型、属性变量的类型等。
经验表明,联合使用接口和抽象类是一种良好的风格。由于抽象类能提供缺省的实现,而接口具有其他优点,所以联合使用两者是很好的选择。首先,用接口来说明一种类型的抽象行为规范,同时给出一个抽象类,为此接口提供一个缺省实现。其他同属于这个抽象类型的具体类可选择实现这个接口,也可选择继承这个抽象类。如果需要向接口加入一个方法的话,只要同时向这个抽象类加入这个方法的具体实现就可以了,因为抽象类的子类都可继承这个具体方法。
DIP原则虽然强大,但不易实现。对象的创建可能会使用对象工厂,以避免使用具体类。此外,DIP假定所有的具体类都会更改,也不符合实际。如果一个具体类是稳定的、不会发生变化,那么使用这个具体类的客户端程序完全可依赖于这个具体类型。
我们讨论了5个原则,其中什么原则最重要?OCP开闭原则最重要。其他4个原则都附属于开闭原则,违背其他4个原则都直接或间接违背OCP。