设计中的挑战
“软件设计”意味着去构思。创造或发明一套方案,把一份软件的规格说明书变成功能可运行的软件。设计就是把需求分析和编码调试连起来的活动。好的高层次设计能提供一个稳妥容纳多个较低层次设计的结构。好的设计对于小型项目非常有用,对于大型项目就更是不可或缺。
设计是一个险恶的问题
设计是一个险恶的问题。“险恶”问题就是那种只能通过解决或部分解决才能被明确的问题。说通俗一点,就是你必须先把这个问题解决一遍以便能够明确地定义它,然后再次解决问题,从而形成一个可行的方案。
举个栗子,位于美国华盛顿州塔科马的Tacoma Narrows大桥曾经坍塌过一次,只因设计者在设计时只关注它是否足够结实以承受设计负荷,却忽略了大风给它带来的 横向谐波。在1940年狂风大作的某一天,这种谐波越来越大且不可控制,从而让大桥坍塌。
这就是一个险恶问题的好例子,因为直到这座桥坍塌,工程师们才知道应该充分地考虑空气动力学的因素。只有通过建造这座大桥(即解决这个问题),他们才能学会从这一问题中应该额外考虑的环节,从而才能建造出到现在依然屹立不倒的另一座桥梁。
设计是个了无章法的过程
说设计了无章法,是因为在此过程中你我会采取很多错误的步骤,多次误入歧途,当然,我们也会改正。事实上,犯错正是设计的关键所在,在设计阶段犯错并加以改正,其代价要比在编码后才发现同样的错误并彻底修改低得多。说设计了无章法,还因为优、劣设计之间的差异往往非常微妙。
另外,说设计了无章法,还因为你很难判断设计何时算是“足够好”?设计到什么细节才算够?有多少设计需要用形式化的设计符号完成,又有多少设计可以留到编码时再做?什么时候才算完成?因为设计永无止境,因此对上述问题最常见的回答是“到你没时间再做了为止”。
设计就是确定取舍和调整顺序的过程
理想的世界中,每一套系统都能即刻完成运行,不消耗任何储存空间,不占用任何网络带宽,没有任何错误,也无需任何成本即可生成。而在现实世界中,设计者工作的一个关键内容便是去衡量彼此冲突的各项设计特性,并尽力在区中寻求平衡。如果快速的反应速度比缩减开发时间更重要,那么设计者会选取一套设计方案。如果缩减开发时间更重要,那么设计者可能又会形成另一套不同的设计方案。
设计受到诸多限制
设计的要点,一部分是在创造可能发生的事情,而另一部分又是在限制可能发生的事情。假如人们有无限的时间、无限的木材,不考虑外在因素的情况下,人们建造的房屋可以无限蔓延,房屋可以无限高无限大。如果毫无约束,软件最后也会是这样的结果。正是由于这些限制,才会促使产生简单的方案,并最终改善这一解决方案。软件设计的目标也是如此。
设计是不确定的
如果你让三个人去设计一套同样的程序,他们很可能会做出三套截然不同的设计,而每套设计可能都很不错。
设计是一个启发式过程
正因为设计过程充满不确定性,因此设计技术也就趋于具有探索性——“经验法则”或者“试试没准能行的办法”——而不是保证能产生预期结果的可重复的过程。设计过程中总会有试验和犯错误。在一件工作或一件工作的某个方面十分凑效的设计或技术,不一定在下一个项目中适用。。没有任何工具和方法是用之四海皆灵的。
设计是自然而然形成的
把设计的这些特性综合归纳起来,我们可以说设计是“自然而然形成的”。设计不是在谁的头脑中直接跳出来的。它是在不断的设计评估、非正式讨论、写试验代码以及修改试验代码中演化和完善的。
关键的设计概念
好的设计源于对一小批关键设计概念的理解。这一节将会讨论“复杂度”所扮演的角色、设计应具有的特征,以及设计的层次。
偶然的难题和本质的难题
Brooks认为,两类不同的问题导致软件开发变得困难——本质的问题和偶然的问题。在哲学界,本质的属性是一个事物必须具备、如果不具备就不再是该事物的属性。举个栗子,碳烤活鱼必须要有鱼,如果碳烤活鱼没有鱼的话,就失去了它必须具备的本质。偶然的属性则是指一件事物碰巧具有的属性,有没有这些属性都不影响事物的本身。比方碳烤活鱼可以加一些佐料,黄瓜或者豆芽,但是如果没有这些也无伤大雅,这些细节都是次要的偶然属性,可以把偶然属性想成是附属的、任意的、非必要的或偶然出现的性质。
软件开发中大部分的偶然性难题在很久以前就已得到解决了。比如说,与笨拙的语法相关的那些偶然性难题大多已经从汇编语言到第三代编程语言的演进过程中被解决了,而且这类问题的重要性也渐渐下降了。与非交互式计算机相关的偶然性难题也随着分时操作系统取代批模式系统而被解决。集成编程环境更是进一步解决了由于开发工具之间无法很好地协作而带来的效率问题。
Brooks论述说,在软件开发剩下的那些本质性困难上的进展将会变得相对缓慢。究其原因,是因为从本质上说软件开发就是不断地去挖掘错综复杂、相互连接的整套概念的所有细节。其本质性的困难来自很多方面:必须去面对复杂、无序的现实世界;精确而完整的识别出各种依赖关系与例外情况;设计出完全正确而不是大概正确的解决方案;诸如此类。即使我们能发明出一种与现实中亟待解决的问题有着相同术语的编程语言,但是人们想要弄清现实世界到底如何运作仍有很多挑战,因此编程仍会十分困难。当软件要解决更大规模的现实问题时,现实的实体之间的交互行为就会变得更为复杂,这些问题又增加了软件解决方案的本质性困难。
管理复杂度的重要性
在对软件项目失败的原因进行调查时,人们很少吧技术原因归为项目失败的首要因素。项目的失败大多数都是不尽如人意的需求、规划和管理所导致的。但是,当项目确由技术因素导致失败时,其原因通常就是失控的复杂度。有关的软件变得极端复杂,让人无法知道它究竟是做什么的。当没人知道对一处代码的改动会对其他代码带来什么影响时,项目也就快停止进展了。
作为软件开发人员,我们不应该试着在同一时间把整个程序都塞进自己的大脑,而应该试着以某种方式去组织程序,以便能够在一个时刻可以专注于一个特定的部分。这么做的目的是尽量减少在任一时间所要考虑的程序量。
从软件架构的层次上,可以通过吧整个系统分解为多个子系统来降低问题的复杂度。人类更易于理解许多项简单的信息,而不是一项复杂的信息。所有软件设计技术的目标都是把复杂问题分解成简单的部分。子系统间的相互依赖越少,你就越容易在同一时间里专注问题的一小部分。精心设计的对象关系使关注点相互分离,从而使你能在每时每刻专注于一件事情。在更高汇聚的层次上,包提供了相同的好处。
保持子程序的短小精悍也能帮助你减少思考的负担。从问题的领域着手,而不是从底层实现细节入手去编写程序,在最抽象的层次上工作,也能减少人的脑力负担。
如何应对复杂度
高代价、低效率的设计源于下面的三种根源:
- 用复杂的方法解决简单的问题;
- 用简单但错误的方法解决复杂的问题;
- 用不恰当的复杂方法解决复杂的问题
正如Dijkstra所指出的,现代的软件本身就很复杂,无论你多努力,最终都会与存于现实世界问题本身的某种程度的复杂性不期而遇。这就意味着要用下面这两种方法来管理复杂度:
- 把任何人在同一时间需要处理的本质复杂度的量减少最少;
- 不要让偶然性的复杂度无谓地快速增长。
一旦你能理解软件开发中任何其他技术目标都不如管理复杂度重要时,众多设计上的考虑就都会变得直截了当。
理想的设计特征
高质量的设计具有很多常见的特征。如果你能实现所有这些目标,你的设计就真的非常好了。这些目标之间有时会相互抵触,但这也正是设计中的挑战所在——在一系统相互竞争的目标之中,做出一套最好的折中方案。有些高质量设计的特征也同样是高质量程序的特征,如可靠性和性能等。而有些则只是设计的范畴内的特征。
下面列出一些设计范畴内的特征:
- 最小的复杂度(Minimal complexity) :正如刚刚说过的,设计的首要目标就是要让复杂度最小。要词句做出“聪明的”设计,因为“聪明的”设计常常都是难以理解的。应该做出简单且易于理解的设计。如果你的设计方案不能让你在专注于程序的一部分时安心地忽视其他部分的话,这一设计就没有什么作用了。
- 易于维护(Easy of maintenance) :易于维护意味着在设计时为做维护工作的程序员着想。请时刻想着这些维护程序员可能会就你写的代码而提出的问题。把这些程序员当成你的听众。进而设计出能自明的系统来。
- 松散耦合(loose coupling) :松散耦合意味着在设计时让程序的各个组成部分之间关联最小。通过应用类接口中的合理抽象、封装性及信息隐藏等原则,设计出相互关联尽可能最少的类。减少关联也就是减少了集成、测试与维护时的工作量。
- 可扩展性(extensibility):可扩展性就是说你能增强系统的功能而无须破坏其底层结构。你可以改动系统的某一部分而不会影响到其他部分。越是可能发生的改动,越不会给系统造成什么破坏。
- 可重用性(reusability) :可重用性意味着所设计的系统的组成部分能在其它系统中重复使用。
- 高扇入(high fan-in):高扇入就是说让大量的类使用某个给定的类。这意味着设计出的系统很好的利用了在较低层次上的工具类。
- 低扇出(low fan-out) :低扇出就是说让一个类里少量或适中的使用其他的类。高扇出(超过约7个类)说明一个类使用了大量的其它的类。因此可能变得过于复杂。研究发现,无论考虑某个子程序调用其他子程序的量,还是考虑某个类使用其他类的量,低扇出的原则都是有益的。
- 可移植性(portability) :可移植性是说应该这样设计系统,使它能很方便地移植到其他环境中。
- 精简性(leanness):精简性意味着设计出的系统没有多余的部分。伏尔泰说,一本书的完成,不在它不能再加入任何内容的时候,而在不能再删去任何内容的时候。在软件领域中,这一观点就更正确,因为任何多余的代码也需要 开发、复审和测试,并且当修改了其他代码之后还要重新考虑它们。软件的后续版本也需要和这些多余代码保持向后兼容。要问这个关键的问题:“这虽然简单,但把它加进来之后会损害什么呢?”
- 层次性(stratification) :层次性意味着尽量保持系统各个分解层的层次性,使你能在任意的层面上观察系统,并得到某种具有一致性的看法。设计出来的系统应该在任意层次上观察而不需要进入其他层次。举例来说,假设你正在编写一个新系统,其中用到很多设计不佳的旧代码,这时你应该为新系统编写一个负责同旧代码交互的层。在设计这一层时,要让它能隐藏旧代码的低劣质量,同时为新的层次提供一组一致的服务。这样,你的系统的其他部分就只需与这一层进行交互,而无须直接同旧代码打交道了。在这个例子中,层次化设计的益处有:(1)它把低劣代码的烂泥潭禁闭起来;(2)如果你最终能抛弃或者重构旧代码,那时就不必修改除交互层之外的任何新代码。
- 标准技术(standard techniques) :一个系统所依赖的外来的、古怪的东西越多,别人在第一次想要理解它的时候就越是头疼。要尽量用标准化的、常用 的方法,让整个系统给人一种熟悉的感觉。
设计的层次
需要在一个软件系统中的若干不同细节层次上进行设计。有些设计技术适用于所有的层次,而有些只适用于某些层次上。图1-1展示了这些层次。
图1-1 一个程序最后那个的层次设计。系统①首先被组织为系统②。子系统被进一步分解为类③,然后类又被分解为子程序和数据④。每个子程序的内部也需要进行设计⑤
第一层:软件系统
第一层次就是整个系统。有的程序员直接从系统层次就开始设计类,但是往往先从子系统或者包这些类的更高组织层次来思考会更有益处。
第2层:分解为子系统或包
在这一层次上设计的主要成果是识别出所有的主要子系统。这些子系统可能会很大,比如说数据库、用户界面、业务规则、命令解释器、报表引擎等。这一层的主要设计活动就是确定如何把程序分为主要的子系统,并定义清楚允许各子系统如何使用其他子系统。对于任何至少需要几周时间才能完成的项目,在这一层次上进行划分通常都是必需的。在每个子系统的内部可能要用到不同的设计方法,请对系统中的每一部分选用最恰当的方法。在图1-1中,这一层次的设计是用②注明的。
在这一层中,定义子系统之间的通信规则是非常重要的,即不同子系统之间互相通信的规则。如果所有的子系统都能同其他子系统通信,你就会完全失去把它们分开来所带来的好处。应该通过限制子系统之间的通信来让每个子系统更有存在意义。
举个栗子,在图1-2中,你把一个系统划分成六个子系统。在没有定义任何规则时,热力学第二定律就会发生作用,整个系统的熵将会增加。熵之所以增加的一种原因是,如果不对子系统的通信加任何限制,那么它们之间的通信就会肆意地发生,如图1-3所示。
图1-2 一个有六个子系统的系统示例
图1-3 当子系统之间的通信没有任何限制时就会像这个样子
正如你所看到的,这里的每个子系统最终都会直接与其他子系统进行通信,从而为我们提出一些重要的问题:
- 一个开发人员需要理解系统中多少个不同的部分?哪怕只理解一丁点儿,才能在图形子系统中改动某些东西?
- 当你想在另一个系统中试图使用业务规则时会发生什么?
- 当你想在系统中加入一套新的用户界面时,比如说为了测试而开发的命令行界面会发生什么?
- 当你想把数据存储放到一台远程计算机上,又会发生什么?
你可以把子系统之间的连线当成水管。当你想去掉某个子系统时,势必会有不少水管连在上面。你需要断开再重新连接的水管数量越多,弄出来的水就会越多。你肯定想把系统的架构设计成这样:如果想把某个子系统取走重用时,不用重新连接太多水管,重新连接起来也不会太难。
有先见之明的话,所有这些问题就不会花太多额外功夫。只有当必要时,才应该允许子系统之间的通信。如果你还拿不准该如何设计的话,那么就应该先对子系统之间的通信加以限制,等日后需要时再放松,这要比先不限制,等子系统之间已经有上百个调用时再加以限制要容易得多。图1-4展示了施加少量通信规则后可以把1-3中的系统变成的样子。
图1-4 施加若干通信规则后,子系统之间的交互得以显著地简化
为了让子系统之间的连接简单易容且易于维护,就要尽量减少子系统之间的交互关系。最简单的交互关系就是让一个子系统去调用另一个子系统中的程序;稍微复杂一点的交互关系是在一个子系统中包含另一个子系统中的类;而最复杂的交互关系是让一个子系统中的类继承另一个子系统中的类。
有一条很好的基本原则,像如图1-4这样的系统层设计图应该是无环图。换句话说,程序中不应该有任何环形关系,比如说A类使用了B类、B类使用了C类,而C类又使用了A类这种情况。对于大型程序或一系列程序而言,在子系统这一层次上进行设计是至关重要的。如果你觉得自己要写的程序小到可以跳过在子系统层次上进行设计这一步骤,那么只要确保跳过这层设计的决定是经过深思熟虑的。
常用的子系统
有些种类的子系统会在不同的系统中反复出现。下面几种就较为常见。
业务规则:业务规则是指那些在计算机系统中编入的法律、规则、政策以及过程。比方你在开发一套薪资系统,你可能要把国家国税局关于允许扣提的金额和估算的税率编到你的系统中。
用户界面:应创建一个子系统,把用户界面组件同其他部分分隔开来,以便使用户界面的演化不会破坏程序的其余部分。在大多数情况下,用户界面子系统会使用多个附属的子系统或类来处理用户界面、命令行接口、菜单操作、窗体管理、帮助系统,等等。
数据库访问:可以把对数据库进行访问的实现细节进行隐藏起来,让程序的绝大部分可以不必关心处理底层结构的繁琐细节,并能像在业务层次一样处理数据。隐藏实现细节的子系统可以为系统提供有价值的抽象层,从而减少程序的复杂度。它把和数据库相关的操作集中起来,减少了在对数据进行操作时发生错误的几率。同时,它还能让数据库的设计结构更易于变化,做这种修改时无须修改程序的主要部分。
对系统的依赖性:把对操作系统的依赖因素归到一个子系统里,就如同把对硬件的依赖因素风中起来一样。比如说,你在开发一个运行于Windows操作系统上的程序,可为什么一定要把自己局限在Windows环境中呢?把所有与Windows相关的系统调用都隔离起来,放到一个Windows接口子系统中,这样日后你想把程序移植到Linux或Mac OS操作系统时,只要新增或修改接口子系统就可以了。
第三层:分解为类
在这一层次上的设计包括识别出系统中所有的类。例如,数据库接口子系统可能会被进一步划分成数据访问类、持久化框架类以及数据库元数据。图1-1中的第三层就展示了第二层中一个子系统是如何被分解为类的,当然这一暗示着第二层的其他三个子系统也被分解为类了。
当定义子系统中的类时,也就同时定义了这些类与系统的其余部分打交道的细节。尤其是要确定好类的接口。总的来说,这一层的主要设计任务是把所有子系统进行适当的分解,并确保分解出的细节都恰到好处,能够用单个的类实现。
类和对象的比较:面向对象设计的一个核心概念就是对象(object)与类(class)的区分。对象是指运行期间在程序中实际存在的具体实体,而类是指在程序源码中存在的静态事物。对象是动态的,它拥有在程序运行期间所能得到的具体的值和属性。例如,你可以定义一个名为Person的类,它具有姓名、年龄、性别等属性。程序运行期间,你可以有nancy、hank等Person对象,它们都是类的实例。
第四层:分解子程序
这一层的设计包括把每个类细分为子程序。在第三层中定义出类的接口已经定义了其中一些子程序,而第四层的设计将细化出类的私用(private)子程序。当你查看类里面子程序的细节时,就会发现很多子程序都很简单,但也有些子程序是由更多层次化组织的子程序所组成的,这就需要更多的设计工作了。
完整地定义出类内部的的子程序,常常会有助于更好地理解类的接口,反过来,这又有助于对类的接口进行进一步修改,也就是说再次返回第三层的设计。
这一层次的分解和设计通常是留给程序员个人来完成的,它对于用时超过几小时的项目而言就是必须做的了。虽然不用非常正式地完整这一步骤,但至少也要在脑中完成。
第五层:子程序内部的设计
在子程序层次上进行设计就是为每个子程序布置详细的功能。子程序内部的设计工作通常是由负责该子程序的开发人员来完成的。这里的设计工作包括编写伪代码、选择算法、组织子程序内部的代码块,以及用编程语言编写代码。这一步的工作是不可跳过的,能够达到的质量也因人而异有好有坏。在图1-1中的第五步就是这一层设计的工作。