第二章 复杂性的本质

这本书是关于如何设计软件系统以最小化其复杂性。第一步是了解敌人。究竟什么是“复杂性”?你如何判断系统是否过于复杂?是什么导致系统变得复杂?本章将在较高层次上解决这些问题。后续章节将向你展示如何从较低的层次上根据特定的结构特征来识别复杂性。

识别复杂性的能力是至关重要的设计技能。它可以让你在投入大量精力之前发现问题,并让你在各种选择中做出正确的选择。判断一个设计是否简单比创建一个简单的设计要容易得多,但是一旦你能够认识到一个系统过于复杂,你就可以用这种能力来指导你的设计理念,使之走向简单。如果一个设计看起来很复杂,可以尝试不同的方法,看看这样是否更简单。随着时间的推移,你会注意到某些技术倾向于产生更简单的设计,而其他技术则与复杂性相关。这将使你能够更快地产生更简单的设计。

本章还列出了一些基本假设,这些基本假设为本书的其余部分奠定了基础。后面的章节将采用本章的内容,并利用它来证明各种细化和结论。

复杂性的定义

出于本书的目的,我以实用的方式定义“复杂性”。复杂性是指与软件系统的结构有关的任何东西,它使人难以理解和修改该系统。复杂性可以有多种形式。例如,可能很难理解一段代码是如何工作的;可能需要花费大量的精力来实现一个小的改进,或者不清楚系统的哪些部分必须被修改才能实现改进;可能很难在不引入另一个错误的情况下修复一个错误。如果一个软件系统难以理解和修改,那么它就是复杂的;如果它容易理解和修改,那么它就是简单的。

你也可以从成本和效益的角度来考虑复杂性。在一个复杂的系统中,即使是小的改进也需要大量的工作来实施。在一个简单的系统中,可以用较少的努力实现较大的改进。

复杂性是指开发者在某一特定时间点上试图实现某一特定目标时所经历的事情。它不一定与系统的整体规模或功能有关。人们通常使用“复杂”一词来描述具有复杂功能的大型系统,但是如果这样的系统易于使用,那么就本书而言,它并不复杂。当然,实际上几乎所有大型复杂的软件系统都很难使用,所以它们也符合我对复杂性的定义,但情况不一定如此。一个小而不复杂的系统也有可能是相当复杂的。

复杂度是由最常见的活动决定的。如果一个系统有几个非常复杂的部分,但这些部分几乎不需要被触及,那么它们对系统的整体复杂性没有什么影响。用一种粗略的数学方法来描述这个问题。

00009.gif

一个系统的整体复杂性(C)是由每个部分的复杂性(cp)加上开发人员花在该部分的时间比例(tp)决定的。将复杂性隔离在一个永远不会被看到的地方,几乎和完全消除复杂性一样好。

复杂性对读者来说比作者更明显。如果你写了一段代码,在你看来很简单,但其他人认为它很复杂,那么它就是复杂的。当你发现自己处于这样的情况时,值得去探究一下其他开发者,看看为什么这段代码在他们看来很复杂;从你的观点和他们的观点之间的脱节中可能会有一些有趣的教训。作为一个开发者,你的工作不仅仅是创建你可以轻松使用的代码,而且要创建其他人也可以轻松使用的代码。

复杂性的症状

复杂性一般有三种表现形式,在下面的段落中进行了描述。这些表现中的每一种都会使开发任务更难执行。

变更放大:复杂性的第一个症状是,一个看似简单的变化需要在许多不同的地方进行代码修改。例如,考虑一个包含多个页面的网站,其中每个页面都显示一个带有背景颜色的横幅。在许多早期的网站中,颜色是在每个页面上明确指定的,如图 2.1(a)所示。为了改变这样一个网站的背景,开发者可能必须手工修改每一个现有的页面;这对于一个有数千个页面的大型网站来说几乎是不可能的。幸运的是,现代网站使用了类似于图2.1(b)的方法,即在一个中心位置指定一次横幅颜色,所有的各个页面都参考这个共享值。通过这种方法,整个网站的横幅颜色可以通过一次修改来改变。良好设计的目标之一是减少受每个设计决策影响的代码量,因此设计变更不需要太多的代码修改。

认知负荷:复杂性的第二个症状是认知负荷,这是指开发人员需要多少知识才能完成一项任务。认知负荷越高,意味着开发人员必须花更多的时间来学习所需的信息,而且由于他们错过了一些重要的东西,出现错误的风险就越大。例如,假设C语言中的一个函数分配了内存,返回一个指向该内存的指针,并假设调用者将释放该内存。这增加了使用该函数的开发人员的认知负担;如果开发人员未能释放内存,就会出现内存泄漏。如果系统能够被重组,使调用者不需要担心释放内存的问题(分配内存的模块也负责释放内存),这将减少认知负荷。认知负荷产生于很多方面,比如有很多方法的API,全局变量,不一致,以及模块之间的依赖。

系统设计者有时认为复杂性可以用代码行来衡量。他们认为,如果一个实现比另一个短,那么它一定更简单;如果只需要几行代码就能做出改变,那么这个改变一定很容易。然而,这种观点忽略了与认知负荷有关的成本。我曾见过一些框架,它们允许只用几行代码就能写出应用程序,但要弄清楚这些行数是什么,却非常困难。有时候,需要更多行代码的方法实际上更简单,因为它减少了认知负荷。

NLcSPK.png

图 2.1:网站中的每个页面都显示一个彩色横幅。在(a)中,横幅的背景色在每页中都明确指定。在(b)中,共享变量保留背景色,并且每个页面都引用该变量。在(c)中,某些页面会显示其他用于强调的颜色,即横幅背景颜色的暗色;如果背景颜色改变,则强调颜色也必须改变。

未知的未知: 复杂性的第三个症状是,要完成一项任务必须修改哪些代码,或者开发人员必须掌握哪些信息才能成功执行任务,这一点并不明显。图 2.1(c)说明了这个问题。该网站使用一个中心变量来决定横幅的背景颜色,所以它看起来很容易改变。然而,有几个网页为了强调,使用了一种较深的背景颜色,而且这种较深的颜色在各个网页中都有明确规定。如果背景色改变了,那么强调色也必须改变以与之匹配。不幸的是,开发人员不太可能意识到这一点,所以他们可能会改变中央bannerBg变量而不更新强调色。即使开发者意识到了这个问题,也不会很明显地发现哪些页面使用了强调色,所以开发者可能不得不在网站中搜索每一个页面。

在复杂性的三种表现形式中,未知的未知是最糟糕的。未知的未知意味着有一些你需要知道的东西,但你没有办法发现它是什么,甚至没有办法发现是否有问题。你不会发现它,直到你做了一个改变之后出现了bug。变更放大是很烦人的,但只要清楚哪些代码需要修改,一旦变更完成,系统就能正常工作。同样的,高认知负荷会增加改变的成本,但如果清楚哪些信息需要阅读,改变还是有可能是正确的。对于未知的未知,不知道该怎么做,也不知道提出的解决方案是否会奏效。唯一能确定的方法是阅读系统中的每一行代码,这对任何规模的系统来说都是不可能的。即使这样做也可能是不够的,因为一个变化可能取决于一个从未被记录下来的微妙的设计决定。

良好设计的最重要目标之一就是使系统显而易见。这与高认知负荷和未知的未知相反。在一个显而易见的系统中,开发者可以迅速了解现有的代码是如何工作的,以及需要做什么改变。一个显而易见的系统是这样的:开发人员可以在不费力地思考的情况下快速猜测要做什么,同时又可以确信该猜测是正确的。第 18 章讨论使代码更明显的技术。

复杂性的原因

现在你知道了复杂性的高级症状以及为什么复杂性会使软件开发变得困难,下一步就是要了解是什么导致了复杂性,这样我们就可以设计系统来避免这些问题。复杂性是由两件事引起的:依赖性和模糊性。本节从高层次上讨论了这些因素;后续章节将讨论它们与低层次设计决策的关系。

就本书而言,当某段代码不能被孤立地理解和修改时,就存在依赖关系。该代码以某种方式与其他代码相关,如果更改了给定代码,则必须考虑和/或修改其他代码。在图2.1(a)的网站例子中,背景颜色在所有的页面之间产生了依赖关系。所有的页面都需要有相同的背景,所以如果一个页面的背景被改变,那么所有的页面都必须被改变。依赖关系的另一个示例发生在网络协议中。通常,协议的发送方和接收方有单独的代码,但它们必须各自符合协议。改变发送方的代码几乎总是需要在接收方进行相应的改变,反之亦然。一个方法的签名在该方法的实现和调用该方法的代码之间建立了一种依赖关系:如果一个方法增加了一个新的参数,那么该方法的所有调用都必须被修改以指定该参数。

依赖关系是软件的基本组成部分,不能完全消除。事实上,我们有意引入依赖关系作为软件设计过程的一部分。每当你编写一个新的类时,你都会围绕该类的API创建依赖关系。然而,软件设计的目标之一是减少依赖关系的数量,并使剩下的依赖关系尽可能的简单和明显。

考虑一下网站的例子。在旧的网站中,每个页面都单独指定背景,所有的网页都是相互依赖的。新的网站解决了这个问题,它在一个中心位置指定了背景颜色,并提供了一个API,各个页面在渲染时使用该API来检索该颜色。新的网站消除了页面之间的依赖性,但它围绕着检索背景颜色的API创造了一个新的依赖性。幸运的是,新的依赖关系更加明显:很明显,每个单独的网页都依赖于bannerBg的颜色,开发者可以通过搜索它的名字轻松找到所有使用该变量的地方。此外,编译器有助于管理API的依赖性:如果共享变量的名称改变了,任何仍然使用旧名称的代码都会发生编译错误。新网站用一个更简单、更明显的依赖关系取代了一个不明显的、难以管理的依赖关系。

复杂性的第二个原因是模糊性。当重要的信息不明显时就会出现模糊不清的情况。一个简单的例子是,一个变量的名字是如此的通用,以至于它没有携带多少有用的信息(例如,time)。或者,一个变量的文档可能没有说明它的单位,所以唯一的方法就是扫描代码中使用该变量的地方来找出答案。模糊性通常与依赖关系有关,即依赖关系的存在并不明显。例如,如果向系统添加了一个新的错误状态,可能有必要向保存每个状态的字符串消息的表中添加一个条目,但是消息表的存在对于查看状态声明的程序员来说可能并不明显。不一致性也是造成不透明性的一个主要原因:如果同一个变量名用于两个不同的目的,那么开发人员就无法清楚地知道某个特定变量的目的是什么。

在许多情况下,晦涩难懂是由于文档不充分造成的;第13章讨论了这个话题。然而,晦涩难懂也是一个设计问题。如果一个系统有一个干净而明显的设计,那么它需要的文档就会比较少。对大量文档的需求通常是一个警告,即设计不正确。减少模糊性的最佳方法是简化系统设计。

依赖性和模糊性共同构成了第2.2节中描述的复杂性的三种表现形式。依赖性导致了变化的放大和高认知负荷。晦涩难懂造成了未知的未知,也造成了认知负荷。如果我们能够找到将依赖性和模糊性最小化的设计技术,那么我们就可以降低软件的复杂性。

复杂度是递增的

复杂性不是由单个灾难性错误引起的;它是由很多小块的东西积累起来的。单一的依赖性或不明确性,本身不太可能对软件系统的可维护性产生重大影响。复杂性的产生是由于成百上千个小的依赖关系和不确定性随着时间的推移而积累起来的。最终,这些小问题多到系统的每一个可能的变化都会受到其中几个问题的影响。

复杂性的递增性质使其难以控制。你很容易说服自己,你当前的变化所引入的一点点复杂性并不是什么大问题。然而,如果每个开发人员都对每一个变化采取这种方法,复杂性就会迅速积累起来。一旦复杂性积累起来,就很难消除,因为修复一个依赖性或模糊性本身并不会带来很大的变化。为了减缓复杂性的增长,你必须采取一种 "零容忍 "的哲学,正如第三章所讨论的那样。

结论

复杂性来自于依赖性和模糊性的积累。随着复杂性的增加,它导致了变化的放大,高认知负荷和未知的未知数。因此,需要更多的代码修改来实现每个新功能。此外,开发人员要花更多的时间获取足够的信息来安全地进行修改,在最坏的情况下,他们甚至无法找到他们需要的所有信息。最重要的是,复杂性使得修改现有代码库变得困难且冒险。


本文转载自:
https://go7hic.github.io/A-Philosophy-of-Software-Design/#/./ch2
https://www.bookstack.cn/read/A-Philosophy-of-Software-Design-zh/spilt.3.docs-ch2.md

标签: none

评论已关闭