购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

4 模块性:保持清晰,保持简洁
Modularity: Keeping It Clean,Keeping It Simple

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.

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

The Emperor’s Old Clothes, CACM February 1981
《皇帝的旧衣》,CACM 1981 年 2 月
—C.A.R.Hoare

代码划分的方法有一个自然的层次体系,随着程序员必须面对的复杂度日益增加,这个体系也在演变中。一开始,一切都是一大块机器码。最早的过程语言首先引入了用子程序划分代码的概念。后来,我们发明了服务程序库,在多个程序间共享公用函数。再后来,我们发明了独立地址空间和可以相互通信的进程。今天,我们习以为常地把程序系统分布在通过成千上万英里的网络电缆连接的多台主机上。

Unix的早期开发者也是软件模块化的先锋。在他们之前,模块化原则只是计算机科学的理论,还不是工程实践。在研究工程设计中模块经济性的《设计原理》( Design Rule s)[Baldwin-Clark]这本探路性质的著作中,作者以计算机行业的发展为研究案例,并认为,相对硬件而言,Unix社区实际上第一个将模块分解法系统地应用到了生产软件中。毫无疑问,自从 19 世纪晚期人们采用标准螺纹以来,硬件的模块性就一直是工程技术的基石之一。

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

相对其他程序员而言,Unix程序员骨子里的传统是:更加笃信重视模块化、更注重正交性和紧凑性等问题。

早期的Unix程序员擅长模块化是因为他们被迫如此。操作系统就是一堆最复杂的代码。如果没有良好的架构,操作系统就会崩溃。在人们早期开发Unix时就犯过几次这种错,代码不得不全数报废。虽然大家可以把这些怪罪于早期的(非结构化)C语言,但主要还是因为操作系统太复杂,太难编写。所以,我们既需要改进工具(如C语言的结构化),也需要养成使用工具的好习惯(如Rob Pike提出的编程原理),这样才能应对这种复杂性。

—Ken Thompson

早期的Unix黑客为此在很多方面进行了艰苦的努力。1970 年的时候,函数调用开销昂贵,不是因为调用语句太复杂(PL/1.Algo),就是因为编译器牺牲了调用时间来优化其它因素,如快速内层循环(fast inner loops)。这样,代码往往就写成一大块。Ken和其他早期Unix开发者知道模块化是个好东西,但是他们记得PL/1 的经验,不愿意编写小函数,怕影响性能。

Dennis Ritchie告诉所有人C中的函数调用开销真的很小很小,极力倡导模块化。于是人人都开始编写小函数,搞模块化。然而几年后,我们发现在PDP-11中函数调用开销仍然昂贵,而VAX代码往往在“CALLS”指令上花费掉 50%的运行时间。Dennis对我们撒了谎!但为时已晚,我们已经欲罢不能……

—Steve Johnson

今天所有的编程者,无论是不是Unix下的程序员,都被教导要在程序的子程序层上进行模块化。有些人学会了在模块或抽象数据类型层上玩这一手,并称之为“良好的设计”。设计模式运动正在进行一项宏伟的努力,希望更进一步,找到成功的设计抽象原则,以组织大规模程序的结构。

将这些问题作一个更好的划分是一个有价值的目标,而且到处都可以找到有关模块划分的优秀方法。我们不期望太深入地涵盖与程序模块化相关的所有问题:首先,因为该论题本身就足够写整整一本(或好几本)书;其次,因为这是一本关于 Unix 编程艺术的书。

我们在此会更详细地分析Unix传统是如何教导我们遵循模块化原则的。本章中的例子仅限于进程单元内。我们将在第 7 章分析其它一些情形,那里,程序划分为几个协作进程是个不错的想法,我们还将讨论实现这种划分所采用的具体技术。

4.1 封装和最佳模块大小

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

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

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

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

然而,也可能因过度划分造成模块太小。证据[hatton97]如下:绘制一张缺陷密度和模块大小关系图,发现曲线呈U形,凹面向上(见图 4.1)。跟中间大小的模块相比,模块过大或者过小都和更多的bug相关联。另一个观察这些同样数据的方法是,绘制每个模块的代码行数和bug的关系曲线图。曲线看上去大致成对数上升至平坦的“最佳点”(对应缺陷密度曲线中的最小值),然后按代码行数的平方上升(这正是人们根据Brook定律对整个曲线的直观预期)。

图 4.1 缺陷数量和缺陷密度与模块大小的定性曲线图

在模块很小时,bug发生率也出乎意料地增多,这在大量以不同语言实现的各种系统中均是如此。Hatton曾经提出过一个模型,将这种非线性同人类短期记忆的记忆块大小相比较 。这种非线性的另一种解释是,模块小时,几乎所有复杂度都在于接口;想要理解任何一部分代码前必须理解全部代码,因此阅读代码非常困难。我们将在第 7 章讨论程序划分的更高级形式;在那里,当组件进程规模更小以后,接口协议的复杂度也就决定了系统的整体复杂度。

用非数学术语来说,Hatton的经验数据表明,假设其它所有因素(如程序员能力)都相同,200 到 400 之间逻辑行的代码是“最佳点”,可能的缺陷密度达到最小。这个大小与所使用的语言无关——这个结论有力支持了本书中其它地方提出的建议,即尽可能用最强大的语言和工具编程。当然,不能完全照搬这些具体数字。根据分析人员对逻辑行的理解以及其它偏好(比如注释是否剔除)的不同,代码行的统计方法会有较大差别。根据经验,Hatton建议逻辑行与物理行之间为两倍的折算率,即最佳物理行数建议应在 400 至 800 行之间。

4.2 紧凑性和正交性

具有最佳尺寸的模块并不意味着代码有高质量。由于受到同样的人类认知限制,语言和API(如程序库集和系统调用)也会产生Hatton U形曲线。

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

4.2.1 紧凑性

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

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

紧凑不等于“薄弱”。如果一个设计构建在易于理解且利于组合的抽象概念上,则这个系统能在具有非常强大、灵活的功能的同时保持紧凑。紧凑也不等同于“容易学习”:对于某些紧凑设计而言,在掌握其精妙的内在基础概念模型之前,要理解这个设计相当困难;但一旦理解了这个概念模型,整个视角就会改变, 紧凑 的奥妙也就十分简单了。对很多人来说,Lisp语言就是这样一个经典的例子。

紧凑也不意味着“小巧”。即使一个设计良好的系统,对有经验的用户来说没什么特异之处、“一眼”就能看懂,但仍然可能包含很多部分。

—Ken Arnold

极少有绝对意义上紧凑的软件设计,不过从宽松一些的意义上,许多软件设计还是相对紧凑的。他们有一个紧凑的工作集:一个功能子集,能够满足专家用户 80%以上的一般需求。实际上,这类设计通常只需要一个参考卡(reference card)或备忘单(cheat sheet),而不是一本手册。相对严格紧凑性而言,我们将此类设计称为 “半紧凑型”

也许最好还是用例子来阐明这个概念。Unix系统调用API是半紧凑的,而C标准程序库无论如何都算不上是紧凑的。Unix程序员很容易记住满足大多数应用编程(文件系统操作、信号和进程控制)的系统调用子集,但现代Unix上的C标准库却包括成百上千个条目,如数学函数等,一个程序员不可能把所有这些都记在脑中。

《魔数七,加二或减二:人类信息处理能力的局限性》( The Magical Number Seven, Plus or Minus Two: Some Limits on Our Capacity for Processing Information [Miller])是认知心理学的基础性文章之一(顺带一句,这也正是美国本地电话号码只有七位的原因)。这篇文章表明,人类短期记忆能够容纳的不连续信息数就是七,加二或减二。这给了我们一个评测API紧凑性的很好的经验法则:编程者需要记忆的条目数大于七吗?如果大于七,则这个API不太可能算是严格紧凑的。

在Unix工具软件中, make (1)是紧凑的; autoconf (1)和 automake (1)则不是。在标记语言中,HTML是半紧凑的,DocBook(我们将在第 18 章讨论这个文件标记语言)则不是。 man (7)宏是紧凑的, troff (1)标记则不是。

在通用编程语言中,C和Python是半紧凑的;Perl,java,Emacs Lisp,和shell则不是(尤其是严格的shell编程,要求你必须知道其他六个工具,如 sed (1)和 awk (1)等)。C++是反紧凑性的——该语言的设计者已经承认,他根本不指望有哪个程序员能够完全理解C++。

有些不具备紧凑性的设计具有足够的内部功能冗余,结果程序员通过选择某个工作的语言子集就能够搞出能满足 80%普通任务的紧凑方言。比如,Perl就有这种伪紧凑性。此类设计存在一个固有的陷阱:当两个程序员试图就一个项目进行交流时,他们可能会发现,对工作子集的不同选择成了他们理解和修改代码的巨大障碍。

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

4.2.2 正交性

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

显示器就是正交控制的。你可以独立改变亮度而不影响对比度,而色彩平衡控制(如果有的话)也独立于前两个属性。想象一下,如果按亮度按钮会影响色彩平衡,这样的显示器调节起来会有多么困难:每次调节亮度之后还得调节色平衡进行补偿。更糟糕的是,如果对比度控制也影响色平衡,那么要改变对比度或色平衡同时保持另一个不变,你必须严格按照正确的方法同时调节两个旋钮。

非正交的软件设计不胜枚举。例如,代码中常见的一类设计错误出现在从某一(源)格式到另一(目标)格式进行数据读取和解析过程中。如果设计者想当然地认为源格式总是存储在某个磁盘文件中,那么他可能会编写一个打开和读取指定文件名的转换函数。但是,通常情况下,输入也完全有可能就是一个文件句柄。如果转换函数是正交设计的,例如,无需额外打开一个文件,那么以后当转换函数要处理来自标准输入、网络套接字或其它来源的数据流时,可能会省事一些。

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

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

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

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

重构 refactoring )概念是作为“极限编程(Extreme Programming)”学派的一个明确思想首次出现的,跟正交性紧密相关。重构代码就是改变代码的结构和组织,而不改变其外在行为。当然,自从软件领域诞生之日起,软件工程师就一直在从事这项工作,给这种做法命名并把重构的一套技术方法确定下来,则非常有效地帮助了人们集中思路。因为重构概念与Unix设计传统关注的核心问题非常契合,所以Unix开发者很快就吸收了这一术语和它的思想 [1]

Unix的基本API设计在正交性方面虽不完美,但也颇为成功。比如,我们理所当然地认为能够打开文件进行写入操作,而无需为此进行排他锁定。并不是所有的操作系统都如此优雅。老式(System III)的信号就不是正交的,因为信号接收的副作用是把信号处理器(signal handler)重置成缺省的“接收即崩溃”(die-on-receipt)。许多大幅修正也不是正交的,如BSD套接字API,还有一些更大的修正也不是正交的,如X window系统的绘图库。

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

4.2.3 SPOT原则

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

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

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

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

● 如果代码中含有重复数据是因为在两个不同的地方必须使用两个不同的表现形式,能否写个函数、工具或代码生成程序,让其中一个由另一个生成,或两者都来自同一个来源?

● 如果文档重复了代码中的知识点,能否从部分代码中生成部分文档,或者反之,或者两者都来自同一个更高级的表现形式?

● 如果头文件和接口声明重复了实现代码中的知识点,是否可以找到一种方法,从代码中生成头文件和接口声明?

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

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

● 是不是因为缓存了某个计算或查找的中间结果而复制了数据?仔细考虑一下,这是不是一种过早优化;陈旧的缓存(以及保持缓存同步所必需的代码层)是滋生bug的温床,而且如果(实际经常是)缓存管理的开销比预想的要高,甚至可能降低整体性能 [2]

● 如果有大量重复的样板代码,是不是可以用单一的更高层表现形式生成这些代码、然后通过提供不同的细调选项生成不同个例呢?

到此,读者应该能看出一个轮廓逐渐清晰的模式。

在Unix世界中,SPOT原则作为一个统一性理念很少被明确提出过——但是Unix传统中SPOT原则在各种形式的代码生成器中充分体现。我们将在第 9 章讨论这些技法。

4.2.4 紧凑性和强单一中心

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

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

—Doug McIlroy

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

最清楚的例子也许就是 diff (1)——一个Unix用于报告相关文件不同之处的工具。这个工具及其搭裆 patch (1)已经成为当代Unix网络分布式开发风格的核心。diff的可贵性之一在于它很少标新立异。它既没有特殊情况,也没有令人痛苦的边界条件,因为它使用一个简单、从数学上看很可靠的序列比较方法。这导致了以下结果:

由于采用了数学模型和可靠的算法,Unix diff和其仿效者形成鲜明的对比。首先,diff的核心引擎小巧可靠,没有一行代码需要维护。其次,结果清晰一致,不会出现试探法可能带来的意外。

—Doug McIlroy

这样,使用diff的人无需完全理解核心算法,就能对diff在任何给定条件下的行为形成一种直觉。在Unix中,其它通过强大核心算法达到这种特定清晰性的著名例子非常多:

● 通过模式匹配从文件中挑选文本行的 grep (1)实用程序是一个简单包装器,围绕正则表达式(regular-expression)模式的形式代数问题(参见 8.2.2 部分的讨论)。如果它没有这个一致的数学模型,它可能就会很像最古老的Unix中原始的 glob (1)设计,只是一堆无法组合在一起的专门通配符。

● 用于生成语法解析器的 yacc (1)实用程序是围绕LR(1)语法形式理论的瘦包装器。它的搭档——词法分析生成器 lex (1),则是围绕不确定有限态自动机的瘦包装器。

以上这三个程序都极少出bug,大家认为它们绝对理所当然地应该正确运行,而且它们也非常紧凑,程序员用起来得心应手。这些良好性能只有一部分归功于长期服务和频繁使用所产生的改进,绝大部分还是因为建立在强大且被证明为正确的算法核心上,它们从一开始就无需多少改进。

与形式法相对的是 试探法 ——凭经验法则得出的解决方案,在概率上可能正确,但不一定总是正确。有时我们使用试探法是因为不可能找到绝对正确的解决方案。例如,想一想垃圾邮件过滤:一个算法上完美的垃圾邮件过滤器需要完全解决自然语言的理解问题。其它一些时候,我们使用试探法是因为所有已知的形式上正确的方法开销都贵得难以想象。虚拟内存管理就是这样一个例子:虽然确实存在接近完美的解决方案,但是它们需要的运行时间太长,以至其相比试探法所能获得的任何理论上的收益优势完全被抵消掉了。

试探法的问题在于这种方案会增生出大量特例和边界情况。通常情况下,当试探法失效,如果没什么其它方法的话,你必须采用某种恢复机制作为后备。复杂度一增加,所有常见的问题都会随之而来。为了折衷,一开始就要小心使用试探法。始终要记着问一问,如果试探法以增加代码复杂性为代价,根据会获得的性能来判断一下是否值得这么做——不要猜想可能产生的性能差异,在做出决定前应该实际衡量一下。

4.2.5 分离的价值

本书开头,我们引用了禅的“教外别传,不立文字”。这不仅是为了追求风格上的异国情调,而是因为Unix的核心概念一向都有清瘦如禅般的简洁性,在围绕这些核心概念发生的历史事件中如影随形,熠熠生辉。这种特性也反映在Unix的基础性著作中,如《C程序设计语言》( C Programming Language )[Kernighan-Ritchiel]和向世人介绍Unix的 1974 年CACM论文。文中最常被人引用的一句话是这样的:“……限制不仅提倡了经济性,而且某种程度上提倡了设计的优雅”。要达到这种简洁性,尽量不要去想一种语言或操作系统最多能做多少事情,而是尽量去想这种语言或操作系统最少能做的事情——不是带着假想行动,而是从零开始(禅称为“初心”(beginner's mind)或者叫“虚心”(empty mind))。

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

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

关于Unix和禅的关系的笑话同样也是Unix传统中一个仍然鲜活的部分 。这绝非偶然。

4.3 软件是多层的

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

4.3.1 自顶向下和自底向上

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

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

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

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

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

如果要举一个更大的例子,可以考虑网页浏览器的设计。网页浏览器的顶层设计是对浏览器预期行为的规格说明:可以解析什么类型的URL(http:,ftp:还是file:),可以渲染哪些类型的图像,是否可以或者带哪些限制来支持Java或Javascript等等。与这个顶层意图相对应的实现层是浏览器的主事件循环;在每个周期内,这个循环等待、收集、分派用户的动作(例如点击网页链接或在某个域内键入字符)。

但是,网页浏览器要正常工作还必须调用大量域原语操作。其中一组跟建立连接、通过连接发送数据和接收响应有关。另一组则是浏览器将使用的GUI工具包操作。然而,可能还有第三组集合,即“将接收的HTML从文本转换为文档对象树”的解析机制。

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

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

这些条件容易在相对接近最终用户和软件设计的较上层——应用软件编程——中得到满足。但即便如此,这些前提也常常满足不了。在用户界面经过最终用户测试前,别指望能提前知道什么算是字处理软件或绘图程序的“正确”行为方式。如果纯粹地自顶向下编程,常常产生在某些代码上的过度投资效应,这些代码因为接口没有通过实际检验而必须废弃或重做。

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

Unix程序员继承了一个居于系统程序设计核心的传统,在这一传统中,底层的原语是硬件层操作,后者特性固定且极其重要。因此,出于后天学得的本能,Unix程序员更倾向于自底向上的编程方式。

无论是否是系统程序员,当你用一种探索的方式编程,想尽量领会你还没有完全理解的软件、硬件抑或真实世界的现象时,自底向上法看起来也会更有吸引力。它给你时间和空间去细化含糊的规范,同时也迎合了程序员身上人类通有的懒惰天性——当必须丢弃和重建代码时,与之相比,如果用自顶向下的设计,需要抛弃的代码往往更多。

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

4.3.2 胶合层

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

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

在网页浏览器这个例子中,胶合层包括渲染代码(rendering code),它使用GUI域原语将从发过来的HTML中解析出的文档对象绘制成平面的可视化表达——即显示缓冲区中的位图。渲染代码作为浏览器中最易产生bug的地方而臭名昭著。它的存在,是为了解决HTML解析(因为形式不良的标记太多了)和GUI工具包(可能未必具有真正需要的原语)中存在的问题。

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

一个容易产生bug的胶合层还不是设计所能遇到的最坏命运。如果设计者意识到胶合层的存在,并试图围绕自身的一套数据结构或对象把胶合层组织成一个中间层,结果却导致出现 两个 胶合层——一个在中间层之上,另一个在中间层之下。那些天资聪慧但经验不足的程序员特别容易掉进这种陷阱;他们将每种类别(应用逻辑、中间层和域原语集)的基本集都做得很好,就像教科书上的例子一样漂亮,结果却因为整合这些漂亮代码所需的多个胶合层越来越厚,而最终在其中苦苦挣扎。

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

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

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

上个世纪九十年代后期,Gerrit Blaauw和Fred Brooks在《计算机体系:概念和演化》 Computer Architecture: Concepts and Evolution [BlaauwBrooks] 一书中提出,每一代计算机的体系结构,从早期的大型机到小型机、工作站再到PC,都在趋近同一种形式。技术年代越靠后,设计越接近Blaauw和Brooks所称的“经典体系”:二进制表示、平面地址空间、内存和运行期存储(寄存器)的区分、通用寄存器、定长字节的地址解析、双地址指令、高位字节优先 [3] 以及大小一致为 4 位或 6 位整数倍(6 位系列现在已经不存在了)的数据类型。

Thompson 和Ritchie将C语言设计成一种结构汇编程序,可为理想化的处理器和存储器体系服务,他们期望这种体系能有效建立在大多数普通计算机上。幸运的是,他们的理想化处理器模型机是PDP-11——一款设计非常成熟、优雅的小型机,非常接近Blaauw & Brook的经典体系。凭借敏锐的判断力,Thompson和Rithcie拒绝在其语言中加入PDP-11 不匹配的少数特性(比如低位优先字节序)中的绝大多数

PDP-11 成为接下来几代微处理器架构的重要模型。结果证明,C语言的基本抽象相当优美地反映出了经典体系。这样,C语言一开始就非常适合微处理器,而且随着硬件更紧密地向经典架构靠拢,C语言不仅没有随其假设的过时而失去价值,反而更加适合微处理器了。这种硬件向经典体系会聚的非常著名的例子就是:1985 年后Intel的 386机器用平面存储地址空间代替了 286 糟糕的分段内存寻址。跟 286 相比,纯C语言实际上 更适合 386。

计算机架构的实验性时代在二十世纪八十年代中期结束,同期,C语言(和近亲后代C++)作为通用程序设计语言所向无敌,两者在时间上并非巧合。C语言,作为经典体系之上一个薄而灵活的胶合层,在经过了 20 年后,现在看来似乎可以算是其定位的结构汇编程序中的最佳设计。除了紧凑、正交和分离(与最初设计时的机器架构分离),C语言还拥有我们将在第 6 章讨论的透明性这一重要特性。C语言之后的少数语言设计(是否比C语言更好还有待证明),为了不被C语言所吞并,不得不进行大的改动(比如引进垃圾收集功能等),以和C语言保持功能上的足够距离。

这段历史很值得回味和了解,因为C语言向我们展示了一个清晰、简洁的最简化设计能够多么强大。如果Thompson和Ritchie当初没有这么明智,他们设计的语言也许能完成更多任务,但要依赖更强的前提,永远都无法满意地从原始的硬件平台移植出去,也必将随着外部世界的改变而消亡。但相反的是,C语言一直生机勃勃——而Thompson和Ritchie所树立的榜样从此影响了Unix的开发风格。正如法国作家、冒险家、艺术家和航空工程师安东尼·德·圣埃克苏佩里(Antoine de Saint-Exupéry)在论飞机设计时所说的: La perfection est atteinte non quand il ne reste rien à ajouter, mais quand il ne reste rien à enlever (完美之道,不在无可增加,而在无可删减)。

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

以前每当我要求在C语言中加一些特别奢侈的功能时,Dennis就对我说,“如果你需要PL/1,你知道到哪里去找”。他不必和那些说着:“但我们需要在销售材料中加一个卖点”的销售人员打交道。

—Mike Lesk

在标准化之前最好先有个有效的参考实现,C语言的历史在这方面教了我们一课。我们将在第 17 章讨论C语言和Unix标准的发展时再谈这个话题。

4.4 程序库

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

如果谨慎而聪明地处理设计,那么常常可以将程序划分开来,一个是用户界面处理的主要部分(策略),另一个是服务例程的集合(机制),中间不带任何胶合层。当程序要进行图形图像、网络协议包、硬件接口控制块等多种数据结构的具体操作处理时,这种方法特别合适。《可复用库架构的守则和方法》( The Discipline and Method Architecture for Reusable Libraries )[Vo]一书中收集了Unix传统中关于体系的一些不错的通用性建议,尤其适合这种程序库的资源管理。

在Unix下,通常是清晰地划分出这种层次,并把服务程序集中在一个库中并单独文档化。在这样的程序中,前端专门解决用户界面和高层协议的问题。如果设计更仔细一些,可以将原始的前端分离出来,用适于不同用途的其它部件代替。通过实例研究,你还会发现其它一些优势。

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

API应该随程序一起提供,反之亦然。如果一个API必须要编写C语言代码来使用,考虑到C代码不能方便地从命令行调用,则这个API学习和使用起来就更困难。反之,如果接口唯一开放、文档化的形式是程序,而无法方便地从C程序中调用这些接口,也会非常痛苦——例如,老版本Linux中的 route (1)。

—Henry Spencer

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

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

4.4.1 实例分析:GIMP插件

GIMP(GNU图像处理程序,GNU Image Manipulation program)是一个由交互方式GUI驱动的图形图像编辑器。但是GIMP被做成了一个图像处理和辅助程序的库,由一个相对较薄的控制层代码调用。驱动码知道GUI,但不直接知道图像格式;反过来,程序库程序知道图像格式和图像操作,但不知道GUI。

这个库层次已经文档化了(而且,实际上已作为“libgimp”发布,供其它程序使用)。这意味着C程序写成的所谓“插件”可以由GIMP动态载入,然后调用该库进行图像处理,实际上掌握了和GUI同一级别的控制权(参见图 4.2)。

图 4.2 载入插件的GIMP中调用和被调用关系图

插件可用来完成多种专用转换,如色图调整(colormap hacking)、模糊和去斑;可用于读写非GIMP自带的文件格式;也可用于扩展功能,如编辑动画和窗口管理器主题;通过在GIMP内核中编写图像调整逻辑脚本,还可实现其他多种图像调整处理的自动化。万维网中有各种GIMP插件的注册中心。

虽然大多数GIMP插件都是小巧简单的C程序,但是也有可能编制一个插件让库API能被脚本语言调用。我们将在第 11 章分析“多价程序”模式时讨论这种可能性。

4.5 Unix和面向对象语言

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

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

在Unix的模块化传统和围绕OO语言发展起来的使用模式之间,存在着某些紧张对立的关系。Unix程序员一直比其他程序员对OO更持怀疑态度,原因之一就源于多样性原则。OO经常被过分推崇为解决软件复杂性问题的唯一正确办法。但是,还有其它一些原因,这些原因值得我们在第 14 章讨论具体OO(面向对象)语言之前作为背景问题加以探讨,这也将有助于我们对Unix的一些非OO编程风格特征有更深刻的认识。

前面我们提到,Unix的模块化传统就是薄胶合层原则,也就是说,硬件和程序顶层对象之间的抽象层越少越好。这部分是因为C语言的影响。在C语言中模仿真正的对象很费力。正因为这样,堆砌抽象层是一件非常累人的事。这样,C语言中的对象层次倾向于比较平坦和透明。即使Unix程序员使用其它语言,他们也愿意继续沿用Unix模型教给他们的薄胶合/浅分层风格。

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

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

可能正是因为许多编程课程都把厚重的软件分层作为实现表达原则的方法来教授,这种趋势还在恶化。根据这种观点,拥有很多类就等于在数据中嵌入了很多知识。问题在于,胶合层中的“智能数据”却经常不代表任何程序处理的自然实体——仅仅只是胶合物而已。(这种现象的一个确定标志就是抽象子类或混入(mix-in's)类的不断扩散。)

OO抽象的另一个副作用就是程序往往丧失了优化的机会。例如, a + a + a + a 可以用 a * 4 来表示,如果a是整数,也可以表示成 a << 2。但是如果构建了一个类并重新定义了操作符,就根本没什么东西可表明运算操作的交换律、分配律和结合律。既然不能查看对象内部,就不可能知道两个等价表达式中哪一个更有效。这本身并不是在新项目中避免使用OO技法的正当理由,那样只会导致过早优化。但这却是在把非OO代码转换为类层次之前需要三思而后行的原因。

Unix程序员往往对这些问题有本能的直觉。在Unix下,OO语言没能代替非OO的主力语言,如C、Perl(其实有OO功能,但用得不多)和shell等,这种直觉似乎也是原因之一。跟其它正统领域相比,Unix世界对OO语言的批判更直接了当;Unix程序员知道什么时候不该用OO;就算用OO,他们也尽可能保持对象设计的整洁清晰。正如《网络风格的元素》( The Elements of Networking Style )一书的作者在另一个略有不同的背景下所说的[Padlipshy]:“如果你知道自己在做什么,三层就足够了;但如果你不知道自己在做什么,十七层也没用。”

OO在其取得成功的领域(GUI、仿真和图形)之所以能成功,主要原因之一可能是因为在这些领域里很难弄错类型的本体问题。例如,在GUI和图形系统中,类和可操作的可见对象之间有相当自然的映射关系。如果你发现增加的类和所显示的对象没有明显对应关系,那么很容易就会注意到胶合层太厚了。

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

我们将在第 14 章探讨面向对象的语言时继续讨论并应用以上一些观点。

4.6 模块式编码

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

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

● 单个模块的大小是否在Hatton的“最佳范围”内?如果回答是“不,很多都超过”的话,就可能产生长期的维护问题。知道自己的“最佳范围”是多少吗?知道与你合作的其他程序员的最佳范围是多少吗?如果不知道,最好保守点儿,坚持Hatton最佳范围的下限。

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

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

—Ken Thompson

● 代码是不是有内部API——即可作为单元向其他人描述的函数调用集和数据结构集,并且每一个单元都封装了某一层次的函数,不受其它代码的影响?好的API应是意义清楚,不用看具体如何实现就能够理解的。对此有一个经典的测试方法:通过电话向另一个程序员描述。如果说不清楚,API很可能就是太复杂,设计太糟糕了。

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

● 整个项目中每个模块的入口点数量如何分布 [5] ?是不是不均匀?有很多入口点的模块真的需要这么多入口点吗?模块复杂性往往和入口点数量的平方成正比——这也是简单API优于复杂API的另一个原因。

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

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

[2] 不良缓存的一个典型例子是 csh (1)rehash指令。欲了解详情可键入man 1 csh。另一个例子参见 12.4.3。

[3] 高位字节优先 (big-endian )和低位字节优先( little-endian )术语指比特在机器字内解析顺序的架构选择。虽然没有规范的位置,但你在网上搜索“On Holy Wars and a Plea for Peace”,会找到有关这个论题的一篇经典而有趣的文章。

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

[5] 收集这种信息有一个简便的方法,就是分析 etags (1)或 ctags (1)等工具程序生成的标记文件。 9Eg3NSKngWhDwZWz56KeIFip7gJdbj0HOaWDyt1hQCD5LjPxTNZiRxzQhu6AOe+i

点击中间区域
呼出菜单
上一章
目录
下一章
×