《UNIX 编程艺术》Chapter4 读书笔记



模块性:保持清晰,保持简洁

There are two ways of constructing a software design. One is to make it so simple that there are obviously no deficiencies; the other is to make it so complicated that there are no obvious deficiencies. The first method is far more difficult.

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

《皇帝的旧衣》,CACM 1981 年 2 月, By C. A. R. Hoare

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

封装和最佳模块大小

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

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

有一个很好的方式来验证 API 是否设计良好:如果试着用纯人类语言描述设计(不许摘录任何源代码),能否把事情说清楚?养成在编码前为 API 编写一段非正式描述的习惯,是一个非常好的办法。实际上,一些最有能力的开发者,一开始总是定义接口,然后编写简要注释,对其进行描述,最后才编写代码——因为编写注释的过程就阐明了代码必须达到的目的。这种描述能够帮助你组织思路,本身就是十分有用的模块说明,而且,最终你可能还想把这些说明做成路标文档(roadmap document),方便以后的人阅读代码。

模块分解的越彻底,每一块就越小,API 的定义也就越重要。全局复杂度和受 bug 影响的程度也会相应降低。然而,也可能因过度划分造成模块太小。在模块很小时,bug 发生率也出乎意料地增多,这在大量以不同语言实现的各种系统中均是如此。因此,Hatton 的经验数据表明,假设其他所有因素(如程序员的能力)都相同,200 到 400 之间的逻辑行的代码是“最佳点”,可能的缺陷密度达到最小。这个大小与所使用的语言无关 —— 这个结论有力支持了书中的建议,即尽可能用最强大的语言和工具编程。根据经验,Hatton 建议逻辑行与物理行之间为两倍的折算率,即最佳物理行数建议应在 400 至 800 行之间。

紧凑性和正交性

具有最佳尺寸的模块并不意味着代码有高质量。因此,在设计 API、命令集、协议以及其它让计算机工作的方法时,还要考虑另外两个特性:紧凑性和正交性。

紧凑性

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

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

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

《魔数七,加二或减二:人类信息处理能力的局限性》是认知心理学的基础性文章之一。这篇文章表明,人类短期记忆能够容纳的不连续信息数就是七,加二或减二。这给了我们一个评测 API 紧凑性的很好的经验法则:编程者需要记忆的条目数大于七吗?如果大于七,则这个 API 不太可能算是严格紧凑的。

然而,不紧凑的设计也未必注定会灭亡或很糟糕。有些问题域简直是太复杂了,一个紧凑的设计不可能有如此跨度。有时,为了其它优势,如纯性能和适用范围等,也有必要牺牲紧凑性。troff 标记就是一个很好的例子,BSD 套接字 API 也是如此。把紧凑性作为优点来强调,并不是要求大家把紧凑性看作一个绝对要求,而是要像 Unix 程序员那样:合理对待紧凑性,设计中尽量考虑,绝不随意抛弃

正交性

正交性是有助于使复杂设计也能紧凑的最重要特性之一。在纯粹的正交设计中,任何操作均无副作用;每一个动作(无论是 API 调用、宏调用还是语言运算)只改变一件事,不会影响其它。无论你控制的是什么系统,改变每个属性的方法有且只有一个。显示器就是正交控制的,相应地,非正交的软件设计不胜枚举。

人们通常认为 Doug McIlroy “只做好一件事”的忠告是针对简单性的建议。但是,这句话也暗含了对正交性至少同等程度的强调。

如果一个程序做好一件事之外,顺带还做其它事情的时候既不增加系统的复杂性也不会使系统更易产生 bug,就没什么问题。我们将在第 9 章检视一个名为 ascii 的程序,这个程序能打印 ASCII 字符的同名符,包括十六进制值、八进制值和二进制值;其副作用是可以对 0——255 范围内的数字进行快速进制转换。这第二个作用并不违反正交性,因为所有支持该用途的特性全部是主功能所必需的,而且这样也没有增加程序文档化或维护的难度。

如果副作用扰乱了程序员或用户的思维模式,带来种种不便甚至可怕的结果,这就是出现了非正交性问题。尤其在没有忘记这些副作用时,你总要被迫做额外工作来抑制或修正它们。

《程序员修炼之道》一书中对正交性以及如何达到正交性有精彩的讨论。正如该书所指出的,正交性缩短了测试和开发的时间,因为那种既不产生副作用也不依赖其它副作用的代码,校验起来要容易得多 —— 需要测试的情况组合要少得多。如果正交性代码出现问题,把它替换掉而不影响系统其余部分也很容易做到。最后,正交性代码更容易文档化和复用。

重构(refactoring)概念是作为“极限编程(Extreme Programming)”学派的一个明确思想首次出现的,跟正交性紧密相关。重构代码就是改变代码的结构和组织,而不改变其外在行为

在这一概念的奠基性著作《重构:改善既有代码的设计》一书中,作者差一点就道出了“重构的原则性目标就是提高正交性”的天机。但是由于缺少这个概念,他只能从几个不同的方向接近这个思想:比如消除重复代码和各种“坏味道”,大部分就是指一些违背正交性的做法。

Unix 的基本 API 设计在正交性方面虽不完美,但也颇为成功。就整体而言,Unix API 是一个很好的例子:否则,将不仅不会、也不可能这么广泛地被其它操作系统上的 C 库效仿。所以,即便不是Unix 程序员,Unix API 也值得学习(TODO),因为从中可以学到一些关于正交性的东西。

SPOT 原则

《程序员修炼之道》针对一类特别重要的正交性明确提出了一条原则——“不要重复自身(Don’t Repeat Yourself)”,意思是说:任何一个知识点在系统内部都应当有唯一、明确、权威的表述。在本书中,我们更愿意根据 Brian kernighan 的建议,把这个原则称为“真理的单点性(Single Point of Truth)”或者 SPOT 原则。

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

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

通常,可以通过重构去除重复代码;也就是说,更改代码的组织而不更改核心算法。有时重复数据好像无法避免,但碰到这种情况时,下面问题值得你思考:

  • 如果代码中含有重复数据是因为在两个不同的地方必须使用两个不同的表现形式,能否写个函数、工具或代码生成程序,让其中一个由另一个生成,或两者都来自同一个来源?
  • 如果文档重复了代码中的知识点,能否从部分代码中生成部分文档,或者反之,或者两者都来自同一个更高级的表现形式?
  • 如果头文件和接口声明重复了实现代码中的知识点,是否可以找到一种方法,从代码中生成头文件和接口声明?

数据结构也存在类似的 SPOT 原则:“无垃圾,无混淆”(No junk, no confusion)。“无垃圾”是说数据结构(模型)应该最小化,比如,不要让数据结构太通用,居然还能表示不可能存在的情况。“无混淆”是指在真实世界中绝对明确清晰的状态,在模型中也应该同样明确清晰。简言之,SPOT 原则就是提倡寻找一种数据结构,使得模型中的状态跟真实世界系统的状态一一对应。

更深入 Unix 传统一步,我们可以从 SPOT 原则得出以下结论:

  • 是不是因为缓存了某个计算或者查找的中间结果而复制了数据?仔细考虑一下,这是不是一种过早优化;陈旧的缓存(以及保持缓存同步所必需的代码层)是滋生 bug 的温床,而且如果(实际经常是)缓存管理的开销比预想的要高,甚至可能降低整体的性能。
  • 如果有大量重复的样板代码,是不是可以用单一的高层表现形式生成这些代码、然后通过提供不同的细调选项生成不同个例呢?

在 Unix 世界中,SPOT 原则作为一个统一性理念很少被明确提出过——但是 Unix 传统中 SPOT 原则在各种形式的代码生成器中充分体现。

紧凑性和强单一中心

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

而后列举了 Unix 中四个包装器的例子:diff(一个用于报告相关文件不同之处的工具)、grep(通过模式匹配从文件中挑选文本行)、yacc(用于生成语法解析器,围绕 LR(1) 语法形式理论)和 lex(词法分析器,围绕不确定有限状态自动机)。

形式法 相对的是 试探法 —— 凭经验法则得出的解决方案,在概率上可能正确,但不一定总是正确。有时我们使用试探法是因为不可能找到绝对正确的解决方案;其他一些时候,是因为所有已知的形式上正确的方法开销极大。

分离的价值

“……限制不仅提倡了经济性,而且某种程度上提倡了设计的优雅。”

要达到紧凑、正交的设计,就从零开始。禅教导我们:依附导致痛苦;软件设计的经验教导我们:依附于被人忽略的假定将导致非正交、不紧凑的设计,项目不是失败就是成为维护的梦魇。

禅授超然,可以得教化、去苦痛。Unix 传统也从产生设计问题的特定、偶然的情形讲授分离的价值。抽象、简化、归纳。因为我们编制软件是为了解决问题,所以我们不可能完全超然于问题之外 —— 但是值得费点心思,看看可以抛弃多少先入之见,看看这样做能不能使设计变得更紧凑、更正交。这样做下来,代码复用经常由此变为可能。

软件是多层的

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

自顶向下和自底向上

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

另一个方向是自顶向下,从抽象到具体——从最高层面描述整个项目的规格说明或应用逻辑开始,向下进行,直到各个具体操作。这样,如果要为一个能处理不同介质的大容量存储控制器设计软件,可以从抽象的操作开始,如“移动逻辑块”、“读逻辑块”、“写逻辑块”、“开关状态指示”等。这和以上命名方式类似的硬件层操作的不同之处在于,这些操作在设计时就考虑到要能在不同的物理设备间通用。

以上这两个例子可视为同一类硬件的两种设计方式。在这种情况下,你的选择无非是两者取其一:要么抽象化硬件(这样,对象封装了实际事物,程序只不过是针对这些事物的操控动作列表),要么围绕某个行为模型组织代码(然后在行为逻辑流中嵌入实际执行的硬件操控动作)。

编程新手往往被教导以“正确的方法是自顶向下”:逐步求精,在拥有具体的工作码前,先在抽象层面上规定程序要做些什么,然后用实现代码逐步填充。当以下三个条件都成立时,自顶向下不失为好方法:(a)能够精确预知程序的任务,(b)在实现过程中,程序规格不会发生重大变化,(c)在底层,有充分自由来选择程序完成任务的方式。

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

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

胶合层

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

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

薄胶合层原则可以看作是分离原则的升华。策略(应用逻辑)应该与机制(域原语集)清晰地分离。如果有许多代码既不属于策略又不属于机制,就很有可能除了增加系统的整体复杂度之外,没有任何其它用处。

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

C 语言本身就是一个体现薄粘合层有效性的良好例子。

完美之道,不在无可增加,而在无可删减。 —— 安东尼.德.圣埃克苏佩里(法国作家、冒险家、艺术家、航空工程师)

Ritchie 和 Thompson 坚信该格言。即便当早期 Unix 软件所受的种种资源限制得到缓解之后很久,他们仍努力使 C 语言成为尽可能薄的“硬件之上的胶合层”。

程序库

Unix 编程风格强调模块性和定义良好的 API,它所产生的影响之一就是:强烈倾向于把程序分解成由胶合层连接的库集合,特别是共享库(在 Windows 和其它操作系统下叫做“动态连接库” DLL)。

库分层的一个重要的形式是插件,即拥有一套已知入口、可在启动以后动态从入口处载入来执行特定任务的库。这种模式必须将调用程序作为文档详备的服务库组织起来,以使得插件可以回调。

Unix 和面向对象语言

前面我们提到,Unix 的模块化传统就是薄胶合层原则,也就是说,硬件和程序顶层对象之间的抽象层越少越好(这部分是因为 C 语言的影响)。

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

所有的 OO 语言都显示出某种使程序员陷入过度分层陷阱的倾向。对象框架和对象浏览器并不能代替良好的设计和文档,但却常常被混为一谈。过多的层次破坏了透明性:我们很难看清这些层次,无法在头脑中理清代码到底是怎样运行的。简洁、清晰和透明原则通通被破坏了,结果代码中充满了晦涩的 bug,始终存在维护问题。

Unix 风格程序设计所面临的主要挑战就是如何将分离法的优点(将问题从原始的场景中简化、归纳)同代码和设计的薄胶合、浅平透层次结构的优点相结合。

模块式编码

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

  • 有多少全局变量?全局变量对模块化是毒药,很容易使各个模块轻率、混乱地相互泄漏信息。全局变量同时也意味着代码不能重入;也就是说,同一进程的多个实例可能彼此干涉。
  • 单个模块的大小是否在 Hatton 的“最佳范围”内?如果回答是“不,很多都超过”的话,就可能产生长期的维护问题。知道自己的“最佳范围”是多少吗?知道与你合作的其他程序员的“最佳范围”是多少吗?如果不知道,最好保守点儿,坚持 Hatton 最佳范围的下限。
  • 模块内的单个函数是不是太大了?与其说这是一个行数计算问题,还不如说是一个内部复杂性问题。如果不能用一句话来简单描述一个函数与其调用程序之间的约定,这个函数可能太大了。

很多年前,我从 Kernighan 和 Plauger 的《编程风格的元素》一书中学到一个非常有用的原则,就是在函数原型之后立即写一行注释。每个函数都这样,绝无例外。

  • 代码是不是有内部 API——即可作为单元向其他人描述的函数调用集和数据结构集,并且每一个单元都封装了某一层次的函数,不受其它代码影响?好的 API 应是意义清楚,不用看具体如何实现就能够理解的。对此有一个经典的测试方法:通过电话向另一个程序员描述。如果说不清楚,API 很可能就是太复杂了,设计太糟糕了。
  • API 的入口点是不是超过了七个?有没有哪个类有七个以上的方法?数据结构的成员是不是超过七个?
  • 整个项目中每个模块的入口点数量如何分布?是不是不均匀?有很多入口点的模块真的需要这么多入口点吗?模块复杂性往往和入口点数量的平方成正比——这也是简单 API 优于复杂 API 的另一个原因。

你可能会发现,如果把以上这些问题和第六章关于透明性和可见性问题的清单加以比较,将颇有启发性。

版权声明

本作品采用知识共享署名 4.0 国际许可协议进行许可,转载时请注明原文链接。