• 沒有找到結果。

免费在线版本 (非印刷免费在线版)

N/A
N/A
Protected

Academic year: 2022

Share "免费在线版本 (非印刷免费在线版)"

Copied!
120
0
0

加載中.... (立即查看全文)

全文

(1)
(2)

免费在线版本

(非印刷免费在线版)

登录China-Pub网站购买此书完整版

了解本书更多信息请登录本书的官方网站

InfoQ 中文站出品

本书由 InfoQ 中文站免费发放,如果您从其他渠道获取本书,请注册 InfoQ 中文站以支持作者 和出版商,并免费下载更多 InfoQ 企业软件开发系列图书。

本书主页为

http://infoq.com/cn/minibooks/beautiful-code

(3)

© 2008 C4Media Inc.

版权所有

C4Media 是 InfoQ.com 这一企业软件开发社区的出版商

本书属于 InfoQ 企业软件开发丛书

如果您打算订购 InfoQ 的图书,请联系 [email protected]

未经出版者预先的书面许可,不得以任何方式复制或者抄袭本书的任何部分,本书任何部分不 得用于再印刷,存储于可重复使用的系统,或者以任何方式进行电子、 机械、复印和录制等形 式传播。

本书提到的公司产品或者使用到的商标为产品公司所有。

如果读者要了解具体的商标和注册信息,应该联系相应的公司。

本书在征得华章出版公司许可下制作,以电子文档形式发布。

欢 迎 共 同 参 与 InfoQ 中 文 站 的 内 容 建 设 工 作 , 包 括 原 创 投 稿 和 翻 译 等 , 请 联 系 [email protected]

(4)

Greg Wilson

我在1982年夏天获得了第一份程序员工作。在我工作了两个星期后,一位系统管理员借给 了我两本书:Kernighan和Plauger编写的《The Elements of Programming Style》(McGraw-Hill 出版社)和Wirth编写的《Algorithms + Data Structures = Programs》 (Prentice Hall出版 社)。这两本书让我大开眼界——我第一次发现程序并不仅仅只是一组计算机执行的指令。它们 可以像做工优良的橱柜一样精致,像悬索吊桥一样漂亮,或者像George Orwell的散文一样优美。

自从那个夏天以来,我经常听到人们感叹我们的教育并没有教会学生看到这一点。建筑师 们需要观摩建筑物,作曲家们需要研习他人的作品,而程序员——他们只有在需要修改bug时才 会去阅读其他人的代码;即使在这个时候,他们也会尽可能减少阅读量。我们曾告诉学生使用 有意义的变量名,曾向他们介绍过一些基本的设计模式,但很奇怪,为什么他们编写的大多数 代码都是很难看的呢!

本书将试图改变这种状况。2006年5月,我邀请了一些著名的(以及不太著名的)软件设计 师来分析和讨论他们所知道的漂亮代码。正如在本书中将要介绍的,他们在许多不同的地方发 现了代码的漂亮性。有些漂亮性存在于手工精心打造软件的细微之处,而有些漂亮性是蕴涵在 大局之中——那些使程序能够持续发展的架构,或者用来构造程序的技术。

无论他们是在什么地方发现的这些漂亮性,我都非常感谢我们的投稿人抽出时间为我们奉 献了这样的一次学习旅程。我希望你能够享受阅读此书的乐趣,就像Andy和我非常享受编辑这 本书的过程,此外,我还希望这本书能激发你创建出一些漂亮的作品。

(5)

前言

《Beautiful Code》是由Greg Wilson在 2006 年构思的,本书的初衷是希望从优秀的软件 开发人员和计算机科学家中提炼出一些有价值的思想。他与助理编辑Andy Oram一起走访了世界 各地不同技术背景的专家。本《代码之美》精选版是从原书中精选出其中的 6 章。

本书章节内容的组织

第 1 章,正则表达式匹配器,作者 Brian Kernighan,介绍了对一种语言和一个问题的深入分 析以及由此产生的简洁而优雅的解决方案。

第 2 章,我编写过的最漂亮代码,作者 Jon Bentley,介绍了如何在无需执行函数的情况下测 试函数的性能。

第 3 章,美丽的测试,作者 Alberto Savoia,介绍了一种全新的测试方法,不仅能够消除 bug,

还可以使你成为一个更优秀的程序员。

第 4 章,NASA 火星漫步者任务中的高可靠企业系统,作者 Ronald Mak,介绍了如何使用工业标 准,最佳实践和 Java 技术来满足 NASA 探险任务的高可靠性需求。

第 5 章,美丽的并发,作者 Simon Peyton Jones,通过软件事务内存(Software Transactional Memory)来消除大多数并发程序中的困难,在本章中使用 Haskell 语言来说明。

第 6 章,以 REST 方式集成业务伙伴,作者 Andrew Patzer,通过根据需求来设计一个 B2B Web Service 从而表现出设计者对程序开发人员的尊重。

(6)

目录

序 ...i

前言 ... ii

第 1 章 正则表达式匹配器...1

1.1 编程实践...2

1.2 实现...3

1.3 讨论...4

1.4 其他的方法...5

1.5 构建...6

1.6 小结...8

第 2 章 我编写过的最漂亮代码...10

2.1 我编写过的最漂亮代码...10

2.2 事倍功半...11

2.3 观点...16

2.4 本章的中心思想是什么?...18

2.5 结论...18

2.6 致谢...20

第 3 章 美丽测试...21

3.1 讨厌的二分查找...22

3.2 JUnit 简介 ...27

3.3 将二分查找进行到底...29

3.4 结论...47

第 4 章 NASA 火星漫步者任务中的高可靠企业系统 ...49

4.1 任务与 CIP ...49

4.2 任务需求...50

4.3 系统架构...51

4.4 案例分析:流服务...54

4.5 可靠性...57

(7)

4.6 稳定性...66

4.7 结束语...67

第 5 章 美丽的并发...68

5.1 一个简单的例子...68

5.2 软件事务内存...71

5.3 圣诞老人问题...80

5.4 对 Haskell 的一些思考 ...90

5.5 结论...91

5.6 致谢...92

第 6 章 以 REST 方式集成业务伙伴 ...93

6.1 项目背景...93

6.2 把服务开放给外部客户...93

6.3 使用工厂模式转发服务...97

6.4 用电子商务协议来交换数据...98

6.5 结束语...104

后记 ...106

(8)

第 1 章 正则表达式匹配器

Brian Kernighan

正则表达式是描述文本模式的表示法,它可以有效地构造一种用于模式匹配的专用语言。

虽然正则表达式可以有多种不同的形式,但它们都有着共同的特点:模式中的大多数字符都是 匹配字符串中的字符本身,但有些元字符(metacharacter)却有着特定的含义,例如*表示某 种重复,而[...]表示方括号中字符集合的任何一个字符。

实际上,在文本编辑器之类的程序中,所执行的查找操作都是查找文字,因此正则表达式 通常是像“print”之类的字符串,而这类字符串将与文档中所有的“printf”或者“sprintf”

或者“printer paper”相匹配。在Unix和Windows中可以使用所谓的通配符来指定文件名,其 中字符*可以用来匹配任意数量的字符,因此匹配模式*.c就将匹配所有以.c结尾的文件。此外,

还有许许多多不同形式的正则表达式,甚至在有些情况下,这些正则表达式会被认为都是相同 的。Jeffrey Friedl编著的《Mastering Regular Expressions》一书对这一方面问题进行了广 泛的研究。

Stephen Kleene在20世纪50年代的中期发明了正则表达式,用来作为有限自动机的表示法,

事实上,正则表达式与其所表示的有限自动机是等价的。20世纪60年代年代中期,正则表达式 最初出现在Ken Thompson版本的QED文本编辑器的程序设置中。1967年Thompson申请了一项基于 正则表达式的快速文本匹配机制的专利。这项专利在1971年获得了批准,它是最早的软件专利 之一[U.S. Patent 3,568,156, Text Matching Algorithm, March 2, 1971].

后来,正则表达式技术从QED移植到了Unix的编辑器ed中,然后又被移植到经典的Unix工具 grep中,而grpe正是由于Thompson对ed进行了彻底地修改而形成的。这些广为应用的程序使得 正则表达式为早期的Unix社群所熟知。

Thompson最初编写的匹配器是非常快的,因为它结合了两种独立的思想。一种思想是在匹 配过程中动态地生成机器指令,这样就可以以机器指令执行的速度而不是解释执行的速度来运 行。另一种思想是在每个阶段中都尽可能地执行匹配操作,这样无需回朔(backtrack)就可以 查找可能的匹配。在Thompson后来编写的文本编辑器程序中,例如ed,匹配代码使用了一种更 为简单的算法,这种算法将会在必要的时候进行回朔。从理论上来看,这种方法的运行速度要 更慢,但在实际情况中,这种模式很少需要进行回朔,因此,ed和grep中的算法和代码足以应 付大多数的情况。

在后来的正则表达式匹配器中,例如egrep和fgrep等,都增加了更为丰富的正则表达式类

(9)

型,并且重点是要使得匹配器无论在什么模式下都能够快速执行。功能更为强大的正则表达式 正在被越来越多地使用,它们不仅被包含在用C语言开发的库中,而且还被作为脚本语言如Awk 和Perl的语法的一部分。

1.1 编程实践

在1998年,Rob Pike和我还在编写《The Practice of Programming》(Addison-Wesley)

一书。书中的最后一章是“记法”,在这一章中收录了许多示例代码,这些示例都很好地说明 了良好的记法将会带来更好的程序以及更好的设计。其中包括使用简单的数据规范(例如 printf)以及从表中生成代码。

由于我们有着深厚的Unix技术背景以及在使用基于正则表达式记法的工具上有着近30年的 经验,我们很自然地希望在本书中包含一个对正则表达式的讨论,当然包含一个实现也是必须 的。由于我们强调了工具软件的使用,因此似乎最好应该把重点放在grep中的正则表达式类型 上——而不是,比方说,在shell的通配符正则表达式上——这样我们还可以在随后再讨论grep 本身的设计。

然而,问题是现有的正则表达式软件包都太庞大了。grep中的代码长度超过500行(大约10 页书的长度),并且在代码的周围还有复杂的上下文环境。开源的正则表达式软件包则更为庞 大——代码的长度几乎布满整本书——因为这些代码需要考虑通用性,灵活性以及运行速度;

因此,所有这些正则表达式都不适合用来教学。

我向Rob建议我们需要一个最小的正则表达式软件包,它可以很好地诠释正则表达式的基本 思想,并且能够识别出一组有用的并且重要类的模式。理想的情况是,所需代码长度只有一页 就够了。

Rob听了我的提议后就离开了他的办公室。我现在还记得,一,两个小时后他回来了,并且给了 我一段大约30行的C代码,在《The Practice of Programming》一书的第9章中包含了这段代码。

在这段代码实现了一个正则表达式匹配器,用来处理以下的模型。

字符 含义

c 匹配任意的字母c

.(句点) 匹配任意的单个字符

^ 匹配输入字符串的开头

$ 匹配输入字符串的结尾

* 匹配前一个字符的零个或者多个出现

这是一个非常有用的匹配器,根据我在日常工作中使用正则表达式的经验,它可以轻松解 决95%的问题。在许多情况下,解决正确的问题就等于朝着创建漂亮的程序迈进了一大步。Rob

(10)

值得好好地表扬,因为他从大量可选功能集中选出了一组非常小但却重要的,并且是明确的以 及可扩展的功能。

Rob的实现本身就是漂亮代码的一个极佳示例:紧凑,优雅,高效并且实用。这是我所见过 的最好的递归示例之一,在这段代码中还展示了C指针的强大功能。虽然当时我们最关心的是通 过使程序更易于使用(同时也更易于编写)来体现良好记法的重要性,但正则表达式代码同样 也是阐述算法,数据结构,测试,性能增强以及其他重要主题的最好方式。

1.2 实现

在《The Practice of Programming》一书中,正则表达式匹配器是一个模拟grep程序中的 一部分,但正则表达式的代码完全可以从编写环境中独立出来。这里我们并不关心主程序;像 许多Unix工具一样,这个程序将读取其标准输入或者一组文件,然后输出包含与正则表达式匹 配的文本行。

以下是匹配算法的代码:

/* match: search for regexp anywhere in text */

int match(char *regexp, char *text) {

if (regexp[0] == '^')

return matchhere(regexp+1, text);

do { /* must look even if string is empty */

if (matchhere(regexp, text)) return 1;

} while (*text++ != '\0');

return 0;

}

/* matchhere: search for regexp at beginning of text */

int matchhere(char *regexp, char *text) {

if (regexp[0] == '\0') return 1;

if (regexp[1] == '*')

return matchstar(regexp[0], regexp+2, text);

if (regexp[0] == '$' && regexp[1] == '\0') return *text == '\0';

if (*text!='\0' && (regexp[0]=='.' || regexp[0]==*text)) return matchhere(regexp+1, text+1);

return 0;

(11)

}

/* matchstar: search for c*regexp at beginning of text */

int matchstar(int c, char *regexp, char *text) {

do { /* a * matches zero or more instances */

if (matchhere(regexp, text)) return 1;

} while (*text != '\0' && (*text++ == c || c == '.'));

return 0;

}

1.3 讨论

函数match(regexp,text)用来判断文本中是否出现正则表达式;如果找到了一个匹配的正 则表达式则返回1,否则返回0。如果有多个匹配的正则表达式,那么函数将找到文本中最左边 的并且最短的那个。

match函数中的基本操作简单明了。如果正则表达式中的第一个字符是^(固定位置的匹配),

那么匹配就一定要出现在字符串的开头。也就是说,如果正则表达式是^xyz,那么仅当xyz出现 在文本的开头而不是中间的某个位置时才会匹配成功。在代码中通过把正则表达式的剩余部分 与文本的起始位置而不是其他地方进行匹配来判断。如果第一个字符不是^,那么正则表达式就 可以在字符串中的任意位置上进行匹配。在代码中通过把模式依次与文本中的每个字符位置进 行匹配来判断。如果存在多个匹配,那么代码只会识别第一个(最左边的)匹配。也就是说,

如果则在表达式是xyz,那么将会匹配第一次出现的xyz,而且不考虑这个匹配出现在什么位置 上。

注意,对输入字符串的推进操作是在一个do-while循环中进行的,这种结构在C程序中使用 相对较少。在代码中使用do-while而不是while通常会带来疑问:为什么不在循环的起始处判断 循环条件,而是在循环末尾当执行完了某个操作之后才进行判断呢?不过,这里的判断是正确 的:由于*运算符允许零长度的匹配,因此我们首先需要判断是否存在一个空的匹配。

大部分的匹配工作是在matchhere(regexp,text)函数中完成的,这个函数将判断正则表达 式与文本的开头部分是否匹配。函数matchhere把正则表达式的第一个字符与文本的第一个字符 进行匹配。如果匹配失败,那么在这个文本位置上就不存在匹配,因此matchhere将返回0。然 而,如果匹配成功了,函数将推进到正则表达式的下一个字符和文本的下一个字符继续进行匹 配。这是通过递归地调用matchhere函数来实现的。

由于存在着一些特殊的情况,以及需要设置终止递归的条件。因此实际的处理过程要更为

(12)

复杂些最简单的情况就是,当正则表达式推进到末尾时(regexp[0] == '\0'),所有前面的判断 都成功了,那么这个正则表达式就与文本匹配。

如果正则表达式是一个字符后面跟着一个*,那么将会调用matchstar来判断闭包(closure)

是否匹配。函数matchstar(c, regexp, text)将尝试匹配重复的文本字符c,从零重复开始并且 不断累加,直到匹配text的剩余字符,如果匹配失败,那么函数就认为不存在匹配。这个算法 将识别出一个“最短的匹配”,这对简单的模式匹配来说是很好的,例如grep,这种情况下的 主要问题是尽可能快地找到一个匹配。而对于文本编辑器来说,“最长的匹配”则是更为直观,

且肯定是更好的,因为通常需要对匹配的文本进行替换。在目前许多的正则表达式库中同时提 供了这两种方法,在《The Practice of Programming》一书中给出了基于本例中matchstar函 数的一种简单变形,我们在后面将给出这种形式。

如果在正则表达式的末尾包含了一个$,那么仅当text此时位于末尾时才会匹配成功:

if (regexp[0] == '$' && regexp[1] == '\0') return *text == '\0';

如果没有包含$,并且如果当前不是处于text字符串的末尾(也就是说,*text!='\0')并 且如果text字符串的第一个字符匹配正则表达式的第一个字符,那么到现在为止都是没有问题 的;我们将接着判断正则表达式的下一个字符是否匹配text的下一个字符,这是通过递归调用 matchhere函数来实现的。这个递归调用不仅是本算法的核心,也是这段代码如此紧凑和整洁的 原因。

如果所有这些匹配尝试都失败了,那么正则表达式和text在这个位置上就不存在匹配,因 此函数matchhere将返回0。

在这段代码中大量地使用了C指针。在递归的每个阶段,如果存在某个字符匹配,那么在随 后的递归调用中将执行指针算法(例如,regexp+1 and text+1),这样在随后的函数调用中,

参数就是正则表达式的下一个字符和text的下一个字符。递归的深度不会超过匹配模式的长度,

而通常情况下匹配模式的长度都是很短的,因此不会出现耗尽内存空间的危险。

1.4 其他的方法

这是一段非常优雅并且写得很好的代码,但并不是完美的。我们还可以做哪些其他的工作?

我可能对matchhere中的操作进行重新安排,在处理*之前首先处理$。虽然这种安排不会对函数 的执行带来影响,但却使得函数看上去要自然一些,而在编程中一个良好的规则就是:在处理 复杂的情况之前首先处理容易的情况。

不过,通常这些判断的顺序是非常重要的。例如,在matchstar的这个判断中:

} while (*text != '\0' && (*text++ == c || c == '.'));

(13)

无论在什么情况下,我们都必须推进text字符串中的一个或多个字符,因此在text++中的 递增运算一定要执行。

该代码对终止条件进行了谨慎的处理。通常,匹配过程的成功与否,是通过判断正则表达 式和text中的字符是不是同时处理完来决定的。如果是同时处理完了,那么就表示匹配成功,

如果其中一方在另一方之前被处理完了,那么就表示匹配失败。在下面这行代码中很明显地说 明了这个判断。

if (regexp[0] == '$' && regexp[1] == '\0') return *text == '\0';

但在其他的情况下,还有一些微妙的终止条件。

如果在matchstar函数中需要识别最左边的以及最长的匹配,那么函数将首先识别输入字符 c的最大重复序列。然后函数将调用matchhere来尝试把匹配延伸到正则表达式的剩余部分和 text的剩余部分。每次匹配失败都会将cs的出现次数减1,然后再次开始尝试,包括处理零出现 的情况:

/* matchstar: leftmost longest search for c*regexp */

int matchstar(int c, char *regexp, char *text) {

char *t;

for (t = text; *t != '\0' && (*t == c || c == '.'); t++) ;

do { /* * matches zero or more */

if (matchhere(regexp, t)) return 1;

} while (t-- > text);

return 0;

}

我们来看一下正则表达式(.*),它将匹配括号内任意长度的text。假设给定了text:

for (t = text; *t != '\0' && (*t == c || c == '.'); t++)

从开头位置起的最长匹配将会识别整个括号内的表达式,而最短的匹配将会停止在第一次 出现右括号的地方。(当然,从第二个左括号开始的最长匹配将会延伸到text的末尾)

1.5 构建

《The Practice of Programming》一书主要讲授良好的程序设计。在编写该书时,Rob和 我还在贝尔实验室工作,因此我们知道在课堂上使用这本书有什么样的效果。令人高兴的是,

(14)

我们发现这本书中的某些内容在课堂上确实有着不错的效果。从2000年教授程序设计中的重点 要素时,我们就使用了这段代码。

首先,这段代码以一种全新的形式展示了递归的强大功能及其带来的整洁代码。它既不是 另一种版本的快速排序(或者阶乘!)算法,也不是某种树的遍历算法。

这段代码同时还是性能试验的一个很好示例。其性能与系统中的grep并没有太大的差异,

这表明递归技术的开销并不是非常大的,因此没有必要对这段代码进行调整。

此外,这段代码还充分说明了优良算法的重要性。如果在模式中包含了几个.*序列,那么 在简单的实现中将需要进行大量的回溯操作,并且在某些情况下将会运行得极慢。

在标准的Unix grep中有着同样的回朔操作。例如,下面这个命令:

grep 'a.*a.*a.*a.a'

在普通的机器上处理一个4 MB的文本文件要花费20秒的时间。

如果某个实现是基于把非确定有限自动机转换为确定有限自动机,例如egrep,那么在处理 恶劣的情况时将会获得比较好的性能;它可以在不到十分之一秒的时间内处理同样的模式和同 样的输入,并且运行时间通常是独立于模式的。

对于正则表达式类型的扩展将形成各种任务的基础。例如:

1.增加其他的元字符,例如+用于表示前面字符的一个或多个出现,或者?用于表示零个或 一个字符的匹配。还可以增加一些方式来引用元字符,例如\$表示在模式中的$字符。

2.将正则表达式处理过程分成编译阶段和执行阶段。编译阶段把正则表达式转换为内部形 式,使匹配代码更为简单或者使随后的匹配过程运行得更为迅速。对于最初设计中的简单的正 则表达式来说,这种拆分并不是必须的,但在像grep这样的程序中,这种拆分是有意义的,因 为这种类型的正则表达式要更为丰富,并且同样的正则表达式将会用于匹配大量的输入行。

3.增加像[abc]和[0-9]这样的类型,这在传统的grep中分别匹配a或b或c和一个数字。可 以通过几种方式来实现,最自然的方式似乎就是把最初代码中的char*变量用一个结构来代替:

typedef struct RE {

int type; /* CHAR, STAR, etc. */

int ch; /* the character itself */

char *ccl; /* for [...] instead */

int nccl; /* true if class is negated [^...] */

} RE;

并且修改相应的代码来处理一个结构数组而不是处理一个字符数组。在这种情况下,并不 一定要把编译阶段从执行阶段中拆分出来,但这里的拆分过程是非常简单的。如果学生们把匹

(15)

配代码预编译成这个结构,那么总会比那些试图动态地解释一些复杂模式数据结构的学生要做 得更好。

为字符类型编写清晰并且无歧义的规范是件有难度的工作,而要用代码完美地实现处理更 是难上加难,这需要大量的冗长并且晦涩的编码。随着时间的推移,我简化了这个任务,而现 在大多数人会要求像Perl那样的速记,例如\d表示数字,\D表示非数字,而不再像最初那样在 方括号内指定字符的范围。

4.使用不透明的类型来隐藏RE结构以及所有的实现细节。这是在C语言中展示面向对象编 程技术的好方法,不过除此之外无法支持更多的东西。在实际情况中,将会创建一个正则表达 式类,其中类中成员函数的名字像RE_new( )和RE_match( )这样,而不是使用面向对象语言 的语法。

5.把正则表达式修改为像各种shell中的通配符那样:匹配模式的两端都被隐含地固定了,

*匹配任意数量的字符,而?则匹配任意的单个字符。你可以修改这个算法或者把输入映射到现 有的算法中。

6.将这段代码转换成Java。最初的代码很好地使用了C指针,并且把这个算法在不同的语 言中实现出来是一个很好的实践过程。在Java版本的代码中将使用String.charA(使用索引而 不是指针)或者String.substring(更接近于指针)。这两种方法都没有C代码整洁,并且都不 紧凑。虽然在这个练习中性能并不是主要的问题,但有趣的是可以发现了Java版本比C版本在运 行速度上要慢6到7倍。

7.编写一个包装类把这种类型的正则表达式转换成Java的Patter类和Matcher类,这些类 将以一种与众不同的方式来拆分编译阶段和匹配阶段。这是适配器(Adapter)模式或者外观

(Façade)模式的很好示例,这两种模式用来在现有的类或者函数集合外部设置不同的接口。

我曾经大量地使用了这段代码来研究测试技术。正则表达式非常的丰富,而测试也不 是无足轻重的,但正则表达式又是很短小的,程序员可以很快地写出一组测试代码来执行。对 于前面所列出的各种扩展,我要求学生用一种紧凑的语言写出大量的测试代码(这是“记法”

的另一种示例),并且在他们自己的代码中使用这些测试代码;自然我在其他学生的代码中也 使用了他们的测试代码。

1.6 小结

当Rob Pike最初写出这段代码时,我被它的紧凑性和优雅性感到惊讶——这段代码比我以 前所想像的要更为短小并且功能也更为强大。通过事后分析,我们可以看到为什么这段代码如 此短小的众多原因。

首先,我们很好地选择了功能集合,这些功能是最为有用的并且最能从实现中体现出核心

(16)

思想,而没有多余的东西。例如,虽然固定模式^和$的实现只需要写3~4行代码,但在统一处 理普通情况之前,它展示了如何优雅地处理特殊情况。闭包操作*必须出现,因为它是正则表达 式中的基本记号,并且是提供处理不确定长度的惟一方式。增加+和?并不会有助于理解,因此 这些符号被留作为练习。

其次,我们成功地使用了递归。递归这种基本的编程技巧通常会比明确的循环带来更短、

更整洁的以及更优雅的代码,这也正是这里的示例。从正则表达式的开头和tex的开头剥离匹配 字符,然后对剩余的字符进行递归的思想,模仿了传统的阶乘或者字符串长度示例的递归结构,

但这里是用在了一种更为有趣和更有用的环境中。

第三,这段代码的确使用了基础语言来达到良好的效果。指针也可能被误用,但这里它们 被用来创建紧凑的表达式,并且在这个表达式中自然地表达了提取单个字符和推进到下一个字 符的过程。数组索引或者子字符串可以达到同样的效果,但在这段代码中,指针能够更好的实 现所需的功能,尤其是当指针与C语言中的自动递增运算和到布尔值的隐式转换结合在一起使用 时。

我不清楚是否有其他的方法能够在如此少的代码中实现如此多功能,并且同时还要提供丰 富的内涵和深层次的思想。

(17)

第 2 章 我编写过的最漂亮的代码

Jon Bentley

我曾经听一位大师级的程序员这样称赞到,“我通过删除代码来实现功能的提升。”而 法国著名作家兼飞行家 Antoine de Saint-Exupéry 的说法则更具代表性,“只有在不仅没 有任何功能可以添加,而且也没有任何功能可以删除的情况下,设计师才能够认为自己的工 作已臻完美。” 某些时候,在软件中根本就不存在最漂亮的代码,最漂亮的函数,或者最 漂亮的程序。

当然,我们很难对不存在的事物进行讨论。本章将对经典 Quicksort(快速排序)算法 的运行时间进行全面的分析,并试图通过这个分析来说明上述观点。在第一节中,我将首先 根据我自己的观点来回顾一下 Quicksort,并为后面的内容打下基础。第二节的内容将是本 章的重点部分。我们将首先在程序中增加一个计数器,然后通过不断地修改,从而使程序的 代码变得越来越短,但程序的功能却会变得越来越强,最终的结果是只需要几行代码就可以 使算法的运行时间达到平均水平。在第三节将对前面的技术进行小结,并对二分搜索树的运 行开销进行简单的分析。最后的两节将给出学完本章得到的一些启示,这将有助于你在今后 写出更为优雅的程序。

2.1 我编写过的最漂亮代码

当 Greg Wilson 最初告诉我本书的编写计划时,我曾自问编写过的最漂亮的代码是什么。

这个有趣的问题在我脑海里盘旋了大半天,然后我发现答案其实很简单:Quicksort 算法。

但遗憾的是,根据不同的表达方式,这个问题有着三种不同的答案。

当我撰写关于分治(divide-and-conquer)算法的论文时,我发现 C.A.R. Hoare 的 Quicksort 算法(“Quicksort”,Computer Journal 5)无疑是各种 Quicksort 算法的鼻 祖。这是一种解决基本问题的漂亮算法,可以用优雅的代码实现。我很喜欢这个算法,但我 总是无法弄明白算法中最内层的循环。我曾经花两天的时间来调试一个使用了这个循环的复 杂程序,并且几年以来,当我需要完成类似的任务时,我会很小心地复制这段代码。虽然这 段代码能够解决我所遇到的问题,但我却并没有真正地理解它。

我后来从 Nico Lomuto 那里学到了一种优雅的划分(partitioning)模式,并且最终编 写出了我能够理解,甚至能够证明的 Quicksort 算法。William Strunk Jr.针对英语所提出 的“良好的写作风格即为简练”这条经验同样适用于代码的编写,因此我遵循了他的建议,

“省略不必要的字词”(来自《The Elements of Style》一书)。我最终将大约 40 行左右 的代码缩减为十几行的代码。因此,如果要回答“你曾编写过的最漂亮代码是什么?”这个 问 题 , 那 么 我 的 答 案 就 是 : 在 我 编 写 的 《 Programming Pearls, Second Edition 》 (Addison-Wesley)一书中给出的 Quichsort 算法。在示例 2-1 中给出了用 C 语言编写的 Quicksort 函数。我们在接下来的章节中将进一步地研究和改善这个函数。

【示例】 2-1 Quicksort 函数 void quicksort(int l, int u) { int i, m;

if (l >= u) return;

(18)

swap(l, randint(l, u));

m = l;

for (i = l+1; i <= u; i++) if (x[i] < x[l]) swap(++m, i);

swap(l, m);

quicksort(l, m-1);

quicksort(m+1, u);

}

如果函数的调用形式是 quicksort(0, n-1),那么这段代码将对一个全局数组 x[n]进行 排序。函数的两个参数分别是将要进行排序的子数组的下标:l 是较低的下标,而 u 是较高 的下标。函数调用 swap(i,j)将会交换 x[i]与 x[j]这两个元素。第一次交换操作将会按照 均匀分布的方式在 l 和 u 之间随机地选择一个划分元素。

在《Programming Pearls》一书中包含了对 Quicksort 算法的详细推导以及正确性证明。

在本章的剩余内容中,我将假设读者熟悉在《Programming Pearls》中所给出的 Quicksort 算法以及在大多数初级算法教科书中所给出的 Quicksort 算法。

如果你把问题改为“在你编写那些广为应用的代码中,哪一段代码是最漂亮的?”我的 答案还是 Quicksort 算法。在我和 M. D. McIlroy 一起编写的一篇文章("Engineering a sort function," Software-Practice and Experience, Vol. 23, No. 11)中指出了在原来 Unix qsort 函数中的一个严重的性能问题。随后,我们开始用 C 语言编写一个新排序函数库,并 且考虑了许多不同的算法,包括合并排序(Merge Sort)和堆排序(Heap Sort)等算法。

在比较了 Quicksort 的几种实现方案后,我们着手创建自己的 Quicksort 算法。在这篇文章 中描述了我们如何设计出一个比这个算法的其他实现要更为清晰,速度更快以及更为健壮的 新函数——部分原因是由于这个函数的代码更为短小。Gordon Bell 的名言被证明是正确的:

“在计算机系统中,那些最廉价,速度最快以及最为可靠的组件是不存在的。”现在,这个 函数已经被使用了 10 多年的时间,并且没有出现任何故障。

考虑到通过缩减代码量所得到的好处,我最后以第三种方式来问自己在本章之初提出的 问题。“你没有编写过的最漂亮代码是什么?”。我如何使用非常少的代码来实现大量的功 能?答案还是和 Quicksort 有关,特别是对这个算法的性能分析。我将在下一节给出详细介 绍。

2.2 事倍功半

Quicksort 是一种优雅的算法,这一点有助于对这个算法进行细致的分析。大约在 1980 年左右,我与 Tony Hoare 曾经讨论过 Quicksort 算法的历史。他告诉我,当他最初开发出 Quicksort 时,他认为这种算法太简单了,不值得发表,而且直到能够分析出这种算法的预 期运行时间之后,他才写出了经典的“Quicksoft”论文。

我们很容易看出,在最坏的情况下,Quicksort可能需要n2的时间来对数组元素进行排 序。而在最优的情况下,它将选择中值作为划分元素,因此只需nlgn次的比较就可以完成对 数组的排序。那么,对于n个不同值的随机数组来说,这个算法平均将进行多少次比较?

Hoare 对于这个问题的分析非常漂亮,但不幸的是,其中所使用的数学知识超出了大多 数程序员的理解范围。当我为本科生讲授 Quicksort 算法时,许多学生即使在费了很大的努 力之后,还是无法理解其中的证明过程,这令我非常沮丧。下面,我们将从 Hoare 的程序开

(19)

始讨论,并且最后将给出一个与他的证明很接近的分析。

我们的任务是对示例 2-1 中的 Quicksort 代码进行修改,以分析在对元素值均不相同的 数组进行排序时平均需要进行多少次比较。我们还将努力通过最短的代码、最短运行时间以 及最小存储空间来得到最深的理解。

为了确定平均比较的次数,我们首先对程序进行修改以统计次数。因此,在内部循环进 行比较之前,我们将增加变量 comps 的值(参见示例 2-2)。

【示例 2-2】 修改 Quicksort 的内部循环以统计比较次数。

for (i = l+1; i <= u; i++) { comps++;

if (x[i] < x[l]) swap(++m, i);

}

如果用一个值 n 来运行程序,我们将会看到在程序的运行过程中总共进行了多少次比 较。如果重复用 n 来运行程序,并且用统计的方法来分析结果,我们将得到 Quicksort 在对 n 个元素进行排序时平均使用了 1.4 nlgn 次的比较。

在理解程序的行为上,这是一种不错的方法。通过十三行的代码和一些实验可以反应出 许多问题。这里,我们引用作家 Blaise Pascal 和 T. S. Eliot 的话,“如果我有更多的时 间,那么我给你写的信就会更短。”现在,我们有充足的时间,因此就让我们来对代码进行 修改,并且努力编写出更短(同时更好)的程序。

我们要做的事情就是提高这个算法的速度,并且尽量增加统计的精确度以及对程序的理 解。由于内部循环总是会执行 u-l 次比较,因此我们可以通过在循环外部增加一个简单的操 作来统计比较次数,这就可以使程序运行得更快一些。在示例 2-3 的 Quicksort 算法中给出 了这个修改。

【示例 2-3】 Quicksort 的内部循环,将递增操作移到循环的外部 comps += u-l;

for (i = l+1; i <= u; i++) if (x[i] < x[l]) swap(++m, i);

这个程序会对一个数组进行排序,同时统计比较的次数。不过,如果我们的目标只是统 计比较的次数,那么就不需要对数组进行实际地排序。在示例 2-4 中去掉了对元素进行排序 的“实际操作”,而只是保留了程序中各种函数调用的“框架”。

【示例 2-4】将 Quicksort 算法的框架缩减为只进行统计 void quickcount(int l, int u)

{ int m;

if (l >= u) return;

m = randint(l, u);

comps += u-l;

quickcount(l, m-1);

quickcount(m+1, u);

}

(20)

这个程序能够实现我们的需求,因为 Quichsort 在选择划分元素时采用的是“随机”方 式,并且我们假设所有的元素都是不相等的。现在,这个新程序的运行时间与 n 成正比,并 且相对于示例 2-3 需要的存储空间与 n 成正比来说,现在所需的存储空间缩减为递归堆栈的 大小,即存储空间的平均大小与 lgn 成正比。

虽然在实际的程序中,数组的下标(l 和 u)是非常重要的,但在这个框架版本中并不 重要。因此,我们可以用一个表示子数组大小的整数(n)来替代这两个下标(参见示例 2-5)

【示例 2-5】 在 Quicksort 代码框架中使用一个表示子数组大小的参数 void qc(int n)

{ int m;

if (n <= 1) return;

m = randint(1, n);

comps += n-1;

qc(m-1);

qc(n-m);

}

现在,我们可以很自然地把这个过程整理为一个统计比较次数的函数,这个函数将返回 在随机 Quicksort 算法中的比较次数。在示例 2-6 中给出了这个函数。

【示例 2-6】 将 Quicksort 框架实现为一个函数 int cc(int n)

{ int m;

if (n <= 1) return 0;

m = randint(1, n);

return n-1 + cc(m-1) + cc(n-m);

}

在示例 2-4、示例 2-5 和示例 2-6 中解决的都是相同的基本问题,并且所需的都是相同 的运行时间和存储空间。在后面的每个示例都对这些函数的形式进行了改进,从而比这些函 数更为清晰和简洁。

在定义发明家的矛盾(inventor's paradox)(How To Solve It, Princeton University Press)时,George Póllya 指出“计划越宏大,成功的可能性就越大。”现在,我们就来研 究在分析 Quicksort 时的矛盾。到目前为止,我们遇到的问题是,“当 Quicksort 对大小为 n 的数组进行一次排序时,需要进行多少次比较?”我们现在将对这个问题进行扩展,“对 于大小为 n 的随机数组来说,Quichsort 算法平均需要进行多少次的比较?”我们通过对示 例 2-6 进行扩展以引出示例 2-7。

【示例 2-7】 伪码:Quicksort 的平均比较次数 float c(int n)

if (n <= 1) return 0 sum = 0

for (m = 1; m <= n; m++)

sum += n-1 + c(m-1) + c(n-m) return sum/n

如果在输入的数组中最多只有一个元素,那么 Quichsort 将不会进行比较,如示例 2-6

(21)

中所示。对于更大的 n,这段代码将考虑每个划分值 m(从第一个元素到最后一个,每个都 是等可能的)并且确定在这个元素的位置上进行划分的运行开销。然后,这段代码将统计这 些开销的总和(这样就递归地解决了一个大小为 m-1 的问题和一个大小为 n-m 的问题),然 后将总和除以 n 得到平均值并返回这个结果。

如果我们能够计算这个数值,那么将使我们实验的功能更加强大。我们现在无需对一个 n 值运行多次来估计平均值,而只需一个简单的实验便可以得到真实的平均值。不幸的是,

实 现 这 个 功 能 是 要 付 出 代 价 的 : 这 个 程 序 的 运 行 时 间 正 比 于 3n ( 如 果 是 自 行 参 考

(self-referential)的,那么用本章中给出的技术来分析运行时间将是一个很有趣的练 习)。

示例 2-7 中的代码需要一定的时间开销,因为它重复计算了中间结果。当在程序中出现 这种情况时,我们通常会使用动态编程来存储中间结果,从而避免重复计算。因此,我们将 定义一个表 t[N+1],其中在 t[n]中存储 c[n],并且按照升序来计算它的值。我们将用 N 来 表示 n 的最大值,也就是进行排序的数组的大小。在示例 2-8 中给出了修改后的代码。

【示例 2-8】 在 Quicksort 中使用动态编程来计算 t[0] = 0

for (n = 1; n <= N; n++) sum = 0

for (i = 1; i <= n; i++)

sum += n-1 + t[i-1] + t[n-i]

t[n] = sum/n

这个程序只对示例 2-7 进行了细微的修改,即用 t[n]来替换 c(n)。它的运行时间将正 比于 N2,并且所需的存储空间正比于 N。这个程序的优点之一就是:在程序执行结束时,数 组 t 中将包含数组中从元素 0 到元素 N 的真实平均值(而不是样本均值的估计)。我们可以 对这些值进行分析,从而生成在 Quichsort 算法中统计比较次数的计算公式。

我们现在来对程序做进一步的简化。第一步就是把 n-1 移到循环的外面,如示例 2-9 所示。

【示例 2-9】 在 Quicksort 中把代码移到循环外面来计算 t[0] = 0

for (n = 1; n <= N; n++) sum = 0

for (i = 1; i <= n; i++) sum += t[i-1] + t[n-i]

t[n] = n-1 + sum/n

现在将利用对称性来对循环做进一步的调整。例如,当 n 为 4 时,内部循环计算总和为:

t[0]+t[3] + t[1]+t[2] + t[2]+t[1] + t[3]+t[0]

在上面这些组对中,第一个元素增加而第二个元素减少。因此,我们可以把总和改写为:

2 * (t[0] + t[1] + t[2] + t[3])

我们可以利用这种对称性来得到示例 2-10 中的 Quicksort。

【示例 2-10】 在 Quichsort 中利用了对称性来计算 t[0] = 0

(22)

for (n = 1; n <= N; n++) sum = 0

for (i = 0; i < n; i++) sum += 2 * t[i]

t[n] = n-1 + sum/n

然而,在这段代码的运行时间中同样存在着浪费,因为它重复地计算了相同的总和。此 时,我们不是把前面所有的元素加在一起,而是在循环外部初始化总和并且加上下一个元素,

如示例 2-11 所示。

【示例 2-11】 在 Quicksort 中删除了内部循环来计算 sum = 0; t[0] = 0

for (n = 1; n <= N; n++) sum += 2*t[n-1]

t[n] = n-1 + sum/n

这个小程序确实很有用。程序的运行时间与 N 成正比,对于每个从 1 到 N 的整数,程序 将生成一张 Quicksort 的估计运行时间表。

我们可以很容易地把示例 2-11 用表格来实现,其中的值可以立即用于进一步的分析。

在 2-1 给出了最初的结果行。

表 2-1 示例 2-11 中实现的表格输出 N Sum t[n]

0 0 0 1 0 0 2 0 1 3 2 2.667 4 7.333 4.833 5 17 7.4 6 31.8 10.3 7 52.4 13.486 8 79.371 16.921

这张表中的第一行数字是用代码中的三个常量来进行初始化的。下一行(输出的第三行)

的数值是通过以下公式来计算的:

A3 = A2+1 B3 = B2 + 2*C2 C3 = A2-1 + B3/A3

把这些(相应的)公式记录下来就使得这张表格变得完整了。这张表格是“我曾经编写 的最漂亮代码”的很好的证据,即使用少量的代码完成大量的工作。

但是,如果我们不需要所有的值,那么情况将会是什么样?如果我们更希望通过这种来 方式分析一部分数值(例如,在 20 到 232 之间所有 2 的指数值)呢?虽然在示例 2-11 中构 建了完整的表格 t,但它只需要使用表格中的最新值。因此,我们可以用变量 t 的定长空间 来替代 table t[]的线性空间,如示例 2-12 所示。

【示例 2-12】 Quicksoft 计算——最终版本 sum = 0; t = 0

(23)

for (n = 1; n <= N; n++) sum += 2*t

t = n-1 + sum/n

然后,我们可以插入一行代码来测试 n 的适应性,并且在必要时输出这些结果。

这个程序是我们漫长学习旅途的终点。通过本章所采用的方式,我们可以证明 Alan Perlis 的经验是正确的:“简单性并不是在复杂性之前,而是在复杂性之后” ("Epigrams on Programming," Sigplan Notices, Vol. 17, Issue 9)。

2.3 观点

在表 2-2 中总结了本章中对 Quicksort 进行分析的程序。

表 2-2 对 Quicksort 比较次数的统计算法的评价

示例编号 代码行数 答案类型 答案数量 运行时间 空间 2 13 Sample 1 n l g n N 3 13 " " " "

4 8 " " n lg n 5 8 " " " "

6 6 " " " "

7 6 Exact " 3 N N 8 6 " N N

2

N 9 6 " " " "

10 6 " " " "

11 4 " " N "

12 4 Exact N N 1

在我们对代码的每次修改中,每个步骤都是很直接的;不过,从示例 2-6 中样本值到示 例 2-7 中准确值的过渡过程可能是最微妙的。随着这种方式进行下去,代码变得更快和更有 用,而代码量同样得到了缩减。在 19 世纪中期,Robert Browning 指出“少即是多(less is more)”,而这张表格正是一个证明这种极少主义哲学(minimalist philosophy)的实例。

我们已经看到了三种截然不同的类型的程序。示例 2-2 和示例 2-3 是能够实际使用的 Quicksort,可以用来在对真实数组进行排序时统计比较次数。示例 2-4 到示例 2-6 都实现 了 Quicksort 的一种简单模型:它们模拟算法的运行,而实际上却没有做任何排序工作。从 示例 2-7 到示例 2-12 则实现了一种更为复杂的模型:它们计算了比较次数的真实平均值而 没有跟踪任何单次的运行。

我们在下面总结了实现每个程序所使用的技术:

* 示例 2-2,示例 2-4,2-7:对问题的定义进行根本的修改。

* 示例 2-5,示例 2-6,2-12:对函数的定义进行轻微的修改

* 示例 2-8:实现动态编程的新数据结构

这些技术都是非常典型的。我们在简化程序时经常要发出这样的疑问,“我们真正要解

(24)

决的问题是什么?”或者是,“有没有更好的函数来解决这个问题?”

当我把这个分析过程讲授给本科生时,这个程序最终被缩减成零行代码,化为一阵数学 的轻烟消失了。我们可以把示例 2-7 重新解释为以下的循环关系:

0

= 0

C

+

+

=

n i

i n i

n

n n C C

C

1

)

1

/ 1 ( ) 1 (

这正是 Hoare 所采用的方法,并且后来由 D.E.Knuth 在他经典的《The Art of Computer Programming》(Addison-Wesley)一书的第三卷:排序与查找中给出的方法中给出了描述。

通过重新表达编程思想的技巧和在示例 2-10 中使用的对称性,使我们可以把递归部分简化 为:

+

=

1 0

) / 2 ( 1

n i

i

n

n n C

C

Knuth 删除了求和符号,从而引出了示例 2-11,这可以被重新表达为一个在两个未知量 之间有着两种循环关系的系统:

0

= 0

C S

0

= 0 S

n

= S

n1

+ 2 C

n1

C

n

= n − 1 + S

n

/ n

Knuth 使用了“求和因子”的数学方法来实现这种解决方案:

n n H

n

C

n

= ( + 1 )( 2

n+1

− 2 ) − 2

n

~ 1 . 386 lg

其中 表示第 n 个调和数(harmonic number),即 1 + 1/2 + 1/3 + … 1/n。这样,

我们就从对程序不断进行修改以得到实验数据顺利地过渡到了对程序行为进行完全的数学 分析。

H

n

在得到这个公式之后,我们就可以结束我们的讨论。我们已经遵循了 Einstein 的著名 建议:“尽量使每件事情变得简单,并且直到不可能再简单为止。”

附加分析

Goethe 的著名格言是:“建筑是静止的音乐”。按照这种说法,我可以说“数据结构 是静止的算法。”如果我们固定了 Quichsort 算法,那么就将得到了一个二分搜索树的数据 结构。在 Knuth 发表的文章中给出了这个结构并且采用类似于在 Quichsort 中的循环关系来 分析它的运行时间。

如果要分析把一个元素插入到二分搜索树中的平均开销,那么我们可以以这段代码作为 起点,并且对这段代码进行扩展来统计比较次数,然后在我们收集的数据上进行实验。接下 来,我们可以仿照前面章节中的方式来简化代码。一个更为简单的解决方案就是定义一个新 的 Quichsort,在这个算法中使用理想的划分算法把有着相同关联顺序的元素划分到两边。

Quichsort 和二分搜索树是同构的,如图 2-1 所示。

图 2-1 实现理想划分的 Quicksort 以及相应的二分搜索树

(25)

左边的方框给出了正在进行中的理想划分的 Quicksort,右边的图则给出了相应的从相 同输入中构建起来的二分搜索树。这两个过程不仅需要进行相同次数的比较,而且还将生成 相同的比较集合。通过在前面对于在一组不同元素上进行 Quicksort 实验的平均性能分析,

我们就可以得到将不同的元素随机插入到二分搜索树中的平均比较次数。

2.4 本章的中心思想是什么?

表面上看来,我“所写的”内容就是从示例 2-2 到示例 2-12 的程序。我最初是漫不经 心地编写这些程序,然后将这些程序写在给本科生讲课的黑板上,并且最终写到本章中。我 有条不紊地进行着这些程序的修改,并且花了大量的时间来分析这些程序,从而确信它们都 是正确的。然而,除了在示例 2-11 中实现的表格外,我从来没有把任何一个示例作为计算 机程序运行过。

我在贝尔实验室呆了将近二十年,我从许多教师(尤其是 Brian Kernighan,他所编写 的编程内容作为本书的第 1 章)那里学到了:要“编写”一个在大众面前展示的程序,所涉 及到的东西比键入这个程序要多得多。有人用代码实现了这个程序,最初运行在一些测试示 例中,然后构建了完整的系统框架、驱动程序以及一个案例库来支撑这段代码。理想的情况 是,人们可以手动地把编译后的代码包含到文本中,不加入任何的人为干涉。基于这种想法,

我编写了示例 2-1(以及在《Programming Pearls》中的所有代码)。

为了维护面子,我希望永远都不要实现从示例 2-2 到示例 2-12 的代码,从而使我保持 诚实的名声。然而,在计算机编程中的近四十年的实践使我对这个任务的困难性有着深深的 敬畏(好吧,更准确地说,是对于错误的害怕)。我妥协了,把示例 2-11 用表格方式实现 出来,并且无意中得到了一个完备的解答。当这两个东西完美地匹配在一起时,你可以想象 一下我当时的喜悦吧!因此,我向世界提供了这些漂亮的并且未曾实现的程序,虽然在这些 程序中可能会有一些还未发现的错误,但我对这些程序的正确性还是有一定信心的。我希望 一些细微的错误不会掩盖我在这些程序中所展示的那些漂亮思想。

当我为给出这些没有被实现过的程序感到不安时,Alan Perlis 的话安慰了我,他说“软 件是不是不像任何一个事物,它就是意味着被抛弃:软件的所有意义就是把它看作为一个肥 皂泡?”

2.5 结论

漂亮的含义有着许多来源。本章通过简化、优雅以及精简来刻画了漂亮的含义。下面这

(26)

些名言表达的是同样的意思:

* 通过删除代码来实现功能的提升。

* 只有在不仅没有任何功能可以添加,而且也没有任何功能可以删除的情况下,设计 师才能够认为自己的工作已臻完美。

* 有时候,在软件中根本就不存在最漂亮的代码,最漂亮的函数,或者最漂亮的程序。

* 良好的写作风格即为简练。省略不必要的字词。 (Strunk and White)

* 在计算机系统中,那些最廉价、速度最快以及最为可靠的组件是不存在的(Bell)

* 努力做到事倍功半。

* 如果我有更多的时间,那么我给你写的信就会越短(Pascal)

* 发明家的矛盾:计划越宏大,成功的可能性就越大。(Pólya)

* 简单性并不是在复杂性之前,而是在复杂性之后(Perlis)

* 少即是多。(Browning)

* 尽量使每件事情变得简单,并且直到不可能再简单为止(Einstein)

* 软件有时候应该被视作为一个肥皂泡(Perlis)

* 在简单中寻找漂亮。

本章的内容到此结束。读者可以复习所学到的内容并进行模拟实验。

对于那些想要得到更具体信息的人们,我在下面给出了一些观点,这些观点分为三类

程序分析

深入理解程序行为的方式之一就是修改这个程序,然后在具有代表性的数据上运行这个 程序,就像示例 2-2 那样。不过,我们通常会更关心程序的某个方面而不是程序的整体。例 如,我们只是考虑 Quichsort 所使用的平均比较次数,而忽略了其他的方面。Sedgewick ("The analysis of Quicksort programs," Acta Informatica, Vol. 7)研究了 Quichsort 的其他 特性,例如算法所需的存储空间以及各种 Quicksort 运行时间的其他方面。我们可以关注这 些关键问题,而暂时)忽略了程序其他不太重要的方面。在我的一篇文章"A Case Study in Applied Algorithm Design" (IEEE Computer, Vol. 17, No. 2)中指出了我曾经遇到过的 一个问题:对在单元空间中找出货郎行走路线的strip 启发式算法的性能进行评价。我估计 完成这个任务所要的程序大概在 100 行代码左右。在经历了一系列类似于本章前面看到的分 析步骤之后,我只使用了十几行代码的模拟算法就实现了更为精确的效果(在我写完了这个 模拟算法后,我发现 Beardwood 等人["The Shortest Path Through Many Points," Proc.

Cambridge Philosophical Soc., Vol. 55]已经更完整地表述了我的模拟算法,因此已经在 二十几年前就从数学上解决了这个问题)。

小段代码

我相信计算机编程是一项实践性的技术,并且我也同意这个观点:“任何技术都必须通 过模仿和实践来掌握。” 因此,想要编写漂亮代码的程序员应该阅读一些漂亮的程序以及 在编写程序时模仿所学到的技术。我发现在实践时有个非常有用的东西就是小段代码,也就 是一二十行的代码。编写《Programming Pearls》这本书是一件艰苦的工作,但同时也有着 极大的乐趣。我实现了每一小段代码,并且亲自把每段代码都分解为基本的知识。我希望其 他人在阅读这些代码时与我在编写这些代码时有着同样的享受过程。

(27)

软件系统

为了有针对性,我极其详尽地描述了一个小型任务。我相信其中的这些准则不仅存在于 小型程序中,它们同样也适用于大型的程序以及计算机系统。Parnas("Designing software for ease of extension and contraction," IEEE T. Software Engineering, Vol. 5, No.

2)给出了把一个系统拆分为基本构件的技术。为了得用快速的应用性,不要忘了 Tom Duff 的名言:“在尽可能的情况下,利用现有的代码。”

2.6 致谢

非常感谢 Dan Bentley, Brian Kernighan, Andy Oram 和 David Weiss 卓有见识的评语。

(28)

美丽测试 > 讨厌的二分查找

第 3 章 美丽测试

Alberto Savoia

许多程序员都有过这样的经历:看一段代码,觉得它不仅实现了功能,而且实现得很漂 亮。通常,如果一段代码能优雅、简洁地完成了需要完成的功能,我们就认为这样的代码很 漂亮。

那对于漂亮代码的测试,尤其是那种开发者在编写代码的同时编写的(或者应该编写的)

测试,情况又是怎样的呢?在这一章,我将专注于讨论测试,因为测试本身也可以是漂亮的。

更重要的是,它们能起到非常关键的作用,可以帮你写出更漂亮的代码。

正如我们将要看到的,有些东西,如果把它们组合起来会使测试很漂亮。跟代码不同的 是,我无法让自己认为某个单一的测试很漂亮,至少跟我看待一个排序函数,并认为它漂亮 的情况不一样。原因是测试天生就带有组合性和试探性。代码中的每一条 if 语句至少需要 两个测试(一个用于条件表达式为真的情况,另一个用于为假的情况)。一条拥有多个条件 的 if 语句,比如:

if ( a || b || c )

理论上需要 8 个测试——每一个对应 a、b 和 c 不同值的一个可能的组合。如果再考虑 循环中的异常,多个输入参数,对外部代码的依赖,不同的软硬件平台等,所需测试的数量 和类型将大大增加。

除了最简单的情况,任何代码,不管漂亮与否,都需要一组(而不是一个)测试,这些 测试中的每一个都应该专注于检查代码的一个特定的方面,就像球队一样,不同的队员有不 同的职责,负责球场的不同区域。

我们已经知道应该以“组”为单位来对测试进行整体评估,现在我们需要进一步了解都 有哪些特性能决定一组测试是否漂亮——“漂亮”,一个很少用来修饰“测试”的形容词。

一般来讲,测试的主要目的是逐步建立,不断加强并再次确认我们对于代码的信心:即 代码正确并高效地实现了功能。因此对我来讲,最漂亮的测试是那些能将我们的信心最大化

(29)

的测试,这个信心就是代码的确实现了它被要求的功能,并将一直保持这一点。由于代码不 同方面的属性需要不同类型的测试来验证,所以对于“漂亮”的评判准则也不是固定的。本 章考查了能使测试漂亮的三种方法。

测试因简单而漂亮

简单的几行测试代码,使我能描述并验证目标代码的基本行为。通过在每次构 建时自动运行那些测试,能确保代码在不断开发的过程中始终保持所要求的行为。

本章将使用 JUnit 测试框架来给出一些比较基本的测试例子,这些只需几分钟就能 编写的测试,将在项目的整个生命周期中使我们不断受益。

测试因揭示出使代码更优雅,更可维护和更易测试的方法而漂亮

换句话讲,测试能帮我们把代码变得更漂亮。编写测试的过程不仅能帮我们找 出实现中的逻辑错误,还能帮我们发现结构和设计上的问题。在这一章,通过尝试 编写测试,我将演示我是怎样找到了一种能使我的代码更健壮、更有可读性、结构 也更好的方法的。

测试因其深度和广度而漂亮

深入彻底、覆盖无遗的测试会大大增强开发者的信心,这种信心就是代码不仅 在一些基本的、手工挑选的情形下,而且在所有的情形下都实现了所需的功能。在 这一章,我将演示怎样根据测试理论中的概念来编写和运行这类测试。

由于大多数程序开发者都已经熟悉了诸如冒烟测试(smoke testing)和边界测试

(boundary testing)等基本的测试技术,我将花更多的时间来讨论更有效类型的测试和那 些很少被讨论和应用的测试技术。

3.1 讨厌的二分查找

(30)

为了演示多种不同的测试技术,同时又保持本章的篇幅合理,需要一个简单、易描述,

并能通过几行代码就能实现的例子。同时,这个例子还必须足够生动,拥有一些有趣的挑战 测试的特性。最理想的情况是这个例子要有一个悠久的总是被实现出许多 bug 的历史,从 而显出对彻底测试的迫切需要。最后但并非最不重要的一点:如果这个例子本身也被认为是 漂亮的代码那就再好不过了。

每当讨论漂亮的代码,就很容易让人联想起 Jon Bentley 那本经典的由 Addison-Wesley 出版的《Programming Pearls》 (中文名《编程珠玑》,译者注)。我就是在读那本书的 时候,发现了我要找的代码例子:二分查找。

让我们快速复习一下,二分查找是一个简单而又高效的算法(但我们即将看到,要正确 实现它也是有点难度的),这个算法用来确定一个预先排好顺序的数组 x[0..n-1]中是否含有 某个目标元素 t。如果数组包含 t,程序返回它在数组中的位置,否则返回-1。

Jon Bentley 是这样向学生们描述该算法的:

在一个包含 t 的数组内,二分查找通过对范围的跟综来解决问题。开始时,范围就是整 个数组。通过将范围中间的元素与 t 比较并丢弃一半范围,范围就被缩小。这个过程一直持 续,直到在 t 被发现,或者那个能够包含 t 的范围已成为空。

他又说到:

大多数程序员认为,有了上面的描述,写出代码是很简单的事情。他们错了。能使你相 信这一点的惟一方法是现在就合上书,去亲手写写代码试试看。

我 Second Bentley 的建议。如果你从来没有写过二分查找,或者有好几年没写过了,

我建议你在继续读下去之前亲手写一下;它会使你对后面的内容有更深的体会。

二分查找是一个非常好的例子,因为它是如此简单,却又如此容易被写错。在

《Programming Pearls》一书中,Jon Bentley 记述了他是怎样在多年的时间里先后让上百 个专业程序员实现二分查找的,而且每次都是在他给出一个算法的基本描述之后。他很慷慨,

每次给他们两个小时的时间来实现它,而且允许他们使用他们自己选择的高级语言(包括伪 代码)。令人惊讶的是,大约只有 10%的专业程序员正确地实现了二分查找。

(31)

更让人惊讶的是,Donald Knuth 在他的《Sorting and Searching》[1]一书中指出,尽管第 一个二分查找算法早在 1946 年就被发表,但第一个没有 bug 的二分查找算法却是在 12 年 后才被发表出来。

[注]见《计算机程序设计艺术,第 3 卷:排序和查找(第二版)》,Addison-Wesley,

1998。(国内由清华大学出版社出版影印版-译者注)。

然而,最让人惊讶的是,Jon Bentley 正式发表的并被证明过的算法,也就是被实现或 改编过成千上万次的那个,最终还是有问题的,问题发生在数组足够大,而且实现算法的语 言采用固定精度算术运算的时候。

在 Java 语言中,这个 bug 导致一个 ArrayIndexOutOfBoundsException 异常被抛出,

而在 C 语言中,你会得到一个无法预测的越界的数组下标。你可以在 Joshua Bloch 的 blog 上找到更多关于这个 bug 的信息:

http://googleresearch.blogspot.com/2006/06/extra-extra-read-all-about-it-nearly.html

以下就是带有这个著名的 bug 的 Java 实现:

public static int buggyBinarySearch(int[] a, int target) { int low = 0;

int high = a.length - 1;

while (low <= high) {

int mid = (low + high) / 2;

int midVal = a[mid];

if (midVal < target) low = mid + 1;

else if (midVal > target) high = mid - 1;

else

(32)

return mid;

}

return -1;

}

Bug 位于这一行:

int mid = (low + high) / 2;

如果low和high的和大于Integer.MAX_VALUE(在Java中是 231 -1),计算就会发生溢 出,使它成为一个负数,然后被 2 除时结果当然仍是负数。

推荐的解决方案是修改计算中间值的方法来防止整数溢出。方法之一是用减法——而不 是加法——来实现:

int mid = low + ((high - low) / 2);

或者,如果你想炫耀一下自己掌握的位移运算的知识,那个 blog(还有 Sun 微系统公 司的官方 bug report[1])建议使用无符号位移运算,这种方法或许更快,但对大多数 Java 程序员(包括我)来说,可能也比较晦涩。

[1] http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=5045582

int mid = (low + high) >>> 1;

想一下,二分查找算法的思想是多么的简单,而这么多年又有多少人的多少智力花在它 上面,这就充分说明了即使是最简单的代码也需要测试,而且需要很多。Joshua Bloch 在 它的 blog 中对这个 bug 作了非常漂亮的陈述:

这个 bug 给我上的最重要的一课就是要懂得谦逊:哪怕是最简单的一段代码,要写正 确也并非易事,更别提我们现实世界中的系统:它们跑在大段大段的复杂代码上。

(33)

下面是我要测试的二分查找的实现。理论上讲,对于中间值的计算方法的修正,应该是 解决了这段令人讨厌的代码的最后一个 bug,一个在好几十年的时间里,连一些最好的程序 员都抓不到的 bug。

public static int binarySearch(int[] a, int target) { int low = 0;

int high = a.length - 1;

while (low <= high) {

int mid = (low + high) >>> 1;

int midVal = a[mid];

if (midVal < target) low = mid + 1;

else if (midVal > target) high = mid - 1;

else

return mid;

}

return -1;

}

这个版本的 binarySearch 看上去是正确的,但它仍可能有问题。或许不是 bug,但至 少是可以而且应该被修改的地方。这些修改可以使代码不仅更加健壮,而且可读性,可维护 性和可测试性都比原来更好。让我们看看是否可以通过测试来发现一些有趣的和意想不到的 改善它的机会。

(34)

3.2 JUnit 简介

谈到漂亮测试,就很容易想到 JUnit 测试框架。因为我使用 Java,通过使用 JUnit 来构 建我的漂亮的测试是一个很自然的决定。但在做之前,考虑到你对 JUnit 可能尚未熟悉,让 我先对它做一个简单介绍吧。

JUnit 是 Kent Beck 和 Erich Gamma 设计的,他们创造 JUnit 来帮助 Java 开发者编写 和运行自动的和自检验的测试。它有一个很简单,却又很宏伟的目标:就是使得程序开发者 更容易去做他们本来就应该做的事情:测试自己的代码。

遗憾的是,我们还要走很长的路才能到达那种大多数程序员都像是被“测试病毒”所感 染的阶段(在那种情况下,程序员们试着自己编写测试,并决定把它看作开发中的一个常规 的重要组成部分)。然而,自从被引入开发领域,任何其他的东西都没能像 JUnit 那样使这 么多的程序员开始编写测试。不过这也得感谢极限编程和其他敏捷开发方法的巨大帮助,在 这些方法中,开发者参加程序测试是必须的[1]。Martin Fowler 对 JUnit 的影响作了这样的 概括:“少量代码对大量的代码起了如此重要的作用,这在软件开发领域中是前所未有的事。”

[1] 能够彰显 JUnit 的成功及影响力的另一个事实是,如今针对大多数现代编程语言 的测试框架都出现了,它们都是从 JUnit 那里得到的灵感,JUnit 的各类扩展也出现了。

JUnit 被特地设计得很简单,易学易用。这是 JUnit 的一个重要的设计准则。Kent Beck 和 Erich Gamma 花费了大量心思来确保 JUnit 的易学易用,于是程序员们才会真正使用它。

它们自己是这样说的:

我们的第一目标就是要写出一个框架,使我们可以对程序员们真正在其中编写测试抱有 希望。这个框架必须使用人们熟悉的工具,这样大家就不用学习很多新东西;必须保证编写 一个新的测试所需的工作量降至最低;还必须能够消除重复劳动。[1]

[1]《JUnit: A Cook’s Tour》,Kent Beck,Erich Gamma。

http://junit.sourceforge.net/doc/cookstour/cookstour.htm.

參考文獻

相關文件

After a brief introduction on Master Juzan's experiences for the reform of Chinese Buddhism, this paper describes and analyzes the theory and practice of &#34;new Buddhism&#34;

版面為 A4 單面全彩印刷。桂冠葉及星星圖案以燙金方式印刷。... 附件四

Wang Sanching, A Comparative Study on Shi Yingzhi’s the Wushan Lianruo Xinxue Beiyong Part One and the Compilation of the &#34;Fashu&#34; in Dunhuang Manuscripts Chu Fengyu, On

And, the value or function of the various theories of consciousness should be judged on the basis of Hua Yen's &#34; complete teaching &#34; in order to ascertain the status

In order to assess and appreciate the results of all these studies, and to promote further research on the Suan Shu Shu, an international Symposium was held on August 23-25

The relief fresco &#34;Stories of the Buddha's Life&#34; embody the advancement of life education: a profound outlook on life, religion and life and death, ultimate care, life

學校需確保計算器讀數只包括特許協議所涵蓋的版權印刷作品

Wolfgang, &#34;The Virtual Device: Expanding Wireless Communication Services through Service Discovery and Session Mobility&#34;, IEEE International Conference on