软件设计有两种方式:一种是设计得极为简洁,没有看得到的缺陷;另一种是设计得极为复杂,有缺陷也看不出来。第一种方式的难度要大得多。
—— 《皇帝的旧衣》 C.A.R.Hoare

模块化原则在这里展开来说就是:要编写复杂软件又不至于一败涂地的唯一方法,就是用定义清晰的接口把若干简单模块组合起来,如此一来,多数问题只会出现在局部,那么还有希望对局部进行改进或优化,而不至于牵动全身。

#4.1 封装和最佳模块大小

模块化代码的首要特质就是封装。封装良好的模块不会过多向外部披露自身的细节,不会直接调用其它模块的实现码,也不会胡乱共享全局数据。

模块之间通过应用程序编程接口(API),一组严密、定义良好的程序调用和数据结构来通信。这就是模块化原则的内容。

API 在模块间扮演双重角色。在实现层面,作为模块之间的滞塞点(choke point),阻止各自的内部细节被相邻模块知晓;在设计层面,正是 API(而不是模块间的实现代码)真正定义了整个体系。

  • 一种很好的方式来验证 API 是否设计良好:如果试着用纯人类语言描述设计(不许摘录任何源代码),能否把事情说清楚?

模块分解得越彻底,每一块就越小,API 的定义也就越重要。全局复杂度和受 Bug 影响的程度也会相应降低。但模块过小时,几乎所有的复杂度都在接口,这就导致想要理解任何一部分代码前必须理解全部代码,因此阅读代码非常困难。200 到 400 之间逻辑行的代码是“最佳点”。

#4.2 紧凑性和正交性

#4.2.1 紧凑性

紧凑性就是一个设计是否能装进人脑中的特性。测试软件紧凑性的一个很实用的好方法是:有经验的用户通常需要操作手册吗?如果不需要,那么这个设计(或者至少这个设计的涵盖正常用途的子集)就是紧凑的。

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

  • 紧凑不等于“薄弱”:如果一个设计构建在易于理解且利于组合的抽象概念上,则这个系统能在具有非常强大、灵活的功能的同时保持紧凑。
  • 紧凑也不等同于“容易学习”:对于某些紧凑设计而言,在掌握其精妙的内在基础概念模型之前,要理解这个设计相当困难;但一旦理解了这个概念模型,整个视角就会改变,紧凑的奥妙也就十分简单了。
  • 紧凑也不意味着“小巧”。即使一个设计良好的系统,对有经验的用户来说没什么特异之处、“一眼”就能看懂,但仍然可能包含很多部分。

有时,为了其他优势,如纯性能和适应范围等,也有必要牺牲紧凑性。

把紧凑性作为有点来强调,并不是要求大家把紧凑性看作一个绝对要求。

#4.2.2 正交性

正交性是有助于使复杂设计也能紧凑的最重要特性之一。在纯粹的正交设计中,任何操作均无副作用:每一个动作只改变一件事,不会影响其它。

无论你控制的是什么系统,改变每个属性的方法有且只有一个。

“只做好一件事” 的忠告不仅仅是针对简单性的建议,同时也强调了正交性。

#4.2.3 SPOT 原则

《程序员修炼之道》(The Pragmatic Programmer) 针对一类特别重要的正交性明确提出了一条原则——“不要重复自身(Don’t Repeat Yourself")”。这个原则也可称为 真理的单点性(Single Point of Truth) 或者 SPOT 原则。

重复会导致前后矛盾、产生隐微问题的代码,原因是当你修改重复点时,往往只改变了一部分而非全部。

数据结构也存在类似的 SPOT 原则:“无垃圾,无混淆”(No junk, no confusion)

  • “无垃圾”是说数据结构(模型)应该最小化,比如,不要让数据结构太通用,甚至表示不可能存在的情况。
  • “无混淆”是指在真实世界中绝对明确清晰的状态在模型中也同样明确清晰。

#4.2.4 紧凑性和强单一中心

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

这是 Unix 传统中常常被忽视的一个优点。其实,Unix 许多非常有效的工具都是围绕某个单一强大算法直接转换的一个瘦包装器(thin wrapper)。如 diffgrepyacc 三个程序都极少出 bug,大家认为它们绝对理所当然地应该正确运行,而且它们也非常紧凑,程序员用起来得心应手。这些良好性能只有一部分归功于长期服务和频繁使用所产生的改进,绝大部分还是因为建立在强大且被证明为正确的算法核心上,它们从一开始就无需多少改进。

与形式法相对的是 试探法——凭经验法则得出的解决方案,在概率上可能正确,但不一定总是正确。有时我们使用试探法是因为不可能找到绝对正确的解决方案。例如垃圾邮件过滤:一个算法上完美的垃圾邮件过滤器需要完全解决自然语言的理解问题。

试探法的问题在于这种方案会增生出大量特例和边界情况。

#4.2.5 分离的价值

要达到这种简洁性,尽量不要去想一种语言或操作系统最多能做多少事情,而是尽量去想这种语言或操作系统最少能做的事情,不是带着假想行动,而是从零开始。

#4.3 软件是多层的

#4.3.1 自顶向下和自底向上

一个方向是自底向上,从具体到抽象 —— 从问题域中你确定要进行的具体操作开始,向上进行。例如,如果为一个磁盘驱动器设计固件,一些底层的原语可能包括“磁头移至物理块”、“读物理块”、“写物理块”、“开关驱动器 LED”等。

另一个方向是自顶向下,从抽象到具体——从描述逻辑开始,向下进行,直到各个具体操作。例如要为一个能处理不同介质的大容量存储控制器设计软件,可以从抽象的操作开始,如“移到逻辑块”、“读逻辑块”、“写逻辑块”、“开关状态指示”等。

编程新手往往被教导以 “正确的方法是自顶向下”:逐步求精,在拥有具体的工作码前,先在抽象层面上规定程序要做些什么,然后用实现代码逐步填充。但如果纯粹地自顶向下编程,常常产生在某些代码上的过度投资效应,这些代码因为接口没有通过实际检验而必须废弃或重做。

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

#4.3.2 胶合层

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

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

#4.3.3 被视为薄胶合层的 C 语言

完美之道,不在无可增加,而在无可删减

#4.4 程序库

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

这捎带引起了一个小问题。在 Unix 世界里,作为“程序库”发布的库必须携带练习程序(exerciser program)。

除了学习起来更容易外,库的练习程序常常可以作为优秀的测试框架。因此,有经验的 Unix 程序员并不仅仪把这些练习程序看作是为库使用者提供便利,也会认为代码应已经过很好的测试。

#4.5 Unix 和面向对象语言

过多的层次破坏了透明性:我们很难看清这些层次,无法在头脑中理清代码到底是怎样运行的。简洁、清晰和透明原则统统被破坏了,结果代码中充满了晦涩的 bug,始终存在维护问题。

#4.6 模块式编码

在编写代码时,问问自己以下这些问题,可能会有助于提高代码的模块性:

  • 有多少全局变量?全局变量对模块化是毒药,很容易使各模块轻率、混乱地互相泄漏信息。

  • 单个模块的大小是否在 “最佳范围”内?如果回答是“不,很多都超过”的话,就可能产生长期的维护问题。

  • 模块内的单个函数是不是太大了?与其说这是一个行数计算问题,还不如说是一个内部复杂性问题。如果不能用一句话来简单描述一个函数与其调用程序之间的约定,这个函数可能太大了。

就我个人而言,如果局部变量太多,我倾向于拆分子程序。另一个办法是看代码行是否存在(太多)缩进。我几乎从来不看代码长度。
—— Ken Thompson

  • 是否 每个 API 不受其它代码的影响?好的 API 应是意义清楚,不用看具体如何实现就能够理解的。对此有一个经典的测试方法:通过电话向另一个程序员描述。如果说不清楚,API 很可能就是太复杂,设计太糟糕了。

  • API 的入口点是不是超过七个?有没有哪个类有七个以上的方法?数据结构的成员是不是超过七个?

  • 整个项目中每个模块的入口点数量如何分布?是不是不均匀?有很多入口点的模块真的需要这么多入口点吗?模块复杂性往往和入口点数量的平方成正比。