面向对象开发参考手册(txt+pdf+epub+mobi电子书下载)


发布时间:2020-05-24 23:53:12

点击下载

作者:黄磊

出版社:人民邮电出版社

格式: AZW3, DOCX, EPUB, MOBI, PDF, TXT

面向对象开发参考手册

面向对象开发参考手册试读:

前言

传统的软件工程学科,正在从冰冷坚硬的技术无机物,逐渐进化为充满人文主义的温暖柔和的生命有机体。

从软件的外观交互而言,用户的心理模型替代了机械化的实现模型,软件从一个粗鲁、丑陋、不自信、孩子气、没有礼貌、反应迟钝、肤浅无知、固执虚伪、需要让人去忍受的家伙,变成了一个斯文、体贴、睿智、乖巧、大度、知书达理的谦谦君子。是人,而不是软件,成为了应用的中心。

从软件的内部构造而言,从笨拙的机制化的过程,变成了对象之间的协作。对象一如我们人类,他们有血肉、有灵魂、有思想、有职责、也有隐私;他们爱恨分明——有所为、也有所不为;他们会交流——使用通用语言和模式语言进行交谈;他们会思考——用抽象来思考领域中发生的事情;他们有个性——各自定义属于自己的行为规则;他们结群而居,又理智地保持着彼此的距离;而代码却日益成为自然语言的表达——去揭示暗藏在复杂世界表象下的本质和规律。

从软件的过程组织而言,瀑布模型把程序员视为没有个性和思想的流水线操作工,傲慢而高高在上的系统分析员、设计人员把所谓“需求”和“设计”交给他们去编码,如同过时的泰勒主义一般人为地制造着形式化的垃圾和团队中的裂痕;而简约和渐进的敏捷过程,则为程序员的生存方式带来变革,沟通、反馈、简单、勇气、尊重成为我们的哲学价值观,管理人员、开发人员和用户之间的互相伤害变成了协调、平衡,这日益成为我们的一种生活方式和文化。

过去的软件,言其无机,因为它只会简单地堆砌功能和模块;言其坚硬,因为软件不软,不能灵活快速地适应客户和环境的变化;言其冰冷,因为纯粹从技术、机制、过程的角度考虑而实现的软件,不就是一台冷冰冰的机器吗?

现在,让我们给它温暖,让它变得柔和,让它充满人文关怀和深刻思想,让它成为绽放活力和价值的生命有机体。

在专业学科和社会分工愈来愈细致的同时,各学科间的界限又变得越来越模糊,因为真理并非属于哪一个学科,相反,它总是相通的。当我像写散文一般写技术文章的时候,我隐约看见,Alexander深邃的目光穿越了宇宙和未来,这是一个宽广、深刻、充满挑战的世界,语言并不足以传其神妙。

这本小册子浓缩了笔者所在公司对软件开发人员近一年的培训课程中的精华部分。这些精华,凝聚了前辈大师们的智慧和心血,是软件开发行业的“圣经”。后来者如我们,不需要为之顶礼膜拜,而是要将其融入自己的思想和智慧。

学习从来是没有诀窍的。如果一定要说有,那么有三条:第一是重复;第二是重复;第三,还是重复。思想的改造更是缓慢而深刻的,或是水滴石穿,或是豁然顿悟,其中的精义都需要我们慢慢去认识和体会。所以,这不是一本可以读完一遍以后就束之高阁的书,您需要把它放在您的桌面,在设计过程中反复参阅以获取设计灵感,即便在闲暇的时候,也可以细细地琢磨、领悟其中的微言大义。第1章 转变观念即使使用了面向对象的编程语言,我们仍然可能以“面向对象之名”,行“面向过程之实”。系统设计仍然可能在结构化设计的圈圈中原地打转。编程语言仅仅是一种语法规则,不可能依赖编程语言的面向对象机制,来掌握面向对象。单纯从编程语言上获得的面向对象知识,不能胜任面向对象设计与开发。正如学会了中文,有的人可以写出《红楼梦》,有的人可以造出“写诗机”和“梨花体”。因此,仅仅知道封装、继承和多态并不足以做出好的面向对象设计,我们需要重新认识对象,以及面向对象设计的精髓。1.1 传统的面向对象

传统的面向对象教科书中,描述了对象的三个基本特征。(1)封装,即内部的改动不会对外部产生影响。例如,访问数据的对象可以使用ADO或DAO对象模型,也可以直接使用ODBC API函数,但都不会影响其外部特性。(2)继承,通过派生来解决实现的重用问题。例如,从SalesOrder类派生出WebSalesOrder类,在WebSalesOrder中,可以重载父类的Confirm方法(发邮件而不是传真),也可以自动继承实现父类的Total方法,实现相同的行为。(3)多态(可替代性),不论何时创建了派生类对象,在使用基类对象的地方都可以使用此派生类对象。不同类型的对象就可以处理交互时使用的一组通用消息,并且以它们各自的方式进行。如前面的例子中,WebSalesOrder“is a”SalesOrder,也就是说,在任何使用SalesOrder的地方,都可以使用WebSalesOrder。

对象之间的关系有以下四种。(1)聚合关系。比如,A聚合了B,B是A的一部分,则表示为A has a B,例如“飞机场has a飞机”。它在UML静态类图中的表示如图1-1所示。(2)组合关系。比如A是由B组成的,A包含B,B是A的一部分,则也表示为A has a B,例如“飞机has a发动机”。它在UML静态类图中的表示如图1-2所示。图1-1 对象的聚合关系图1-2 对象的组合关系(3)继承关系。比如A派生了B,B是A的一种,A是B的泛化,则表示为B is a A。例如,“波音777 is a飞机”。它在UML静态类图中的表示如图1-3所示。(4)依赖关系。比如A依赖B,A使用B,则表示为A use a B。例如,“飞机use a飞行员”。它在UML静态类图中的表示如图1-4所示。图1-3 对象的继承关系图1-4 对象的依赖关系

在传统的面向对象中,我们像下面这样实现对象的基本特征。

1.基于组件的封装。

传统的封装是基于组件的封装。这个时候,模块功能的改变仍然会影响到其他模块。例如,当使用一个类的时候,我们必须清楚地知道这个类有哪些方法、属性、行为和数据,而且不能简单地用另外一个类来替换掉这个类,因为我们还必须对所有对这个类进行过引用的代码进行改变。即使不改变类的方法、域、属性的名字,而只改变类的实现代码,也不能轻易地改变,因为还要进行重新编译。当很多不同组件中的类都对另外一个组件中的类进行了引用的时候,情况有可能变得更糟。

基于组件的封装最明显的后果就是“DLL灾难”。多重应用会依赖于同一个DLL,只要其中的一个应用被更新,这个DLL就要相应地改动以适应这个应用的新版本,随之而来的是其他所有依赖于这个DLL的应用都要改动。

2.基于实现的继承。

传统的继承是基于“白盒重用”的实现继承,是紧耦合的重用。这样会将父类的内部结构暴露出来,继承会把子类紧紧耦合在其父类上,这意味着对父类的修改可能会是子类的灾难。改变层顶端的类通常需要改变许多次级类。而另一方面,冻结关键上级类接口通常会产生一个不能扩展的系统。还有,没有必要的继承层次结构,往往是低内聚、紧耦合的,继承必须限制在一个单独的层次结构中(除非使用多继承)。例如图1-5所示的继承结构。图1-5 基于实现的继承例子

最初我们按照进食特性从动物派生出肉食动物和植食动物,然后按照其移动特性分别从肉食动物和植食动物中派生出了飞行肉食动物、爬行肉食动物、飞行植食动物、爬行植食动物。

可是如果需求变化了,飞行的动物也要求能在地上行走,怎么办?更多的需求变化来了,比如要按哺乳方式分类,怎么办?继续往下派生?进而产生按级数增长的天文数字般的类?

下面我们再来看一个典型的为了适应多个不同客户需求的应用软件的统一版本的“标准面向对象解决方案”,如图1-6所示。

这个解决方案存在以下一些问题。■ 多态:无法获得跨客户的多态。■ 冗余:XXX For C1和XXX For C2业务之间存在冗余。■ 杂乱。■ 紧耦合:不同业务间接地相互关联(例如,某个客户的某个管理

模式需要体现在各个不同的业务中)。■ 弱内聚:相同的业务处理分散在多个类中。■ 类爆炸:如果发生变化(更多的客户,或者更多的业务),存在

着失控的危险。图1-6 统一版本的“标准面向对象解决方案”1.2 重新认识对象

在现代的程序设计中,面向对象不是一个选择,而是一种必需。在现代的程序设计语言中,万物皆对象。

过去我们以为,有了可视化编程,然后把数据和方法装到一个类里面,就是面向对象了。现在我们知道,这种认识有多肤浅。因为面向对象并非是目的,我们的目的是设计“高内聚、松耦合”的软件以应对变化。只有“好的”面向对象设计才能做到这一点。

传统的OOP认识到的不是面向对象的全部,甚至只是其浅陋的部分。传统的OOP没有回答面向对象的根本性问题,即我们为什么要使用面向对象,我们应该怎样使用三大机制来实现“好的面向对象”,我们应该遵循什么样的面向对象原则。

传统OOP的三大机制“封装、继承、多态”可以表达面向对象的所有概念,但并没有刻画出面向对象的核心精神。程序员既可以用这三大机制做出“好的面向对象设计”,也可以用这三大机制做出“差的面向对象设计”。

面向对象的精髓在于“封装”。

传统的面向对象认为封装就是隐藏数据。实际上,对象是有责任的实体,封装是隐藏一切,包括数据、设计细节、实现细节、派生类、实例化规则等。

简而言之,就是封装对象的一切“实现机制”,而只表现出对象的“意图”。

图1-7所示的例子中,封装包含了以下三个方面的内容。图1-7 对象封装的例子■ 数据封装。点、线、方、圆对象中所有的数据对其他对象是隐藏

的。■ 方法封装。例如,圆对象中的SetLocation方法。■ 类型封装。这是最重要的,在“设计模式”中,这就是通常的“封装”的含义。在上面的例子中,除了“圆”以外,其他对象

都不知道“椭圆”的存在;使用“形状”的客户不知道“点、线、

方、圆”对象的存在。

下面是另一个例子,有如下两个程序。(1)我去邮局寄包裹给张三。(2)我去邮局,找了一个邮递员,委托他送包裹。要他先坐车去火车站,买火车票,搭今天晚上9:27的T110次火车,坐36个站,明天下午3:13到达县城,在县城搭38路汽车坐11个站到镇上的汽车站下车,然后往西北方向步行371步在十字路口左转,再步行167步在三叉路口右转,再……找一个叫张三的人,把包裹交给他,然后原路回来,最后向我汇报结果:0表示成功,1表示没找到人,2表示对方拒收,3表示……

第一个程序是面向对象的;第二个程序是面向过程的。第一个程序委托有责任的实体,无知、懒惰,却很幸福;第二个程序事事亲历亲为,最后可能落得劳累过度、吐血身亡。第一个程序是自然语言的表达,是真实生活中的自然场景;第二个程序是机制化的过程,是荒诞世界里的黑色幽默。第一个程序可以应对变化,比如火车、汽车班次的改变;第二个程序遇到变化就会死机。

让这一切截然不同的,就是对象的封装。

对象如果像人类,那么他应该是这样的一种人。■ 无知:只了解自己,不了解他人,让他们无知地幸福着吧!■ 自私:个人自扫门前雪,休管他人瓦上霜,永远不要热心帮助别

人。■ 懒惰:一个人就只干一件工作(一个职责、一种变化),要推卸

责任(不该自己负责的),休想让我身兼两职。■ 孤僻:独立地做自己的事,尽可能地少联络、少依赖其他人。■ 内向:不想要别人知道的东西,绝不让别人知道,包括自己的父

母和儿女。

在构成实现上,对象应同时包含数据和行为,数据好比是躯干、肢体、血肉、器官,而方法好比是灵魂、思想、行为、言语。没有方法的纯数据类是“行尸走肉”,因为纯数据类一定是任其他对象蹂躏、糟踏的对象,自己没有任何主见,也不可能对自己的数据进行封装;而没有数据的纯方法类是“孤魂野鬼”,因为它们只能去操作别人的数据,或者等着别人把数据送上门;如果对象的行为和自己的数据没有任何关系,则是“鬼魂附体”,借用别人的身体表现自己的行为。在设计中应该尽量避免出现这样的对象。既有数据又有方法,才能成为身体健全、人格完整、行为独立、有责任、有隐私的健康的人(对象)。1.3 如何分解对象?

按“变化”进行领域分解和设计。

面对复杂的领域,如何将其分解为对象呢?

传统方法从问题域中寻找名词,并创建对象来表示它们;然后找到与这些名词相关的动词,并在对象中添加方法来实现这些动词。这通常会导致得到比预期更大的类层次结构。

应该尝试将问题域分解为责任,然后定义必需的对象来实现这些责任,让它对自己的行为负责。

从另一个角度理解,“责任”就是对象的意图,其实也就是“变化”。

发现变化,并且封装它!正如《笑傲江湖》中的独孤九剑那样,哪里有破绽,剑尖就指向哪里;哪里有变化,封装和模式就应用在哪里!

千万注意,不要让一个类封装两个要变化的事物,除非这些变化明确地耦合在一起。否则,会降低内聚性,变化之间的耦合也无法松散。

如何发现变化的地方呢?可以使用很多方法,例如,if/switch语句往往预示着变化。作为通用的办法,可以使用共性和可变性分析方法(CVA)。CVA的目的是寻找变化,并用高内聚、松耦合的类封装变化。其原则是每个共性一个问题。否则设计中就不能有较强的内聚。

所谓共性分析,就是寻找一些共同的要素,它们能帮助我们理解系列成员的共同之处在哪里,找到不可能随时间而改变的结构,为架构提供长效的要素。

所谓可变性分析,就是揭示系列成员之间的不同,要找到可能变化的结构,促进架构适应实际使用的需要。变化是相对不变而言的,可变性是相对共性而言的,可变性只有在给定了共性之后才有意义。

表1-1所示的是一个国际电子商务案例的CVA分析矩阵。在行中体现共性(概念、抽象),在列中体现变化(在不同国家的具体实现)。表1-1 电子商务案例的CVA分析矩阵

CVA告诉我们如何明确系统中的变化,然后找到应该在设计中使用什么模式。在某种情况下,找到最重要的特性,用矩阵组织它们,用特性所表示的概念为每个特性标记;继续处理其他情况,按需要扩展矩阵;处理每一情况时应独立于其他情况;用新的概念扩展该分析矩阵:用行发现规则,用列发现特定情况;从分析中确定模式,得到高层设计。

CVA在问题越大、特殊情况越多、人脑越无法得到总体视图时越有用,而且经常会用到子矩阵。

另外,在确定变化点的时候,要保证隔离,确保不同的变化之间划清界限,绝不要拖泥带水。

图1-8所示的是对前文中所举的动物类的继承和派生设计进行改进的例子。图1-8 按“变化”进行重新设计的动物类

在改进的设计中,飞行的动物也要求能在地上行走了,让“移动方式”去处理就好了(同时支持飞行与爬行);更多的需求变化(如新增“哺乳方式”的划分)的时候,只需要增加一个或几个类,而不会导致类爆炸。

传统设计与新设计的不同,就在于我们对对象看法的不同。

在传统看法中,继承就是创建基类(泛化类),然后派生特化类。

按新看法来看,则是将类按相同行为进行分类(对行为变化进行分类),是一种一致地处理概念上相同的各个具体类的方法。

在传统方法中,我们通常从问题域中寻找名词,并创建对象来表示它们;然后找到与这些名词相关的动词,并在对象中添加方法来实现这些动词。这样做通常会导致得到比我们预期更大的类层次结构(如同前面传统方法所设计的动物类结构那样)。

在新方法中,则使用共性和可变性分析的方法来设计:发现变化的地方,并把它封装成为一个对象,让它对自己的行为负责。

在分解问题域的时候,应该按责任来进行分解,没有必要只对问题域进行面向对象的分解(也就是将问题域分解为多少个对象),可以尝试将问题域分解为责任,然后定义必需的对象来实现这些责任(最终还是达到了对象分解的目的)。

我们常说的“松耦合”,使用松紧耦合的讨论实际就是关于支持变动的讨论。当改变很有可能发生时,相对较松的耦合方式就变得更重要一些;相反,当发生变动的可能性较小的时候,松耦合方式的重要性也会相对降低。松耦合意味着:进行变动付出更小的代价;各部分的依赖关系是已知的,是可以被控的;变动并非昂贵耗时的;双方(或多方)中的各个部分可以相互独立地进行改变;变动造成的影响是已知或可以预期的。

松耦合是一种投资:出于对未来变动频度和幅度的预期,决定了我们需要划分类和对象的粒度,以及为此付出的代价。越是变动频繁和快速的系统,越需要更细的划分。

当然,按变动进行松耦合的设计,可能导致接口激增的问题,但它不是以级数增长的,而且不一定是坏事;相反,这正是我们所需要的。1.4 如何设计对象?

软件开发有以下三个视角,如图1-9所示。■ 概念视角:软件要负责做什么?■ 规约视角:怎样使用软件(接口如何)?■ 实现视角:如何履行自己的职责?图1-9 软件开发的视角

传统的面向对象设计只从实现的视角出发,把对象看成数据与方法的简单集合。这使得我们从一开始就跌入了陷阱:关心每个对象的实现细节。

细节的处理方法总是显而易见的,将一切作为特例来解决是非常容易的。这种解决方法直截了当,但引起的结果是高冗余、紧耦合、低内聚、类爆炸。

应该把对象看成具有责任的实体,以下面这样的步骤构建软件。(1)先做一个初步的设计,从概念视角出发,定义共性,规划对象(抽象对象或者接口)的意图,而不操心其具体实现细节。(2)考虑对象如何协同工作,从规约视角出发,考虑需要用什么接口来处理它的所有责任,定义对象间的接口。(3)实现这个设计,从实现视角出发,考虑对于给定的变化,应该怎样根据给定的接口规约来实现具体对象(派生类或者实现)。

按变化设计有以下三个基本原则。(1)针对接口进行编程,而不是针对实现进行编程,不要过早关注细节。(2)优先使用对象组合(聚集),而不是类继承,避免不必要的继承结构。(3)考虑设计中什么是可以改变的,并对变化的概念进行封装(封装到一个单独的类,并将其包含在另一个类中),发现变化并将其封装。1.5 设计对象的接口

什么是接口?从根本上来讲,接口是一套公共方法签名。从设计的观点来看,一个接口就是一个合同,它为一套逻辑相关的请求定义调用的语法。当接口定义方法签名时,它不能包含任何实现或数据属性。由于提供了一定层次的间接性,接口减弱了类和使用它的Client之间的关系,如图1-10所示。图1-10 接口示意图“不要过早关注细节”的原则其实就是在告诉我们:针对接口进行编程,而不是针对实现进行编程。

还是使用前面讲过的“动物类设计”的例子,按接口设计的方案如图1-11所示。图1-11 按接口设计的动物类方案

这个方案同样是能很好地应对变化的。最初的需求可能只是进食特性和移动方式,如果需要增加哺乳方式的需求,只需要增加“I哺乳方式”接口,然后让相应的动物实现这个接口就行了。如果飞行动物也需要在地上行走,那么只要让飞行动物在“I移动方式”接口中增加“行走”方法的实现就行了。

接口是经典OOP的一个发展。传统面向对象中的实现继承是紧耦合的,而接口是松耦合的;实现继承是白盒重用,接口是黑盒重用;黑盒重用性建立在接口和实现正式分离的基础上。一个接口就是一个自定义的、独立的数据类型,类实现的详细资料是永远不会向外暴露的,外部只知道一个可用的请求(是什么)设置,对象永远不会暴露内部详细资料。

但接口与类不同的是,接口不是一个可创建的实体,所以它是一个抽象的数据类型。为了可用,接口必须被一个或多个类实现。一旦接口已经被一个类实现了,客户就能从这个类创建一个对象并通过引用接口来和它通信。

多个类可以实现同一个接口;一个类可以实现多个接口。和运行时类型检查一起使用时,这会变得非常强大。客户可以在运行时检查一个对象,问它是否支持指定的接口。如果对象确实支持这个接口,客户可以调用它的功能;如果对象不支持这个接口,客户可以比较优雅地降低性能。应用程序中,在运行时决定对象的功能的能力是非常有用的;在未来版本的对象中,客户代码可以预见被支持的功能。

接口和实现继承相似。不过,接口无须冒紧耦合的危险,使用实现继承和白盒重用时可能会发生这种情况。

普通类的继承是实现继承,基于接口的编程是建立在第二种形式的继承的基础之上的,这种继承被称为接口继承。

这两种形式的继承都能获得多态,但它们使用封装时就很不同了:■ 实现继承是建立在白盒重用的基础上的。它允许一个次级类了解

它扩展的类的私有信息。这就允许一个次级类对一个上级类的方

法实现和数据属性的绝对重用。■ 实现继承在重用状态和行为方面远比接口继承强大,然而,这一

重用也带来了成本:白盒重用中的封装损失限制了它在大型设计

中的可伸缩性。■ 接口继承作为一个黑盒重用,增强了封装的概念。严格坚持类中

实现的详细资料的封装,可以设计可伸缩性更好的应用程序。■ 基于接口的编程解决了和白盒重用有关的许多问题。

和实现继承比较时,接口继承看起来要做更多的工作。从一个类继承时,大部分工作已经做好了;但当你从一个接口继承时,你的工作才刚刚开始。实现继承看起来、闻起来像是一块三明治,而接口继承看起来像是一碗冒着热气的椰菜。你必须抑制对获得三明治的渴望而达到对接口更高层次的了解。接口继承超过实现继承的主要优点是可扩展性和松耦合。

接口不能包含数据成员,因为接口不像类,是绝不能用于创建对象的。

接口和抽象类也有所不同。在表面上有区别:抽象类允许有公共的状态和行为,而接口没有;对于C#和Java,只允许单继承,所以在不需要的时候不应该使用抽象类,因为只有一个类派生的机会。在设计层次上有区别:抽象类关心如何抽象实现,使用它们的对象才不会与任何特定于实现的细节耦合;而接口关心如果许多实现要被以相同的方式来使用,必须都有什么样的公共接口。两者也各有所长:具有公共状态或行为的对象使用抽象类;而不直接共享公共状态或行为的对象使用接口。

达尔文说过,“最后生存下来的,不是最快的,也不是最强的,而是最适合环境的。”基于接口的编程方法使软件更适于生存。接口使你的代码更容易维护和扩展,因为你可以引入新的接口来安全地扩展各种对象的行为:可以独立地修改客户和对象,原来的客户和原来的对象可以和新客户和新对象和谐地工作;原来的客户和对象可以使用较早的接口,而较新的客户和对象可以通过较新的接口进行通信;所有这些可以通过接口支持的运行时类型检查来实现。如果被请求的接口不被支持,可以发现它并优雅地降低性能。

在紧耦合的软件中,同时依赖于DLL的接口和它的底层实现是造成DLL灾难的一个原因。

与底层实现的绑定是问题的关键所在,而解决方案就是“合同优先设计方法”(按接口设计)。接口对应着软件的三个视角中的“规约视角”。接口不提供实现,是为了更大程度的实现。

为了实现“按接口编程”,“客户方”(Client,与服务方相对)需要:■ 与合同进行绑定,而不是与底层实现绑定(概念视角和规约视

角)。■ 用户与某一组件的绑定并不需要代码编写,可以使用配置(对象

工厂)获得组件访问入口。“服务方”(Server)需要:■ 为重大改动提供新的合同和组件,最好是在保留原有合同和组件

的基础上。■ 在不影响合同和组件的基础上完成对底层实现的替换更新(实现

视角)。■ 将服务方视为一个黑盒子——有责任的对象。■ 服务方为每一个合同提供一个组件(提供多个版本的合同)。■ 进行一个与以往合同不兼容的改变需要增加一个新的组件,也就

是提供一个新合同的访问入口,此时,最好保留旧的组件。1.6 改进对象的继承

因为有继承,所以才有对象的多态。

所谓多态,就是对象的可替代性:不论何时创建了派生类对象,在使用基类对象的地方都可以使用此派生类对象。不同类型的多态对象在交互时使用一组通用消息,并且以它们各自的方式进行。

传统的面向对象设计(OOD)认为继承就是创建基类,然后派生子类。这就是所谓的“实现继承”。实现继承是造成软件紧耦合的另一个原因。

实际上,继承是把类按行为的变化进行分类、一致地处理概念上相同的各个具体类的方法。“接口继承”是传统的实现继承概念的一个发展。

实现继承是紧耦合的,接口是松耦合的;实现继承是白盒重用,接口是黑盒重用。

黑盒重用性建立在接口和实现正式分离的基础上,一个接口就是一个自定义的、独立的数据类型。类的实现的详细资料是永远不会向外暴露的,外部只知道一个可用的请求(是什么)设置。

实现继承并非没有作用,但要注意以下几点。■ 层次最好不超过两层,或者精心规划设计的继承结构。■ 创建基类时要十分小心:要确保正在表达一个清晰的层次,并且

分析出了要重载的行为。■ 把它当作框架设计时需要完成的任务,而不是在设计某一特定应

用和编码时所使用的战略。■ 现代的主流程序设计工具(如.NET Framework)自身就包含了

许多正在被使用的继承实例,并且需要创建派生类才能执行很多

通常的编程活动,应当习惯从框架设计提供的基类中派生类。■ 优先使用对象组合(聚集),而不是类继承,以避免不必要的继

承结构。1.7 设计抽象的系统

在实现了“高内聚,松耦合”之后,系统会自然地发展成为一个抽象的系统。这样的系统具有更好的适应未来变化的能力。

我们过去习惯于一上来就扎进细节中硬编码,不考虑以后如何维护的长期问题,这样就带来了一个僵化的、不可维护的设计;又或者走向反面——过度分析、过度设计、永远无法交付。

我们现在有第三种选择:“可维护”的设计。那就是能够适应变化的设计,在设计中能考虑什么是可变的(进行适当的抽象),并且在维护过程中不断地重构。

为了设计抽象的系统,不要死死盯住具体的需求,应该站在整个系统设计的高度,考虑如何规划一个抽象的设计、良好的软件结构,以实现特化的需求:■ 透过具体看到抽象。■ 透过细节看到整体。■ 透过表象看到本质。■ 透过实现看到概念。1.8 设计美的系统

我们今天在这里学习的目的,就是要创造高质量的、美的软件。

那么,如何看待质量和速度的关系呢?两者是不是矛盾的呢?

新手往往认为:美是不实用的,任何事情必须快;为了追求速度,可以适当地牺牲质量。错!高质量的、美的软件更廉价、更快捷、更灵活、更易于理解、交付得更快、代价更小、更能适应变化、更具有生存力;构建和维护这样的软件是一种快乐,是一种骄傲。而当你为了速度而打算放弃一部分对质量的追求的时候,最后会发现:你的(最终)速度反而变慢了!

美的软件表现在以下几个方面。■ 功能之美:通过直观、简单的界面呈现出恰当的特性。■ 质量之美:高质量的软件、无缺陷的代码让我们充满信心。■ 结构之美:软件被简单、直观地分割,具有最小的内部耦合。■ 过程之美:每周都会取得重大进展,并且产生出具有活力的团队。第2章 经典模式每个模式描述了一个在我们周围不断重复发生的问题,以及该问题的解决方案的核心。这样,你就能一次又一次地使用该方案而不必做重复劳动。——Christopher Alexander正如建筑设计师考虑更多的是如何更快完成工作并得到奖赏,而不是更多地考虑如何让住户舒适一样,实际的设计过程中,设计人员更多地考虑如何解决业务问题,对于软件内部结构考虑较少。设计模式则填补了这个缺陷。良好软件的基本特征是高内聚、松耦合,这样的软件更易于维护、扩展和复用,设计人员正确地使用设计模式就可以优化系统内部的结构。应用设计模式的好处在于重用经典、优秀的设计方案、形成更有效交流的模式语言、提供更高层次的设计视角,从过早关注细节的桎梏中解脱出来。设计模式就像武功中基本的招式。我们将这些招式合理地组合起来,就形成套路。招式攻击的是敌人的破绽,而模式则是要封装领域中的变化。招式的最高境界是无招胜有招,而模式的最高境界则是忘记模式。如果说我们以前写程序像是在做算算术题,那么设计模式又像是代数,它用泛化的、抽象的代数符号代表着多态的、具体的取值,帮助我们针对遇到的问题列出代数方程式的思考方法。问题千变万化,解决问题的代数方法也各有千秋。本章给我们展示了其中的一些问题和方法,但它不是参考指南,而是引导我们登堂入室,从小朋友的算术时代进入高深莫测、五彩斑斓的数学王国。

设计模式(Design Parttern)一词是建筑师Christopher Alexander提出的。在若干年前,他一直在扪心自问这样一些问题:在文化人类学中,如果超越个人信仰和文化差异,对美感的评价是否具有一致性?是否存在一个描述我们共同认知的基础?质量是客观的吗?是什么因素让我们认为一个建筑(软件)设计是好的设计?他最后给出的答案是肯定的:那些美的建筑都符合一定的模式,并且写出了著名的《建筑的永恒之道》一书。

模式学说数十年来影响深远,其影响力横贯了建筑学、装饰学、工业设计、软件设计乃至人类学、哲学的领域。

在20世纪90年代,软件领域的设计模式经典著作《设计模式:可复用面向对象软件设计基础》诞生了。其四位作者Erich Gamma、Richard Helm、Ralph Johnson、John Vlissides被尊称为“GoF四人组”,其描述的23种经典模式被称为“GoF设计模式”。

设计模式就是解决某个特定的问题的特定方法。需要对面向对象设计中建立的类型进行更进一步的抽象。小到代码,大到架构,都是如此。

设计模式是经过提炼和验证的设计思想。好的程序员需要了解尽可能多的模式,不断学习新的模式,更重要的是识别反复出现的问题并通过模式来解决问题。

我有一个很有趣的比喻:模式就像是武功的招式。我们将这些招式合理地组合起来,就形成套路。

村子里面的小顽童打架和江湖中的大侠巅峰对决,有什么差别?武侠小说中传说的内力是什么东西,我是不知道。但是我知道大侠们打架可比顽童打架好看多了,也讲究多了。大侠之间未必能分出胜负,而大侠如果对付顽童,那结果是不言而喻的。

也就是说,要编写面向对象的程序,如果你是没有掌握模式的程序员,那么你就跟打架的顽童没有什么区别。

当然,正如武功中对于招式一板一眼、规规矩矩刻板地盲从,只能造就不会应变的耍花枪,模式可以有很多变体。本章的模式结构图仅仅是例子而已,并不是规范。结构图只是向我们描绘了最常见的实现,具体领域的实现和结构图的差别可能相当大。

还有,模式的最高境界就是:忘记模式。

模式的鼻祖Christopher Alexander在《建筑的永恒之道》一书中说道,“在这最后阶段,模式不再重要……”(P418),就是这个意思。在武功中,我们也有“无招胜有招”的说法:《笑傲江湖》中的风清扬传授给令狐冲的独孤九剑,《倚天屠龙记》中的“太极初传柔克刚”。我还清晰地记得小说中张三丰传授太极拳给张无忌的时候,一直问他还记得多少,张无忌说记得的越来越少,旁人听得着急不已,到最后张无忌说全部都忘记了的时候,张三丰欣然言道:“可以了,那就都学会了。”

为什么要学习模式?

良好程序的一个基本标准是高内聚,松耦合。不良或者没有充分考虑的设计将会导致软件重新设计和开发。实际的设计过程中,设计人员更多地考虑如何解决业务问题,对于软件内部结构考虑较少。设计模式则填补了这个缺陷。它主要考虑如何减少对象之间的依赖性,降低耦合程度,使得系统更易于扩展,提高了对象的可复用性。因此,设计人员正确地使用设计模式就可以优化系统内部的结构。

模式的好处有如下几点。■ 复用解决方案:复用公认的设计,能学习他人经验,避免重蹈覆

辙。■ 确立通用术语:在分析和设计阶段都提供了共同的基准点,用于

交流和协作的共同词汇基础和对问题的共识。■ 提供更高层次的视角,从过早处理细节的桎梏中解放出来。■ 使用更优秀的设计。■ 提高可维护性。■ 发现巨型继承层次结构的替代方案。

上面的第二点,我也有一个有趣的比喻。

比方我要装修房子了,我跟设计师说,这里我要装两个上水口、两个下水口,两个上水口一个是冷水口、一个是热水口,两个下水口一个是污水下水口、一个是排泄物下水口;这里要装有线电视线、电话线、网络线……听得设计师一头雾水,打断了我,“你就说这里要装一个厕所,那里是客厅不就完了嘛。”

是的,“厕所”、“客厅”就是所谓的“模式语言”。如果我们不使用模式语言,沟通就会很累。厕所和客厅都是有其固定的模式的,这些模式都经过了几十年的锤炼,是大家公认的,我们也很清楚里面应该有什么东西、不应该有什么东西、它们的内部结构一般应该是什么样的。

说到底,学习模式,就是学一种理念、一种审美观、一种思考方式。

面向对象指使用离散的对象来构建软件系统;而设计模式利用了对象的继承、组合和委托(Delegation),在比OOP更高的层次上考虑问题,尤其是使用委托来对任何不稳定或不确定的方面,如状态、对象的创建、应用平台等,进行封装,从而保证了源代码的重用和设计的稳定。因此设计模式可以理解成OOP中虚函数、多态概念的延伸:OOP中的虚函数和多态实现的是方法、对象行为上的多态;而设计模式则对创建、结构和高层次的行为进行了多态。

解决“类与相互通信的对象之间的组织关系”,包括它们的角色、职责、协作方式几个方面。所谓“好的面向对象设计”,是指那些可以满足“应对变化,提高复用”的设计。

设计模式描述的是软件设计,因此它是独立于编程语言的,但是最终实现仍然要使用编程语言来表达。设计模式不像算法技巧,可以照搬照用,它是建立在对“面向对象”纯熟、深入理解的基础上的经验性认识,掌握面向对象设计模式的前提是首先掌握“面向对象”!

有时我们可能会问:模式虽好,但是不会使代码更复杂吗?

代码复杂与否很多时候是主观的。人们对模式的熟悉程度极大地左右他们如何看待基于模式的重构。当他们不熟悉某个模式时,会认为模式过于复杂,而熟悉了某个模式之后,通常就不会这么认为。

模式是前人智慧的结晶,重用这种智慧是非常有益的。通常,实现模式有助于去除重复、简化逻辑、澄清意图、提高灵活性。尽量地学习更多的模式,而不要认为模式太复杂而不使用模式。

但是,有些模式确实使代码更复杂。出现这种情况,就应该返回做进一步的重构(发现问题再处理,而不是凭空担心)。

应用设计模式的最好方式就是:重构为模式(Refactoring to Patterns)。也就是说,不是不加变通地套用模式,而是通过重构渐进地引入设计模式,这样才可能避免过度设计。重构使我们的注意力集中在去除重复、简化代码、澄清意图,从而使我们更明智地使用模式。

在哪里应用设计模式的一个重要指标就是:看哪里有变化点。

设计模式建立在对系统变化点的基础上进行,哪里有变化点,哪里应用设计模式。设计模式应该以演化的方式来获得,系统的变化点往往经过不断演化才能准确定位。

这个也有一个很有趣的比喻:变化点就像武功中的“破绽”。风清扬传授的“无招胜有招”的独孤九剑要诀,就是寻找对手的“破绽”,只要找到了对手的破绽在哪里,就把剑尖指向哪里,敌人即不战自败。应用设计模式的时候,要先找到哪里会发生(需求的)变化,然后在那里寻找合适的模式并运用。在不会发生任何变化的地方,是不需要使用设计模式的。

设计模式的危险就是:为了使用模式而使用模式,把任何需求都看作模式的拼凑,一开始就使用模式来设计。正如学习武功而痴迷于招式,同样会走火入魔。模式不是目的,目的是更好地设计。只有明智地使用模式,才能为我们带来模式的乐趣。

本章中收录的模式,不仅包括设计模式,还有分析模式、架构模式等。模式的种类区分并不重要,重要的是了解并融入模式的思想和语言。到了最后,模式将不再重要,因为设计模式是起点,而不是终点。我们自己也可以发现模式(也就是传说中的“自创招式”),也可以根据敏捷软件的原则自己把模式推导出来。2.1 设计模式的基本原则

不论是应用,还是自创设计模式,应遵循的基本原则包括以下几条。■ 开闭原则(Open-Closed Principle,OCP),详见第3章。■ 从背景设计原则:在设计各部分呈现细节之前,先创建总体概念(Big Picture),然后逐步复杂化。不要过早关注于细节。■ 依赖倒置原则(Dependency Inversion Principle,DIP),详见第

3章。■ Liskov替换原则,详见第3章。■ 封装变化原则:不要让一个类封装两个要变化的事物,除非这些

变化明确地耦合在一起,否则,会降低内聚性,变化之间的耦合

也无法松散。2.2 抽象工厂模式(Abstract Factory)意图:提供一个创建一系列相关或者相依赖的对象的接口,而无须指定它们的具体类型。

上下文:多个系列的Product需要被Client创建,需求在变化,需要创建的Product也随之变化,Client不得不采用硬编码的方式,写很多New语句来创建ConcreteProduct。如何绕过常规的New方法,提供一种封装来避免Client和“多系列ConcreteProduct的创建工作”的紧耦合?图2-1 抽象工厂模式静态类图■ AbstractFactory:声明一个创建AbstractProduct对象的抽象接

口。■ ConcreteFactory:实现创建ConcreteProduct对象的操作。■ AbsbractProduct:为一类产品对象声明一个接口。■ ConcreteProduct:定义一个将被相应的ConcreteFactory创建的

产品对象。■ Client:只使用AbstractoryFactory和AbstractProduct声明的接

口。2.3 抽象服务模式(Abstract Server)意图:定义一个服务的抽象接口,而将实现延迟到子类中,使得子类可以在不改变服务的抽象接口的情况下就可以重新定义其特定的具体实现。

上下文:Client依赖于某个Server所提供服务的具体实现,这种依赖性使得Client和Server双方都难以扩展和变化。如何消除这种依赖性,使得双方都能变化和扩展,让Client不受Server的影响,而Server的变化又不影响Client?图2-2 抽象服务模式静态类图■ AbstractServer:定义抽象的服务接口。■ ConcreteServer:实现抽象的服务接口以完成特定的、变化的实

现。■ Client:使用抽象的服务接口提供的服务,不依赖抽象服务的任

何具体实现。

该模式是DIP(依赖倒置)原则的直接体现。

注意:AbstractServer应该从Client的角度出发进行命名,因为接口属于Client,而不是其实现,Client与接口之间的绑定关系要强于派生类和接口之间的绑定关系。2.4 无环访问者模式(Acyclic Visitor)

注意:学习本模式之前请先学习访问者模式。意图:消除Visitor模式中的依赖环,同时让Visitor不要为每个ConcreteElement都实现Visit方法,而是可以选择其中一些来实现,即忽略某些ConcreteElement和Visitor的组合。

上下文:访问者模式的固有缺点是存在依赖环:Element依赖于Visitor,而Visitor中对每一个ConcreteElement有一个对应的方法,因此Visitor依赖于ConcreteElement,而ConcreteElement又依赖于Visitor,于是依赖环出现了。如果Element不需要经常增加新的ConcreteElement,那么访问者模式可以很好地工作。否则的话,增加起来会很费劲或者会破坏设计。同时,Visitor依赖于每个ConcreteElement,对每个ConcreteElement都要实现Visit方法;对于某些不需要实现Visit方法的ConcreteElement,则显得没有必要。图2-3 无环访问者模式静态类图■《 degenerate》Visitor:把Visitor变成退化类——没有任何方法的

类。■ ConcreteElement:Accept方法把Visitor转型为适当的

ConcreteElementXVisitor,如果转型成功,就调用相应的Visit函

数。■ 其他:同访问者模式。2.5 适配器模式(Adapter)意图:将一个类的接口转换为Client希望的另一个接口,使得原本由于接口不兼容而不能一起工作的那些类在一起工作。

上下文:需要将一些“现有的对象”放在新的环境中应用,但是新环境要求的接口是这些现存对象所不满足的。如何应对这种“迁移的变化”?如何既能利用现有对象的既有实现,同时又能满足新的应用环境所要求的接口?图2-4 适配器模式静态类图■ Target:定义Client使用的与特定领域相关的接口。■ Client:与符合Target接口的对象协同工作。■ Adaptee:定义一个已经存在的接口,这个接口需要适配。■ Adapter:对Adaptee的接口与Target接口进行适配。2.6 桥接模式(Bridge)意图:将抽象部分和实现部分分离,使它们都可以独立变化。

上下文:某些类型由于自身的逻辑,具有两个或多个维度的变化。如何应对“多维度的变化”?如何使得该类型既能够沿着多个方向进行变化,又不引入额外的复杂度?■ Abstraction:定义抽象类的接口;维护一个指向Implementor类型

对象的指针。■ RefinedAbstraction:扩充Abstraction定义的接口。图2-5 桥接模式静态类图■ Implementor:定义Implementor类型的接口,这个接口可以和

Abstraction接口完全不同。一般而言,Implementor接口提供基本

操作,而Abstraction接口定义较高层次的操作。■ ConcreteImplementor:实现Implementor接口。2.7 生成器模式(Builder)意图:将一个复杂对象的构建和表示相分离,使得相同的构建过程可以创建不同的表示。

上下文:需要创建一个复杂对象,该对象由多个子对象通过一定的构建算法构成。由于需求的变化,这个复杂对象的各个部分经常面临着剧烈的变化,但是将它们组合在一起的算法却相对稳定。如何应对这种变化?如何提供一种封装来隔离出“复杂对象的各个部分”的变化,从而保持系统中的“稳定构建算法”不随着需求的改变而改变?图2-6 生成器模式静态类图■ Director:构造一个使用Builder接口的对象,稳定的构建算法封

装在这里。■ Builder:为创建一个Product对象的各个Part指定抽象接口,创建

Part的步骤封装在这里。■ ConcreteBuilder:实现Builder接口以构造和装配该Product的各个

Part;定义并明确它所创建的表示;提供一个检索产品的接口。■ Product:● 表示被构造的复杂对象,ConcreteBuilder创建该Product的内部表示并定义其装配过程;● 包含定义Part的类,包括将这些Part装配成最终产品的接口。2.8 职责链模式(Chain of Responsibility)意图:使多个对象都有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系。将这些对象连成一条链子,沿着这条链子传递请求,直到有其中一个处理它为止。

上下文:一个请求可能被多个对象处理,但是每个请求在运行时只能有一个接收者;如果显式指定,将必不可少地带来请求发送者与接收者的紧耦合。如何使请求的发送者不需要指定具体的接收者?如何让请求的接收者自己在运行时决定处理请求,从而使两者解耦?图2-7 职责链模式静态类图■ Handler:定义一个处理请求的接口;实现后继链(可选)。■ ConcreteHandler:处理其负责的请求;可以访问它的后继者;

如果可以处理该请求,就处理;否则将该请求发给其后继者。■ Client:向Handler提交请求。2.9 命令模式(Command)意图:将客户的“请求”封装为一个对象,从而可以用不同的请求对象来对客户进行参数化,还可以对客户的请求进行排队处理,记录客户请求的日志,以及支持客户撤销其请求。

上下文:耦合是软件不能抵御变化灾难的根本性原因,不仅实体对象与实体对象之间存在耦合关系,实体对象与行为操作之间也存在耦合关系。另外,在某些场景中,需要对行为进行“记录、undo、redo、事务”等处理。在这种情况下,如何将“行为请求者”与“行为实现者”解耦?能否将一组行为抽象为对象,以实现与Client之间的松耦合?图2-8 命令模式静态类图■ Command:声明执行操作的接口。■ ConcreteCommand:将一个Receiver绑定到一个动作;调用

Receiver的相应操作,以实现Execute。■ Client:创建一个ConcreteCommand并设定其Receiver。■ Invoker:要求Command执行请求。■ Receiver:知道如何实施与执行与一个请求相关的操作,任何类

都可能作为一个Receiver。2.10 组合模式(Composite)意图:将对象组成树状结构以表示“部分-整体”的层次结构,使得Client对单个对象和组合对象的使用具有一致性。

上下文:在树型结构的问题中,Client必须以不同的方式处理单个对象和组合对象。能否提供一种封装,统一简单元素和复杂元素的概念,让对象容器自己来实现自身的复杂结构,让Client可以像处理简单元素一样来处理复杂元素,从而使Client与复杂元素的内部结构解耦?图2-9 组合模式静态类图■ Component:● 为Composite中的对象声明接口;● 在适当情况下,实现所有类公共接口的默认行为;● 声明一个接口,用于访问和管理Component的子部件;● 在递归结构中定义一个接口,用于访问一个父部件,并在适当的情况下实现它。■ Leaf:在Composite中表示叶子对象。■ Composite:存储子部件,并定义有子部件的那些部件的行为。■ Client:通过Component接口操作Composite的对象。2.11 装饰模式(Decorator)意图:用比增加子类更灵活的方式,动态地给对象添加一些额外的职责。

上下文:使用继承来扩展对象是静态的,这种扩展方式缺乏灵活性,而且随着子类的增多(扩展功能的增多),各种子类的组合(扩展功能的组合)会导致更多子类的膨胀。怎样才能使“对象功能的扩展”能够根据需要来动态地实现,同时又避免“扩展功能的增多”带来的子类膨胀问题?如何使得任何“功能扩展变化”所导致的影响降为最低?图2-10 装饰模式静态类图■ Component:定义接口,可以给这些接口的对象动态地增加职责。■ ConcreteComponent:定义对象,可以给这些对象动态地增加职

责。■ Decorator:维持一个执行Component的指针,并定义一个和

Component接口一致的接口。■ ConcreteDecorator:向组件添加职责。2.12 扩展对象模式(Extension Object)意图:让层次结构中的每个对象都保持一个特定的扩展对象的列表,同时给每个对象提供一个按名字查找扩展对象的方法,从而可以在扩展对象中动态地扩展对象的功能。

上下文:访问者模式中,不同的ConcreteElement的Visit功能会集中到同一个ConcreteVisitor对象中,无法把不同的ConcreteElement的Visit功能分离,以符合SRP(单一职责原则)。其目的是提供访问者模式的另一种功能更强大的实现方式,以优雅地实现这个目标:把针对每种不同的ConcreteElement对象的Visit代码分离到它自己的类中?图2-11 扩展对象模式静态类图■ Element:定义按关键字符串添加Extension对象和获取Extension

对象方法的接口,持有一个扩展对象的列表。■ ConcreteElement:需要扩展功能的层次结构对象。■《 degenerate》Extension:退化接口,没有任何方法,仅作为

Extension对象的一个标记。■《 degenerate》BadExtension:退化接口,没有任何方法,作为

无效的Extension对象的标记。■ ConcreteExtension:通过接口定义和实现分别来扩展Element的

功能。2.13 外观模式(Façade)意图:为子系统中的一组接口提供一致的界面,定义一个高层接口,使得这个子系统更易于使用。

上下文:Client经常会与复杂系统的内部产生耦合,而导致Client随着复杂系统内部的变化而变化。如何简化Client与复杂系统之间的交互接口?如何将复杂系统的内部子系统与Client之间的依赖解耦?图2-12 外观模式静态类图■ Façade:知道哪些子系统类负责处理请求;将Client的请求代理

给适当的子系统类。■ 子系统类:实现子系统的功能,处理由Façade指派的任务,没

有Façade的任何信息,即没有指向Façade的指针。2.14 工厂方法模式(Factory Method)意图:定义一个用于创建对象的接口,让子类决定实例化哪个类。Factory Method使得一个类的实例化延迟到其子类。

上下文:Client需要创建“某个对象”。由于需求的变化,这个对象的具体实现经常剧烈地变化,但是它却拥有比较稳定的接口。如何应对这种变化,提供一种封装来隔离出这个“易变对象”的变化,从而保持系统中“其他依赖该对象的对象”不随着需求的改变而改变?图2-13 工厂方法模式静态类图■ Creator:声明FactoryMethod,该方法返回一个Product类型的对

象(也可以定义一个FactoryMethod的默认实现,返回默认的

ConcreteProduct对象)。■ ConcreteCreator:重新定义FactoryMethod的实现,返回一个

ConcreteProduct对象。■ Product:定义FactoryMethod所返回对象的类型。■ ConcreteProduct:实现Product接口。2.15 享元模式(Flyweight)意图:运用共享技术支持大量细粒度的对象。

上下文:面向对象的思想很好地解决了抽象性的问题,一般也不会出现性能上的问题。但是在某些情况下,对象的数量可能会太多,从而导致了运行时的代价。如何避免大量细粒度的对象,同时又不影响Client使用面向对象的方式进行操作?■ Flyweight:描述一个接口,让它通过这个接口可以接收外部状态(extrinsicState)并可以作用于外部状态。■ ConcreteFlyweight:实现Flyweight接口,为内部状态(InstrisicState)增加存储空间。它必须是共享的,其状态必须

是内部的,即必须独立于其被使用的场景。图2-14 享元模式静态类图■ UnsharedConcreteFlyweight:Flyweight使得共享成为可能,但它

不强制要求其所有子类都被共享,UnsharedConcreteFlyweight子

类就是非共享的Flyweight子类。它们通常将ConcreteFlyweight作

为其子节点。■ FlyweightFactory:创建并管理Flyweight对象;确保合理地共享

Flyweight——当Client请求一个Flyweight时,它提供一个已经创

建的实例或者创建一个。■ Client:维持对Flyweight的引用、计算或存储Flyweight的外部状

态。

注意:享元是一个不适合于领域建模的设计模式,因为模式应该说明问题域中的某个概念,而不仅仅是针对某个技术问题的技术解决方案。享元适用于值对象,而不适用于实体。享元是一种实现(编码)阶段的选择,却不能用于说明某个概念,因此不能用于领域模型,详见第6章。2.16 解释器模式(Interpreter)意图:给定一个语言,定义其文法的一种表示,并定义一个解释器,这个解释器使用该表示来解释语言中的句子。

上下文:如果业务规则频繁变化,类似的模式不断重复出现,这种特定类型的变化发生的频率足够高,但效率不是关键。能否抽象并表示一种简单的语法规则,这种语法能将各种变化表述为一种简单语言的句子,以应对这种频繁的变化?图2-15 解释器模式静态类图■ AbstractExpression:声明一个抽象的解释操作,这个接口为抽

象语法树中所有节点共享。■ TerminalExpression:实现语法中的终结符的解释操作,一个句

子中的每个终结符都需要该类的一个实例。■ NonterminalExpression:● 语法中的每个规则R::=R,R,…,R都需要一个这样的类;12n● R,…,R中每个符号都需要该类的一个实例;1n● 实现语法中非终结符的解释操作,解释一般需要递归地调用R,…,R的解释操作。1n■ Context:解释器之外的全局信息。■ Client:● 构建表示语法中一个特定的句子的抽象语法树,该抽象语法树由TerminalExpression和NonterminalExpression的实例装配而成;● 调用AbstractExpression的解释操作(Interpret)。2.17 迭代器模式(Iterator)意图:提供一种方法顺序访问一个聚合对象中的各个元素,而又不需要暴露该对象的内部表示。

上下文:集合对象内部结构常常变化各异。对于这些集合对象,能否在不暴露其内部结构的同时,让外部Client透明地访问其中包含的元素,同时让这种“透明遍历”也为“同一种算法在多种集合对象上进行操作”提供可能?图2-16 迭代器模式静态类图■ Iterator:定义访问和遍历元素的接口(.NET中定义了标准的

IEnumrator接口)。■ ConcreteIterator:实现Iterator接口,同时在对Aggregate遍历时

跟踪当前的位置。■ Aggregate:定义创建相应Iterator对象的接口(.NET中定义了标

准的IEnumrable接口)。■ ConcreteAggregate:实现创建相应Iterator对象的接口,该操作

返回一个适当的ConcreteIterator实例。注意:● .NET中的foreach关键字在编译时会自动创建迭代器对象,并使用该对象对集合进行遍历。● .NET中的yield return关键字使得定义迭代器对象更加容易。2.18 中介者模式(Mediator)意图:用一个中介对象来封装一系列对象的交互,中介者使得各个对象不需要显示地相互引用,从而使耦合松散,而且可以独立地改变它们之间的交互。

上下文:多个对象互相关联交互,对象之间维持一种复杂的网状引用关系。如果遇到一些需求更改,这种直接的引用关系将面临不断的变化。如何管理对象间的复杂关联关系,避免相互交互的对象之间的紧耦合引用关系,从而更好地抵御变化?图2-17 中介者模式静态类图■ Mediator:定义一个接口与各个Colleague通信。■ ConcreteMediator:● 通过协调各个Colleague实现协作行为;● 了解并维护它的各个Colleague。■ Colleague:● 每个Colleague都知道它的Mediator对象;● 每个Colleague在需要与其他Colleague通信的时候,都与它的Mediator通信。2.19 备忘录模式(Memento)意图:在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态,这样就可以在以后将该对象恢复到原先保存的状态。

上下文:某些对象的状态在转换过程中,可能由于某种需要,要求程序能够回溯到对象之前处于某个点时的状态。如果使用一些公共接口来让其他对象得到对象的状态,便会暴露对象的细节实现。如何实现对象状态的保存与恢复,但同时又不会因此而破坏对象本身的封装性?图2-18 备忘录模式静态类图■ Memento:● 存储Originator(原发器)的内部状态,原发器根据需要决定Memento存储自己的哪些内部状态;● 防止Originator以外的其他对象访问Memento。Memento实际有两个接口,Caretaker只能看到其窄接口(只能将Memento传递给其他对象);而Originator可以看到一个宽接口(允许访问返回到之前状态所需的数据)。理想情况是,只允许生成该Memento的那个Originator访问该Memento的内部状态。■ Originator:创建一个Memento,用以记录当前时刻的内部状

态;使用Memento恢复内部状态。■ Caretaker:负责保存Memento;不能对Memento的内容进行操

作或检查。2.20 空对象模式(Null Object)意图:消除对Null进行检查的需要,简化代码。

上下文:在执行任何方法前都要判断非空性是丑陋而烦琐的写法,而使用try/catch更加丑陋,而且为了抛出异常,就必须先定义异常。如何才能在执行对象的方法之前消除对对象是否为空的判断,简化丑陋的代码,保持一致性?图2-19 空对象模式静态类图■ ObjectRepository:创建并管理Object的实例。实际应用中多是根

据客户方的请求从数据库查找并产生Object的实例。当数据库中

存在该对象时,创建ObjectImplementation的实例,否则创建

NullObject的实例(参见领域模型中的Repository模式)。■ Object:定义客户方使用的对象接口。■ NullObject:实现Object接口,并执行空操作,它表示数据库中

实际不存在的对象。空操作的具体含义与Object相关,例如

Name属性可能返回空字符串,而bool Save()操作则不执行任

何动作并返回true。■ ObjectImplemention:正常的Object实现,它表示数据库中实际

存在的对象。2.21 观察者模式(Observer)意图:定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并且被自动更新。

上下文:需要为某些对象建立一种“通知依赖关系”,即一个对象(Subject对象)的状态发生改变,所有的依赖对象(Observer对象)都需要得到通知。如果这样的依赖关系过于紧密,将使软件不能很好地抵御变化。如何将这种依赖关系弱化,并形成一种稳定的依赖关系,从而实现松耦合?■ Subject:知道它的观察者,可以有任意多个观察者,同时提供

注册和删除观察者的接口。■ Observer:为那些在Subject发生改变时需要获得通知的对象定

义一个更新接口。■ ConcreteSubject:将有关的状态存入各ConcreteObserver对象。

当它的状态改变时,向它的各个Observer发出通知。图2-20 观察者模式静态类图■ ConcreteObserver:维护一个指向ConcreteSubject的引用;存储

有关状态,这些状态和Subject的状态保持一致;实现Observer

的更新接口,以使自身状态与Subject的状态保持一致。

注意:C#中的delegate充当了Observer接口,提供事件的对象充当了Subject,delegate是比Observer更为松耦合的设计。2.22 原型模式(Prototype)意图:用原型对象的实例指定创建对象的种类,并通过克隆这些原型来创建新的对象。

上下文:产品类具有一定的等级结构,这个等级结构是动态变化的。如果用工厂模式,则与产品类等级结构平行的工厂方法类也要随着这种变化而变化,这显然不太合适。那么,如何封装这种动态的变化,从而使依赖于这些易变对象的Client不随着产品类的变化而变化?■ Prototype:声明一个克隆自身的接口。■ ConcretePrototype:实现一个克隆自身的操作。■ Client:让一个原型对象克隆自己,从而创建一个新的对象;它

依赖抽象的Prototype,从而满足依赖倒置原则。图2-21 原型模式静态类图2.23 代理模式(Proxy)意图:为其他对象提供一种代理,以控制对这个对象的访问。

上下文:有些对象有时候由于某些障碍(比如对象创建的开销很大,或者某些操作需要安全控制,或者需要进程外的访问等),不能够或者不想直接访问;如果直接访问,会给系统带来不必要的复杂性。如何在不失去透明操作对象的同时来管理/控制这些对象特有的复杂性?能否在Client和目标对象之间增加一层间接层,让代理对象来代替目标对象打点一切?图2-22 代理模式静态类图■ Proxy:● 保存一个访问RealSubject的引用,如果Subject和RealSubject接口相同,则Proxy会引用Subject;● 提供一个与Subject接口相同的接口,这样Proxy就可以替代RealSubject;● 控制对RealSubject的存取,并可能创建和删除它;● 其他功能依赖于代理的类型,RemoteProxy负责对请求和参数进行编码,并发送编码的请求;VirtualProxy缓存RealSubject的附加信息以延迟对它的访问;ProtectionProxy检查调用者的权限。■ Subject:定义RealSubject和Proxy的公共接口,这样就在任何使

用RealSubject的地方都可以使用Proxy。■ RealSubject:定义Proxy所代表的实体。2.24 数量模式(Quantity)意图:在记录测量信息时,封装不同单位的数量之间的转换、比较、基本运算、显示、解析等一系列的操作。

上下文:一般记录测量信息采用基本的数字类型,缺乏单位,也缺乏不同单位之间的自动转换。如何封装测量信息的数字和单位,并封装不同单位之间的自动转换以及显示等各种与数量概念相关的职责?图2-23 数量模式静态类图■ Quantity:● 包含数字和单位;● 通过重载运算符支持不同数量之间的基本算术运算;● 封装不同单位之间的转换、数量信息的显示、解析等操作,具体操作可能委托给Unit进行。■ Unit:● 包括单位的名称,还可能包含与基准单位之间的转换率等属性;● 接受Quantity的委托,处理和本单位相关的操作(转换、显示、解析等)。■ Weight、Length……:质量单位、长度单位等各种具体单位,派

生于Unit。■ ConversionRatio:引用两个Unit,实现从一个Unit到另一个Unit

的转换。● 从米到厘米,可以定义一个1:100的转换率。转换具有复合传递性,厘米到毫米的转换率是1:10,那么将两个转换率复合,可以将米转换为毫米。● 某些特殊的转换率需要实现单独的实例方法(如摄氏温度与华氏温度的转换)。● 月和日的转换关系是不确定的,币种的转换率是不固定的,需要指定时间范围。2.25 范围模式(Range)意图:封装范围,不要让那些使用范围的对象对范围进行重复定义和计算。

上下文:对于范围,我们并不关心其起始点和终止点到底是多少,而是关心某个值是否在范围内,两个范围是否相交、相邻、包含、连续等等,这些操作通常在客户代码中重复定义。如何对范围相关的操作进行封装,减少客户代码中的重复?图2-24 范围模式静态类图■ Range<T>:在.NET中可以使用泛型实现。2.26 单件模式(Singleton)意图:保证一个类仅有一个实例,并提供一个全局访问点。

上下文:没有控制对象实例化的全局对象(C#中不允许全局变量),希望所有Client使用该对象的相同实例,而无须将引用传给它们。如何绕过常规的构造器,提供一种机制来保证一个类只有一个实例?图2-25 单件模式静态类图■ Singleton:● 定义一个静态的getInstance()方法,允许客户方访问它的唯一实例;●( 可能)负责创建它自己的唯一实例;● 通过将构造函数私有化的办法使得客户方只能通过getInstance()访问Singleton实例。

单件模式的变体包括双重锁定模式(Double-Checked Locking,多线程环境中使用)、对象池模式(Object Pool,如Com+)。

要慎用有状态的Singleton,因为这样的全局数据会使系统的不同部分耦合。而无状态的Façade对象、无状态的Strategy对象、无状态的Factory对象都可以这样实例化。2.27 规格模式(Specification)意图:Specification提供了一种精确表达某些业务规则的方法,它将规则从条件逻辑中抽取出来,并显式地体现到模型中。

上下文:业务规则往往不适于放在任何一个对象中,因为规则的变化和组合会掩盖那些对象的基本职责;而如果将规则移出领域层会更糟糕,因为领域代码不再能够表达模型了。如何让业务规则得以保持在领域模型中,同时又不混淆对象的基本职责?图2-26 规格模式静态类图■ Specification:● 定义一个规格的抽象接口,它声明了施加于Product之上的抽象约束;● 可以对任何Product对象通过IsSatisfiedBy()方法进行测试,看它是否符合给定的规则。■ Specification:实现具体的规格。■ Product:被测试的产品对象的抽象接口。

参见领域模型中的规格模式(见6.4.4节)。

因为Specification.IsSatisfiedBy()方法返回逻辑值,因此可以进行逻辑组合,它提供了从简单模型构造复杂模型的途径。比如,可以定义AndSpec(Spec,Spec)、OrSpec(Spec,Spec)、NotSpec(Spec)、NorSpec(…)、SubSumeSpec(…)等操作,实现各种规格间的逻辑运算。2.28 状态模式(State)意图:允许一个对象在其内部状态改变时改变它的行为,使得对象看起来好像修改了它的类型一样。

上下文:如何在运行时根据对象的状态来透明地更改对象的行为,而又不会为对象操作和状态转化之间引入紧耦合?图2-27 状态模式静态类图■ Context:● 定义客户感兴趣的接口;● 维护一个ConcreteState子类的实例,这个实例定义当前的状态。■ State:定义一个接口来封装Context的与特定状态相关的行为。■ ConcreteState:每个ConcreteState实现一个与Context的一个状

态相关的行为。

状态模式是用途非常广泛的模式,所有使用到有限状态机(FSM)的地方都可以使用该模式。当然,如果不使用状态模式的话,switch/case语句也可以胜任简单的状态机,但对于大型的状态机具有大量的状态和事件,维护冗长、嵌套的switch/case语句是非常困难和容易出错的,而且switch/case语句通常都没有很好地分离状态机的逻辑和要执行的操作。另外一种选择状态转移表(WIS20中采用),它更容易维护,如果要增加新的状态转移,只要向表中增加一行就可以了,而且可以在运行时动态改变状态机的逻辑,还可以创建多个不同的状态转移表,在运行时动态地选择解释执行哪一个,但缺点是要编写大量的代码去支持状态转移表,而且需要查询和解释执行,速度较慢。

状态模式是最灵活、最高效的选择,因为它彻底分离了状态机的逻辑和动作行为,二者可以独立变化、互不影响,而且容易扩展,同时效率很高。当然,它也有缺点,就是编写State的派生类是一项乏味的工作,同时状态逻辑分散,无法在一个地方看到整个状态机的逻辑。为了克服这两个缺点,可以用一个文本描述状态转移表,然后用适当的Software Factory工具把它变成状态模式所必需的类的代码。

GUI是典型的状态应用。哪些菜单项和按钮是Disabled,哪个窗口应该激活,焦点应放在哪里,等等,都和状态有关。如果不把这些要素组织成为一个单一的、集中的状态机控制结构,那将是一场噩梦。2.29 策略模式(Strategy)意图:定义一系列算法,把它们一个个封装起来,并且使它们可以相互替换,算法可以独立于使用它的客户而变化。

上下文:对象使用的算法可能多种多样,经常改变。如果将这些算法都编码到对象中,将会使对象变得异常复杂;而且有时候支持不使用的算法也是一个性能负担。如何在运行时根据需要透明地更改对象的算法,将算法与对象本身解耦,从而避免上述问题?图2-28 策略模式静态类图■ Strategy:定义所有支持的算法的公共接口,Context用这个接口

来调用某个ConcreteStrategy定义的算法。■ ConcreteStrategy:以Strategy接口实现某个具体算法。■ Context:● 用一个ConcreteStrategy对象来配置;● 维护一个对Strategy对象的引用;● 可定义一个接口来让Strategy访问它的相关数据。2.30 模板方法模式(Template Method)意图:定义一个操作中的算法骨架,而将一些步骤延迟到子类中,使得子类不改变一个算法的结构就可以重新定义该算法的某些特定步骤。

上下文:某一项任务有稳定的整体操作结构,但各个子步骤却有很多变化的需求,或者由于固有的原因(比如框架与应用之间的关系)而无法和任务的整体结构同时实现。如何在确定稳定操作结构的前提下,灵活应对各个子步骤的变化或者晚期实现需求?图2-29 模板方法模式静态类图■ AbstractClass:● 定义抽象的原语操作(PrimitiveOperation),具体的子类将Override它们以实现一个算法的步骤。● 实现一个TemplateMethod,定义一个算法的骨架,该TemplateMethod不仅调用PrimitiveOperation,而且调用AbstractClass或其他对象中的操作。■ ConcreteClass:实现PrimitiveOperation以完成算法中与特定子类

相关的步骤。2.31 访问者模式(Visitor)意图:表示一个作用于某对象结构中的各个元素的操作,它使你可以在不改变各元素的类的前提下,定义作用于这些元素的新操作。

上下文:由于需求的改变,某些类层次结构中常常需要增加新的行为(方法),如果直接在基类中做这样的更改,将会给子类带来很繁重的变更负担,甚至破坏原有设计。如何在不更改类层次结构的前提下,在运行时根据需要透明地为类层次结构上的各个类动态添加新的操作,从而避免上述问题?■ Visitor:为ObjectStructure中的每个ConcreteElement类声明一个

Visit操作,该操作的特征和名字标识了发送Visit请求给哪个

Element类,这使得Visitor可以确定正在被访问的Element的具体

类型,这样Visitor就可以通过该Element的特定接口直接访问

它。■ ConcreteVisitor:实现每个由Visitor声明的操作。每个操作实现

本算法的一部分,而该算法片断乃是对应于结构中对象的类,

ConcreteVisitor为该算法提供了上下文并存储其局部状态,这一

状态常常在遍历该结构的过程中累积结果。图2-30 访问者模式静态类图■ Element:定义一个Accept操作,它以一个Visitor为参数。■ ConcreteElement:实现Accept操作,该操作以一个Visitor为参数。■ ObjectStructure:● 能枚举它的元素;● 可以提供一个高层接口以允许Visitor访问它的元素;● 可以是一个Composite或者一个集合(列表或者无序集合)。

注意:访问者模式的最大缺点在于,扩展类层次结构(增添新的Element子类)会导致Visitor类的改变,因此访问者模式适用于“Element类层次结构稳定,而其中的操作却经常面临频繁改动”的情况。访问者模式提供了倾斜的可扩展性设计:方法集合的可扩展性和类集合的不可扩展性。如果系统的数据结构对象易于变化,则不适合使用访问者模式,因为再增加新的节点比较困难。

访问者模式的一个典型应用是报表。例如,WIS的BOM最适合采用组合模式(树状结构)。图2-31中定义了两个Visitor报表,一个需要取BOM的零件数量,另一个需要取BOM的成本。通过定义报表访问者,可以避免对原BOM对象的修改,而且报表可以独立地编译和分发。图2-31 访问者模式的应用例子:BOM报表设计2.32 设计模式应用的综合例子

例子的需求是要做一个文件分割器,界面如图2-32所示。图2-32 文件分割器的界面

最初的做法是下面这样的,通过硬编码来分割文件(使用C#语言,下同)。

第一个改进是:把分割文件的功能做成类库,以达到重用的目的。界面(Client)与类的关系如图2-33所示。图2-33 文件分割器的第一次改进

这样改进以后,还是存在依赖关系:更新的FileSplitter版本发布时,Form1应用程序也要随之改变(至少是重新编译)。

第二个改进是:定义抽象接口。界面(Client)与类的关系如图2-34所示。图2-34 文件分割器的第二次改进

在上面的例子中,创建FileSplitter时,应该使用工厂模式,以及反射机制(略)。

第三个改进是:提供进度信息。

在这次改进中,我们为提供进度信息定义了一个新接口,而不是修改原有的接口。另外,利用了C#中的委托,实现了观察者(Observer)模式。

第四个改进是:通过策略模式实现算法更改,分割大文件时防止内存崩溃;对于多核系统提供多线程支持……界面与类的关系如图2-35所示。图2-35 文件分割器的第四次改进第3章 敏捷软件世界上有三种必然会发生的事情:一是收税,二是死亡,三是变化。传统的瀑布式软件开发没有表现出软件开发的动态特性——需求经常变化、优先级经常更改、用户反馈很重要。它具有预测性质,无法针对快速变化的环境很好地进行伸缩。这使得许多大公司的软件团队陷入了软件过程不断增长的泥沼,开发过程日益成为官僚、烦琐的过程。敏捷软件开发向传统软件开发的观念发起了挑战。在编写代码之前是先创建UML图,还是避开任何UML图而仅仅编写大量代码?应该编写大量描述我们的设计的叙述性文档,还是使代码具有自释义能力以及表达力,不再需要辅助性的文档?应该结对编程吗?应该在编写产品代码之前先编写测试吗?需要明确的设计阶段吗?2001年,一批业界专家聚集在一起,召开了涉及自适应软件开发的“五个系列”的会议,他们创造了术语“敏捷”来囊括这些方法的所有共同点,成立了“敏捷联盟”,提出了敏捷联盟宣言。其特点是:强调适应而非预测;以人为中心,而非以过程为中心。敏捷软件开发认为,源代码就是设计;目标(交付最大可能的价值)最重要,而不是过程。膨胀的、重型的过程和方法用意虽好,却导致失败。敏捷过程就是一个持续运用设计原则、设计模式、敏捷实践来改进软件的结构和可读性的过程。

什么是敏捷的软件?什么是敏捷的设计?

敏捷就是一个持续运用设计原则、设计模式、敏捷实践来改进软件的结构和可读性的过程。

不是每个月或者每一周才进行重构和清洁,而是每天、每小时、每分钟地进行。保持软件在任何时候都是干净的、漂亮的、富有表现力的。

随着软件开发的持续,在我们的程序中会散发出越来越多的味道。下面是一些坏味道。■ 僵化性(rigidity):难以对软件进行改动,改动导致依赖关系的

模块的连锁改动。连锁改动的模块越多,设计就越僵化。■ 脆弱性(fragility):一个改动会引起很多问题,而且新的问题与

改动的地方并没有概念上的关联。而要修正新的问题,又会引起

更多的问题。■ 牢固性(immobility):设计中包含了对其他系统有用的部分,把

这部分分离出来有巨大的风险和工作量。■ 粘滞性(viscosity):改动的方法有很多种,其中一些会保持原

有的设计,而另一些会破坏原有的设计。如果发现前者很难而后

者很容易,那就说明粘滞性很高。■ 不必要的复杂性(needless complexity):设计中包含当前没有

用的组成部分。如果预测未来需求的变化,并在软件中放置了处

理这些变化的代码,就会出现不必要的复杂性。■ 不必要的重复(needless repetition):复制和粘贴是带来不必要

的重复的根本原因。当代码以微小的差别的形式在不同地方反复

出现的时候,就表示开发人员忽略了抽象。每个重复代码体中的

错误必须在其他所有重复体中修正,而且修正的方法各不相同。

行业应用软件中面向不同客户的不同分支版本,就是不必要重复

的典型例子。应当发现所有的重复,并通过适当的抽象去消灭它

们。■ 晦涩性(opacity):模块难以理解,缺乏清晰的表现力。3.1 基本原则

敏捷的面向对象软件应该遵循的基本原则如表3-1所示。表3-1 对象与包的基本设计原则注:平行的对象设计原则和包设计原则具有对等意义。3.1.1 对象设计原则单一职责原则(Single-Responsibility Principle,SRP)就一个类而言,应该有且仅有一个引起它变化的原因。

SRP体现内聚性。

所谓“职责”,就是“变化的原因”——如果能想到多个动机去改变一个类,那么这个类就具有多个职责;如果类承担了多于一个的职责,那么就会有多个引起它变化的原因。不要按照习惯的想法:按功能的形式考虑职责,认为每个类完成一个“功能”就是满足SRP。

如果一个类承担的职责过多,等于把这些职责耦合在了一起,一个职责的变化可能会影响其他职责,这种耦合将导致脆弱的设计,当变化发生时,设计会遭到意外的破坏。

与SRP等价的原则是:一个规则、一次实现。

推论:(1)变化的轴线只有在变化实际发生的时候才有意义。如果没有征兆,那么去应用SRP或者其他原则都是不明智的。(2)业务规则和持久化控制,在大多数情况下绝不应该放在一起。(3)软件中,某一个配置项在任何时候都只能由一个类去访问。因为一个配置项意味着一个变化,而一个变化必须在一个地点实现,对某个配置项(配置文件、数据库……)的多点访问是霰弹式手术味道(见第5章“重构方法”)的征兆,这可以是检验软件的建模是否符合SRP的一个方面。开闭原则(Open-Closed Principle,OCP)对扩展开放,对修改封闭。

对扩展开放的意思是:对模块的扩展使得其满足新的需求改变,从而改变模块的功能。

对修改封闭的意思是:对模块的扩展不必改动原来的代码,包括源代码和二进制代码,都无须改动。

开闭原则并非意味着排斥修改,而是要求先重构(为符合OCP的模式)再扩展。

满足OCP的最常用模式,包括模板方法模式、策略模式等。

OCP通常要求我们对软件进行抽象,因为只有抽象的接口(共性),才会有具体的实现(可变性)。

抽象的接口放在哪里?应该是放在客户端(Client)而不是实现的一方。因为抽象类与它们的客户的关系要比实现它们的类的关系更加密切。

需要预测变化吗?需要为了符合OCP而尽早地考虑抽象吗?不需要!

为了防止不必要的复杂性,在软件编写之初,应先假设变化不会发生,而当变化发生时,就应该进行抽象,运用开闭原则来隔离变化。不要对程序中的每个部分进行肆意的抽象,拒绝不成熟的抽象和抽象本身一样重要。

但应该刺激变化,因为变化发生得越早,对我们越有利。可以用下面的手段来刺激变化:■ 测试驱动开发:一个可测试的系统会要求变化;■ 短周期迭代;■ 尽可能快地完成特性(功能),并展示出来;■ 先开发最重要的特性;■ 尽早地、经常地发布软件。依赖倒置原则(Dependency Inversion Principle,DIP)高层模块不应该依赖于低层模块。高层模块和低层模块都应该依赖于抽象。抽象不应该依赖于细节,细节应该依赖于抽象。对象之间只能在概念层次,而不是在实现层次(按接口设计)耦合。

DIP是面向过程的软件设计和面向对象的软件设计的根本区别。

在传统的结构化分析和设计方法中,高层模块依赖低层模块、调用低层模块。相对而言,设计良好的面向对象程序的结构则是“倒置”了。

例如,图3-1中的依赖关系是顽固而混乱的。

根据DIP原则进行改造以后,其关系变成如图3-2所示。

接口所有权也需要倒置:低层模块实现的接口的所有权归高层模块所有,在高层模块中声明,并被高层模块所调用;接口属于客户方,仅当客户方需要时才对接口进行改动。接口可以属于很多不同的客户,这时候,接口需要放在一个单独的命名空间或者动态链接库(DLL)中。图3-1 顽固而混乱的依赖关系图3-2 依赖于抽象

DIP的推论:■ 任何变量都不应该声明为具体类型;■ 如果需要创建具体类的实例,应该使用反射机制;■ 任何类都不应该从具体类派生;■ 任何方法都不应该覆写在基类中已经实现的方法。

任何使用了创建(New)语句的代码都违反了DIP。但对于那些不易变的类,违反DIP是无害的,因为DIP是对于不稳定的具体类而言的,在应用软件中所编写的类大多都具有不稳定性;而在系统软件中,很多类则是稳定的,比如String类是稳定的(还有其他所有系统类库中所定义的类),所以直接依赖不会造成损害。Liskov替换原则(Liskov Substitution Principle,LSP)■ 一个从基类派生的类应该支持基类的所有行为。■ 对于给定的基类(接口),让使用该基类对象的对象无法知道其是否存在派生类(或实现类)。■ 子类型不应该在基类型的公开接口中添加新的公开方法。■ 基类型必须是所建模概念的完整规格说明。

OCP是核心原则,而LSP则是使得OCP成为可能的主要原则之一。正是子类型的可替换性,使得使用基类型的模块在无须修改的情况下就可以扩展,这种可替换性是开发人员可以隐式依赖的东西。相比而言,术语“is-a”过于宽泛,LSP要求更加严格。

遵守LSP不仅仅体现在方法及其签名上,而且要体现在行为上。例如,子类不应该抛出基类没有抛出的异常,因为基类的客户方没有期望这些异常;又如,如果基类依赖于某个抽象类型,那么其子类不应该依赖比基类所依赖的该抽象类型更具体的子类型。接口隔离原则(Interface Separation Principle,ISP)不应该强迫客户依赖于他们不使用的方法。

ISP用来处理胖接口所具有的缺点。胖接口的意思是:如果类的接口不是内聚的,就表示该类具有胖接口。胖接口会“污染”客户方,也就是说强迫客户方依赖他们不使用的接口,将导致不必要的耦合。

胖类会导致其客户方产生不正常的、有害的耦合关系。当一个客户方要求他进行一个改动时,会影响到其他所有的客户方,因此客户方应该仅仅依赖于它实际调用的方法。

按照ISP原则,通过把胖类分解成为多个特定于客户方的接口就可以达到这样的目的,胖类可以实现所有这些特定于不同客户方的接口,这样就解除了客户方和他们所不需要使用的方法间的依赖关系,并使得客户方之间相互不依赖。3.1.2 包的设计原则

不能自顶向下设计包结构。也就是说,包结构不是设计系统时首要考虑的事情,而是应该随着系统的增长、变化而逐步演化的。■ 随着设计的类越来越多,就越来越需要对包的依赖关系进行管

理,避免“晨后综合症”。■ 尽可能地把修改局部化,需要关注单一职责原则(SRP)和共同

封闭原则(CCP),把可能一同变化的类放在一起。■ 随着应用程序的增长,开始关注重用性,于是需要共同重用原则(CRP)来指导包的组合。■ 最后,当依赖环出现的时候,需要使用无环依赖原则(ADP)来

解除依赖环。

因此,不能在类设计之前去试图设计包的依赖关系结构,包的依赖关系图和应用程序中的功能没有关系,而是应用程序的可构建性的映射图。

包的内聚性原则包括重用发布等价原则(REP)、共同重用原则(CRP)、共同封闭原则(CCP)。

包的耦合性原则包括无环依赖原则(ADP)、稳定依赖原则(SDP)、稳定抽象原则(SAP)。重用发布等价原则(Resuable Equipollence Principle,REP)重用的粒度就是发布的粒度。

包的重用发布等价原则(REP),相当于对象的接口隔离原则(ISP)。■ 如果一个包中的软件是用来重用的,那么它就不能再包含不是为

了重用的目的而设计的软件。■ 一个包中的软件要么都是可以重用的,要么都是不可重用的。■ 一个包中的所有类对于同一类用户来说都是可重用的。■ 我们不希望一个用户发现包中的类中,一些是他所需要的,另一

些却对他完全不适合。共同重用原则(Corporate Reusage Principle,CRP)一个包中的所有类应该是共同重用的。如果重用了一个包中的类,就要重用包中所有的类。

什么类应该放在一起?一般来说,可重用的类很少是孤立的,它需要与作为可重用的抽象的一部分与其他类协作,这些相互协作和依赖的类应该放到同一个包中,例如容器类和与其关联的迭代器类。

什么类不应该放在一起?放到一个包中的类是不可分开的,仅仅依赖其中的一个或者一部分的情况是不可能的。共同封闭原则(Corporate Closed Principle,CCP)包中所有类对于同一类性质的变化应该是共同封闭的。一个变化如果对一个包产生影响,那么就应该对包中所有的类产生影响,而对于其他包不产生任何影响。

包的共同封闭原则(CCP),相当于对象的单一职责原则(SRP)。■ 可维护性的重要性超过可重用性。如果一个应用中的代码必须更

改,那么我们宁愿所有的更改都集中在最少数量的包中,而不是

多个包。■ 共同封闭原则与开闭原则密切相关,这里的“封闭”与OCP中

的“封闭”具有同样的含义。■ 绝对封闭是不可能的,应当针对最常见的变化做到封闭。■ 把一些对于确定变化类型开放的类放到同一个包中,当变化到来

时,就可以将它限制在最少数量的包中。无环依赖原则(Acyclic Dependency Principle,ADP)在包的依赖关系中不允许存在环。

如果包之间的依赖关系存在环,将产生复杂的包依赖关系,这使得它非常难以发布,所有包都需要一致行动;另外,在测试某个包的时候,可能必须链接它完全不使用的包;而且难以确定包的构建顺序。

需求的改变可能导致包结构不稳定,需要始终对依赖关系环进行监控,如果出现了环,就要消灭它。

解除依赖环的第一种办法是使用依赖倒置(DIP)。消除服务方对客户方的依赖,改为客户方对服务方的依赖,那么可以创建服务方的抽象接口,放在客户方的包中,让服务方实现这个接口,这样就可以倒置服务方和客户方的依赖关系,从而解除了依赖环。(注意:接口属于客户规则)

解除依赖环的第二种办法是使用新包。稳定依赖原则(Stable Dependency Principle,SDP)朝着稳定的方向进行依赖,依赖于稳定。

稳定性与改变它的工作量有关。如果让它变化的工作量很小,那么就是不稳定的(如一枚竖立的硬币);如果让它变化要花很大的力气,那么它就是稳定的(如一张桌子)。

软件的可变性要求:软件中的某些包,应该被设计成对某些变化类型敏感并且是易于改变的。

如果一个包是不稳定的(易变的),那么不应该让一个稳定的(难以更改的)包依赖于它。

稳定的包有很多包依赖于它,说明它要承担很多责任,有很多理由不去修改它。

不稳定的包依赖于其他包,不需要承担被依赖的责任,没有理由不修改它。

在绘制包的依赖关系图时,约定:把可变的(flexible)包放在顶部,而稳定的(stable)包放在底部。这样很容易发现:任何向上的箭头都违反SDP。稳定抽象原则(Stable Abstract Principle,SAP)包的抽象程度应该和其稳定程度一致。

包的稳定抽象原则(SAP),相当于对象的开闭原则(OCP)。该原则把包的稳定性和抽象性联系起来。

一个稳定的包应该是抽象的,这样它的稳定性不会造成它无法扩展;另一方面,一个不稳定的包应该是具体的,因为它的不稳定性使得它更加易于修改。

稳定的、抽象的包易于扩展,不稳定的、具体的包易于修改。

SAP和SDP结合在一起形成了针对包的DIP原则:SDP规定依赖于稳定,而SAP规定稳定意味着抽象,所以应该依赖于抽象(DIP)。

类没有灰度的概念(要么是抽象的,要么是具体的),而包可能是部分稳定、部分抽象的。3.2 敏捷建模

敏捷模型是一种刚刚好(just good enough)的模型,如表3-2所示。表3-2 敏捷建模的特点

敏捷建模描述的是个人感受,而不是规范的过程和详细的指定。敏捷建模是一种艺术,而不是一种科学。敏捷建模的价值观除了极限编程(XP)中的沟通、简单、勇气、反馈和尊重以外,还有“谦虚”。3.2.1 关于建模的一些认识误区

关于建模的一些认识误区如图3-3所示。图3-3 建模的认识误区3.2.2 敏捷建模的原则和实践

敏捷建模的原则和实践如表3-3所示。表3-3 敏捷建模的原则和实践3.3 按意图编程

按意图编程(programming by intention)是极限编程(XP)的核心理念之一,意思就是在编写代码的时候清晰地表达自己的意图。永远不要让人家在看了你的代码之后问:这个家伙到底想干什么?

按意图编程的目的是代码的可读性。可读性与高内聚息息相关,就是“按接口编程”观念的体现。

编写代码时,需要实现一些函数,先假定它们存在,为它们指定“反映意图的名字”,编写对这些函数的方法调用,然后继续。以后再实现函数。其结果就是可读性非常好的代码,因为在更高的层次,读者看到的是代码的意图,而不是具体的实现细节。

每当我们感觉需要为什么东西加注释的时候,我们会编写一个方法,其结果就是更简短、更清晰、更紧凑的方法。

按意图编程的要点如图3-4所示。图3-4 按意图编程的要点3.3.1 名字:选择语义清晰的名字

名不正则言不顺,代码必须清晰而易于理解。一种简单而强大的澄清意图的方法就是一个好的名字。■ 使用名词/名词短语作为类的名字,如Movie、

MovieRatingComparator、XMLMovieListReader。■ 使用形容词,或者具有一般性的名词/名词短语作为接口的名

字,加上前缀I和/或后缀-able,如Runnable、Serializable。■ 使用动词/动词短语作为方法的名字,如Add、Save。■ 使用公认的取值/赋值方法的命名习惯,如getXXX、setXXX、

isXXX、hasXXX。● 有时去掉get前缀更清晰一些,如size()。■ 不要在方法名中保留多余的信息,如X.add不要命名为X.addX。■ 使用名词/名词短语作为变量名,如name、connectionToXX。■ 要选择共同的词汇,让业务人员和技术人员跨越其自身知识背景

之间的鸿沟,都理解有关领域和系统的描述,必须基于每个人都

能理解的、共同挑选的术语。3.3.2 简单:做最简单但又能工作的事情

向他人解释对方不熟悉的事物的时候,必须使用简单的描述;同样,编写以后会有其他人阅读的代码,也必须尽可能地简单。

还需要通过重构来使代码保持简单。重构方法参见第5章。3.3.3 假设:做有根据的假设

编写类和方法的最终目的是让用户去调用它,测试驱动开发是先做假设后写代码。

在测试中假设外界要调用一个什么样的类,调用这个类的什么样的方法,这样可以让我们选择有意义的、清晰而易理解的名字,并判断什么样的行为才是用户所需要的。这样,我们的一切活动都以用户的需要为出发点,就能编写更少的代码,更有效地工作,更快地发布产品。

在编写测试的时候,直接写出需要的类名和方法名,完全不要担心没有具体的实现(不要过早关注于细节!)。这样你可以把注意力集中在头脑中的问题上,而直到编译器给你做出提示的时候才去操心实现的问题。

做出假设之后,完全没有必要把这些假设记下来,因为编译器会安全地告诉我们。3.3.4 注释:“不要注释”

大多数注释的编写是没有正当理由的。注释是“除臭剂”,用来掩盖代码的臭味。正因为代码不清晰、丑陋、名字不好、逻辑混乱等,才需要增加注释。

应该对代码进行重构,使得这些注释成为不必要的东西。但事情并非绝对。正如“不到必要的时候不编写文档”并非“不要编写文档”,“不到必要的时候不写注释”也并非“绝对不要编写注释”。

如果代码意图是明确的,那么大多数注释都是不必要的。如果确实有必要写注释的话,注释的内容应该是“为什么这样做”,而不是“做什么”和“怎样做”。具体参见5.1.22节。3.4 软件的度量

Tom DeMacro说过:“如果你无法度量它,那么你就无法管理它。”3.4.1 对象的度量

首先,从感观上,通过代码判断类的结构是否合理。■ 存在过大、过小类吗?■ 存在数据类(大部分或者全部都是实例)、函数类(大部分或者

全部都是函数)吗?■ 类之间过度交织(相互依赖)吗?

其次,可以度量具体的数据,如表3-4所示。表3-4 可度量的具体数据

对代码质量而言,统计数据并不能盖棺定论,但是可以说明代码存在的一些症状和查找潜在问题的提示。

另外,还可以统计界面代码、非界面代码的比例。如果界面代码的比例比较高(超过30%),则可能说明界面和业务逻辑的耦合度较高;如果程序结构是松耦合的,那么GUI负责完成的工作应该仅仅是创建控件、布局摆放、事件处理,而业务领域的工作则由完全非界面代码完成。3.4.2 包的度量

如果开发人员不多,就可以在大脑中管理开发环境,因此度量包和维持包的可管理性(处于主序列上)的需求并不强烈。随着开发人员的增多,良好的开发环境难以保持,“晨后综合症”越来越频繁,重新测试和发布的工作量越来越大,以至于远大于计算这些度量值的工作量。而计算度量值可以使用工具进行,几乎不需要时间。

是否需要对包结构进行管理,取决于团队的规模和程序的规模。即使是小团队,也需要对源代码进行划分,以使开发人员间不会造成干扰。如果没有这样的划分,大程序就会变成晦涩难懂的源文件堆积。

包稳定性是可以度量的。■ 输入耦合度Ca(Afferent Coupling):处于包的外部并依赖于包

内的类的类的数量。■ 输出耦合度Ce(Efferent Coupling):处于包的内部并依赖于包

外的类的类的数量。■ 不稳定性I=Ce/(Ca+Ce),取值在0~1之间,1表示最大的不稳

定性。

SDP规定:I较大的包应该依赖于I较小的包,不能出现相反的情况。

包的抽象性也是可以度量的。■ Nc:包中类的总数。■ Na:包中抽象类的数量。■ 抽象性A=Na/Nc。A的取值范围为0~1,1表示最大的抽象性。

可以用A-I坐标图定义抽象性和稳定性之间的关系,如图3-5所示。稳定而抽象的包位于(0,1)处,易变而具体的包位于(1,0)处。

包的抽象性和稳定性是有灰度的,因此不可能所有的包都落在这两个点上。因此需要在A-I坐标图上定义一个包的合理位置轨迹,并指出包不应该在的位置(被排除的区域)。■ 痛苦地带:高度稳定并且具体的包,这样的包是痛苦的(难以更

改,难以扩展)。有些情形确实会落入痛苦地带,比如数据库模

式(面向对象应用程序和数据库之间的接口难以定义)和框架类

库、工具库(但是它们具有不易变的特性,因此不会造成损害)。■ 无用地带:高度抽象却又不稳定(没有依赖者),这样的包是没

有用的。■ 主序列:离痛苦地带和无用地带最远的区域,这是理想的位置。

可以度量包离主序列的规范化距离:D'=|A+I–1|。取值范围是0~1,0表示位于主序列上。也可以计算D'的均值和方差,并期望建立一个D'的均值和方差均接近于0的设计。还可以观察超过一个或者两个标准偏差(Z)的包:什么原因导致它们偏离特别远。图3-5 包的稳定性和抽象性(A-I)坐标图

对于某一个包,可以观察它的D值随时间变化的分布图,找出它的依赖关系变化导致包远离主序列的原因,如图3-6所示。图3-6 发布版本D值变化图3.4.3 测试的度量

测试代码行数应该高于应用代码的行数比例(2倍以上),否则可能说明测试不够详尽。

作为一个规定,除了框架自动生成的代码、软件工厂自动生成的代码以外,所有手工编写的应用代码的测试代码覆盖率应该达到100%。

如果发现有某个地方的测试代码覆盖率未达到100%,应该逐个分析是否合理。在大多数情况下,代码覆盖率达不到100%都说明我们没有完全按TDD来进行开发,应该增加测试以便覆盖它,以确保这些代码不会在今后的重构修改中被破坏。

某些情况下,应用中有些代码不再被任何地方所引用,那么代码覆盖率测试也可以发现这些问题,这些代码则应该被删除。3.5 延伸阅读:源代码就是设计

注意:这是Jack Reeves在十几年前撰写的一篇伟大的论文的译文,至今仍然历久弥新,现引用如下。至今,我仍能记起当我顿悟并最终产生下面的文章时所在的地方。那是1986年的夏天,我在加利福尼亚中国湖海军武器中心担任临时顾问。在这期间,我有幸参加了一个关于Ada的研讨会。讨论当中,有一位听众提出了一个具有代表性的问题:“软件开发者是工程师吗?”我不记得当时的回答,但是我却记得当时并没有真正解答这个问题。于是,我就退出讨论,开始思考我会怎样回答这样一个问题。现在,我无法肯定当时我为什么会记起几乎10年前曾经在Datamation杂志上阅读过的一篇论文,不过促使我记起的应该是后续讨论中的某些东西。这篇论文阐述了工程师为什么必须是好的作家(我记得该论文谈论的就是这个问题——好久没有看了),但是我从该论文中得到的关键一点是:作者认为工程过程的最终结果是文档。换句话说,工程师生产的是文档,不是实物。其他人根据这些文档去制造实物。于是,我就在困惑中提出了一个问题:“除了软件项目正常产生的所有文档以外,还有可以被认为是真正的工程文档的东西吗?”我给出的回答是:“是的,有这样的文档存在,并且只有一份——源代码。”把源代码看作一份工程文档——设计——完全颠覆了我对自己所选择的职业的看法。它改变了我看待一切事情的方式。此外,我对它思考得越多,我就越觉得它阐明了软件项目常常遇到的众多问题。更确切地说,我觉得大多数人不理解这个不同的看法,或者有意拒绝这样一个事实,就足以说明很多问题。几年后,我终于有机会把我的观点公开发表。C++ Journal中的一篇有关软件设计的论文促使我给编辑写了一封关于这个主题的信。经过交换几封书信后,编辑LivleenSingh同意把我关于这个主题的想法发表为一篇论文。下面就是这篇文章。——Jack Reecves,2001年12月22日

什么是软件设计?Jack W.Reeves,1992面向对象技术,特别是C++,似乎给软件界带来了不小的震动,出现了大量的论文和书籍去描述如何应用这项新技术。总体来说,那些关于面向对象技术是否只是一个骗局的问题,已经被那些关于如何付出最小的努力即可获得收益的问题所替代。面向对象技术出现已经有一段时间了,但是这种爆炸式的流行却似乎有点不寻常。人们为何会突然关注它呢?对于这个问题,人们给出了各种各样的解释。事实上,很可能就没有单一的原因。也许,把多种因素结合起来才能最终取得突破,并且这项工作正在进展之中。尽管如此,在软件革命的这个最新阶段中,C++本身看起来似乎成为了一个主要因素。同样,对于这个问题,很可能也存在很多种理由。不过,我想从一个稍微不同的视角给出一个答案:C++之所以变得流行,是因为它使软件设计变得更容易的同时,也使编程变得更容易。虽然这个解释好像有点奇特,但是它却是深思熟虑的结果。在这篇论文中,我就是想要关注一下编程和程序设计之间的关系。近10年来,我一直觉得整个软件行业都没有觉察到做出一个软件设计和什么是真正的软件设计之间的一个微妙的不同点。只要看到了这一点,我认为我们就可以从C++增长的流行趋势中,学到关于如何才能成为更好的软件工程师的意义深远的知识。这个知识就是,编程不是构建软件,而是设计软件。几年前,我参加了一个讨论会,其中讨论到软件开发是否是一门工程学科的问题。虽然我不记得讨论结果了,但是我却记得它是如何促使我认识到:软件业已经做出了一些错误的和硬件工程的比较,而忽视了一些绝对正确的对比。其实,我认为我们不是软件工程师,因为我们没有认识到什么才是真正的软件设计。现在,我对这一点更是确信无疑。任何工程活动的最终目标都是某些类型的文档。当设计工作完成时,设计文档就被转交给制造团队。该团队是一个和设计团队完全不同的群体,并且其技能也和设计团队完全不同。如果设计文档正确地描绘了一个完整的设计,那么制造团队就可以着手构建产品。事实上,他们可以着手构建该产品的许多实物,完全无需设计者的任何进一步的介入。在按照我的理解方式审查了软件开发的生命周期后,我得出一个结论:实际上,满足工程设计标准的唯一软件文档,就是源代码清单。对于这个观点,人们进行了很多的争论,无论是赞成的还是反对的,都足以写成无数的论文。本文假定最终的源代码就是真正的软件设计,然后仔细研究了该假定带来的一些结果。我可能无法证明这个观点是正确的,但是我希望证明:它确实解释了软件行业中一些已经观察到的事实,包括C++的流行。在把代码看作软件设计所带来的结果中,有一个结果完全盖过了所有其他的结果。它非常重要并且非常明显,也正因为如此,对于大多数软件机构来说,它完全是一个盲点。这个结果就是:软件的构建是廉价的。它根本就不具有昂贵的资格;它非常廉价,几乎就是免费的。如果源代码是软件设计,那么实际的软件构建就是由编译器和连接器完成的。我们常常把编译和连接一个完整的软件系统的过程称为“进行一次构建”。在软件构建设备上所进行的主要投资是很少的—— 实际需要的只有一台计算机、一个编辑器、一个编译器以及一个连接器。一旦具有了一个构建环境,那么实际的软件构建只需花费少许的时间。编译50 000行的C++程序也许会花费很长的时间,但是构建一个具有和50 000行C++程序同样的设计复杂性的硬件系统要花费多长的时间呢?把源代码看作软件设计的另外一个结果是,软件设计相对易于创作,至少在机械意义上如此。通常,编写(也就是设计)一个具有代表性的软件模块(50至100行代码)只需花费几天的时间(对它进行完全的调试是另外一个议题,稍后会对它进行更多的讨论)。我很想问一下,是否还有任何其他的学科可以在如此短的时间内,产生出和软件具有同样复杂性的设计来。不过,首先我们必须要弄清楚如何来度量和比较复杂性。然而,有一点是明显的,那就是软件设计可以极为迅速地变得非常庞大。假设软件设计相对易于创作,并且在本质上构建起来也没有什么代价,一个不令人吃惊的发现是,软件设计往往是难以置信地庞大和复杂。这看起来似乎很明显,但是问题的重要性却常常被忽视。学校中的项目通常具有数千行的代码。具有10 000行代码(设计)的软件产品被它们的设计者丢弃的情况也是有的。我们早就不再关注于简单的软件。典型的商业软件的设计都是由数十万行代码组成的。许多软件设计达到了上百万行代码。另外,软件设计几乎总是在不断地演化。虽然当前的设计可能只有几千行代码,但是在产品的生命期中,实际上可能要编写许多倍的代码。尽管确实存在一些硬件设计,它们看起来似乎和软件设计一样复杂,但是请注意两个有关现代硬件的事实。第一,复杂的硬件工程成果未必总是没有错误的,在这一点上,它不存在像软件那样让我们相信的评判标准。多数的微处理器在发售时都具有一些逻辑错误:桥梁坍塌,大坝破裂,飞机失事以及数以千计的汽车和其他消费品被召回——所有的这些,我们都记忆犹新,所有的这些都是设计错误的结果。第二,复杂的硬件设计具有与之对应的复杂、昂贵的构建阶段。结果,制造这种系统所需的能力限制了真正能够生产复杂硬件设计公司的数目。对于软件来说,没有这种限制。目前,已经有数以百计的软件机构和数以千计的非常复杂的软件系统存在,并且数量以及复杂性每天都在增长。这意味着软件行业不可能通过仿效硬件开发者找到针对自身问题的解决办法。倘若一定要说出有什么相同之处的话,那就是,当CAD和CAM可以做到帮助硬件设计者创建越来越复杂的设计时,硬件工程才会变得和软件开发越来越像。设计软件是一种管理复杂性的活动。复杂性存在于软件设计本身之中,存在于公司的软件机构之中,也存在于整个软件行业之中。软件设计和系统设计非常相似。它可以跨越多种技术,并且常常涉及多个学科分支。软件的规格说明往往不固定,经常快速变化,这种变化常常在正进行软件设计时发生。同样,软件开发团队也往往不固定,常常在设计过程的中间发生变化。在许多方面,软件都要比硬件更像复杂的社会或者有机系统。所有这些都使得软件设计成为了一个困难的并且易出错的过程。虽然所有这些都不是创造性的想法,但是在软件工程革命开始将近30年后的今天,和其他工程行业相比,软件开发看起来仍然像是一种未受过训练(undisciplined)的技艺。一般的看法是,当真正的工程师完成了一个设计时,不管该设计有多么复杂,他们都非常确信该设计是可以工作的。他们也非常确信该设计可以使用公认的技术建造出来。为了做到这一点,硬件工程师花费了大量的时间去验证和改进他们的设计。例如,考虑一个桥梁设计。在这样一个设计实际建造之前,工程师会进行结构分析——他们建立计算机模型并进行仿真,他们建立比例模型并在风洞中或者用其他一些方法进行测试。简而言之,在建造前,设计者会使用他们能够想到的一切方法来证实设计是正确的。对于一架新型客机的设计来说,情况甚至更加严重;必须构建出和原物同尺寸的原型,并且必须进行飞行测试来验证设计中的种种预计。对于大多数人来说,软件中明显不存在和硬件设计同样严格的工程。然而,如果我们把源代码看作设计,那么就会发现软件工程师实际上对他们的设计做了大量的验证和改进。软件工程师不把这称为工程,而称它为测试和调试。大多数人不把测试和调试看作真正的“工程”——在软件行业中肯定没有被看作是。造成这种看法的原因,更多的是软件行业拒绝把代码看作设计,而不是任何实际的工程差别。事实上,试验模型、原型以及电路试验板已经成为其他工程学科公认的组成部分。软件设计者之所以不具有或者没有使用更多的正规方法来验证他们的设计,是因为软件构建周期的简单经济规律。第一个启示:仅仅构建设计并测试它比做任何其他事情要廉价一些,也简单一些。我们不关心做了多少次构建——这些构建在时间方面的代价几乎为零,并且如果我们丢弃了构建,那么它所使用的资源完全可以重新利用。请注意,测试并非仅仅是让当前的设计正确,也是改进设计的过程的一部分。复杂系统的硬件工程师常常建立模型(或者,至少他们把设计用计算机图形直观地表现出来)。这就使得他们获得了对于设计的一种“感觉”,而仅仅去检查设计是不可能获得这种感觉的。对于软件来说,构建这样一个模型既不可能,也无必要。我们仅仅构建产品本身。即使正规的软件验证可以和编译器一样自动进行,我们也还是会去进行构建/测试循环。因此,正规的验证对于软件行业来说从来没有太多的实际意义。这就是现今软件开发过程的现实。数量不断增长的人和机构正在创建着更加复杂的软件设计。这些设计会被先用某些编程语言编写出来,然后通过构建/测试循环进行验证和改进。过程易于出错,并且不是特别严格。相当多的软件开发人员并不想相信这就是过程的运作方式,也正因为这一点,问题变得更加复杂。当前大多数的软件过程都试图把软件设计的不同阶段分离到不同的类别中。必须在顶层的设计完成并且冻结后,才能开始编码。测试和调试只对清除建造错误是必要的。程序员处在中间位置,他们是软件行业的建造工人。许多人认为,如果我们可以让程序员不再进行“随意的编码(hacking)”并且按照交给他们的设计去进行构建(还要在过程中犯更少的错误),那么软件开发就可以变得成熟,从而成为一门真正的工程学科。但是,只要过程忽视了工程和经济学事实,这就不可能发生。例如,任何一个现代行业都无法忍受在其制造过程中出现超过100%的返工率。如果一个建造工人常常不能在第一次就构建正确,那么不久他就会失业。但是在软件业中,即使最小的一块代码,在测试和调试期间,也很可能会被修正或者完全重写。在一个创造性的过程(比如设计)中,我们认可这种改进不是制造过程的一部分。没有人会期望工程师第一次就创建出完美的设计。即使他做到了,仍然必须让它经受改进过程,目的就是为了证明它是完美的。即使我们从日本的管理方法中没有学到任何东西,我们也应该知道由于在过程中犯错误而去责备工人是无益于提高生产率的。我们不应该不断地强迫软件开发去符合不正确的过程模型,相反,我们需要去改进过程,使之有助于而不是阻碍产生更好的软件。这就是“软件工程”的石蕊测试。工程是关于你如何实施过程的,而不是关于是否需要一个CAD系统来产生最终的设计文档。关于软件开发有一个压倒性的问题,那就是一切都是设计过程的一部分。编码是设计,测试和调试是设计的一部分,并且我们通常认为的设计仍然是设计的一部分。虽然软件构建起来很廉价,但是设计起来却是难以置信的昂贵。软件非常复杂,具有众多不同方面的设计内容以及它们所导致的设计考虑。问题在于,所有不同方面的内容是相互关连的(就像硬件工程中的一样)。我们希望顶层设计者可以忽视模块算法设计的细节。同样,我们希望程序员在设计模块内部算法时不必考虑顶层设计问题。糟糕的是,一个设计层面中的问题侵入了其他层面之中。对于整个软件系统的成功来说,为一个特定模块选择算法可能和任何一个更高层次的设计问题同样重要。在软件设计的不同方面的内容中,不存在重要性的等级。最低层模块中的一个不正确设计可能和最高层中的错误一样致命。软件设计必须在所有的方面都是完整和正确的,否则,构建于该设计基础之上的所有软件都会是错误的。为了管理复杂性,软件被分层设计。当程序员在考虑一个模块的详细设计时,可能还有数以百计的其他模块以及数以千计的细节,他不可能同时顾及。例如,在软件设计中,有一些重要方面的内容不完全属于数据结构和算法的范畴。在理想情况下,程序员不应该在设计代码时还去考虑设计的这些其他方面的内容。但是,设计并不是以这种方式工作的,并且原因也开始变得明朗。软件设计只有在其被编写和测试后才算完成。测试是设计验证和改进过程的基础部分。高层结构的设计不是完整的软件设计;它只是细节设计的一个结构框架。在严格地验证高层设计方面,我们的能力是非常有限的。详细设计最终会对高层设计造成的影响至少和其他的因素一样多(或者应该允许这种影响)。对设计的各个方面进行改进,是一个应该贯穿整个设计周期的过程。如果设计的任何一个方面的内容被冻结在改进过程之外,那么对于最终设计将会是糟糕的或者甚至无法工作这一点,就不会觉得奇怪了。如果高层的软件设计可以成为一个更加严格的工程过程,那该有多好呀!但是软件系统的真实情况不是严格的。软件非常复杂,它依赖于太多的其他东西。或许,某些硬件没有按照设计者认为的那样工作,或者一个库例程具有一个文档中没有说明的限制。每一个软件项目迟早会遇到这些种类的问题。这些种类的问题会在测试期间被发现(如果我们的测试工作做得好的话),之所以如此,是因为没有办法在早期就发现它们。当它们被发现时,就迫使对设计进行更改。如果我们幸运,那么对设计的更改是局部的。时常,更改会波及整个软件设计中的一些重要部分(莫非定律)。当受到影响的设计的一部分由于某种原因不能更改时,那么为了能够适应影响,设计的其他部分就必须遭到破坏。这通常导致的结果就是管理者所认为的“随意编码”,但是,这就是软件开发的现实。例如,我在最近工作的一个项目中,发现了模块A的内部结构和另一个模块B之间的一个时序依赖关系。糟糕的是,模块A的内部结构隐藏在一个抽象体的后面,而该抽象体不允许以任何方法把对模块B的调用合入它的正确调用序列中。当问题被发现时,当然已经错过了更改A的抽象体的时机。正如所料,所发生的就是把一个日益增长的复杂的“修正”集应用到A的内部设计上。在我们还没有安装完版本1时,就普遍感觉到设计正在衰退。每一个新的修正很可能会破坏一些老的修正。这是一个正规的软件开发项目。最后,我和我的同事决定对设计进行更改,但是为了得到管理层的同意,我们不得不自愿无偿加班。在任何一般规模的软件项目中,肯定会出现像这样的问题,尽管人们使用了各种方法来防止它的出现,但是仍然会忽视一些重要的细节。这就是工艺和工程之间的区别。如果经验可以把我们引向正确的方向,这就是工艺。如果经验只会把我们带入未知的领域,然后我们必须使用一开始所使用的方法并通过一个受控的改进过程把它变得更好,这就是工程。我们来看一下只是作为其中很小一点的内容。所有的程序员都知道,在编码之后而不是之前编写软件设计文档会产生更加准确的文档。现在,原因是显而易见的。用代码来表现的最终设计是唯一一个在构建/测试循环期间被改进的东西。在这个循环期间,初始设计保持不变的可能性和模块的数量以及项目中程序员的数量成反比。它很快就会变得毫无价值。在软件工程中,我们非常需要在各个层次都优秀的设计。我们特别需要优秀的顶层设计。初期的设计越好,详细设计就会越容易。设计者应该使用任何可以提供帮助的东西。结构图表、Booch图、状态表、PDL等——如果它能够提供帮助,就去使用它。但是,我们必须记住,这些工具和符号都不是软件设计。最后,我们必须创建真正的软件设计,并且是使用某种编程语言完成的。因此,当我们得出设计时,我们不应该害怕对它们进行编码。在必要时,我们必须乐于去改进它们。至今,还没有任何设计符号可以同时适用于顶层设计和详细设计。设计最终会表现为以某种编程语言编写的代码。这意味着在详细设计可以开始前,顶层设计符号必须被转换成目标编程语言。这个转换步骤耗费时间并且会引入错误。程序员常常是对需求进行回顾并且重新进行顶层设计,然后根据它们的实际去进行编码,而不是从一个可能没有和所选择的编程语言完全映射的符号进行转换。这也是软件开发的部分现实情况。也许,如果让设计者本人来编写初始代码,而不是后来让其他人去转换与语言无关的设计,就会更好一些。我们所需要的是一个适用于各个层次设计的统一符号。换句话说,我们需要一种编程语言,它同样适用于捕获高层的设计概念。C++正好可以满足这个要求。C++是一门适用于真实项目的编程语言,同时它也是一门非常具有表达力的软件设计语言。C++允许我们直接表达关于设计组件的高层信息。这样,就可以更容易地进行设计,并且以后可以更容易地改进设计。由于它具有更强大的类型检查机制,所以也有助于检测到设计中的错误。这就产生了一个更加健壮的设计,实际上也是一个更好的工程化设计。最后,软件设计必须用某种编程语言表现出来,然后通过一个构建/测试循环对其进行验证和改进。除此之外的任何其他主张都完全没有用。请考虑一下都有哪些软件开发工具和技术得以流行。结构化编程在它的时代被认为是创造性的技术。Pascal使之变得流行,从而自己也变得流行。面向对象设计是新的流行技术,而C++是它的核心。现在,请考虑一下那些没有成效的东西。CASE工具,流行吗?是的。通用吗?不是。结构图表怎么样?情况也一样。同样,还有Warner-Orr图、Booch图、对象图以及你能想起的一切。每一个都有自己的强项,以及唯一的一个根本弱点——它不是真正的软件设计。事实上,唯一一个可以被普遍认可的软件设计符号是PDL,而它看起来像什么呢?这表明,在软件业的共同潜意识中本能地知道,编程技术,特别是实际开发所使用的编程语言的改进和软件行业中任何其他东西相比,具有压倒性的重要性。这还表明,程序员关心的是设计。当出现更加具有表达力的编程语言时,软件开发者就会使用它们。同样,请考虑一下软件开发过程是如何变化的。从前,我们使用瀑布式过程。现在,我们谈论的是螺旋式开发和快速原型。虽然这种技术常常被认为可以“消除风险”以及“缩短产品的交付时间”,但是它们事实上也只是为了在软件的生命周期中更早地开始编码。这是好事。这使得构建/测试循环可以更早地开始对设计进行验证和改进。这同样意味着,顶层软件设计者很有可能也会去进行详细设计。正如上面所表明的,工程更多的是关于如何去实施过程的,而不是关于最终产品看起来像什么。处在软件行业中的我们,已经接近工程师的标准,但是我们需要一些认知上的改变。编程和构建/测试循环是工程软件过程的中心。我们需要以像这样的方式去管理它们。构建/测试循环的经济规律,再加上软件系统几乎可以表现任何东西的事实,就使得我们完全不可能找出一种通用的方法来验证软件设计。我们可以改善这个过程,但是我们不能脱离它。最后一点:任何工程设计项目的目标是一些文档产品。显然,实际设计的文档是最重要的,但是它们并非唯一要产生的文档。最终,会期望某些人来使用软件。同样,系统很可能也需要后续的修改和增强。这意味着,和硬件项目一样,辅助文档对于软件项目具有同样的重要性。虽然暂时忽略了用户手册、安装指南以及其他一些和设计过程没有直接联系的文档,但是仍然有两个重要的需求需要使用辅助设计文档来解决。辅助文档的第一个用途是从问题空间中捕获重要的信息,这些信息是不能直接在设计中使用的。软件设计需要创造一些软件概念来对问题空间中的概念进行建模。这个过程需要我们得出一个对问题空间中概念的理解。通常,这个理解中会包含一些最后不会被直接建模到软件空间中的信息,但是这些信息却仍然有助于设计者确定什么是本质概念以及如何最好地对它们建模。这些信息应该被记录在某处,以防以后要去更改模型。对辅助文档的第二个重要需要是对设计的某些方面的内容进行记录,而这些方面的内容是难以直接从设计本身中提取的。它们既可以是高层方面的内容,也可以是低层方面的内容。对于这些方面的许多内容来说,图形是最好的描述方式。这就使得它们难以作为注释包含在代码中。这并不是说要用图形化的软件设计符号代替编程语言。这和用一些文本描述来对硬件科目的图形化设计文档进行补充没有什么区别。绝不要忘记,是源代码而不是辅助文档,决定了实际设计的真实样子。在理想情况下,可以使用软件工具对源代码进行后期处理并产生出辅助文档。对于这一点,我们可能期望过高了。次一点的情况是,程序员(或者技术方面的编写者)可以使用一些工具从源代码中提取出一些特定的信息,然后把这些信息以其他一些方式文档化。毫无疑问,手工对这种文档保持更新是困难的。这是另外一个支持需要更具表达力的编程语言的理由。同样,这也是一个支持使这种辅助文档保持最小并且尽可能在项目晚期才使之变成正式的理由。同样,我们可以使用一些好的工具;不然的话,我们就得求助于铅笔、纸以及黑板。总结如下:■ 实际的软件运行于计算机之中。它是存储在某种磁介质中的0和

1的序列。它不是使用C++语言(或者其他任何编程语言)编写

的程序。■ 程序清单是代表软件设计的文档。实际上,把软件设计构建出来

的是编译器和连接器。■ 构建实际软件设计的廉价程度是令人难以置信的,并且它始终随

着计算机速度的加快而变得更加廉价。■ 设计实际软件的昂贵程度是令人难以置信的,之所以如此,是因

为软件的复杂性是令人难以置信的,并且软件项目的几乎所有步

骤都是设计过程的一部分。■ 编程是一种设计活动——好的软件设计过程认可这一点,并且

在编码显得有意义时,会毫不犹豫地去编码。■ 编码要比我们所认为的更频繁地显现出它的意义。通常,在代码

中表现设计的过程会揭示出一些疏漏以及额外的设计需要。这发

生得越早,设计就会越好。■ 因为软件构建起来非常廉价,所以正规的工程验证方法在实际的

软件开发中没有多大用处。仅仅建造设计并测试它,要比试图去

证明它更简单、更廉价。■ 测试和调试是设计活动——对于软件来说,它们就相当于其他

工程学科中的设计验证和改进过程。好的软件设计过程认可这一

点,并且不会试图去减少这些步骤。■ 还有一些其他的设计活动——称它们为高层设计、模块设计、

结构设计、构架设计或者诸如此类的东西。好的软件设计过程认

可这一点,并且慎重地包含这些步骤。■ 所有的设计活动都是相互影响的。好的软件设计过程认可这一点,

并且当不同的设计步骤显示出有必要时,它会允许设计改变,有

时甚至是根本上的改变。■ 许多不同的软件设计符号可能是有用的 ——它们可以作为辅助

文档以及工具来帮助简化设计过程。它们不是软件设计。■ 软件开发仍然是一门工艺,而不是一个工程学科,主要是因为缺

乏验证和改善设计的关键过程中所需的严格性。■ 最后,软件开发的真正进步依赖于编程技术的进步,而这又意味

着编程语言的进步。C++就是这样的一个进步。它已经取得了爆

炸式的流行,因为它是一门直接支持更好的软件设计的主流编程

语言。■ C++在正确的方向上迈出了一步,但是还需要更大的进步。

后记当我回顾几乎10年前所写的东西时,有几点让我印象深刻。第一点(也是和本书最有关的)是,现今,我甚至比那时更加确信我试图去阐述的要点在本质上的正确性。随后的一些年中,许多流行的软件开发方法增强了其中的许多观点,这支持了我的信念。最明显的(或许也是最不重要的)是面向对象编程语言的流行。现在,除了C++外,出现了许多其他的面向对象编程语言。另外,还有一些面向对象设计符号,比如UML。我关于面向对象的语言之所以得到流行,是因为它们允许在代码中直接表现出更具表达力的设计的论点。现在看来,它有点过时了。重构的概念——重新组织代码基础,使之更加健壮和可重用——同样和我的关于设计的所有方面的内容都应该是灵活的并且在验证设计时允许改变的论点相似。重构只是提供了一个过程以及一组如何去改善已经被证实具有缺陷的设计的准则。最后,文中有一个敏捷开发的总的概念。虽然极限编程是这些新方法中最知名的一个,但是它们具有一个共同点:它们都承认源代码是软件开发工作中的最重要的产品。另一方面,有一些观点——其中的一些我在论文中略微谈到过——在随后的一些年中,对我来说变得更加重要。第一个是构架,或者顶层设计的重要性。在论文中,我认为构架只是设计的一部分内容,并且在构建/测试循环对设计进行验证的过程中,构架需要保持可变。这在本质上是正确的,但是现在回想起来,我认为我的想法有点不成熟。虽然构建/测试循环可能揭示出构架中的问题,但是更多的问题是常常由于改变需求而表现出来的。 一般来说,设计软件是困难的,并且新的编程语言,比如Java或者C++,以及图形化的符号,比如UML,对于不知道如何有效地使用它们的人来说,都没有多大的帮助。此外,一旦一个项目基于一个构架构建了大量的代码,那么对该构架进行基础性的更改,常常相当于丢弃掉该项目并重新开始一个,这就意味着该项目没有出现过。即使项目和机构在根本上接受了重构的概念,但是他们通常仍然不愿意去做一些看起来就像是完全重写的事情。这意味着第一次就把它做对(或者至少是接近对)是重要的,并且项目变得越大,就越要如此。幸运的是,软件设计模式有助于解决这方面的问题。还有其他一些方面的内容,我认为需要更多地强调一下。其中之一就是辅助文档,尤其是构架方面的文档。虽然源代码就是设计,但是试图从源代码中得出构架,可能是一个令人畏惧的体验。在论文中,我希望能够出现一些软件工具来帮助软件开发者自动地维护来自源代码的辅助文档。我几乎已经放弃了这个希望。一个好的面向对象构架通常可以使用几幅图以及少许的十几页文本描述出来。不过,这些图(和文本)必须集中于设计中的关键类和关系。糟糕的是,对于软件设计工具可能会变得足够聪明,以至于可以从源代码的大量细节中提取出这些重要方面的内容这一点,我没有看到任何真正的希望。这意味着还必须由人来编写和维护这种文档。我仍然认为,在源代码完成后,或者至少是在编写源代码的同时去编写文档,要比在编写源代码之前去编写文档更好一些。最后,我在论文的最后谈到了C++是编程——并且因此是软件设计——艺术的一个进步,但是还需要更大的进步。就算我完全没有看到语言中出现任何真正的编程进步来挑战C++的流行,那么在今天,我会认为这一点甚至要比我首次编写它时更加正确。——Jack Reeves,2002年1月1日第4章 测试驱动面对“测试先行”这个名字时,最大的疑问就是:程序都还没有写出来,测试什么呀!其实这是一个泥瓦匠都明白的道理,我们却是自己在画地为牢。泥瓦匠都知道要先拉准绳再砌墙。因为开发太忙,所以拒绝测试驱动?这却导致了恶性循环:越不写测试,质量越差;质量越差;压力越大。抛弃原来的托词吧!为什么不学泥瓦匠那样拉上一根水平线呢?而且,自动化测试框架的引入足以塞住那些以“编写测试代码太麻烦”为理由的开发人员的嘴巴,让他们没有理由逃避单元测试。这样才会进入良性循环:压力越大,进行越多的自动化测试;自动化测试越多,压力越小。可是,为什么要程序员来写测试呢?因为开发人员对自己的代码了如指掌,他们很清楚如何采用适当的方法对代码进行测试。如果开发人员不对自己的代码进行测试,又如何知道代码能否按照预期的方式运行?发现bug(缺陷)的时机越靠后,修复成本越高;修复由最终用户发现的bug可能要耗费100或1 000倍的成本。更不用说bug导致用户工作无法继续,而一直等到下一个版本才能解决。如果开发人员能够在编写代码期间发现所有的软件错误,那就再好不过了。软件有两个基本目的:完成必需的外在功能(满足需要);保持良好的内部结构(应对变化)。以往我们同时考虑两个目的,一心二用,却总是不能很好地兼顾,反而相互影响。TDD的方法则把两个目的分解到两个阶段中分别完成:测试驱动实现:完成外在的功能;测试确保重构:优化内在的结构。

为什么要测试驱动?仿照《大话西游》中的经典对白台词,可以感性地描述如下:“曾经有一个很好写单元测试的机会摆在我面前,我没有去珍惜。

等到项目快要完成而bug却满天飞的时候才后悔莫及。

软件开发中最痛苦的事情莫过于此。

如果上天给我一个重来的机会的话,我会说:我一定要写单元测试!

如果非要在前面加上一个数量的话,我希望是一万个!”4.1 什么是TDD

测试驱动开发(Test-Driven Development,TDD)是一种改善程序质量的编程方法。它使得编程更加容易,代码更加清晰、瑕疵也更少。■ 测试先行,让你站在用户的立场上设计。■ 每次处理一个测试,让你做出针对问题的简单设计。■ 随着小规模测试与开发的完善,你可以将绝大多数代码瑕疵拒之

门外。■ 通过留存这些测试集作为副产品,程序更加容易维护,随着时间

的推移还可以不断进行完善。■ 可以确信在修改代码时没有引入使产品不能运行的错误,或者知

道究竟是哪里出了问题。

测试驱动开发不同于传统测试,它改进了传统测试的下面这些缺点。■ 测试不够详尽,产品中还遗留着很多缺陷(bug)。■ 测试是在代码完成之后编写的。■ 测试不是写代码的人写的,因为他不能理解所有代码,因此会漏

掉一些重要的测试。■ 测试人员依靠文档编写测试,如果文档与代码不一致,会造成问

题。■ 测试不是自动进行的。■ 传统纠错的方法常在一个地方改正了又在别的地方引入新的问

题,传统测试不能发现这种问题。

测试驱动开发还带来更多的好处。■ 程序员编写测试,代码基于测试来编写,确保了代码的可测试性。■ 缺陷率极低的健壮系统,全面彻底地覆盖测试,使得调试期间的

bug能被立即发现、查明和修复。■ 系统发布时,一起发布其测试集,使得将来对系统的扩展和修改

更加容易:向程序中增加功能或者修改结构,而无须担心破坏了

既有的功能。■ 迫使我们做出更好、更简单、更能清晰表达意图的设计。● 它可以迫使我们用不同的视角:必须从程序调用者的角度观察要编写的程序。这样才能在关注实现的同时,关注它的接口。这样才可以设计出便于调用的软件。● 它可以迫使我们设计可测试的软件。● 可调用的、可测试的软件必须和周围的环境解耦,可以迫使我们构造松耦合的软件。■ 测试本身有助于对系统进行描述。测试是另一种形式的文档,它

展现给你如何调用一个函数或者创建一个对象,如何使用代码,

而且是可编译的、可运行的、最新的。4.1.1 测试原则:尽早、经常、自动化

bug破坏了信任。

很多团队忍受bug,认为本质上无bug的代码是不可能的,甚至将bug率减半也认为太过昂贵。敏捷过程的很多实践帮助我们减少bug。例如,首先通过清晰的沟通使得bug不再出现,其次在bug出现时使得团队学会如何在今后避免类似的问题,等等。

缺陷成本递增律(Defect Cost Increase,DCI)告诉我们:越早发现bug,所需花费越少。这意味着,反馈回路长的软件开发是昂贵的,并且会残留很多bug,修复bug的成本越高,残留bug就越多,如图4-1和图4-2所示。图4-1 较晚且成本高的测试会遗留很多bug图4-2 频繁测试可以减少成本和bug■ 测试必须是尽早的、经常的:敏捷过程反向运用DCI来减少bug

和修复它们的成本。通过在编程时引入的自动测试更快、更经济

地修复bug。频繁地运行测试,每次编译的时候同时运行测试

——每天至少运行每个测试一次。■ 测试必须是自动的:测试的即时性意味着测试必须是自动的。敏

捷过程中不存在手工测试或者自动测试的讨论,随着时间的推

移,通过改进设计和开发工具,直到所有测试都变成自动的。■ 测试必须是先行的:软件开发的原则是实现不应该过度影响接

口。而先写测试则是完成接口和实现分离的具体方式。同时,测

试也满足人们希望确保安全的需要。如果你写下所有能想到的失

败的测试,并且这些测试都通过了,那么你就敢肯定你的代码是

正确的。当然,你没有想到的测试可能会失败,但至少你已经指

出了系统实际要做什么,就像测试所演示的那样。

敏捷过程要求:除非存在相关的测试,否则不编写任何代码。要确保系统中所有代码都是可测试的,这是赋予你简单、自信和勇气价值观的一部分。因为系统中不存在任何不是为了响应测试而存在的代码,因此测试集本质上是详尽的。而且只有可测试的代码,才是可集成的、可重构的。如果不能自信地说我修改的代码不会破坏任何先前的行为,那么如何重构?如果无意中破坏了其他的部分代码,而没有一套可以立即察觉的测试集,那么如何集成?4.1.2 验收测试

敏捷过程中有以下两套测试,这两套测试相互复核。■ 一套是单元测试:从程序员的角度写的,完整测试系统的各个组

成部分。■ 一套是验收测试:作为单元测试的补充,验收测试验证系统作为

一个整体工作时的正确性。

作为选择每个所期望的特性的一部分,客户定义出自动验收测试来表明该特性可以工作。

验收测试使用某种脚本语言编写,能自动、反复运行。它是在实现某个用户故事(User Story)的时候或者之前编写的。一旦某项验收测试通过,就放到一个集合中,绝不允许该测试再次失败。

这个不断增长的验收测试集合每天都多次运行,每一次构建(build)系统,都要运行这个测试集合;一旦验收测试失败,则系统的构建也宣告失败。因此,一项需求一旦实现,就不会再遭到破坏。

正如单元测试可以迫使开发人员在小的方面做出优良的设计,验收测试可以使得开发人员在大的方面构建优良的系统架构。因为要使得系统可测试,必须保证解耦,所以编写验收测试对于系统架构层面的解耦有好处。例如,为了使验收测试无须通过UI就能获得对于业务规则的访问,就必须解除UI和业务规则的耦合。

验收测试与单元测试一样,是自动化的、可运行的程序,通常使用脚本语言来编写,而且是一项关于特性的最终文档——如同单元测试一样,是可编译、可运行的文档。它们的区别在于:■ 单元测试是白盒,而验收测试是黑盒;■ 单元测试由程序员编写,而验收测试由客户、业务人员或者测试

人员编写。4.1.3 模拟对象

单元测试必须使用模拟对象,而验收测试一般不需要使用。当被测试的某个类需要依赖其他类的时候,采用模拟(Mock)对象可以模拟出依赖对象的类型和一些特定的调用,从而隔离了被测试单元之间的互相影响。

比如你的某个业务逻辑组件需要调用数据库,这个时候就可以用模拟对象模拟出数据库,并给出一些设定好的调用数据。这样,当对业务逻辑组件进行测试的时候,即使数据库崩溃,也不会让该组件的单元测试亮出一盏红灯。

由于采用IoC模式来作为中间层组件容器和测试驱动开发方式,所以模拟对象对我们来说是必不可少的。

模拟对象的好处有以下几个。■ 有助于保持松耦合的设计:强化接口为中心的设计、消除对象实

现依赖。■ 检查代码中使用另外一个对象是否得当:在模拟对象中设置期望

值,可以验证代码是否合理地使用了模拟的接口。■ 让测试运行更快。■ 推迟必需的实现:对未实现的类使用模拟对象,可以推迟其实现,

以集中精力处理其接口。■ 更容易地开发与硬件、远程系统和其他一些难处理的资源进行交

互的代码。■ 在进行测试驱动开发时把开发的部件与系统的其余部分隔离开:

模拟交互的部分,可以专注于当前部分的开发,控制其与其他部

分的复杂交互。■ 提倡基于接口的设计:基于接口的设计最易于使用模拟对象。■ 鼓励使用组合而不是继承:模拟继承层次中的任何对象都是困难

的,因为它还要承载其继承的责任,而通过较小的聚合在一起相

互协作的类则是容易模拟的。■ 改进接口:你不得不先考虑如何优化接口使用,最后才考虑类的

实现。■ 测试小概率的、难以发生的事件。

模拟对象可以:■ 记录被测试对象发出的调用方法;■ 设定调用的返回值,可以对某一方法的不同调用情况返回不同的

值;■ 就被调用的方法、调用时的参数、频度等设置期望值;■ 验证是否满足期望:对某些调用(调用次数、参数等)出现与期

望不符的情况时抛出异常。

目前,几个主流的模拟工具为RhinoMock、EasyMock、Nmock、Jmock、TypeMock、MockObjects、MockMaker等。4.2 测试技巧

在享受测试驱动开发带来的各种好处之前,必须先承担一个义务:确认遵循一些简单的规则。当方法并不是很好时,单元测试则可能达到一个相反的结果,从而浪费你的时间,并且使测试过程更加复杂。4.2.1 测试之前的思想准备

在测试之前,应该做好如下的思想准备。■ 把自己当作一个QA人员,想象成一个测试人员,而不仅仅是一

个开发人员。■ 我为什么要当自己是测试人员?● 因为你花在设计单元测试上的时间将有助于减少日后解决缺陷所花的时间。■ 跳出自己的思维模式,尽可能多地对测试进行头脑风暴。■ 每当完成的时候,回头查看你可能漏掉的内容。■ 汲取来自团队成员的反馈。● 他们创建了什么其他类型的测试?其他人可能提供一个对熟悉自己代码的开发人员而言非常困难的观点。■ 注意测试对象的细节。● 数据如何在对象之间传输?● 谁使用它们?● 销毁对象容易吗?● 如果进行某个操作,将会发生什么?4.2.2 测试之间的关系——相互独立的测试

测试直接的关系就是:它们是相互独立的。■ 测试也需要高内聚和松耦合——应该是零耦合。■ 相互独立的测试意味着所有测试都是独立运行、不依赖于顺序的。■ 可以从中挑出一些来进行测试,而完全不必担心因为前面的测试

不存在而失败。■ 一个测试的失败不应该导致其他测试失败。■ 在继续创建新的测试前验证所有测试运行成功,这样可以保证在

破坏代码后立刻进行修正。4.2.3 什么时候写测试

在下列情况之一出现的时候就要写测试。■ 在编写新功能和方法之前,先编写测试。■ 如果要在没有测试的代码上写新的功能,请先写目前代码的测

试。■ 如果要修正一个BUG,请先为这个BUG写一个测试。■ 如果要重构没有测试过的代码,请先写一个测试。4.2.4 如何开始写测试——断言优先

请按下面的方法来开始写测试。■ 编写测试从断言开始。● 什么时候写断言?一开始就写。● 先写断言,然后把断言所需要的设置代码补齐。■ 先测试简单的东西。● 有些东西是很容易测试的,比如处理null、空集合的行为、递归或迭代的行为等。● 先对容易的情况进行测试,能很快进入状态。■ 自相似性:● 应该从哪里开始构建一个系统?从系统完成时的功能的描述开始。● 应该从哪里开始编写一项功能?从功能完成时能通过的测试开始。● 应该从哪里开始编写一个测试?从测试完成时能通过的断言开始。4.2.5 如何选择测试数据——显然数据

应该使用“显示数据”(evident Data)作为测试数据。■ 使用容易让人理解的测试数据。● 使用简单的数据。● 或者使用真实的数据。■ 如同按意图编程一样,要让数据表达你的意图,不要让别人问:

这家伙到底想干什么?● 例如,如果美元和英镑的兑换比例为2:1,而每笔交易要收取1.5%的手续费,那么兑换100美元的测试应该写成“100/2*(1-0.015)”,而不是“49.25”。4.2.6 测试如何组织——测试列表

应该通过测试列表来对数量庞大的测试来进行组织。■ 脑子是靠不住的,需要用笔来记录:一张记录现在要做的,一张

记录以后要做的。每当想起一件要做的事情,就先看是现在要做

的,还是以后要做的。运用到TDD上,就是“测试列表”。● 首先,把本次迭代要实现的功能的测试记录到本版本的测试清单上。

试读结束[说明:试读内容隐藏了图片]

下载完整电子书


相关推荐

最新文章


© 2020 txtepub下载