前言
从最初的为电子计算机编程算起,已有 80 来年,但奇怪的是很少有讨论「如何去设计程序」或者「好的代码应该是什么样的」,这本书就是一次尝试。
计算机科学里面最基础的问题就是「问题分解」:如何把一个复杂的问题拆分成一系列可以单独解决的问题。
好的程序员和平庸的程序员区别就在于设计水平。
我在教学生软件设计的课程中采用的方式类似于传统英文写作课程:学生努力学习编写代码,犯错,然后看他们的错误是什么,之后再改正,以此循环。我把这个课程讲述的公共原则提取出来,里面论述了哪些错误要避免,要使用哪些技术,写成了这本书。
第一章:介绍
有两个通用的方法能减少复杂性,本书后面都有讨论:
-
让代码更简单和显而易见。比如减少特殊情况或者使用一致的方式对待标识。
-
封装。也就是模块化设计,系统的复杂性不需要暴露给使用它的程序员,程序员单独编写模块时不需要了解其它模块的详细细节。
因为软件的嬗变,软件设计也是一个跨度整个软件系统生命周期的持续性过程。
作为一名软件开发人员,你应该总是要看看有没有机会改善当前你编写的系统的设计,你应该要有计划地安排时间去改善设计。
这本书有两个大体目标:
-
描述软件复杂性的本质什么,为什么会复杂,如何识别复杂性。
-
更重要的是,在开发过程中如何最小化复杂性。
如何使用本书
最好的方式是作为代码审查者。当你阅读其他人的代码时,思考它是否符合本书中讨论的概念,它和代码复杂性如何相关。你能使用本书中描述过的「❌」去识别代码中的问题,以及改进建议。提高代码设计的最好办法之一就是识别❌。当你看到一个❌,停下来,看有没有其他设计方案能消除这个问题,可能在找到消除的办法之前你需要尝试多种设计方案。
使用本书中的想法时,自制和审慎是很重要的。每一种规则都有其例外和限制。如果你走向了极端,那你就错了。漂亮的设计反映了多种想法、观点之间的平衡。
另外在本书中,应用在模块上的设计观点,其模块可以不止是 class,像子系统或者网络服务也同样适用。
第二章:复杂性的本质
识别复杂性的能力是至关重要的设计能力。一旦你能识别出一个系统是如此之复杂,你就可以用这个能力去让你的设计方法走向简洁。如果设计看上去很复杂,那就尝试另一种方法,看它会不会简单点。一直持续下去,你就会发现某些规则能产生更简单的设计。
复杂性定义
任何和软件系统结构相关的东西,它使系统很难理解和修改,就是复杂性。比如很难理解一块代码是如何工作的、要花很多时间才能实现一个小改进、不清楚修改哪块代码能让系统得到改进、不引入其他东西就很难修复某个 bug。
也可以从代价和收益的角度理解复杂性。复杂的系统要付出很大的代价,但只能有小的收益。
复杂性不跟系统功能规模的多少严格相关,小的系统也可能会很复杂。
一个系统总体的复杂性可以用公式描述:\(C=\sum_{p}c_p t_p\),每一部分的复杂性乘以开发人员在它上面花费的时间之和。
相比作者来说,复杂性对读者更明显。作为开发者,你的任务不仅仅是编写代码,你自己能够在上面轻松工作,而且要让别人也能在上面轻松工作。
复杂性的症状
一、修改放大。一个简单的修改需要改许多不同地方的代码。
好的设计的一个目标就是,要减少每次设计决策影响到的代码数量,以此设计上的变化不需要太多代码修改。
二、认知负载。需要知道很多东西才能去完成一个任务。
很多种方式会导致认知负载增加,比如 API 中包含太多方法、全局变量、不一致的地方、模块间的依赖。
一些人认为代码行数少就更简单,这种观点忽略的认知负载的代价。有时那种需要更多代码行数的方案其实更简单,因为它降低了认知负载。
三、未知的未知。不知道还有什么东西不知道。要完成一个任务,不知道要去修改哪些代码,或者必须要掌握哪些信息。
未知的未知是最糟糕的。修改放大虽然很烦,但最起码它能知道要修改哪些。认知复杂虽然增加了修改的代价,但它很清楚要读懂哪些信息就能正确地完成工作。但未知的未知不一样,它不知道要去做什么,哪种方案会有效。唯一确定的就是要读懂系统中的每一行代码,这对于任何规模的系统来说都是不可能的。即使是读懂代码也不一定够,因为一些修改可能会取决于微妙的设计决策,而它没有记录下来。
好的设计的一个最重要的目标就是,让系统更明显。
复杂性的起因
两个原因导致了复杂性:
一、依赖
本书中的依赖,指的是任何代码只要不能单独理解和修改,就存在依赖。协议中的发送者和接收者之间存在依赖:两边的代码要同时修改。函数调用之间存在依赖:如果函数新加一个参数,那所有调用者都要修改。
依赖是软件的基础部分,不可能被完全消除。实际上,在软件设计过程中我们会有意地引入依赖,比如编写 class 和 API。但是,设计的目标是减少依赖的数量,让依赖尽可能的简单和明显。
二、含糊
当重要的信息不明显时,就是含糊。一个简单的例子就是某个变量名太通用了,没有携带有用的信息,比如 time、data,或者变量名没有指明单位(广受诟病的 sleep)。
含糊经常和依赖有关,也就是依赖关系一点也不明显的时候。不一致性也是造成含糊的一大原因。比如相同的变量名用在两种不同的用途上面。另一个含糊的原因是缺乏文档。最后,含糊也是一个设计问题。减少含糊的最好方法是简化系统的设计。
总之,依赖和含糊都会导致复杂性的那三个症状。依赖导致修改放大和很高的认知负载。含糊引起了未知的未知和认知负载。
复杂性是加速积累的
复杂性不是一下子就有的,而是许多块积累起来的,它来源于开发过程中成百上千个小的依赖和含糊。
第三章:能工作的代码是不够的
许多组织鼓励一种战术的思维方式,强调尽可能快地增加功能特性。
战术式编程
战术式编程几乎不可能产生出一个好的系统设计。它的问题在于:它是短视的。
如果每个人都战术式编程的话,复杂性就会急剧增加,它会很难修改。
「战术龙卷风」是指多产的程序员,他通过完全战术式的编程,能比其他人更快的完成代码编写。战术龙卷风最终只会留下一地鸡毛。
战略式编程
成为好的软件工程师的第一步就是:意识到可以工作的代码是不够的。作为开发者,你最重要的任务是促进功能的扩展,尽管你的代码必须要能够工作,但它不是你的主要目标。你的主要目标是必须产出一个能工作的好设计。
战略式编程需要一种投资的思维方式。你必须要投资时间去改善设计。一些投资是前面做,比如花一点额外时间去为每个 class 找到简单的设计,而不是直接去实现脑中的第一个想法,要去尝试一系列候选方案,选择最清晰的;去想象系统中哪些部分在将来可能会改变,确保它在你的设计中是容易完成的;编写好的文档。另一些投资是后向的,当你发现一个设计问题,不要忽略它或放到一边,要花多余的时间去修复它。
需要投资多少
一个从前到后整体式的投资,比如试图一下子设计出整个系统,通常是无效的。最好的方法是持续不断地做一系列小的投资。我建议花整个开发时间的 10% ~ 20% 用于投资。
你会从你过去的投资中获得收益:它会节省时间,以至于覆盖掉你将来投资的一些成本。也就是由于你过去的投资,会让将来的工作更轻松,以至于在将来完成同样的工作只需要更少的时间,这样多出来的时间能够在将来继续用来投资。
初创企业和投资
初创企业由于想要快速地发布版本,所以会有很大的压力,通常都是战术式编程。但一旦代码库变得像意大利面条一样混乱,就几乎不可能去修复。而且成功企业的一大重要因素就是它的工程师的质量。最好的工程师很关心好的设计。如果你的代码库是糟糕的,优秀的工程师会受不了,这让你招募人才变得更难。因此你更有可能只能找到平庸的工程师,这进一步增加了你未来的付出的代价,让系统架构进一步变差。
有些公司很强调高质量的代码和好的设计,它们也能构建出复杂的产品,去用可靠的软件系统解决复杂的问题。
结论
坚持使用战略式编程,并且在当下而不是将来去投资是至关重要的。一旦你开始推迟去改善设计,那就很容易推迟到永远,你的文化很快就会滑落到战术式编程。
最有效的方式公司里面每一个工程师都为好的设计而持续小的投资。
第四章:模块要深
设计系统要让开发者在任何时候,都只需要面对总体复杂性中的一小部分,这就是模块化设计。
模块化设计
模块有许多种形式,比如 class、子系统或者服务。在理想的情况下,每一个模块都完全独立,开发者在任何模块下开发都不需要关心其他模块。
但实际上理想情况无法实现,模块之间必须互相调用。模块之间会存在依赖:如果一个模块改变,其他模块也必须修改。模块化设计的目标是最小化模块之间的依赖。
为了管理依赖,每个模块必须分为 2 部分:接口和实现。
接口包含开发者在其他模块中使用当前模块时所有要知道信息。接口描述了它能做什么,而不是如何做。实现包含去实现接口这个承诺所需要的全部代码。在本书中,一个模块可以是任何有接口和实现的单元。
最好的模块的接口比起它的实现来是相当简单的。这样有两个优点:一个简单的接口让模块暴露给系统其他部分的复杂性最小化,第二模块的许多修改就不会影响到其他模块。
什么是接口
模块的接口包含两方面的信息:正式的和非正式的。接口中正式的部分在代码里面显式指定,编程语言会检查它的正确性。非正式部分包含更高级的行为,它只能由文档来描述。大多数接口的非正式部分包含更多信息、更复杂。
抽象
抽象是对某个实体进行简化过的概念,它省略了不重要的信息。
在模块化编程中,每个模块都通过接口提供了一个抽象。从模块抽象的角度上看,实现细节是不重要的。越多不重要的信息被省略,这个模块越好。但抽象也可能通过 2 种方式被滥用:
-
它把不重要的详细细节给包含进来了。
-
它省略了真的很重要的细节。这造成了含糊。开发者缺乏必要的信息,因此不能正确地使用这个抽象。
设计抽象的关键在于理解什么是重要的,找到一个设计能最小化地涵盖重要的信息。
深模块
最好的模块能通过简单的接口,提供很强的功能。这就是深模块。一个矩形,它的宽是暴露的接口,实现是高,因此显得深而长。
可以从代价和收益的角度思考深模块。模块的收益就是它提供的功能,而代价就是它的接口(也就是它向系统引入的复杂性)。更小更简单的接口,就只引入更少的复杂性。
深模块,比如 Unix I/O 接口和编程语言中的垃圾回收,它们提供了强大的抽象,因为它们很容易使用,隐藏了很多实现上的复杂性。
浅模块
浅模块就是它的接口和它提供的功能相比,接口太复杂了。有时浅模块是不可避免的,但要记住它们在管理复杂性上面没有提供任何好处,因为它们提供的好处(不需要知道它的内部实现就能使用的功能)和它们的代价(学习它的接口)相比,是微不足道的。小的模块通常都浅。
❌1:浅模块
教条主义
编程上的一个习惯就是 class 要小,而不是深,学生也通常被教导要把大 class 拆分成小的。这种方式导致大量浅 class 和方法,它们增加了系统总体复杂性。
小的 class 不会对功能有所帮助,但它们一多的话,又增加了它们各自的接口。接口的增加在系统层面上使复杂性急剧上升。小 class 导致一种繁琐的编程方式。
例子:Java 和 Unix I/O
Java 读取文件要创建 3 个对象,FileInputStream
、BufferedInputStream
和 ObjectInputStream
,而大多数情况下都只需要 ObjectInputStream
,提供选择是好的,但是接口应该被设计成让最常用的情况尽可能简单,class 应该不需要说明就能做正确的事情。对于那些很少的情形,比如不需要 Buffering,应该提供一种方法禁用它,这种禁用方法应该在接口上面区别开来,以至于大部分开发者不需要意识到这种很少用到的接口的存在。
如果接口有许多特性功能,但是大部分开发者只用到其中的一部分,那它的复杂性就是最经常使用的那些功能特性接口的复杂性。
第五章:信息隐藏(和泄漏)
这章讨论哪些技术能创建深模块。
信息隐藏
实现深模块最重要的技术就是信息隐藏。它最基本的观点就是,每个模块应该封装一些代表设计决策的知识,这些知识嵌入在模块的实现里面,不应该出现在接口里面,以至于让它在模块外不可见。
模块的信息隐藏通常包含如何实现某种机制的细节。隐藏的信息包含:和这个机制有关的数据结构和算法、低级别的细节、高级别的更抽象的概念。
信息隐藏通过两种方式来降低复杂性:
-
简化模块的接口。这降低了认知负载。
-
信息隐藏让系统更容易演化,外部的模块不依赖于这些信息,因此和这些信息相关的修改都只影响到单个模块。
当设计一个新模块时,你应该要仔细思考哪些信息可以被隐藏在模块内。
注意:通过将变量或者方法声明为 private 和信息隐藏不是一回事。私有的东西仍然可以通过公有方法泄漏出去,比如 getter 和 setter。
信息隐藏最好的形式是它完全被隐藏在模块内部,对模块的使用者完全不相关、不可见。但是即使是部分的信息隐藏也是有价值的。比如某个特性或方法只被少数几个 class 使用者需要,在大部分情况下是不可见的。
信息泄漏
当一个设计决策和多个模块相关联时,就发生了信息泄漏。这就造成了依赖:关于这个设计决策的任何修改,都需要修改所有相关的模块。
如果一个信息和模块的接口相关,那它从定义上来说就是泄漏的。然而即使信息没有出现在模块的接口上面,也可能泄漏。比如两个 class 都知道某个特殊的文件格式,并对它做了假定。
软件设计中最大的一个❌就是信息泄漏,要对信息泄漏有很高的敏感性。如果遇到两个 class 之间有信息泄漏,问你自己「我要如何重新组织这两个 class,能让信息只影响到一个 class」?如果受影响的 class都比较小,而且和泄漏的信息紧密相关,那可以合并为单个 class。第二种办法是,把信息提取出来,专门封装到一个 class 里面。但是这种方法只有在能找到可以从细节抽象出一个简单的接口时才有用,如果这个这个新 class 的接口过多的暴露了这个知识,那其实没多大的用,这仅仅只是从后门泄漏换成了接口泄漏而已 。
❌2: 信息泄漏
时间分解
时间分解就是系统的结构和它的操作发生的顺序相对应,在不同时间点发生的操作分布在不同的方法和 class 里面。如果相同的知识在不同时间点用到了多次,那按这样设计就会发生信息泄漏。
顺序其实不是问题,它总会反映到应用的某个地方。但是它不应该反映在模块结构里面,除非这个结构能够实现信息隐藏。设计模块的时候,专注于每个任务需要的知识,而不是任务里面操作的发生顺序,每个模块应该封装一个或更少的信息,而不是操作。
❌3: 时间分解。
将代码拆分成浅 class,就会导致 class 之间的信息泄漏。信息隐藏可以通过制作一个更大的 class 来改善,它有两个好处:
-
和某个能力相关的代码全在一个 class 里面。
-
它提升来接口的层次。
应该尽可能避免暴露内部数据结构。
❌4: 暴露过度。如果一个 API 在大多数使用场景下,强迫用户去学习那些很少使用的特性功能,这就增加来认知负载。用户不需要关心那些很少使用的特性功能。
class 中的信息隐藏
尝试使用私有方法,让每个私有方法封装一些信息或能力,对 class 的其他部分隐藏这些信息。一些变量可能需要被广泛地跨 class 被访问,但另一些可能只是在很少的地方被使用,如果你能减少变量被使用的地方数量,那就减少了这个 class 产生的依赖,降低了它的复杂性。
不要太过
如果信息真的是外部所需要的,那就不要隐藏它。重要的是如何识别信息是否被外部需要,是否要暴露它。
结论
信息隐藏和深模块紧密相关。
第六章:通用的的模块更深
一个很常见的设计决策就是:新模块是实现得通用还是特殊?
让 class 有点通用
按我的经验,实现新模块的时候要让它「有点通用」。「有点通用」意味着模块的功能应该反映你的当前需求,但是它的接口不应该这样。接口应该足够通用以满足多种用途,但又不需要特殊处理就能满足今天的需求。注意,不要构建得太通用,以至于对你的当前需求来说很难去用它。
这种通用方式的最大的好处在于,和比特殊方式比起来,它的接口更简单、更深。
class 设计的一个目标就是允许每个 class 能独立开发。
通用性导致更好的信息隐藏
软件设计的一个重要元素就是,决定谁需要知道什么?什么时候知道?当实现细节很重要时,就要尽可能明显的暴露出来。
问你自己的问题
下面这些问题会帮助你在接口的通用和特殊之间找到平衡。
-
能解决我当前所有需求的最简单的接口是什么?如果你减少 API 中方法的数量却没有降低总体复杂性(比如不得不在方法中引入太多参数,或者使用起来很不方便,很绕),那你就很有可能创建了太通用的接口。
-
在多少种情形中会使用这个方法?如果这个方法只为一种情形而设计,那就太特殊了。可以将多个特殊的方法合并到一个通用的方法里面。
-
这个 API 对我当前的需求来说容易使用吗?如果为了解决当前的需要,还需要写很多额外的代码,那说明这个接口没有提供「正确的功能」。
第七章:不同层,不同的抽象
软件系统通常会划分层次,较高的层使用较低的层提供的设施。在一个良好设计的系统中,每一层从上到下都提供不同的抽象。
如果系统的相邻两层提供了相似的抽象,这就是个❌,说明 class 划分有点问题。
传递性方法
一个症状就是存在传递性方法。传递性方法就是它除了调用另一个方法外,几乎没有其他功能。通常它和它所调用的方法具有一模一样或相似的参数,说明 class 的职责没有清晰的划分,产生了重叠。
传递性方法让 class 更浅:它们增加了接口的复杂性,但没有总体上增加系统的功能。
❌5: 传递性方法
A->B->C 如果是传递性的,有 3 种方法改进它:
-
A 直接调用 C,B 不再包含那些传递性的职责。
-
B 和 C 重叠的职责完全划分给 B,C 只包含剩下的部分。
-
B 和 C 完成合并成一个整体。
什么时候接口重复是可以的
存在相同签名的方法有时也不是个坏事。重要的是,每一个方法应该提供有用的、不同的功能。
一个例子就是分派器。另一个例子是相同的接口但有多个实现。这些情况一般都位于同一层,而且不会调用每一个相同签名的方法。
装饰器
装饰器模式通常导致浅 class。
使用装饰器之前,先考虑一下替代方法,它们在大多数情况下都会更好:
-
可以直接在底层 class 添加新的功能,而不是创建一个新的装饰器 class 吗?如果新功能是相当通用的,或者逻辑上和底层 class 相关,或者大部分情况下使用底层类都需要这个新功能,那就应该这样做。
-
如果新功能比较特殊,那可以把它合并到它的使用用例里面吗?
-
新功能能合并到一个已有的装饰器吗?
-
是否新功能真的需要把已有的功能包装起来?也许可以在一个单独的 class 里面实现。
接口 vs 实现
一个 class 的接口通常应该和它的实现不同:内部使用的表示方法和接口提供出去的抽象应该是不一样的。如果接口和实现是相同的抽象层次,那很可能不会太深。
传递性变量
API 不同层发生重复的另一种形式是传递性变量。一个变量在调用链中一直往下传递。
传递性变量增加了复杂性,因为它迫使途中所有方法都要知道它的存在,即使这些方法都没有使用这个变量。同时如果新增一个变量,那沿途的所有方法的接口都需要修改。
消除传递性变量比较棘手。一个方法是看能否在这些从上到下的方法中,找到一个已共有的对象。但即使存在这样一个对象,那它自己就是一个传递性变量。另一种方法是把信息存到一个全局变量里面,但全局变量会存在其他问题,比如不能在系统中创建多个实例,虽然在生产中很少见这种多个实例的需求,但测试时经常会需要。
我最经常使用的方法是引入一个上下文对象。上下文对象存储应用所有的全局状态(所有的传递性变量和全局变量)。上下文对象允许多个实例存在。但上下文对象实际也会成为传递性变量,为了减少需要知道它存在的方法的数量,可以把上下文对象的引用保存在系统的主要对象里面,需要上下文对象里面的内容时,通过这个指针选择性地获取。
上下文对象让系统的全局状态管理变得简单和统一,也方便测试。但远非完美。存在上下文里的变量面临着跟全局变量一样的缺点,比如不知道它会在哪里被使用,为什么存在。如果没有原则地使用上下文,那它会成为一个巨大的数据垃圾堆,给系统创造不明显的依赖。上下文也有线程安全问题,最好的办法是让上下文里面的变量是不可变的。
第八章:把复杂性置于下层
这一章介绍另一种创造深模块的方法。假设你在开发一个新模块,发现有些复杂性不可避免,那这个复杂性是由模块的使用者来承担?还是模块的作者来承担呢?如果这个复杂性和模块提供的功能有关,那后一种更合适。因为模块的用户一般都比它的开发者多。从另一方面考虑,对模块来说更重要的是简单的接口,方便用户使用,而不是实现上轻松简单。
偷懒的模块开发者往往只做些轻松的活,把棘手的东西抛给它的用户。这种行为只是在短期上让你轻松,长远来看它放大来复杂性。一个例子就是「配置参数」,它虽然给用户提供了更多选择,但是在还多情况下用户很难甚至不可能决定出合适的参数值。如果在实现上能多做工作,自动计算出合适的值会更好,就像 Tcp 重传里面的参数一样。所以要尽可能避免配置参数,在暴露出配置参数之前,先问你自己:
-
你在底层都不知道一个合适的值,用户或上层模块会能找到合适的值吗?(ps. 这个问题同样适合于往上抛错误时)
-
你能计算出一个合理的默认值吗?这样用户只需要在特殊情况下才提供参数值。
理想情况下,一个模块应该完全把问题解决掉,而配置参数实际上只是一个部分解决方案,它增加了系统复杂性。
不要太过
使用这个方法的极端情况就是整个应用只有一个 class,所有函数和功能都放到里面,这毫无用处。把复杂性置于下层只适合这些场合:
-
把复杂性置于下层后,它和接收这个复杂性的 class 的已有功能更相关了。
-
把复杂性置于下层后,系统的许多其它地方都能得到简化。
-
把复杂性置于下层后,接收这个复杂性的 class 的接口得到简化。
这个方法使用不当的一个结果就是信息泄漏。
第九章:合在一起好还是分开好
设计软件时会遇到一个最基本的问题就是:有两个功能,它们是要在一起实现,还是分开单独实现呢?这个问题会出现在系统的所有级别上面,像函数、方法、class、服务等。
决定是合在一起还是分开的时候,目标就是要减少系统整体上的复杂性,提高模块性。
分开相比合起来,会增加系统的复杂性,因为:
-
组件越多,就越难跟踪它们。在一个更大的集合里面也越难找到想要的组件。另外组件越多,接口也就越多。
-
需要额外的代码去管理各个组件才能实现原有的功能。
-
制造了分隔。可能位于不同文件夹里面了,开发者很难同时看到它们,更差的是甚至没有注意到它们的存在。如果组件间还存在依赖,开发者这就需要来回跳转,如果没注意到依赖,它就会导致 bug。
-
可能制造重复。可能某些代码在多个组件里面都需要一份了。
如果代码更相关联,那就合在一起,如果不相关,那分开更好。如何判断两块代码是否相关:
-
它们共享信息,比如都假定已知某种前提知识。
-
它们一起被使用。使用了这个代码,很有可能也要使用另一个代码,反之使用了另一个代码,也需要用到这个代码。若过只是单向的,则不算。
-
它们概念上重叠,有一个更高级别的分类同时包括这两个代码。
-
如果不看另一个代码,那很难理解这个代码。
如果共享了信息,那合起来
如果能简化接口,那合起来
这种情况常出现在,原来的多个模块每个都只解决了部分问题。
合起来以消除重复
如果你发现相同模式的代码反复出现,那就需要重新组织代码以消除重复。一个方式就是提取出重复的代码,放到方法里面,然后在重复的地方调用它,这种方法只有当重复代码较长,提取的方法签名简单的时候有效,如果这个方法本身只有两三行,或者需要的参数太多太复杂,那就没多大效果。另一种方式是重构代码,让重复的代码只需要在一个地方执行就可以了。
❌6: 重复。相同的代码片段反复出现,说明没有找到正确的抽象。
分开通用用途的代码和特殊用途的代码
如果一个模块包含的功能可以用于多个不同的用途,那它就只需要提供这一个通用的功能,它不应该包含特殊代码使之方便用于特殊用途,也不应该包含另一个通用用途的功能。特殊用途的代码和通用用途的代码应该位于不同的模块。通常,系统中较低层次倾向于通用用途,较高层次的倾向于特殊用途。所以分开这两种时,要把特殊用途的代码提到更上层,把通用用途的代码留在底层。
❌7: 特殊-通用相混合。通用用途的功能,但却仍包含特殊代码,让它用于特殊用途。这让这个功能更复杂,并且在通用功能和特殊用例之间创造了信息泄漏。将来修改用例时很可能需要改底层的功能。
分开和合并方法
方法的长度基本上不是一个很好的理由去拆开方法。如果方法的签名简单、容易阅读,那即使几百行也没有关系。
设计方法时最重要的目标是提供清晰、简单的抽象。每个方法都应该只做一件事情,并它做完全。如果能创造一个更干净的抽象,那就可以拆开方法,有两种方式:
-
把方法内的子任务拆出来,单独作为一个方法,原有的方法调用它而且接口不变。这需要两个前提:1. 阅读子任务方法时,不需要了解它的父方法。2. 阅读父方法时也不需要了解子方法的实现。通常这意味着,这个子方法是更通用的方法,可用于多个用途。如果还需要两个方法之间来回跳转来理解它们如何一起工作,那说明拆开反而不好。
-
把原方法,拆成多个接口更简单、单独的方法,每一个都只拥有原方法一部分功能,再由上层去调用它们。这通常是因为原方法接口太复杂,它其实做了多件不相关的事情。拆出来的新方法可以比原方法设计得更通用,能在更多场合用到,否则上层还是要一个个把新方法调用一遍的话,那其实这个拆分增加了复杂性,让方法更浅,甚至出现传递性方法。
系统在几种情形下也可以合并方法:
-
把两个浅方法合并成深方法。
-
能消除重复的代码。
-
能消除原始两个方法之间的依赖,或者中间层数据结构。
-
能创造更好的封装,比如之前暴露出来的信息分布在多个地方,合并后隔离在单独的地方了。
-
能创造更简单的接口。
❌8: 连体的方法。每个方法都应该能够单独理解。如果理解这个方法的实现的时候,必须要理解另一个方法的实现,那就有问题。有时两个代码块在文件上是隔开的,但必须要合起来才能够理解,那同样有问题。
结论
分开还是合并应该基于复杂性来考虑。选择的结构要能创造最好的信息隐藏、最少的依赖、最深的接口。
第十章:让定义错误没有存在的必要
错误处理是软件系统中复杂性的一个最大的来源。处理特殊情况的代码比处理正常情况的代码本质上更难写,开发者经常没有考虑如何处理就去定义异常。这一章的关键在于教导如何减少要处理异常的地方。
为什么异常增加来复杂性
这里说的「异常」是指任何改变了程序正常控制流程的不常见情况,不单单是程序语言里面的异常类型。
几种出现异常的方式:
-
调用者提供错误的参数或者配置信息。
-
调用的方法可能完成不了某个请求操作。比如 I/O 操作可能会失败,资源也可能获取不到。
-
在分布式系统中,网络会丢包、延迟,服务可能不能及时响应,或者网络个体之间以非期待的方式通讯。
-
代码自己检查出了 bug、内部不一致或者没准备处理的情况。
异常扰乱的代码正常的流程,程序员通常有两种方式处理异常,每一种都很复杂:
-
不管这个异常,继续往下处理。想达到这一点,需要复杂的实现,一个例子就是网络丢包处理。
-
终止操作,向上报告异常。然而即使是终止这个动作也会很复杂,因为系统状态可能已经不一致了(比如数据结构的部分初始化),异常处理的代码必须恢复一致,所以可能需要回退异常发生之前的所有变更。无论怎么向上报告异常,总需要在某个地方把异常处理掉。另外异常处理代码本身可能引起更多异常,比如网络丢包,但实际没丢,导致发送两遍。
语言层面提供的异常处理机制通常都很琐碎和笨重,让代码更难阅读。异常处理代码也更难测试,因为异常很少发生,这些代码很少被执行到,当异常处理代码有 bug,异常处理失败时,也很难找到原因。
太多的异常
程序员定义没必要的异常,进一步加剧了这一问题。没有想怎么干净地解决它,只是抛出异常,把问题留给调用者,这增加了系统的复杂性。calss 抛出的异常应该作为它接口的一部分,一个 class 如果有太多异常,说明接口太复杂,它也比更少异常的 class 浅。异常往上抛时,可能跨越了很多层,影响了整个调用链。
抛异常很简单,处理它们却很难。减少异常对复杂性的损害,最好的办法是减少异常必须要被处理的地方。
让定义错误没有存在的必要
定义你的 API 让它没有异常要处理。这是通过重新思考 API 提供出去的语义来达到的,换一种语义,也许就不要对外抛出异常。比如 unset,如果语义是删除一个变量,那变量不存在的时候就要抛出异常,但假如语义是保证这个变量不存在,那直接返回就行了。Unix 的文件删除也是这样的。
掩盖异常
这是第二种方法。异常在底层被检查到,并且被处理,上层就不会察觉到。比如 Tcp 的丢包重发、NFS 的请求重试。
异常聚合
这是第三种方法。将多个异常由一个地方的代码处理掉,而不是分散到各个地方单独处理,这也相当于把多个特殊用途的代码合并为一个通用用途代码里面。另外这种通常需要异常往上传播,由上层统一处理,这和掩盖异常正好相反。
任其崩溃
第四种方式就是直接崩溃程序。在大多数系统中,有许多错误不值得处理,有可能它很难处理,或者不可能处理,或者很少发生。这时打印诊断信息再终止掉就好了。比如内存耗尽、I/O 时不能打开文件、不能打开套接字、甚至程序自身发现了内部数据不一致时。
让特殊情况没有存在的必要
特殊情况让代码充满 if 语句,让它更难读,更容易导致 bug。所要要尽可能的消除特殊情况。而最好的方式是,重新设计正常情况,让它自然而然的、不需要多余代码就能处理特殊情况。
不要太过
如果异常确实不需要暴露给外部模块,使用上面的方法才要意义。异常和软件设计中许多其它领域一样,需要你决定哪些是重要的、哪些是不重要的。不重要的就隐藏,隐藏得越多越好,重要的就必须暴露出去。
第十一章:设计两遍
你脑中第一个想法关于如何设计一个模块或系统的结构,不太可能是最好的方法。与其选第一个想法,不如考虑多种可能性。
你不需要考虑每个替代方案中的所有特性,只要抓住它最重要的几个点就可以。即使你确定只有一个可行的办法,也要尝试考虑第二个设计,无论你认为它会要多糟糕。这激发你去思考它设计上的缺点是什么?它的特性和其它方案有什么不同?在思考几种方案之后,列出每一种的优点、缺点清单,主要考虑更上层的软件使用它们的接口时是否方便。几个考虑因素如下:
-
是否某个方案的接口更简单。
-
是否某个方案的接口更具有通用用途。
-
是否某个方案的接口实现上效率更好。
这时你可以从中选出一个最好方案,或者把多个方案的特性结合起来,设计出一个新的方案。有时候每一种方案都不理想。这时要去识别原有方案存在的问题,驱动你去找到新方案。
「设计两遍」可以应用在系统的多个层级上面。对于模块,你可以用上面的步骤去确定接口,然后在设计实现上,再次利用上面的步骤。实现的目标和接口的目标是不一样的:对于实现来说,最重要的是简单和性能。在更高层次,比如把系统解耦成多个组件,或者 UI 特性,都可以用到这个原则。
设计两遍不需要花太多时间。对于小模块,比如 class,可以不需要超过一两个小时去思考替代方案,这对于几天或一周的实现来说,是微不足道的。最开始的设计尝试,可能会导致明显更好的设计,这个设计两遍上时间的付出是值得的。
聪明人觉得他们对任何问题的第一个想法都不错,不需要思考第二个或者第三个可能性。这容易养成糟糕的工作习惯,随着这些人年龄变大,他们会进入到环境会需要面对越来越难的问题。最终,每个人都会到达一个点,你的第一个想法对于这个问题来说不是足够好的。
设计两遍不仅会改善你的设计,而且会提升你的设计技能。设计和比较多种方案的过程,会教会你哪些东西决定设计方案是好的还是坏的。
第十二章:为什么写注释?四个借口
代码里面的注释在软件设计中扮演者至关重要的角色。注释是必不可少的,它让开发者能理解系统、更有效率的工作。注释也在抽象里面扮演了重要角色,没有注释,你不可能隐藏复杂性。最后,写注释的过程,如果正确的话,实际上也会改善系统的设计。反过来,一个很好的软件设计如果只有很差的文档,那也失去了其中大部分的价值。
不幸的是,这个观点没有得到广泛认同。开发者不写注释通常有 4 个借口,下面会分别讨论。
好的代码自己就是注释
一些人相信代码只要写得好,就会足够明显,以至于不需要注释。这只是一个美丽的神话。有些很重要的信息不可能展现在代码里面,比如接口里面非正式的信息,像更高级别地描述每个方法在做什么、返回结果的含义、调用某个方法的特殊条件等。甚至是更上层的设计决策之间的关系,为什么要这么设计,只能写在注释里面。
一些人认为如果其他人想知道方法是做什么的,他们应该去阅读这个方法的代码,这比注释更准确。但这是耗时而且痛苦的。如果你希望别人去阅读方法的实现,那你就会倾向于把代码写得尽可能的短,这会造成大量的浅方法。最终,它没有让代码更容易阅读:为了理解一个上层方法的行为,读者需要理解它里面嵌套的所有方法的行为。对于稍大的系统,几乎是不可能通过阅读代码来学习行为的。另外,注释是抽象的基础,如果用户必须要阅读方法的实现才能够去使用它,那就不是抽象,因为方法的所有复杂性都暴露给用户了。没有注释的话,方法的声明就是唯一的抽象,这个声明相比它自己提供出去的抽象来说,缺失了太多必要的信息。
我没有时间写注释
如果你允许文档是低优先级的,那你最终就不可能有文档。
如果你想要一个干净的软件架构,好让你在长期上更有效率的工作,那你必须花额外的时间去创造这个架构。好的注释让软件的可维护性有巨大的提高,所以要尽可能快地付出这个时间。另外,写注释也不需要花太多时间,一般不会超过开发时间的 10%。
许多重要的注释和抽象有关,比如 class 和方案的文档,这些注释应该在设计的过程中写出来,写文档这个动作本身就成为了一个重要的设计工具,能改善整体的设计。
注释会过时而变得有误导
保持文档更新不需要巨大的努力。大幅度的文档变化只有在大量代码改变时才会发生,而大量代码改动本身就会安排更多的时间。代码检阅提供了一个很好的方式去检查和修复过时的注释。
我见过的所有注释都是毫无价值的
确实,每个开发者都见过没有提供有用信息的注释,甚至大部分文档都聊甚于无。但这是只是写注释的方法不对而已,写好的注释不难,下一章会给你提供一个框架,去写好的、可维护的注释。
写得好的注释的好处
注释关键的一点在于,记录设计者不能用代码表现出来的想法。这个信息可以在很底层,比如硬件的古怪行为,也可以在很上层,比如 class 之间的关系。
文档能够减少信息负载,开发者需要修改代码时,可以从中得到信息,也可以很容易地忽略不相关的信息。如果没有足够的文档,他就要阅读大量的代码,重构出设计者的想法。
文档能减少未知的未知。因为文档能让系统架构更清晰,更容易知道改了某个地方之后会影响到什么。
好的文档能澄清依赖,这进一步消除了其导致的含糊。
第十三章:注释应该描述代码里面不明显的东西
编写代码的时候,编程语言不能描述开发者脑海里面所有的重要信息。注释的原则就是,注释应该描述代码里面不明显的东西。有时是底层的细节不明显,有时是不明白为什么需要这个代码,或者为什么这样实现,或者开发者假定的规则。
开发者使用模块时,只需要看它对外可见的声明代码,就能理解这个模块提供的抽象。所以必须要用注释对声明做补充。
好的代码通常和它的实现细节描述的不是同一个层级的东西,要么是某些更特殊的层级,要么是更抽象的层级。
挑选公约
写注释的第一步就是选一个公约。公约有两个目的:
-
确保一致性,让注释更容易读和理解。
-
确保你真的会去写注释。如果你连要注释什么、以及如何写注释都没有清晰的想法,那很可能最终一点注释也没有。
大部分注释可以分为这几类:
-
接口注释:位于模块的声明之前,比如 class、数据结构、函数、方法等。对于 class,注释描述这个 class 提供的抽象;对于方法或函数,注释描述它总体的行为、参数、返回值、副作用、异常、任何调用者需要满足的要求。
-
数据结构成员。
-
实现注释:在方法或函数的代码里面,描述代码内部如何工作。
-
跨模块注释:描述跨模块之间的约束。
最重要的注释是前两类。每一个 class 都应该有接口注释,每一个 class 的成员都应该有注释,每一个方法都应该有接口注释。与其浪费精力担心注释是否需要,把这几点全写注释还更轻松一点。
实现注释不是很有必要。
跨模块注释最稀少,而且它们写起来很棘手,但当真正需要时又很重要。
不要重复代码
写注释最大的问题就是重复代码,所有的信息都能在随后的代码里面轻松推断出来。
写完注释之后,问你自己这个问题:从未看过代码的人是否可以仅通过查看注释旁边的代码来写注释?
另一个常犯的错误是在注释里面使用和已有文档记录的东西(比如 class 名、函数名、参数名、变量名等)相同的词,只是简单地把这些词组成句子。
❌9: 注释重复代码
改进的一个方法就是,在注释里面使用不同的词去描述已有文档记录的东西,让注释里的词能对这些东西提供额外的信息,而不是去重复它的名字。
更低层次的注释要补充精度
注释要和代码在不同层次上提供信息。如果注释要提供更低级别信息的话,可以补充精度,使代码的确切含义更清晰。如果注释要提供更高级别、更抽象的信息,可以补充直觉相关的信息,比如代码背后的理由,或者一个更简单更抽象地思考代码的方式。如果注释和代码在同一层次,那很可能就是在重复代码。
精度在注释 class 成员变量、方法参数、方法返回值时最有用。变量的名字和类型一般都不能提供足够的精度信息,注释可以补充以下信息:
-
变量的单位。
-
边界情况是包含还是不包含的。
-
null 值如果是被允许的话,它会做什么。
-
如果变量代表着某个资源,那由谁来释放或关闭呢。
-
有没有不变量,也就是某个变量是否有总为真的属性。
另一个常见问题是,对于变量的注释都太不具体了。注释变量的时候,按名词思考,而不是动词。要关注变量代表的是什么,而不是它可以被怎样操纵。
更高层次的注释增强直觉
帮助读者理解代码的架构和总体意图。通常用于方法内部注释和接口注释。
更高层次的注释通常更难写,你必须要用不同的方式思考代码。问你自己:
-
这个代码想要做什么
-
最简单的、能解释这个代码里面所有内容的东西是什么
-
这个代码里面最重要的事情是什么
工程师都更倾向于细节,但是,好的软件设计师能从细节抽离回来,从更高的层次思考系统,包括决定系统的哪一个方面是最重要的、系统最本质的特征是什么。这就是抽象的本质(找到一个简单的方式去思考复杂的实体),当你写更高层次的注释时,也要这样做。
接口文档
如果你想要代码表现出良好的抽象,你必须要把抽象用注释记录好。记录抽象的第一步是分离接口注释和实现注释。
接口注释提供其他人想使用这个 class 或方法所需要知道的信息,它定义了抽象。实现注释描述 class 或者方法为了实现这个抽象,内部是如何工作的。如果一个接口注释必须要描述它的实现的话,那说明 class 或者方法太浅了。
class 的接口注释描述了它提供出去的抽象是什么。
方法的接口注释可以同时包括关于抽象的更高层次的信息和关于精度的更低层次的信息:
-
通常首先用一两句话,描述方法的行为,这个行为能被调用者感知到。这就是更高层次的抽象。
-
必须描述每一个参数和返回类型。这些注释必须要精准,也必须要描述所有对参数的约束条件和参数之间的依赖关系。
-
方法如果有副作用,也要记录在接口注释里面。这个方法任何会对系统未来的行为造成影响,而又不是它返回结果的一部分,就是副作用。比如写文件、在内部数据结构里面添加值等。
-
必须描述所有抛出去的异常。
-
调用这个方法所需要满足的前提条件。
class 文档如果能包含一些例子,能展示它的方法是如何一起使用的话也很有帮助,尤其是一些深 class 的使用模式不是很明显的时候。
❌10: 实现文档污染了接口。接口文档里面,包含了使用这个接口时不需要知道的详细细节。
实现注释:做什么、为什么做,而不是如何做
方法内部的实现文档的主要目标是帮助读者理解这个代码是在做什么,而不是如何做。
当循环比较长或者比较复杂时,不容易看出它在做什么时,可以加实现注释。短的、简单的循环不用注释。
除了描述做什么,如果注释能解释为什么的话,也很有用。比如修复 bug 时,有些代码的目的不是很明显,可以提供它对应的讨论 issue。
大部分局部变量如果命名很好的话,不需要注释。但如果变量在跨度很大的代码里面被使用,可考虑添加注释,要着重描述给这个变量代表着什么,而不是它在代码里面是如何被操作的。
跨模块设计决策
对跨模块文档来说,最大的挑战是找到一个合适的地方去放它,并且能让开发者自然地找到(不可避免的,有些设计决策会影响到多个模块,开发者要从跨模块文档中得知到这些信息,否则很容易出现 bug)。
我最近实验的一个方法是,把跨模块相关的文档专门放到一个叫做 designNotes 的文件里面,这个文件分成多个小节,每个小节对应一个主题。这个方法的一个缺点是它不靠着依赖这个文档的代码,所以想保持更新比较困难。
结论
注释的目标是确保系统的架构和行为对读者来说是明显的,让他们能快速找到所需的信息、有信心对系统做修改。明显是相对于第一次阅读这个代码的人读者而言的,如果读者认为不显而易见,那就是不明显。
第十四章:选择名字
给变量、方法和其它实体选择名字,是软件设计中最被低估的一个点。选择名字是一个很好的例子,能说明复杂性是如何加速积累的。大部分开发者没有花时间去思考命名,他们倾向于使用脑海中第一次出现的名字,只要它看起来相当接近于所需要命名的东西。你不应当只因为「相当接近」就选择它,要多花一点时间选一个更准确、无歧义、符合直觉的名字。
创造一个图像
选择名字的目标是要能在读者脑海中创造一幅图像,这幅图像要能反映出所命名东西的本质。所以名字也是一种抽象形式:它们提供了一种简单的方式去思考底层更复杂的实体。
考虑某个名字时,问你自己:如果某个人单独看到这个名字,而没有看过它的声明、文档、任何代码时,他能猜出来这个名字所指的东西吗?是否有更好的名字能更清晰地描述这幅画?
名字应当精确
好的名字有两个属性:精确性和一致性。名字最常见的问题就是太一般、太不具体了。
❌11: 不具体的名字。如果变量或方法的名字可以对应很多不同的东西,那它就没有给开发者传达太多信息,底层的实体很可能会被误用。
如果循环很小的话,用 i 和 j 这样的名字作为迭代变量是没有问题的。如果循环很长不能一次看完,或者迭代变量的含义很难推断出来,那最好还是用一个给具有解释性的名字。
如果你很难找到一个精确、符合直觉、又不太长的名字,那说明这个变量没有一个清晰的定义或使用目的,请重构它。有可能它代表的东西太多了,要拆成多个变量。
❌12: 很难选名字
一致性的使用名字
命名一致性能减少认知负载。一旦读者在一个上下文里见过了这个名字,那他在其它上下文再见到这个名字时,能假定它具有相同的知识。
一致性必须满足三个前提:
-
同一个目的使用同一个名字。
-
不是这个目的的,不要使用这个名字。
-
确保目的足够小,使用相同名字的变量都有相同的行为。
第十五章:首先写注释(把注释作为设计过程的一部分)
许多开发者直到开发末尾,在编写代码和单元测试都结束后才开始写注释。这就是为什么文档质量很差的原因。写注释的最好时机是在写代码之前。首先就要写注释,让文档成为设计过程的一部分。
推迟的注释是糟糕的注释
推迟的文档一般都意味这它永远不会写。即使你回过头去写注释,这时关于设计过程的记忆也已经模糊了,你就会看着代码写注释,最终注释就是在重复代码。
首先写注释
我写注释的过程如下:
-
对于一个新的 class,首先写这个 class 的接口注释。
-
接着,写这个 class 最重要的公有方法的接口和签名,不写实现。
-
微调一下注释,直到这个 class 的基本结构看起来可行。
-
写这个 class 最重要的成员属性的声明和注释。
-
最后,填充方法实现,补充所需的实现注释。
-
在填充方法实现的过程中,通常都会发现还需要一些额外的方法和成员属性。对于这些方法和成员,还是跟上面一样先写注释和声明。
这样代码写完之后,注释也写好了。这种方式有三个好处:
-
它能产出一个更好的文档。这样写,设计时面对的问题都很清晰,很容易记录下来。先写接口注释,能让你更将聚焦于抽象,而不会被实现分心。实现时,可以参看注释,如果注释有问题,可以再修复它。
-
最重要的是,它能改善系统的设计。注释提供了一种方式能完全记录抽象。注释成为复杂性的指示灯(前提是注释是完整而清晰的),如果一个方法或变量需要很长的注释,说明它没有一个好的抽象。如果接口注释描述这个接口的使用信息,短而简单,说明这个接口就简单。你也可以对比接口注释和实现,看到是不是深的:如果接口注释必须要描述实现的所有特性功能,那它就是浅的。这样写注释也能让你在设计早期去快速发现和修复问题。
-
在早期写注释能更有趣。如果你是战略式编程,你的主要目标是好的设计而不是能工作的代码,那写注释会更有趣,因为这样你能识别出最好的设计。
❌13: 很难去描述。描述一个方法或者变量的注释应该是简单而完全的。如果很难去写一个注释,这说明你要描述的东西在设计上面存在问题。
第十六章:修改已有的代码
一个成熟系统的设计更取决于它演变中的修改,而不是任何最初的构想。
保持战略式
理想情况下,你完成每一个修改之后系统的架构,和你考虑了所有的修改之后从头开始设计,这两者的结果应该是一样的。为了达到这个目标,你应该抵抗快速修改的诱惑,而是要考虑现在的系统设计是否还是最优的。如果不是的话,就要重构直到它是你目前最好的设计。只有通过这种方式,系统的设计才会在每次变动之后得到改进。
任何对代码做得修改,都要在系统设计上至少优化一点点。如果你没有让设计变得更好,那很可能就是变得更差。
维护注释:保持注释靠近代码
保持注释得到更新的最好方式是,让它们和所描述的代码离得近。
写实现注释的时候,不要把注释放在方法的顶部,而是要让每个注释靠近它描述的代码。
注释属于代码,而不是提交日志
一个常见的错误就是修改代码后,把变更的细节信息放在 commit 里面,而不是在源代码里面。这增加了查看难度。
维护注释:避免重复
如果某一次修改决策影响到了多个地方,不要在每个地方都重复记录,应该找到一个最明显的地方去记录。如果找不到,可以放到一个类似 designNotes 的文件里面,然后在每个变更的地方都加一个简单的注释说明,指向这个记录的地方。相反,如果文档里面有重复的,那有些副本很可能不会得到更新,就会导致过时的信息。
不要在一个模块里重复记录另一个模块的设计决策。比如,不要在调用方法的地方写注释去解释这个方法会做什么。要让开发者能轻松找到文档,但绝不能通过复制来完成。
如果在你的程序之外某个地方已经记录了某个信息,那就不要在你的程序里面重复它,直接指向这个外部文档即可。比如一些资料链接等。
维护注释:检查差异
提交时,检查差异,确保每个变更都反映在了文档上面。
更高层次的注释更容易维护
层次越高、越抽象,注释维护起来越容易。因为这些注释和代码细节越不相关,它们受到变更的影响就越小。只有大的行为改变之后,这些影响到这些注释。
第十七章:一致性
如果一个系统是一致的,这就意味着相似的事情总是相似地完成,不相似的事情总是不一样地完成。如果系统是不一致的,那开发者必须要学习每一个单独的场景,这要花很多时间。
一致性的例子
一致性可以反映在系统的多个层级上面。包括名字、代码风格、接口(一个接口可有多个实现,理解一个实现后其它实现也就更容易理解了)、设计模式、不变量(它减少了必须要考虑的特例,让代码行为更容易推断)。
确保一致性
一致性很难维护,尤其是多个人要长期在同一个项目上工作时。以下是一些方法:
-
文档。写一个文档,列出最重要的公约,像代码风格等。
-
强迫。写一个工具去检查违反情况,确保检查如果不能通过,就不能提交到代码仓库。也可以通过代码检查去教育新开发者。
-
入乡随俗。这是最重要的公约。看看你周围的代码,如果它看起来像一个公约,那就遵守它。
-
不要改变已有的公约。有一个「好想法」不是一个引入不一致性的理由。
不要太过
一致性更意味着,不一样的东西一定要是不一样的。一致性只有当开发者确信「当它看起来像 x 时,它一定就是 x」时才会提供好处。
结论
一致性也是一个投资式思维的例子。它需要做一些额外的工作才能保证一致性。
第十八章:代码应当显而易见
解决含糊问题的方法是,把代码写得显而易见。
如果代码是明显的,那读的人就会读得很快,不用太多思考,他对代码行为或含义的第一个猜测往往就是对的。
明显是对于读者而言的:其他人比起你自己来,会更容易发现代码里面不明显的地方。
让代码更明显的东西
之前已经讨论过了两种能让代码更明显的重要技术:命名和一致性。这里再补充一些。
-
明智地使用空格。
-
空行。尤其是每个空行后面就是注释的时候很有用。
-
注释。
让代码更不明显的东西
事件驱动编程
事件驱动编程,应用会根据外部发生的事情,比如网络包到达或者鼠标按下,进行响应。有一个模块专门负责报告到来的事件,系统中其它模块向事件模块注册某个事件发生后,去调用哪个方法,来表达自己对事件的兴趣。
事件驱动编程让跟踪控制流变得困难。事件处理函数从来都不会被直接调用,它们只是被事件模块间接调用,这一般都是通过函数指针或接口来完成。即使你在事件模块里面找到了调用点,也不太确定到底哪个指定函数会被调用:这取决于运行时哪些处理函数被注册进来。
为了弥补这种不明确性,每个处理函数的接口注释,都要写明它何时会被触发调用。
❌14: 不明显的代码。快速阅读时,如果不能理解代码的含义或者行为,就说明有重要信息没有被清晰地展示出来。
泛型容器
许多语言中都提供泛型容器来把多个元素放到一个对象里面。但这时,这些被归为一组的元素都使用了一个泛型的名字(比如 HashMap),这掩盖了它们的真正含义。最好不要使用泛型容器,如果要使用,那就专门为这个使用用途定义一个新 class 或结构体,为这些元素提供有意义的名字,同时还能给成员变量提供注释。
这个例子也说明了一条通用规则:软件应该被设计为容易阅读,而不是容易写。泛型容器写得时候倒是方便了,但读的时候就会带来疑惑。
声明的类型和分配时的类型不一致
比如声明的是个超类,但实际分配一个子类实例去赋值给它。
代码违背了读者的期待
有些地方的代码,和大部分应用遵守的约定不一样。
第十九章:软件趋势
这一章介绍一些软件趋势,它和本书中描述的原则有什么关系。
面向对象编程和继承
面向对象引入了 class、继承、私有方法、成员属性等概念,如果小心使用,这些方法有助于软件设计。比如,私有函数和变量可以确保信息隐藏。
另一个关键概念是继承。有两种形式的继承:
-
第一种是接口继承,父类定义一个或多个方法的签名而不实现它,每个子类用不同原理去实现这些签名。接口继承通过一个接口能用于多个目的而减少了复杂性。一个接口所具有的不同实现越多,这个接口就越深,为了做到这一点,它就必须抓住所有底层实现的本质特征,这就是抽象。
-
第二种是实现继承,父类提供方法的默认实现,子类可以选择要不要覆盖这个实现。实现继承减少了系统修改时需要改动的地方,也就是减少了改变放大。但实现继承也在父类和子类之间创造了依赖。父类和子类可以同时访问成员属性,这在整个继承体系中造成了信息泄漏,很难在继承体系中修改一个 class 而不影响到其它 class。在最差的时候,程序员需要了解整个继承体系中的底层知识之后,才能修改一个 class。因此实现继承用得时候要小心,使用之前先考虑组合,它能提供跟实现继承一样的好处,比如一个实现一些公共功能的小 class,可以作为一个成员变量,而不是它的父类。
面向对象不能保证一个好的设计,比如 class 可能很浅、接口很复杂、允许外部访问它的内部状态等,这些都会导致高复杂性。
敏捷开发
敏捷开发中最重要的元素就是开发要是增量的、迭代的。敏捷开发的一个风险就是会导致战术式编程。增量开发很好,但应该对抽象进行增量开发,而不是功能特性。
单元测试
测试,尤其是单元测试,在软件设计中很重要,因为它们有助于重构。如果没有测试套件,对系统做任何大的改动都是危险的,这会让开发者避免去重构,他们会尽可能少地改动代码、修复 bug,这就让复杂性积累了下来,设计错误也不会被改正。
测试驱动开发
测试驱动开发的问题就是,它专注于让指定功能工作,而不是找到一个好的设计,这是纯粹而简单的战术式编程,它只是一个又一个的让功能特性检查通过,没有任何时间去做设计工作。
在修复 bug 时,首先写测试是好的。
设计模式
设计模式代表了一个设计候选方案:与其重新设计一个方法,不如使用知名的模式。大部分情况下,这很好,但它最大的风险是在应用中到处使用设计模式。不是每一个问题都能用已有的设计模式干净地解决。
Getter 和 Setter
这两个毫无必要,因为成员属性可以暴露为公有的。但最好不要一开始就暴露为公有。因为暴露成员变量意味着 class 的实现在外部是部分可见的,这违背了信息隐藏的想法,并且增加了 class 接口的复杂性。Getter 和 Setter 都是浅方法,它们增加了方法数量却没有提供多少功能。
结论
当你看到一个新的软件开发范式时,站在复杂性的角度去思考它:它真的有助于在大型软件系统中减少复杂性吗?
第二十章:为了性能而设计
这一章的关键思想是:简单性不止会改善系统的设计,而且会让系统更快。
如何看待性能
第一个问题就是:在正常的软件开发过程中,要对性能关心到什么程度?最好的办法是,使用你对性能的基本知识,选择候选方案时,让它干净、简单,那自然就会性能很好。里面关键一点在于,要意识到哪些操作是代价高昂的,以下是一些操作的例子:
-
网络通讯。即使在同一个数据中心,消息的往返时延也要 10~50 微秒,几万个指令时间。如果是广域网,那需要 10~100 毫秒。
-
I/O 辅助存储。硬盘 I/O 要花 5~10 毫秒,百万个指令时间。刷新存储器要 10~100 微秒。即使是 SSD,可能快至 1 微秒,也有 2000 个指令时间。
-
动态内存分配、垃圾回收。
-
缓存未命中。
要知道某个操作是否代价昂贵,最好的办法是写一小段程序,去单独测试这个操作。
如果改善性能的唯一方式是增加复杂性,那就比较难抉择。如果这个方案只增加一小部分代码的复杂性,并且复杂性可以隐藏起来,不会影响到接口,那就值得做。如果在系统中添加了太多的复杂性,接口变得太复杂,那最好还是保留简单的方案,等性能真的成为问题时再解决。如果你很有把握在某个场景下,性能很重要,那就尽快优化性能。
通常情况下,简单的代码会比复杂的代码要快。如果你没有定义特例情况和异常,代码就不需要检查这些,系统就会跑得快。深 class 比浅 class 性能更好,因为它一次调用做了更多事情。浅 class 导致更多层的调用,每一层都有损耗。
修改之前先测量
程序员关于性能的直觉都是不可靠的,尤其是有经验的程序员。在做任何改动之前,要对系统已有的行为进行测量。
仅测试系统上层的性能是不够的,它可能会告诉你系统很慢,但不会告诉你为什么慢。你需要更深的测量,去识别哪些因素会影响到系统的总体性能。目标是找到一小片代码,系统当前花了许多时间在上面。
围绕关键路径来设计
假设你经过小心的性能分析时候,找到了让系统变慢的代码段。最好的方式是进行基础上的改变,像引入缓存、使用另一种算法等。
如果出现了不能从基础上修复它的情况,那就要重新设计已有的代码,让它更快,这是本章的主要内容。关键的一点是要对关键路径周围的代码重新设计。
首先问你自己;目标任务在大多数情况下都必须要执行的最少量的代码是哪些?当前代码里面可能有特殊情况要处理,忽略那部分。关键路径的代码可能会调用多个函数,想象如果你能把相关的代码全放到一个函数里面。关键路径的代码可能会用到多个变量和数据结构,想象只有一个数据结构会被它用到,并且它无论使用哪个数据结构都会很方便。想象如果你能完全重新设计系统,这个关键路径会变成什么样子。这些我们称之为「想法」。
「想法代码」可能和现有 class 有冲突,也有可能不实际,但它提供了目标方向。下一步是找到一个新的设计,能让它保持干净结构的同时和「想法代码」尽可能接近。你可以运用本书前面提到的所有设计方法,唯一的限制是让「想法代码」完整,为了让抽象干净,你也可以在「想法代码」上添加一些辅助代码。
这个过程中最要的是,移除关键路径中的特例情况。每一个特例情况都会在关键路径上增加一点代码,这都会让代码变慢。理想情况下,只需要在最开始加一个判断,把所有特例情况找出来。之后的代码,在正常情况下就可以快速运行。特殊情况的处理代码对性能不敏感,所以对于特殊情况处理代码要更考虑结构而不是性能。
结论
干净的设计和高性能可以是兼容的。复杂的代码一般会更慢,因为它做了无关紧要的或者多余的工作。