好的设计源于对一小批关键设计概念的理解。这一节将会讨论“复杂度”所扮演的角色、设计应具有的特征,以及设计的层次。
Software’s Primary Technical Imperative: Managing Complexity软件的首要技术使命:管理复杂度
为了理解管理复杂度的重要性,我们有必要引用Fred Brooks的那篇具有里程碑意义的文章——《没有银弹:软件工程中本质性和偶然性》(1987)。
Accidental and Essential Difficulties
偶然的难题和本质的难题
Brooks认为,两类不同的问题导致软件开发变得困难——本质的问题和偶然的问题。关于这两个术语,Brooks引用了亚里士多德时代的一个哲学传统。在哲学界,本质的(essential)属性是一件事物必须具备、如果不具备就不再是该事物的属性。汽车必须具有引擎、轮子和车门,不然就不能称其为汽车。如果不具备这其中任何一个本质的属性,它就不再是一辆汽车。
偶然的(accidental)属性则是指一件事物碰巧具有的属性,有没有这些属性都并不影响这件事物本身。一辆汽车可能有台V8发动机,或是涡轮增压四缸发动机,或其他什么种类的发动机,无论这些细节如何,它总是一辆汽车。一辆汽车也可能是两门或四门的;它可能有粗劣或豪华的轮毂。所有这些细节都是次要的偶然属性。你也可以把偶然属性想成是附属的、任意的、非必要的或偶然出现的性质。
Brooks观察到,软件开发中大部分的偶然性难题在很久以前就已得到解决了。比如说,与笨拙的语法相关的那些偶然性难题大多已在从汇编语言到第三代编程语言的演进过程中被解决了,而且这类问题的重要性也渐渐下降了。与非交互式计算机相关的偶然性难题也随着分时(time-share)操作系统取代批模式(batch-mode)系统而被解决。集成编程环境更是进一步解决了由于开发工具之间无法很好地协作而带来的效率问题。
Brooks论述说,在软件开发剩下的那些本质性困难上的进展将会变得相对缓慢。究其原因,是因为从本质上说软件开发就是不断地去发掘错综复杂、相互连接的整套概念的所有细节。其本质性的困难来自很多方面:必须去面对复杂、无序的现实世界;精确而完整地识别出各种依赖关系与例外情况;设计出完全正确而不是大致正确的解决方案;诸如此类。即使我们能发明出一种与现实中亟待解决的问题有着相同术语的编程语言,但是人们要想清楚地认清现实世界到底如何运作仍有很多挑战,因此编程仍会十分困难。当软件要解决更大规模的现实问题时,现实的实体(entities)之间的交互行为就变得更为复杂,这些转而又增加软件解决方案的本质性困难。
所有这些本质性困难的根源都在于复杂性——不论是本质的,还是偶然的。
Importance of Managing Complexity
管理复杂度的重要性
在对导致软件项目失败的原因进行调查时,人们很少把技术原因归为项目失败的首要因素。项目的失败大多数都是由差强人意的需求、规划和管理所导致的。但是,当项目确由技术因素导致失败时,其原因通常就是失控的复杂度。有关的软件变得极端复杂,让人无法知道它究竟是做什么的。当没人知道对一处代码的改动会对其他代码带来什么影响时,项目也就快停止进展了。
管理复杂度是软件开发中最为重要的技术话题。在我看来,软件的首要技术使命便是管理复杂度,它实在是太重要了。
复杂度并不是软件开发中的什么新特征。计算机先驱Edsger Dijkstra指出,计算是唯一的一种职业,在其中,人的思维需要从一个字节大幅跨越到几百兆字节——跨越比例为109分之1,也就是九个数量级 (Dijkstra 1989)。这一比例大得惊人。Dijkstra是这样描述的:“在语义的层次量上相比,一般的数学理论几乎是平坦的。由于提出了对很深的概念层次的需要,自动化的计算机使我们面临着一种本质上全新的智力挑战,是史无前例的挑战。”当然,从1989年以来软件变得更为复杂了,Dijkstra所说的1比109的比例今天很可能已经变成了1比1015。
Dijkstra还指出,没有谁的大脑能容得下一个现代的计算机程序(Dijkstra 1972),也就是说,作为软件开发人员,我们不应该试着在同一时间把整个程序都塞进自己的大脑,而应该试着以某种方式去组织程序,以便能够在一个时刻可以专注于一个特定的部分。这么做的目的是尽量减少在任一时间所要考虑的程序量。你可以把它想做是一种心理上的杂耍(边抛边接:通过轮流抛接使两个或两个以上物体同时保持于空中)——程序要求你在空中保持的(精神上的)球越多,你就越可能漏掉其中的某一个,从而导致设计或编码的错误。
在软件架构的层次上,可以通过把整个系统分解为多个子系统来降低问题的复杂度。人类更易于理解许多项简单的信息,而不是一项复杂的信息。所有软件设计技术的目标都是把复杂问题分解成简单的部分。子系统间的相互依赖越少,你就越容易在同一时间里专注问题的一小部分。精心设计的对象关系使关注点相互分离,从而使你能在每个时刻只专注于一件事情。在更高汇聚的层次上,包(packages)提供了相同的好处。
保持子程序的短小精悍也能帮助你减少思考的负担。从问题的领域着手,而不是从底层实现细节入手去编写程序,在最抽象的层次上工作,也能减少人的脑力负担。
受着人类固有限制影响的程序员的底线,就是要写出既让自己容易理解,也能让别人容易看懂,而且很少有错误的程序代码。
How to Attack Complexity
如何应对复杂度
高代价、低效率的设计源于下面三种根源:
■ 用复杂的方法解决简单的问题;
■ 用简单但错误的方法解决复杂的问题;
■ 用不恰当的复杂方法解决复杂的问题。
正如Dijkstra所指出的,现代的软件本身就很复杂,无论你多努力,最终都会与存于现实世界问题本身的某种程度的复杂性不期而遇。这就意味着要用下面这两种方法来管理复杂度:
■ 把任何人在同一时间需要处理的本质(essential)复杂度的量减到最少;
■ 不要让偶然性(accidental)的复杂度无谓地快速增长。
一旦你能理解软件开发中任何其他技术目标都不如管理复杂度重要时,众多设计上的考虑就都变得直截了当了。
Desirable Characteristics of a Design
理想的设计特征
高质量的设计具有很多常见的特征。如果你能实现所有这些目标,你的设计就真的非常好了。这些目标之间有时会相互抵触,但这也正是设计中的挑战所在——在一系列相互竞争的目标之中做出一套最好的折中方案。有些高质量设计的特征也同样是高质量程序的特征,如可靠性和性能等。而有些则只是设计范畴内的特征。
下面就列出一些设计范畴内的特征:
最小的复杂度(Minimal complexity) 正如刚刚说过的,设计的首要目标就是要让复杂度最小。要避免做出“聪明的”设计,因为“聪明的”设计常常都是难以理解的。应该做出简单且易于理解的设计。如果你的设计方案不能让你在专注于程序的一部分时安心地忽视其他部分的话,这一设计就没有什么作用了。
易于维护(Ease of maintenance) 易于维护意味着在设计时为做维护工作的程序员着想。请时刻想着这些维护程序员可能会就你写的代码而提出的问题。把这些程序员当成你的听众,进而设计出能自明的(self-explanatory)系统来。
松散耦合(loose coupling) 松散耦合意味着在设计时让程序的各个组成部分之间关联最小。通过应用类接口中的合理抽象、封装性及信息隐藏等原则,设计出相互关联尽可能最少的类。减少关联也就减少了集成、测试与维护时的工作量。
可扩展性(extensibility) 可扩展性是说你能增强系统的功能而无须破坏其底层结构。你可以改动系统的某一部分而不会影响到其他部分。越是可能发生的改动,越不会给系统造成什么破坏。
可重用性(reusability) 可重用性意味着所设计的系统的组成部分能在其他系统中重复使用。
高扇入(high fan-in) 高扇入就是说让大量的类使用某个给定的类。这意味着设计出的系统很好地利用了在较低层次上的工具类(utility classes)。
低扇出(low fan-out) 低扇出就是说让一个类里少量或适中地使用其他的类。高扇出(超过约7个)说明一个类使用了大量其他的类,因此可能变得过于复杂。研究发现,无论考虑某个子程序调用其他子程序的量,还是考虑某个类使用其他类的量,低扇出的原则都是有益的(Card and Glass 1990; Basili, Briand, and Melo 1996)。
可移植性(portability) 可移植性是说应该这样设计系统,使它能很方便地移植到其他环境中。
精简性(leanness) 精简性意味着设计出的系统没有多余的部分(Wirth 1995, McConnell 1997)。伏尔泰曾说,一本书的完成,不在它不能再加入任何内容的时候,而在不能再删去任何内容的时候。在软件领域中,这一观点就更正确,因为任何多余的代码也需要开发、复审和测试,并且当修改了其他代码之后还要重新考虑它们。软件的后续版本也要和这些多余代码保持向后兼容。要问这个关键的问题:“这虽然简单,但把它加进来之后会损害什么呢?”
层次性(stratification) 层次性意味着尽量保持系统各个分解层的层次性,使你能在任意的层面上观察系统,并得到某种具有一致性的看法。设计出来的系统应该能在任意层次上观察而不需要进入其他层次。
举例来说,假设你正在编写一个新系统,其中用到很多设计不佳的旧代码,这时你就应该为新系统编写一个负责同旧代码交互的层。在设计这一层时,要让它能隐藏旧代码的低劣质量,同时为新的层次提供一组一致的服务。这样,你的系统的其他部分就只需与这一层进行交互,而无须直接同旧代码打交道了。在这个例子中,层次化设计的益处有:(1)它把低劣代码的烂泥潭禁闭起来;(2)如果你最终能抛弃或者重构旧代码,那时就不必修改除交互层之外的任何新代码。
标准技术(Standard techniques) 一个系统所依赖的外来的、古怪的东西越多,别人在第一次想要理解它的时候就越是头疼。要尽量用标准化的、常用的方法,让整个系统给人一种熟悉的感觉。
Levels of Design
设计的层次
需要在一个软件系统中的若干不同细节层次上进行设计。有些设计技术适用于所有的层次,而有些只适用于某些层次上。图5-2展示了这些层次。

图5-2 一个程序中的设计层次。系统①首先被组织为子系统②。子系统被进一步分解为类③,然后类又被分解为子程序和数据④。每个子程序的内部也需要进行设计⑤
Level 1: Software System
第1层:软件系统
第一个层次就是整个系统。有的程序员直接从系统层次就开始设计类,但是往往先从子系统或者包(package)这些类的更高组织层次来思考会更有益处。
Level 2: Division into Subsystems or Packages
第2层:分解为子系统或包
在这一层次上设计的主要成果是识别出所有的主要子系统。这些子系统可能会很大,比如说数据库、用户界面、业务规则、命令解释器、报表引擎等。这一层的主要设计活动就是确定如何把程序分为主要的子系统,并定义清楚允许各子系统如何使用其他子系统。对于任何至少需要几周时间才能完成的项目,在这一层次上进行划分通常都是必需的。在每个子系统的内部可能要用到不同的设计方法——请对系统中的每一部分选用最恰当的方法。在图5-2中,这一层次的设计是用②注明的。
在这一层次中,有一点特别重要,即不同子系统之间相互通信的规则。如果所有的子系统都能同其他子系统通信,你就完全失去了把它们分开所带来的好处。应该通过限制子系统之间的通信来让每个子系统更有存在意义。
举例来说,在图5-3中,你把一个系统划分成六个子系统。在没有定义任何规则时,热力学第二定律就会发生作用,整个系统的熵将会增加。熵之所以增加的一种原因是,如果不对子系统间的通信释加任何限制,那么它们之间的通信就会肆意地发生,如图5-4所示。
图5-3 一个有六个子系统的系统示例

图5-4 当子系统之间的通信没有任何限制时就会像这个样子
正如你所看到的,这里的每个子系统最终都会直接与所有其他子系统进行通信,从而为我们提出一些重要的问题:
■ 一个开发人员需要理解系统中多少个不同的部分——哪怕只理解一丁点儿——才能在图形子系统中改动某些东西?
■ 当你想在另一个系统中试图使用业务规则时会发生什么?
■ 当你想在系统中加入一套新的用户界面时——比如说为了进行测试而开发的命令行界面——会发生什么?
■ 当你想把数据存储放到一台远程计算机上,又会发生什么?
你可以把子系统之间的连线当成水管。当你想去掉某个子系统时,势必会有不少水管连在上面。你需要断开再重新连接的水管数量越多,弄出来的水就会越多。你肯定想把系统的架构设计成这样:如果想把某个子系统取走重用时,不用重新连接太多水管,重新连接起来也不会太难。
有先见之明的话,所有这些问题就不会花太多额外功夫。只有当“确需了解”——最好还有合理的理由——时,才应该允许子系统之间的通信。如果你还拿不准该如何设计的话,那么就应该先对子系统之间的通信加以限制,等日后需要时再放松,这要比先不限制,等子系统之间已经有了上百个调用时再加以限制要容易得多。图5-5就展示了施加少量通信规则后可以把图5-4中的系统变成什么样子。

图5-5 施加若干通信规则后,子系统之间的交互得以显著地简化
为了让子系统之间的连接简单易懂且易于维护,就要尽量简化子系统之间的交互关系。最简单的交互关系是让一个子系统去调用另一个子系统中的子程序;稍微复杂一点的交互关系是在一个子系统中包含另一个子系统中的类;而最复杂的交互关系是让一个子系统中的类继承自另一个子系统中的类。
有一条很好的基本原则,即,像图5-5这样的系统层设计图应该是无环图(acyclic graph)。换句话说,程序中不应该有任何环形关系,比如说A类使用了B类、B类使用了C类、而C类又使用了A类这种情况。
对于大型程序或一系列程序而言,在子系统这一层次上进行设计是至关重要的。如果你觉得自己要写的程序小到可以跳过在子系统层次上进行设计这一步骤,那么只要确保跳过这层设计的决定是经过深思熟虑的。
常用的子系统 有些种类的子系统会在不同的系统中反复出现。下面几种就较为常见。
业务规则 业务规则是指那些在计算机系统中编入的法律、规则、政策以及过程。如果你在开发一套薪资系统,你可能要把美国国税局关于允许扣提的金额和估算的税率编到你的系统中。在薪资系统中还可能出现的规则包括与工会签订的有关加班率、休假安排以及节假日薪水的合同,等等。如果你要写一个能计算车险费率的程序,那么业务规则可能会包括政府有关责任范围的法令、保险赔率表以及保险业条款等。
用户界面 应创建一个子系统,把用户界面组件同其他部分隔离开,以便使用户界面的演化不会破坏程序的其余部分。在大多数情况下,用户界面子系统会使用多个附属的子系统或类来处理用户界面、命令行接口、菜单操作、窗体管理、帮助系统,等等。
数据库访问 你可以把对数据库进行访问的实现细节隐藏起来,让程序的绝大部分可以不必关心处理底层结构的繁琐细节,并能像在业务层次一样处理数据。隐藏实现细节的子系统可以为系统提供有价值的抽象层,从而减少程序的复杂度。它把和数据库相关的操作集中起来,减少了在对数据进行操作时发生错误的几率。同时,它还能让数据库的设计结构更易于变化,做这种修改时无须修改程序的主要部分。
对系统的依赖性 把对操作系统的依赖因素归到一个子系统里,就如同把对硬件的依赖因素封装起来一样。比如说,你在开发一个运行于Microsoft Windows操作系统上的程序,可为什么一定要把自己局限于Windows的环境呢?把所有与Windows相关的系统调用都隔离起来,放到一个Windows接口子系统中,这样一来,如果日后你想把程序移植到Mac OS或Linux操作系统时,只要修改接口子系统就可以了。自己实现这一接口子系统可能会太复杂了,但一些商业代码库都已经提供了这一子系统的现成实现。
Level 3: Division into Classes
第3层:分解为类
在这一层次上的设计包括识别出系统中所有的类。例如,数据库接口子系统可能会被进一步划分成数据访问类、持久化框架类以及数据库元数据。图5-2中的第3层就展示了第2层中一个子系统是如何被分解为类的,当然这也暗示着第2层的其他三个子系统也被分解为类了。
当定义子系统中的类时,也就同时定义了这些类与系统的其余部分打交道的细节。尤其是要确定好类的接口。总的来说,这一层的主要设计任务是把所有的子系统进行适当的分解,并确保分解出的细节都恰到好处,能够用单个的类实现。
对于工作期需要超过几天的项目而言,通常就需要把子系统分解为类了。如果项目很大,这种分解就会与第2层的程序分解有明显的区别;相反,如果项目很小,你也可以直接从第1层的系统全局视图直接进入到第3层的类视图。
类与对象的比较 面向对象设计的一个核心概念就是对象(object)与类(class)的区分。对象是指运行期间在程序中实际存在的具体实体(enity),而类是指在程序源码中存在的静态事物。对象是动态的,它拥有你在程序运行期间所能得到的具体的值和属性。例如,你可以定义一个名为Person的类,它具有姓名、年龄、性别等属性。在程序运行期间,你可以有nancy、hank、diane、tony等对象——它们是类的具体实例。如果你熟悉数据库术语的话,类与对象的关系就如同“模式(schema)”与“实例(instance)”一样。你可以把类看做是蛋糕模具,而把对象看做是蛋糕。在本书中,对这两个术语的使用并非十分正规,一般情况也会或多或少互换地使用类和对象这两个术语。
Level 4: Division into Routines
第4层:分解成子程序
这一层的设计包括把每个类细分为子程序。在第3层中定义出的类接口已经定义了其中一些子程序,而第4层的设计将细化出类的私用(private)子程序。当你查看类里面子程序的细节时,就会发现很多子程序都很简单,但也有些子程序是由更多层次化组织的子程序所组成的,这就需要更多的设计工作了。
完整地定义出类内部的子程序,常常会有助于更好地理解类的接口,反过来,这又有助于对类的接口进行进一步修改,也就是说再次返回第3层的设计。
这一层次的分解和设计通常是留给程序员个人来完成的,它对于用时超过几小时的项目而言就是必需做的了。虽然不用非常正式地完成这一步骤,但至少也要在脑中完成。
Level 5: Internal Routine Design
第5层:子程序内部的设计
在子程序层次上进行设计就是为每个子程序布置详细的功能。子程序内部的设计工作通常是由负责该子程序的开发人员来完成的。这里的设计工作包括编写伪代码、选择算法、组织子程序内部的代码块,以及用编程语言编写代码。这一层的设计工作总是需要做的,尽管有时做得很不在意或者很差劲,有时则是经过深思熟虑而出色完成的。在图5-2中的第5步就是这一层的设计工作。







