Skip to content

第四章 模块性:保持清晰,保持简洁

TIP

软件设计有两种方式:一种是设计的极为简洁,没有看得到的缺陷;另一种是设计的极其复杂,有缺陷也看不出来。第一种的方式的难度要大得多。

4.1 封装和最佳模块大小

模块化代码的首要特质就是封装。封装良好的模块不会过多向外部披露自身的细节,不会直接调用其他模块的实现码,也不会胡乱共享全局数据。模块之间通过应用程序编程接口(API)——一组严密、定义良好的程序调用和数据结构来通信。这就是模块化原则的内容。

有一种很好的方式来验证 API 是否设计良好:如果试着用纯人类语言描述设计,能否把事情说清楚?养成在编码前为 API 编写一段非正式书面描述的习惯,是一个非常好的办法。

模块分解得越彻底,每一块就越小,API 的定义也就越重要。全局复杂度和受 Bug 影响的成都也会相应降低。软件系统应设计成由层次分明的嵌套模块组成,而且每个层面上的模块粒度应降至最低,计算机科学领域从二十世纪七十年代起就已经渐渐明白了这个道理。

模块越大,单个模块代码量越大,越难以阅读,模块越小则接口越多,接口协议的复杂度决定了系统的整体复杂度。Hatton 曾提出一个模型,并通过经验数据表明,假设其他因素都相同,200 到 400 之间逻辑行的代码时是“最佳点”,可能的缺陷密达到最小。

4.2 紧凑性和正交性

具有最佳尺寸的模块并不意味着代码有高质量。由于受到同样的人类认知限制,语言和 API 也会产生 Hatton U 型曲线。

因此,在设计 API 、命令集、协议以及其它让计算机工作的方法时,Unix 程序员已经学会了认真考虑另外两个特性:紧凑性和正交性。

4.2.1 紧凑性

紧凑性就是一个设计是否能装入脑中的特性。测试软件紧凑型的一个很实用的好方法是:有经验的用户通常需要操作手册吗?如果不需要,那么这个设计就是紧凑的。

紧凑的软件工具和顺手的自然工具一样具有同样的优点:让人乐于使用,不会在你的想法和工作间格格不入,使你工作起来更有成效——完全不像那些蹩脚的工具,用着别扭,甚至还会把你弄伤。

紧凑不等于“薄弱”。如果一个设计构建在易于理解且利于组合的抽象概念上,则这个系统能在具有非常强大、灵活的功能的同时保持紧凑。

紧凑也不等于“容易学习”。对于某些紧凑设计而言,在掌握其精妙的内在基础概念模型之前,要理解这个设计相当困难;但一旦理解了这个概念模型,整个视角就会改变,紧凑的奥妙也就十分简单的。对很多人来说,Lisp 语言就是这样一个经典的例子。

4.2.2 正交性

正交性是有助于使复杂设计也能紧凑的最重要特性之一。在纯粹的正交设计中,任何操作均无副作用,每一个动作只改变一件事,不会影响其他。无论你控制的是什么系统,改变每个属性的方法有且仅有一个。

4.2.3 SPOT 原则

《程序员修炼之道》针对一类特别重要的正交性明确提出了一条原则——“不要重复自身”,意思是说:任何一个知识点在系统内都应当有一个唯一、明确、权威的表述。这里我们把这个原则称为“真理的单点性”或者 SPOT 原则。

重复会导致前后矛盾、产生隐微问题的代码,原因是当你修改重复点时,往往只改变了一部分而并非全部。通常这也意味着你对代码的组织没有想清楚。

常量、表和元数据只应该声明和初始化一次,并导入其他地方。无论何时,重复代码都是危险信号。复杂度是要花代价的,不要为此重复付出。

TIP

TODO

4.2.4 紧凑性和强单一中心

要提高设计的紧凑性,有一个精妙但强大的方法,就是围绕“一个定义明确的问题”的强核心算法组织设计,避免臆造和捏造。

这是 Unix 传统中常常被忽视的一个优点。其实,Unix 许多非常有效的工具都是围绕某个单一强大算法直接转换的一个瘦包装器。

Doug Mcllroy

形式化往往能极其明晰地阐述一项任务。如果一个程序员只认识到自己的部分任务属于计算机科学一些标准领域的问题——这儿来点深度搜索,那儿来点快速排序——是不够的。只有当任务的核心能够被形式化,能够建立起关于这项工作的明确模型时,才能产生最好的结果。当然最终用户没有必要理解这个模型。统一核心的存在本身就能给人很舒服的感觉,不会出现在像在使用看似无所不能的瑞士军刀式中非常普遍的“他们到底为什么这么做”的情形。

4.2.5 分离的价值

4.3 软件是多层的

一般来说,设计函数或对象的层次结构可以选择两个方向。选择何种方向、何时选择,对代码的分层有着深远的影响。

4.3.1 自顶向下和自底向上

一个方法是自底向上,从具体到抽象——从问题域中你确定要进行的具体操作开始,向上进行。

例如,如果为一个磁盘驱动器设计固件,一些底层的原语可能包括“磁头移动至物理块”、“读物理块”、“写物理块”、“开关驱动器 LED” 等。

另一个方向是自顶向下,从抽象到具体——从最高层面描述整个项目的规格说明或应用逻辑开始,向下进行,直到各个具体操作。

例如,如果为一个能处理不同介质的大容量存储控制器设计软件,可以从抽象的操作系统开始,如“移到逻辑块”、“读逻辑块”、“写逻辑块”、“开关状态显示”等。这和以上命名方式类似的硬件层操作的不同之处在于,这些操作在设计时就考虑到要能在不同的物理设备间通用。

以上两个例子可视为同一类硬件的两种设计方式。在这种情况下,你的选择无非是两者取其一:要么抽象硬件,要么围绕某个行为模型组织代码。

许多不同的情形下都会出现类似的选择。设想你在编写 MIDI 音序器软件,可以围绕最顶层(音轨)或最底层(采样或驱动波形发生器)组织代码。

有一个具体的方法可以考量二者的差异,那就是问问设计是围绕主事件循环组织,还是围绕主循环可能调用的所有操作的服务库组织代码。自顶向下的设计者通常先考虑程序的主事件循环,以后才插入具体的事件。自底向下的设计者通常先考虑封装具体的任务,以后再按某种次序把这些东西粘合在一起。

从哪端开始设计相当重要,因为对端的层次很可能收到最初选择的限制。尤其是,如果程序完全自顶向下设计,你可能发现自己陷入非常不舒服的境地,应用逻辑所需要的域原语和真正能实现的域原语无法匹配。另一方面,如果程序完全自底向上设计,很可能发现自己做了很多与应用逻辑无关的工作——或者,就像你想要造房子,却仅仅设计了一堆砖头。

程序员尽量双管齐下——一方面以自顶向下的应用逻辑表达抽象规范,另一方面以函数或库来收集底层的域原语,这样当高层设计变化时,这些域原语仍然可以重用。

因此实际代码往往是自顶向下和自底向上的综合产物。同一个项目经常同时兼有自顶向下的代码和自底向上的代码。这就导致了“胶合层”的出现。

4.3.2 胶合层

当自顶向下和自底向上发生冲突时,其结果往往是一团糟。顶层的应用逻辑和底层的域原语集必须用胶合逻辑层来进行阻抗匹配。

Unix 程序员几十年的教训之一就是:胶合层是个讨厌的东西,必须尽可能薄,这一点极为重要。胶合层用来将东西粘在一起,但不应该用来隐藏各层的裂痕和不平整。

在网页浏览器这个例子中,胶合层包括渲染代码,它使用 GUI 域原语将从网络发送过来的 HTML 中解析出的文档对象绘制成平面的可视化表达。渲染代码作为浏览器中最易产生 bug 的地方而臭名昭著。它的存在,是为了解决 HTML 解析和 GUI 工具包中存在的问题。

网页浏览器中的胶合层不仅要协调内部规范和域语言集,而且还要协调不同的外部规范:HTTP 标准化的网络行为、HTML 文档结构、各种图形和多媒体格式以及用户对 GUI 的行为预期。

薄胶合层原则可以看作是分离原则的升华。策略应该与机制清晰的分离。如果有许多代码既不属于策略又不属于机制,就可能除了增加系统的整体复杂度,没有任何其他用户。

4.3.3 实例分析:被视为薄胶合层的 C 语言

4.4 程序库

Unix 编程风格强调模块性和定义良好的 API, 它所产生的影响之一就是:强烈倾向于把程序分解成由胶合层链接的库集合

4.5 Unix 和面向对象语言

1980年代中期起,大多数新的语言设计都已自带了对面向对象编程的支持。回想一下,在面向对象的编程中,作用于具体数据结构的函数和数据一起被封装在可视为单元的一个对象中。相反,非 OO 语言中的模块使数据和作用于该数据的函数的联系变得相当无规律,而且模块间还经常互相泄漏数据或内部细节。

OO 设计理念的价值最初在图形系统、图形用户界面和某些仿真程序中被认可。使大家惊讶并逐渐失望的是,很难发现OO设计在这些领域以外还有多少显著优点。其中原因值得我们去探究一番。

在 Unix 的模块化传统和围绕OO语言发展起来的使用模式之间,存在着某些紧张对立的关系。Unix 程序员一直比其他程序员对 OO 更持怀疑态度,原因之一就源于多样性原则。OO 经常被过分推祟为解决软件复杂性问题的唯一正确办法。

OO 语言使抽象变得很容易一一也许是太容易了。OO语言鼓励“具有厚重的胶合和复杂层次“的体系。当问题域真的很复杂、确实需要大量抽象时,这可能是好事,但如果编码员到头来用复杂的办法来做简单的事情一一仅仅是为他们能够这样做,结果便适得其反。

4.6 模块式编码

模块化体现在良好的代码中,但首先来自良好的设计。在编写代码时,问问自己以下问题,可能会有助于提高代码的模块性:

  • 有多少全局变量?全局变量对模块化是毒药,很容易使各模块轻率、混乱地互相泄露信息。
  • 单个模块的大小是否在最佳范围内?如果不是,很多都超过的话,就可能产生长期的维护问题。
  • 模块内的单个函数是不是太大了?
  • 代码是不是有内部 API
  • API 的入口点是不是超过七个?有没有哪个类有七个以上的方法?数据结构的成员是不是超过七个?
  • 整个项目中每个模块的入口点如何分布?是不是不均匀?模块复杂性往往和入口点数量的平方成正比——这也是简单 API 优于复杂 API 的另一个原因