本书讨论的是如何设计软件系统,以尽量降低它们的复杂性。第一步是理解敌人:到底什么是“复杂性”?如何判断一个系统是否不必要地复杂?是什么导致系统变得复杂?本章将在较高的层面上回答这些问题;后续章节将从更低的层次、以具体结构特征的形式,向你展示如何识别复杂性。
识别复杂性的能力是一项至关重要的设计技能。它可以让你在投入大量精力之前就发现问题,也能帮助你在不同方案之间做出更好的选择。判断一个设计是否简单,往往比真正做出一个简单的设计要容易;但一旦你能够识别出系统过于复杂,你就可以利用这种能力,引导你的设计理念朝向简洁。如果一个设计看起来很复杂,不妨尝试换一种方法,看看是否能更简单。随着经验积累,你会注意到某些技术往往会带来更简单的设计,而另一些则与复杂性高度相关。这将帮助你更快地产出简单的设计。
本章还提出了一些基本假设,为全书其余内容奠定基础。后续章节会将本章中的内容视为前提,并在此基础上进行各种细化和推论。
在本书中,我以一种实用的方式来定义“复杂性”。复杂性是指任何与软件系统结构相关、并使系统难以理解和修改的因素。
复杂性可以有多种表现形式。例如,某段代码的工作方式可能难以理解;修改系统可能需要付出大量的精力;实现一个小改进可能需要大量工作;或者不清楚为了实现这个改进,系统的哪些部分必须被修改;又或者很难在修复一个 bug 的同时不引入新的 bug。如果一个软件系统难以理解和修改,那么它就是复杂的;如果它容易理解和修改,那么它就是简单的。
你也可以从成本与收益的角度来思考复杂性。在一个复杂的系统中,即便是很小的改进,也需要付出大量工作;而在一个简单的系统中,较大的改进也可以用较少的努力来完成。
复杂性是开发者在某个特定时间点、为了实现某个特定目标时所体验到的东西。它不一定与系统的整体规模或功能数量直接相关。人们经常用“复杂”来形容那些功能丰富、规模庞大的系统;但如果这样的系统易于使用和开发,那么从本书的定义来看,它并不复杂。当然,几乎所有大型且功能复杂的软件系统在现实中确实都很难开发和维护,因此它们通常也符合我对复杂性的定义,但这并非必然如此。同样,一个小型、功能并不复杂的系统,也完全可能非常复杂。
复杂性取决于最常进行的活动。如果一个系统中有少数几个部分非常复杂,但这些部分几乎从不需要被修改,那么它们对系统整体复杂性的影响并不大。用一种粗略的数学方式来描述:
系统的整体复杂性 (),由每个部分 () 的复杂度 (),以及开发者花在该部分上的时间比例 (t_p) 共同决定。将复杂性隔离在一个几乎不会被触及的地方,几乎和彻底消除这种复杂性一样有效。
复杂性对读者来说往往比对作者更明显。如果你写了一段代码,自己觉得它很简单,但其他人认为它很复杂,那么它就是复杂的。当你遇到这种情况时,值得去深入了解其他开发者为什么觉得它复杂;你很可能能从你和他们观点之间的差异中学到一些有价值的经验。作为一名开发者,你的工作不仅是写出自己容易使用的代码,更重要的是写出其他人也能轻松使用和维护的代码。
复杂性通常以三种总体形式表现出来,下面的段落将分别加以说明。每一种表现形式都会让开发工作变得更加困难。
复杂性的第一个症状是:一个看似简单的改动,却需要在许多不同的地方修改代码。举个例子,假设有一个包含多个页面的网站,每个页面都显示一个带背景色的横幅。在许多早期的网站中,背景颜色是直接在每个页面中单独指定的,如图 2.1(a) 所示。为了修改整个网站的背景颜色,开发者可能不得不手动修改每一个已有页面;对于一个拥有成千上万个页面的大型网站来说,这几乎是不可能完成的任务。
幸运的是,现代网站通常采用如图 2.1(b) 所示的方法:在一个中心位置统一指定横幅颜色,而所有单独的页面都引用这个共享的值。采用这种方式,只需要一次修改,就可以改变整个网站的横幅颜色。良好设计的目标之一,就是减少每个设计决策所影响的代码数量,从而使设计变更不需要涉及大量的代码修改。
复杂性的第二个症状是认知负担,它指的是开发者为了完成一项任务所必须掌握和记住的信息量。认知负担越高,开发者就需要花更多时间学习所需的信息,同时也更容易因为遗漏重要细节而引入 bug。
例如,假设在 C 语言中有一个函数负责分配内存,返回指向该内存的指针,并假定调用者会负责释放这块内存。这就增加了使用该函数的开发者的认知负担;如果开发者忘记释放内存,就会产生内存泄漏。如果系统可以被重新设计,使得调用者不必关心释放内存的问题(由分配内存的同一模块负责释放),就可以降低认知负担。
认知负担可以通过多种方式产生,例如:包含大量方法的 API、接口或行为上的不一致、全局变量的存在、以及模块之间复杂的依赖关系。
系统设计者有时会认为,复杂性可以通过代码行数来衡量。他们假设:如果一个实现比另一个更短,那它一定更简单;如果只需要改动几行代码就能完成修改,那这个修改一定很容易。然而,这种观点忽略了认知负担所带来的成本。我曾见过一些框架,虽然只需写很少的代码就能完成应用,但却极其难以理解这些代码究竟应该写在哪里、如何写。
依赖关系是软件中的一个基本组成部分,不可能被彻底消除。事实上,在软件设计过程中,我们是有意引入依赖关系的。每当你创建一个新类时,就会围绕这个类的 API 产生依赖关系。然而,软件设计的目标之一,是减少依赖关系的数量,并让那些不可避免的依赖尽可能简单且显而易见。
继续以前面的网站示例来说明。在旧的网站中,背景颜色在每个页面中单独指定,因此所有网页彼此之间都存在依赖关系。新的网站通过在一个中心位置统一指定背景颜色,并提供一个 API,让各个页面在渲染时通过该 API 获取颜色,从而解决了这个问题。新的设计消除了页面之间的依赖,但在获取背景颜色的 API 周围创建了一个新的依赖。
幸运的是,这种新的依赖更加明显:很清楚每个网页都依赖于横幅的背景颜色,开发者也可以通过搜索变量名,轻松找到所有依赖该值的地方。此外,编译器还能帮助管理 API 依赖:如果共享变量的名称发生变化,所有仍在使用旧名称的代码都会产生编译错误。新的设计用一个简单且显式的依赖,替换了一个隐蔽且难以管理的依赖。
复杂性的第二个来源是晦涩性(obscurity) 。当关键信息不够清晰时,就会产生晦涩性。一个简单的例子是变量名过于通用,几乎不包含任何有价值的信息(例如 time)。或者变量没有明确单位,导致只能通过扫描代码中变量的使用位置来推断含义。又或者变量的文档说明不充分,只能通过阅读代码来理解。
晦涩性通常与依赖关系有关,因为依赖本身并不总是显而易见的。例如,如果系统中新增了一种错误状态,可能需要在一个保存错误字符串的表中添加对应条目,但从状态定义本身并不容易看出这一点。不一致性也是造成晦涩性的一个重要因素:如果同一个变量名被用于两个不同的目的,开发者就很难判断该变量在特定场景下的含义。
在很多情况下,晦涩性来源于文档不足,第 13 章将专门讨论这一点。不过,晦涩性本身也是一个设计问题。如果系统设计得清晰、直观,那么所需的文档自然会更少。大量文档的存在,往往是设计存在问题的信号。减少晦涩性的最佳方式,是简化系统设计本身。
综合来看,依赖关系和晦涩性共同导致了第 2.2 节中描述的三种复杂性表现形式:依赖关系会引发变更放大和高认知负担,而晦涩性会制造“未知的未知”,同样增加认知负担。如果我们能够采用设计技术来最小化依赖关系和晦涩性,就能有效降低软件的复杂度。
复杂性并不是由一次灾难性的错误造成的;它是由大量微小问题逐步累积而成的。单个依赖关系或晦涩点,本身并不太可能显著影响软件系统的可维护性。真正的问题在于,随着时间推移,成百上千个微小的依赖和晦涩点不断堆积。最终,几乎系统中的每一次修改,都会受到其中多个问题的影响。
复杂性的渐进特性,使得它非常难以控制。人们很容易说服自己:“这次引入的一点点复杂性无关紧要。”然而,如果每个开发者在每一次修改时都采取这种态度,复杂性就会迅速累积。一旦复杂性已经堆积起来,就很难再消除;单独修复某一个依赖或晦涩点,通常并不会带来明显改善。
为了减缓复杂性的增长,必须采取一种 “零容忍” 的态度,这一点将在第 3 章中进一步讨论。
复杂性源自依赖关系和晦涩性的不断累积。随着复杂性的增加,会导致变更放大、高认知负担以及“未知的未知”。结果是:实现每一个新功能,都需要修改更多的代码;此外,开发者需要花费更多时间来获取足够的信息,才能安全地进行修改;在最糟糕的情况下,他们甚至无法找到所需的全部信息。
归根结底,复杂性让对现有代码库的修改变得困难且充满风险。