代码大全《Code Complete》——创建高质量的代码(第5章)

第五章 软件构建中的设计

5.1 设计中的挑战

犯错是设计的关键所在,在设计阶段犯错并加以改正的代价要比在编码阶段这么做要低得多。

一般很难判断设计何时算是足够好,一个简单的原则就是设计到没时间继续了为止。

设计者的关键任务是衡量彼此冲突的各项设计特性,并在其中寻求平衡。永远都不会有一套完美的设计方案,但是会有最适合当前需求的设计方案。

5.2 关键的设计概念

多数软件的设计技术的目标都是把复杂问题分解成简单的部分。子系统间的相互依赖越少,你就越容易在同一时间里专注问题的一小部分。

两种管理复杂度的方法是:

让所有人在同一时间需要处理的essential复杂度的量减少到最少。

不要让accidental复杂度无谓的快速增长。

常见的一些评估设计质量的指标如下:

最小的复杂度

好的设计方案应该要让你在专注于程序的一部分时能安心地忽视其他部分。

易于维护

松散耦合

让程序的各个部分之间关联最小。减少关联也就减少了集成、测试、维护时的工作量。

可扩展性

松散耦合的开发也有助于实现这一目标。

可重用性(high fan-in)

即设计出的系统很好地利用了在较低层次上的工具类。即,一个工具类被n个类引用。

low fan-out

尽量不要让一个类引用太多其他的类,以避免过于复杂,即避免一个类引用n个类。

可移植性

精简性

一个工程的完成的时候不在它不能加入任何代码的时候,而是在它不能删去任何代码的时候。

层次性

层次性意味着尽量保持系统各个分解层的层次性,使得可以在任意的层面上观察系统,并得到某种一致性的看法。层次化设计的优点在于:它能把低劣代码封闭起来,如果最终能抛弃或者重构旧代码,则
不必修改除交互层之外的任何新代码。

标准技术

尽量依赖标准化的、常用的外来方法。

在软件系统的设计过程中,要针对不同层次使用适合的技术。

第一层:软件系统

就是整个系统,一般来说需要做简单的分解后再思考设计方式会更有益处。

第二层:分解成子系统或包

在这一层次上要做的是确定如何把程序分为主要的子系统,并清楚定义各子系统如何使用其他子系统,以及通信规则。各个子系统可能会很大,比如数据库、用户界面等,每个子系统内部可能要用到不同的设计方法。

为了让子系统之间的连接简单且易于维护,就要简化子系统之间的交互关系。

  • 最简单的交互关系是让一个子系统去调用另一个子系统中的子程序;

  • 稍微复杂一点的交互关系是在一个子系统中包含另一个子系统中的类;

  • 最复杂的交互关系是让一个子系统中的类继承自另一个子系统中的类。

一个简单的原则是,不应该出现循环调用的情况,即A使用了B,B使用了C,C使用了A。

第三层: 分解为类

当定义子系统中的类时,也就同时定义了这些类与系统的其余部分打交道的细节,尤其是要确定好类的接口。这一层的主要设计任务是把所有的子系统进行适当的分解,并确保分解出的细节都恰到好处,能够用单个的类实现。

第四层: 分解成子函数

第四层的设计将细化出类的private子程序。
完整地定义出类内部的子程序,常常会有助于更好地理解类的接口,反过来,这又有助于对类的接口进行进一步修改,也就是说再次返回第3层的设计。

第五层: 子函数内部的设计

这里的设计工作包括编写伪代码、选择算法、组织子程序内部的代码块,以及用编程语言编写代码。

5.3 设计构造块:启发式方法

常规的设计方案就是面向对象设计方法,它主要包含:

  • 辨识对象及其属性(方法和数据) 。确定可以对各个对象进行的操作。
  • 确定各个对象能对其他对象进行的操作。
  • 确定对象的哪些部分对其他对象可见——哪些部分可以是public的,哪些部分应该是private的。
  • 定义每个对象的公开接口。

基类是一种抽象,它使你能集中精力关注一组派生类所具有的共同特性,并在基类的层次上忽略各个具体派生类的细节。一个好的接口也是一种抽象,它能让你关注于接口本身而不是类的内部工作方式。

以复杂度的观点看,抽象的主要好处就在于它使你能忽略无关的细节。大多数现实世界中的物体都已经是某种抽象了。房屋是门、窗、墙、线路、管道、隔板等物体及其特定的组织方式所形成的抽象。同样,门是一块长方形材料加上合叶和把手以及一种特定的组织方式的抽象。

抽象建立在封装之上。封装需要对属性和方法进行合适的隐藏。

信息隐藏的一个例子:

假设你有一个程序,其中的每个对象都是通过一个名为id的成员变量来保存一种唯一的ID。一种设计方法是用一个整数来表示ID,同时用一个名为g_maxId的全局变量来保存目前已分配的ID的最大值。每当创建新的对象时,你只要在该对象的构造函数里简单地使用id = ++g_maxId这条语句,就肯定能获得一个唯一的ID值,这种做法会让对象在创建时执行的代码量最少。可这样设计可能有问题吗?

如果你想把某些范围的ID留做它用该怎么办?如果你想使用非连续ID来提高安全性又该怎么办?如果你想重新使用己销毁对象的ID呢?如果你想增加一个断言来确保所分配的ID值不会超过预期的最大范围呢?如果程序中到处都是 id = ++g.maxId这种语句的话,一旦上面说的任何一种情况出现,你就需要修改所有这些语句。另外,如果你的程序是多线程的话,这种方法也不是线程安全的。

创建新ID的方法就是一种你应该隐藏起来的设计决策。如果你在程序中到处使用++g …maxId的话,你就暴露了创建新ID的方法,也就是通过简单递增g_maxId。相反,如果你在程序中都使用语句 id = NewId ( ),那就把创建新ID的方法隐藏起来了。你可以在NewId()子程序中仍然只用一行代码,return ++g_maxId,或者其他与之等价的方法。但如果日后你想把某些范围的ID留做它用,或者重用旧的ID时,只要对NewId()子程序的内部加以改动即可,无须改动几十个甚至成百个id = NewId ( )语句。无论NewId()内部做了多么复杂的改动,这些改动都不会影响到程序的其他部分。

因此,另一个需要隐藏的秘密就是ID的类型。对外界透露ID是个整型变量的做法,实质上是在鼓励程序员们对ID使用针对整数的操作,如>、<、=等等。在C++里,你可以简单地使用typedef来把ID定义为IdType——一个可以解释为int 的用户自定义类型—而避免将其直接定义成int类型。

隐藏设计决策对于减少“改动所影响的代码量”而言是至关重要的。

信息隐藏中所说的秘密主要分为两大类:

  • 隐藏复杂度,这样你就不用再去应付它,除非你要特别关注的时候。

  • 隐藏变化源,这样当变化发生时,其影响就能被限制在局部范围内。

信息隐藏的主要障碍如下

信息过度分散

信息在系统内过度分散。比如某个ID_generator的一次产生上限是500,那就定义一个MAX_NUM,而不要在程序里到处写500。

信息过度分散的另一例子是在系统内部到处都有与人机交互相关的内容。一式改变——比如说从图形用户界面变为命令行界面——几乎所有代码都需要改动。最好是把与人机交互逻辑集中到一个单独的类、包或者子系统中,这样,改动就不会给系统带来全局性的影响了。

循环依赖

比如说A类中的子程序调用了B类中的子程序,然后B类中的子程序又调用A类中的子程序。它会让系统难于测试,因为你既无法单独测试A类,也无法单独测试B类,除非另一个类至少已经部分就绪。

性能损耗

间接访问对象或许会带来性能上的损耗,但是做出高度模块化的设计有利于之后对子程序进行优化,帮助找出性能瓶颈。

在设计时同样要考虑到容易变化的区域。比如我曾经在实习期间的一段经历,要定义一个属性来标记一种邮件,当时那个属性是唯一的,我就用final String的形式来代表它写在了代码里。后续需求发生了改变,那个属性的值变化了,并且后续还可能变化。于是我只好修改代码,把它用配置的形式定义在了配置平台,代码中访问平台获取配置即可。即使后续再改动也不需要改变代码重新发布了。

几种常见的应对措施如下:

    1. 找出看起来容易变化的项目。如果需求做得很好,那么其中就应该包含一份潜在变化的清单,以及其中每一项变化发生的可能性。
    1. 把容易变化的项目分离出来。把第一步中找出的容易变化的组件单独划分成类,或者和其他容易同时发生变化的组件划分到同一个类中。
    1. 把看起来容易变化的项目隔离开来。设法设计好类之间的接口,使其对潜在的变化不敏感。设计好类的接口,把变化限制在类的内部,且不会影响类的外部。任何使用了这个将会发生变化的类的其他类都不会察觉到变化的存在。类的接口应该肩负起保护类的隐私的职责。

通常容易变化的区域有如下几点:

业务规则

业务规则很容易成为软件频繁变化的根源。

对硬件的依赖性

与屏幕、打印机、键盘、鼠标、硬盘、声音设施以及通信设备等之间的接口都是硬件依赖的例子。请把对硬件的依赖隔离在它们自身的子系统或者类中。这种隔离会非常有利于你把程序移植到新的硬件环境下。

输入和输出

在做比纯硬件接口层稍高一些层面上的设计时,输入输出也是一个容易变化的区域。如果你的程序创建了自己的数据文件,那么该文件格式就可能会随软件开发的不断深化而变化。用户层的输入和输出格式也会改变——输出页面上字段的位置、数量和排列顺序等都可能会变。

非标准的语言特性

大多数编程语言的实现中都包含了一些便利的、非标准的扩展。对这些扩展的应用就像双刃剑一样,因为它们可能在其他的环境中不可用。如果你用了编程语言的非标准扩展,请把这样的扩展单独隐藏在某个类里,以便当你转移到新的环境后可以用自己写的代码去取代它。与此类似,如果你使用了并非在所有环境中都可用的子程序库(函数库),请把这些子程序库隐藏在一个接口的后面,为新环境做好准备。

困难的设计区域和构建区域

把困难的设计区域和构建区域隐藏起来也是很好的想法,因为这些代码可能因为设计得很差而需要重新做。请把它们隔离起来,把其拙劣的设计和“构建”对系统其余部分的可能影响降至最低。

状态变量

状态变量用于表示程序的状态,与大多数其他的数据相比,这种东西更容易改变。

不要使用布尔变量作为状态变量,请换用枚举类型。给状态变量增加一个新的状态是很常见的,给枚举类型增加一个新的状态只需要重新编译一次,而无须对每一行检查该状态变量的代码都做一次全面修订。

数据量的限制

当你定义了一个具有100个元素的数组的时候,你实质上是在向外界透露一些它们并不需要知道的信息。信息隐藏并不总是像创建新类一样复杂,有的时候它就像使用具名常量MAX_EMPLOYEES来隐藏100一样简单。

找出容易发生变化的区域的一个好办法是:首先找出程序中可能对用户有用的最小子集。这一子集构成了系统的核心,不容易发生改变。接下来,小心地扩充这个系统。这里的增量可以非常微小,小到看似微不足道。当你考虑功能上的改变时,同时也要考虑非功能性的变化:比如说让程序变成线程安全的,使程序能够本地化等。这些潜在的改进区域就构成了系统中的潜在变化。

设计构造块的另一原则是尽量使模块之间保持松散耦合。耦合度设计的目标是创建出小的、直接的、清晰的类或子程序,使它们与其他类或子程序之间的关系尽可能灵活。

耦合度的部分判断标准如下:

规模

这里的规模指的是模块之间的连接数。对于耦合度来说,小就是美,因为只要做很少的事情,就可以把其他模块与一个有着很小的接口的模块连接起来。子程序需要的参数越少,包含的公开方法越少,与调用方的耦合度越低。(注,这里的参数少并不一定是个数少,比如原有的方法参数是一个包含五个属性的类,其中只有两个属性是必要的,那么传递这两个属性要比传递这样一个类要来的好)

可见性

可见性指的是两个模块之间的连接的明显程度。通过参数表传递数据便是一种明显的连接,因而值得提倡。通过修改全局数据而使另一模块能够使用该数据则是一种不明显的做法,是很不好的设计。如果把与全局数据之间的连接写入文档,那么就会使得这些连接相对明显些,因而会比上面的做法稍微好些。

灵活性

灵活性指的是模块之间的连接是否容易改动。一般情况下,热插拔的组件会比电焊的更受欢迎。

耦合的常见类型如下:

简单数据参数耦合

当两个模块之间通过参数来传递数据,并且所有的数据都是简单数据类型的时候,这两个模块之间的耦合关系就是简单数据参数耦合的。这种耦合关系是正常的,可以接受的。

简单对象耦合

如果一个模块实例化一个对象,那么它们之间(模块和该对象类)的耦合关系就是简单对象耦合的。这种耦合关系也很不错。

对象参数耦合

如果object1要求object2传给它一个object3,那么这两个模块就是对象参数耦合的。与object1仅要求object2传递给它简单数据类型相比,这种耦合关系要更紧密一些,因为它要求object2了解object3。

语义上的耦合

最难缠的耦合关系是这样发生的:一个模块不仅使用了另一模块的语法元素,而且还使用了有关那个模块内部工作细节的语义知识。这里是些例子:

Module1向 Module2传递了一个控制标志,用它告诉Module2该做什么。这种方法要求 Module1对 Module2的内部工作细节有所了解,也就是说需要了解Module2对控制标志的使用。如果 Module2把这个控制标志定义成一种特定的数据类型(枚举类型或者对象),那么这种使用方法还说得过去。

Module1的接口要求它的Module1.Initialize()子程序必须在它的Module1.Routine()之前得到调用。Module2知道Module1.Routine()无论如何都会调用Module1.Initialize(),所以它在实例化 Module1之后只是调用了Module1.Routine(),而没有先去调用Module1.Initialize()。

Module1把 Object传给Module2。由于Module1知道Module2只用了Object的7个方法( method)中的3个,因此它只部分地初始化 Object——只包含那3个方法所需的数据。

Module1把 BaseObject传给Module2。由于Module2知道Module1实际上传给它的是 DerivedObject,所以它把 BaseObject转换成DerivedObject,并且调用了DerivedObject特有的方法。

语义上的耦合是非常危险的,因为更改被调用的模块中的代码可能会破坏调用它的模块,破坏的方式是编译器完全无法检查的。类似这样的代码崩溃时,其方式是非常微妙的,看起来与被使用的模块中的代码更改毫无关系,因此会使得调试工作变得无比困难。

类和子程序是用于降低复杂度的首选和最重要的智力工具。如果它们没帮助你简化工作,那么它们就是失职的。

想要规避软件开发中的最常见的问题,一个简单的方法就是使用设计模式。

image.png

设计模式的缺点在于会降低代码的易读性。

还有一些值得一提的启发式方法:

高内聚性

内聚性指的是类内部的子程序或者子程序内的所有代码在支持一个中心目标上的紧密程度(即,一个类内部的方法解决的问题是否集中)。包含一组密切相关的功能的类被称为有着高内聚性,而这种启发式方法的目标就是使内聚性尽可能地高。内聚性是用来管理复杂度的有用工具,因为当一个类的代码越集中在一个中心目标的时候,你就越容易记住这些代码的功能所在。

构造分层结构

分层结构指的是一种分层的信息结构,其中最通用的或者最抽象的概念描述为层次关系的最上面,而越来越详细的具有特定意义的概念表示放在更低的层次中。

分层结构是实现软件的首要技术使命的有用工具,因为它使你能够只关注于当前正在关注的那-一层细节。其他的细节并没有完全消失,它们只是被放到了另一层次上,这样你就可以在需要的时候去考虑它们,而不是在所有的时间都要考虑所有的细节。

严格描述类契约

把每个类的接口看作是与程序的其余部分之间的一项契约会有助于更好地洞察程序。通常,这种契约会类似于“如果你承诺提供数据x,y和z,并且答应让这些数据具有特征a,b和c,我就承诺基于约束8,9和10来执行操作1,2和3。”在这里,由类的客户向该类所做出的承诺通常被称 preconditions,而该对象向其客户所做的承诺称为postconditions。

为测试而设计

如果为了便于测试而设计这个系统,那么系统会是什么样子?你需要把用户界面与程序的其余部分分离开来以便能够独立地检查它们吗﹖你需要设法组织好每一个子系统,使它与其他子系统之间的依赖关系最小吗?为测试而设计很容易产生更为规整的类接口,而这通常是非常有益处的。

有意识地选择绑定时间

绑定时间指的是把特定的值绑定到某一变量的时间。做早绑定的代码通常比较简单,但是也会比较缺乏灵活性。有时候,你可以通过问类似这样的问题来获得更好的理解:如果我早期绑定这些值又会怎样?如果晚些绑定又该如何﹖如果我在此处就初始化这张表会怎样?如果我在运行期间从用户那里读入这个变量的值又该怎样?

创建中央控制点

对于每一段有作用的代码,应该只有唯一的一个地方可以看到它,并且也只能在一个正确的位置去做可能的维护性修改。控制可以被集中在类、子程序、预处理宏以及#include文件里——甚至一个具名常量也是这种中央控制点的例子。
之所以这么做有助于降低复杂度,其原因在于:为了找到某样事物,你需要查找的地方越少,那么改起它来就会越容易、越安全。

5.4 设计实践

迭代

设计是一种迭代过程。你并非只能从A点进行到B点,而是可以从A点到达B点,再从B点返回到A点。

当你在备选的设计方案之中循环并且尝试一些不同的做法时,你将同时从高层和低层的不同视角去审视问题。你从高层视角中得出的大范围图景会有助于你把相关的底层细节纳入考虑。你从底层视角中所获得的细节也会为你的高层决策奠定基础。这种高低层面之间的互动是一种良性的原动力。

分治

没有人的头脑能大到装得下一个复杂程序的全部细节,这对设计也同样有效。把程序分解为不同的关注区域,然后分别处理每一个区域。如果你在某个区域里碰上了死胡同,那么就迭代!

自上而下的设计方法

自上而下的设计从某个很高的抽象层次开始,定义出基类或其他不那么特殊的设计元素。在开发这一设计的过程中,逐渐增加细节的层次,找出派生类、合作类以及其他更细节的设计元素。

如何确定分解的程度呢﹖持续分解,直到看起来在下一层直接编码要比分解更容易。

自下而上的设计方法

自下而上的设计始于细节,向一般性延伸。这种设计通常是从寻找具体对象开始,最后从细节之中生成对象以及基类。

下面是在做自下而上合成的时候你需要考虑的一些因素:

  • 对系统需要做的事项,你知道些什么。根据上面的问题,找出具体的对象和职责。

  • 找出通用的对象,把它们按照适当方式组织起来——子系统、包、对象组合,或者继承——看哪种方式合适。

  • 在更上面一层继续工作,或者回到最上层尝试向下设计。

建立试验性原型

当我们难以判断一种设计方法能否奏效时,一种低成本的解决方案是建立实验性原型——用尽可能简单的原型代码和随意的数据来完成测试。

合作设计

寻求同事的协助

软件构造中的设计:核对表

设计实践

  • 你已经做过多次迭代,并且从众多尝试结果中选择最佳的一种,而不是简单选择第一次尝试的结果吗?
  • 你尝试用多种方案来分解系统,以确定最佳方案吗?你同时用自下而上和自上而下的方法来解决设计问题吗?
  • 为了解决某些特定的问题,你对系统中的风险部分或者不熟悉的部分创建过原型、写出数量最少的可抛弃的代码吗?
  • 你的设计方案被其他人检查了吗(无论正式与否)?你一直在展开设计,直到实施细节跃然纸上了吗?
  • 你用某种适当的技术一一比如说Wiki、电子邮件、挂图、数码照片、UML、CRC卡或者在代码写注释来保留设计成果吗?

设计目标

  • 你的设计是否充分地处理了由系统架构层定义出并且推迟确定的事项?你的设计被划分为层次吗?
  • 你对把这一程序分解成为子程序、包和类的方式感到满意吗?你把对这个类分解成为子程序的方法感到满意吗?
  • 类与类之间的交互关系是否已设计为最小化了?
  • 类和子程序是否被设计为能够在其他的系统中重用?程序是不是易于维护?
  • 设计是否精简?设计出来的每一部分都绝对必要吗?
  • 设计中是否采用了标准的技术?是否避免使用怪异且难以理解的元素?整体而言,你的设计是否有助于最小化偶然性的和本质性的复杂度吗?

Key Points

软件的首要技术使命就是管理复杂度。以简单性作为努力目标的设计方案对此最有帮助。

简单性可以通过两种方式来获取:一是减少在同一时间所关注的本质性复杂度的量,二是避免生成不必要的偶然的复杂度。

设计是一种启发式的过程。固执于某一种单一方法会损害创新能力,从而损害你的程序。

好的设计都是迭代的。你尝试设计的可能性越多,你的最终设计方案就会变得越好。

信息隐藏是个非常有价值的概念。通过询问“我应该隐藏些什么?”能够解决很多困难的设计问题。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值