Skip to content
On this page

第二章 复杂度的本质

这本书是关于如何设计软件系统并尽可能降低其复杂度。第一步是去了解敌人。究竟什么是“复杂度”?如何判断一个系统是否不必要的复杂?什么导致系统变得复杂?本章将在较高层次上讨论这些问题;后续章节将展示如何在较低层次上根据特定的结构特征来识别复杂度。

识别复杂度的能力是一项极为重要的设计技能。它可以让你在投入大量工作量之前识别问题,并使你可以在替代方案中做出好的选择。判断一个设计是否简单比创建一个简单的设计更容易,但当你可以辨别出一个系统太复杂,你的设计哲学就可以在这种能力的指引下趋向简单。如果一个设计看起来很复杂,尝试一个不同的方法并看看这是否简单点。随着时间的推移,你会察觉到某些技术会使设计更简单,而其他的与复杂度相关。这将使你能够更快速地做更简单的设计。

本章还列出了一些基本假设,为本书的其余部分提供基础。后面的章节将本章的材料作为假设事实,并用其来论证各种改进和推论。

2.1 复杂度的定义

出于本书的目的,我以实用的方式定义“复杂度”。复杂度是与软件系统结构相关使其难以理解和修改的任何东西。复杂度可以有多种形式。例如,它可能是很难理解一段代码是如何运转的;可能是做了大量的努力才实现一个小小地改进;或者可能是不清楚修改系统的哪部分才能获得改进;可能是很难不引入其他问题的情况下修复一个缺陷。如果一个系统很难理解和修改,那么它是复杂的;如果它很容易理解和修改,那么它是简单的。

你也可以从成本与收益的角度来思考复杂度。在一个复杂的系统中,即使很小的改进也需要很大的工作量。在一个简单的系统中,可以较少的投入来实现较大的改进。

复杂度是程序员在努力实现特定目标时在某一特定点的感受。它不一定与系统的整体大小或功能性有关。人们经常用“复杂”这个词来描述具有复杂特性的大型系统,但如果这样的系统很易对其工作,那么就本书而言,它并不是复杂。当然,几乎所有大型且复杂的软件系统实际上很难对其工作,所以它们也符合我对复杂度的定义,但不一定是这样。一个小而基本的系统也可能非常的复杂。

复杂度由最常见的活动决定。如果一个系统有一些非常复杂的部分,但是这些部分几乎不需要接触,那么它们对系统的整体复杂度没有太大影响。用简略的数学方式来描述:

一个系统的整体复杂度( )是由每个部分p的复杂度( )与开发人员在该部分花费时间的分数( )加权。隔离复杂度完全不感知几乎和完全清除复杂度一样好。

复杂度对于阅读者比编写者更显而易见。如果你写了一段对你来说很简单的代码,但其他人认为它很复杂,那么它就很复杂。如果你发现自己处于这样的情形下,值得追问其他开发人员以找出代码他们看起来复杂的原因;从莫衷一是中或许可以学习到一些有趣的经验。作为开发人员,你的工作不仅仅是创造自己可以轻松使用的代码,而是创造其他人也可以轻松使用的代码。

2.2 复杂度的表征

复杂度通过三种一般形式表现出来,下面的段落会详细介绍。每一种表现形式都使开发任务更难以执行。

变更放大:复杂度的第一个表征是,看似简单的变更却需要许多处代码修改。例如,设想一个网站包含一些页面,每个页面都显示一个有背景色的横幅。在许多早期的网站中,颜色被明确定义在每个页面上,如图2.1(a)所示。为了改变这样一个网站的背景,开发人员不得不手动修改每个现有的页面;这对于一个有数千个页面的大型网站来说几乎是不可能的。幸好现代网站使用了图2.1(b)中的方法,横幅颜色只在中心位置定义一次,所有的独立页面都引用这个共享值。使用这种方法,可以通过一次修改来更改整个网站的横幅颜色。优秀设计的目标之一是减少受设计决策影响的代码量,这样设计变更不需要很多代码修改。

认知负荷:复杂度的第二个表征是认知负荷,这是指开发人员为完成一个任务需要知晓的信息量。较高的认知负荷意味着开发人员必须花费较多的时间来学习所需的信息,这就会有更高的错误风险,因为开发人员错过了一些重要信息。例如,假设在C语言中为一个函数分配内存,返回一个指向该内存的指针,并设想调用者会释放内存。这增加了使用该函数开发人员的认知负荷;如果开发人员未释放内存,则会内存泄漏。如果系统可以重构而调用者不必担心释放内存(同一模块分配内存也同样负责释放它),这样会降低认知负荷。认知负荷由很多方面引发,例如接口拥有大量的方法,全局变量,不一致和模块间依赖。

系统设计人员往往认为复杂度可以通过代码行数来衡量。他们认为一个实现比另一个短,那它一定是更简单;如果做一个变更只需要几行代码,那这个变更一定很容易。然而,这种观点忽略了与认知负荷相关的成本。我见过仅需几行代码就可以编写应用程序的框架,但是理解这几行代码是极其困难的。往往需要更多代码的方案实际上更简单,因为它降低了认知负荷。

图2.1:网站中每个页面显示一个有颜色的横幅。在(a)中横幅的背景颜色被明确定义在每个页面上。在(b)中一个共享变量持有背景颜色并且每个页面引用这个变量。在(c)中一些页面显示一个额外的强调色,用于横幅背景色的暗色阴影;如果背景色修改,强调色也必须修改。

未知的未知:复杂度的第三个表征是非显而易见,完成一个任务应当修改哪些代码,或者开发人员需要知晓哪些信息可才可以顺利地执行任务。图2.1(c)说明了此问题。网站使用一个中心变量来确定横幅背景色,因此它似乎很容易更改。然而,一些网页使用了背景色的暗色阴影来强调,并且暗色被明确指定在每个单独的页面上。如果背景色修改,则强调色也必须修改来相配。可惜的是开发人员不会意识到这一点,因此他们可能只修改中心变量 bannerBG 而不更新强调色。即使开发人员意识到这个问题,哪些页面使用了强调色并不显然,所以开发人员不得不搜索网站中的每个页面。

在复杂度的三种表征中,未知的未知是最糟糕的。未知的未知意味着有一些信息你需要知晓,但又没有办法知道是什么,甚至没有办法知道是否有问题。直到做了修改后出现错误,你才能发现它。变更放大虽令人讨厌,但只要清楚哪些代码需要修改,一修改完成系统就可运行。同样,高认知负荷会增加变更成本,但如果清楚哪些信息需要理解,变更仍然有希望做正确。对于未知的未知,不清楚该做什么或提出的解决方案是否有效。有把握的唯一方法是理解整个系统的代码,这对于任何规模的系统来说都是不可能。甚至这样还不够,因为一个变更有可能取决于一个微妙且从未记录过的设计决策。

优秀设计最重要的目标之一就是使系统显然。这与高认知负荷与未知的未知对立。在一个显然的系统中,开发人员可以快速了解现有代码是如果工作和做一个变更所需要的内容。一个显然的系统是开发人员无需费力思考即可快速猜测该做什么,而且很自信猜测是正确的。第18章讨论使代码更然的技术。

2.3 复杂度的成因

现在你知晓了复杂度的高级表征和为什么复杂度使软件开发变得困难,下一步是了解是什么导致了复杂度,这样我们可以设计系统来避免问题。复杂度是由两项因素导致:依赖和莫名。本节从高层次讨论这些因素;后续的章节将讨论它们如何与较低层次的设计决策有关联。

就本书而言,当给定的代码无法单独理解和修改时就存在依赖;一段给定的代码以某种方式与另一段代码有关联,如果给定代码修改了,那么另一段代码也必须要考虑是否修改。在图2.1(a)网站示例中,背景色在所有页面直接创建了依赖关系。所有页面需要有相同的背景,因此如果修改了一个页面的背景,则必须修改所有的页面。依赖的另一个示例发生在网络协议中。通常协议下的发送者和接收者有独立的代码,但它们都必须遵守协议;修改发送者的代码几乎总是也需要在接收者处做相应的修改,反之亦然。方法的签名在方法实现与代码调用之间创建了依赖关系;如果向方法中添加了一个新参数,那么该方法的所有调用都必须修改以指定这个参数。

依赖是软件的基本组成部门,不能完全消除。其实我们故意引入依赖作为软件设计过程的一部分。每次编写新类时,会围绕接口为这个类创建依赖。然而软件设计的目标之一是减少依赖数量并保持尽可能的简单和显然。 细想网站的示例。在过去的网站中每个页面单独定义背景,所有页面都彼此依赖。新网站通过在一个中心位置定义背景色并提供一个接口由单个页面调用去获取它们要渲染的颜色。新网站消除了页面间的依赖,但它围绕获取背景色的接口创建了一个新的依赖。好在新的依赖更显然:每个单个页面依赖 bannerBg 颜色,开发人员可以通过搜索名称轻松找到使用这个变量的所有位置。此外,编译器会帮助管理接口依赖:如果共享变量名称修改,任何仍使用旧名称的代码都会发出现编译错误。新网站用一个更简单更显然的依赖替换了不显然难管理的依赖。

复杂度的第二个成因是莫名。当重要信息非显而易见就会出现莫名。一个简单例子,一个变量的名称是很通用的,它不能携带很有用的信息(如时间)。或者,对一个变量的说明文档中可能没有指定单位,那么查明的唯一方法是扫描代码查找变量使用位置。莫名经常与依赖有关,在依赖关系存在不明显时。例如,如果向系统添加一个新的错误状态,则需要向持有每个状态字符串消息的表中添加一个条目,但是对于查看状态声明的程序员来说消息表的存在可能并不明显。不一也是对莫名的主要促成因素:如果相同的变量名用于两个不同的用途,那么特定变量对哪个用途有用对于开发人员来说不会显而易见。

在许多情况下,莫名是由于文档的不足导致的;第13章讨论这个主题。然而,莫名亦是设计问题。如果系统设计简洁明了,那么它只需较少的文档。需要大量文档通常是设计不完全正确的危险信号。降低莫名的最好方法是简化系统设计。

依赖和莫名共同解释了第2.2小节中描述的复杂度的三种表现形式。依赖导致了变更放大和高认知负荷。莫名造成未知的未知,也会增加认知负荷。如果我们可以找到使依赖和莫名最小化的设计技术,那么我们就可以降低软件的复杂度。

2.4 复杂度是递增的

复杂度不是由单一糟糕的错误造成的;积微成著。单一的依赖或莫名本身不太可能显著影响软件系统的可维护性。复杂度的产生是因为随着时间推移成百上千的小依赖和莫名累积起来。最后系统的每一个可能的变更都会受到这些许多小问题中某几个的影响。

复杂度的增量性使其难以控制。很容易说服自己当前变更带来的一点点复杂度无关大体。然而,如果每个开发人员对每次变更都采取这种方式,那么复杂度会迅速积聚。复杂度一旦累积,就很难消除,因为修复单一依赖或莫名本身不会产生很大作用。为了减缓复杂度的增长,你必须采取“零容忍”的理念,如第3章所述。

2.5 结论

复杂度来自于依赖和莫名的累积。随着复杂度的增加,它导致变更放大、高认知负荷和未知的未知。因此,每个新特性的实现需要更多的代码修改。此外,开发人员要花费更多的时间获取足够的信息以保证变更安稳,在最坏情况下他们甚至找不到所需的所有信息。本质内容是复杂度使修改现有代码库变得困难且有风险。