C语言接口与实现:创建可重用软件的技术
前 言
如今的程序员忙于应付大量关于API(Application Programming Interface)的信息。但是,大多数程序员都会在其所写的几乎每一个应用程序中使用API并实现API的库,只有少数程序员会创建或发布新的能广泛应用的API。事实上,程序员似乎更喜欢使用自己搞的东西,而不愿意查找能满足他们要求的程序库,这或许是因为写特定应用程序的代码要比设计可广泛使用的API容易。
不好意思,我也未能免俗:lcc(我和Chris Fraser为ANSI/ISO C编写的编译器)就是从头开始编写的API。(在A Retargetable C Compiler: Design and Implementation一书中有关于lcc的介绍。)编译器是这样一类应用程序:可以使用标准接口,并且能够创建在其他地方也可以使用的接口。这类程序还有内存管理、字符串和符号表以及链表操作等。但是lcc仅使用了很少的标准C库函数的例程,并且它的代码几乎都无法直接应用到其他应用程序中。
本书提倡的是一种基于接口及其实现的设计方法,并且通过对24个接口及其实现的描述详细演示了该方法。这些接口涉及很多计算机领域的知识,包括数据结构、算法、字符串处理和并发程序。这些实现并不是简单的玩具,而是为在产品级代码中使用而设计的。实现的代码是可免费提供的。
C编程语言基本不支持基于接口的设计方法,而C++和Modula-3这样的面向对象的语言则鼓励将接口与实现分离。基于接口的设计跟具体的语言无关,但是它要求程序员对像C一样的语言有更强的驾驭能力和更高的警惕性,因为这类语言很容易破坏带有隐含实现信息的接口,反之亦然。
然而,一旦掌握了基于接口的设计方法,就能够在服务于众多应用程序的通用接口基础上建立应用程序,从而加快开发速度。在一些C++环境中的基础类库就体现了这种效果。增加对现有软件(接口实现库)的重用,能够降低初始开发成本,同时还能降低维护成本,因为应用程序的更多部分都建立在通用接口的实现之上,而这些实现无不经过了良好的测试。
本书中的24个接口引自几本参考书,并且针对本书特别做了修正。一些数据结构(抽象数据类型)中的接口源于lcc代码和20世纪70年代末到80年代初所做的Icon编程语言的实现代码(参见R. E. Griswold和M. T. Griswold所著的The Icon Programming Language)。其他的接口来自另外一些程序员的著作,我们将会在每一章的“扩展阅读”部分给出详细信息。
书中提供的一些接口是针对数据结构的,但本书不是介绍数据结构的,本书的侧重点在算法工程(包装数据结构以供应用程序使用),而不在数据结构算法本身。然而,接口设计的好坏总是取决于数据结构和算法是否合适,因此,本书可算是传统数据结构和算法教材(如Robert Sedgewick所著的Algorithms in C)的有益补充。
大多数章节会只介绍一个接口及其实现,少数章节还会描述与其相关的接口。每一章的“接口”部分将会单独给出一个明确而详细的接口描述。对于兴趣仅在于接口的程序员来说,这些内容就相当于一本参考手册。少数章节还会包含“例子”部分,会说明在一个简单的应用程序中接口的用法。
每章的“实现”部分将会详细地介绍本章接口的实现代码。有些例子会给出一个接口的多种实现方法,以展示基于接口设计的优点。这些内容对于修改或扩展一个接口或是设计一个相关的接口将大有裨益。许多练习题会进一步探究一些其他可行的设计与实现的方法。如果仅是为了理解如何使用接口,可以不用阅读“实现”一节。
接口、示例和实现都以文学(literate)程序的方式给出,换句话说,源代码及其解释是按照最适合理解代码的顺序交织出现的。代码可以自动地从本书的文本文件中抽取,并按C语言所规定的顺序组合起来。其他也用文学程序讲解C语言的图书有A Retargetable C Compiler和D.E.Knuth写的The Stanford GraphBase: A Platform for Combinatorial Computing。
本书架构
本书材料可分成下面的几大类:
基础 1. 引言
2. 接口与实现
4. 异常与断言
5. 内存管理
6. 再谈内存管理
数据结构 7. 链表
8. 表
9. 集合
10. 动态数组
11. 序列
12. 环
13. 位向量
字符串 3. 原子
14. 格式化
15. 低级字符串
16. 高级字符串
算法 17. 扩展精度算术
18. 任意精度算术
19. 多精度算术
线程 20. 线程
建议大多数读者通读第1章至第4章的内容,因为这几章形成了本书其余部分的框架。对于第5章至第20章,虽然某些章会参考其前面的内容,但影响不大,读者可以按任何顺序阅读。
第1章介绍了文学程序设计和编程风格与效率。第2章提出并描述了基于接口的设计方法,定义了相关的术语,并演示了两个简单的接口及其实现。第3章描述了Atom接口的实现原型,这是本书中最简单的具有产品质量的接口。第4章介绍了在每一个接口中都会用到的异常与断言。第5章和第6章描述了几乎所有的实现都会用到的内存管理接口。其余各章都分别描述了一个接口及其实现。
教学使用建议
我们假设本书的读者已经在大学介绍性的编程课程中了解了C语言,并且都实际了解了类似《C算法》一书中给出的基本数据结构。在普林斯顿,本书是大学二年级学生到研究生一年级的系统编程课程的教材。许多接口使用的都是高级C语言编程技巧,比如说不透明的指针和指向指针的指针等,因此这些接口都是学习这些内容非常好的实例,对于系统编程和数据结构课程非常有用。
这本书可以以多种方式在课堂上使用,最简单的就是用在面向项目的课程中。例如,在编译原理课程中,学生通常需要为一个玩具语言编写一个编译器。在图形学课程中同样也经常有一些实际的项目。本书中许多接口消除了新建项目所需要的一些令人厌烦的编程工作,从而简化了这类课程中的项目。这种用法可以帮助学生认识到在项目中重用代码可以节省大量劳动,并且引导学生在其项目中对自己所做的部分尝试使用基于接口的设计。后者在团队项目中特别有用,因为“现实世界”中的项目通常都是团队项目。
普林斯顿大学二年级系统编程课程的主要内容是接口与实现,其课外作业要求学生成为接口的用户、实现者和设计者。例如其中的一个作业是这样的,我给出了8.1节中描述的Table接口、它的实现的目标代码以及8.2节中描述的单词频率程序wf的说明,让学生只使用我们为Table设计的目标代码来实现wf。在下一个作业中,wf的目标代码就有了,他们必须实现Table。有时我会颠倒这些作业的顺序,但是这两种顺序对大部分学生来说都是很新颖的。他们不习惯在大部分程序中只使用目标代码,并且这些作业通常都是他们第一次接触到在接口和程序说明中使用半正式表示法。
最初布置的作业也介绍了作为接口说明必要组成部分的可检查的运行时错误和断言。同样,只有做过几次这样的作业之后,学生们才开始理解这些概念的意义。我禁止了突发性崩溃,即不是由断言错误的诊断所宣布的崩溃。运行崩溃的程序将被判为零分,这样做似乎过于苛刻,但是它能够引起学生们的注意,而且也能够让学生理解安全语言的好处,例如ML和Modula-3,在这些语言中,不会出现突发性崩溃。(这种评分方法实际上没有那么苛刻,因为在分成多个部分的作业中,只有产生冲突的那部分作业才会判为错误,而且不同的作业权重也不同。我给过许多0分,但是从来没有因此导致任何一个学生的课程总成绩降低达1分。)
一旦学生们有了自己的几个接口后,接下来就让他们设计新的接口并沿用以前的设计选择。例如,Andrew Appel最喜欢的一个作业是一个原始的测试程序。学生们以组为单位设计一个作业需要的任意算术精度的接口,作业的结果类似于第17章到第19章中描述的接口。不同的组设计的接口不同,完成后对这些接口进行比较,一个组对另一个组设计的接口进行评价,这样做很有启迪作用。Kai Li的那个需要一个学期来完成的项目也达到了同样的学习实践效果,该项目使用Tcl/Tk系统(参见J. K. Ousterhout所著的Tcl and the Tk Toolkit)以及学生们设计和实现的编辑程序专用的接口,构建了一个基于X的编辑程序。Tk本身就是一个很好的基于接口的设计。
在高级课程中,我通常把作业打包成接口,学生可以自行修改和改进,甚至改变作业的目标。给学生设置一个起点可以减少他们完成作业所需的时间,允许他们做一些实质性的修改鼓励了有创造性的学生去探索新的解决办法。通常,那些不成功的方法比成功的方法更让学生记忆深刻。学生不可避免地会走错路,为此也付出了更多的开发时间。但只有当他们事后再回过头来看,才会了解所犯的错误,也才会知道设计一个好的接口虽然很困难,但是值得付出努力,而且到最后,他们几乎都会转到基于接口的设计上来。
目 录
第1章 引言 1
1.1 文学程序 2
1.2 程序设计风格 6
1.3 效率 8
1.4 扩展阅读 9
1.5 习题 9
第2章 接口与实现 11
2.1 接口 11
2.2 实现 13
2.3 抽象数据类型 15
2.4 客户程序的职责 17
2.5 效率 21
2.6 扩展阅读 22
2.7 习题 22
第3章 原子 24
3.1 接口 24
3.2 实现 25
3.3 扩展阅读 30
3.4 习题 31
第4章 异常与断言 33
4.1 接口 35
4.2 实现 38
4.3 断言 44
4.4 扩展阅读 46
4.5 习题 47
第5章 内存管理 49
5.1 接口 50
5.2 产品实现 54
5.3 稽核实现 55
5.4 扩展阅读 62
5.5 习题 63
第6章 再谈内存管理 65
6.1 接口 65
6.2 实现 67
6.3 扩展阅读 72
6.4 习题 73
第7章 链表 75
7.1 接口 75
7.2 实现 79
7.3 扩展阅读 83
7.4 习题 83
第8章 表 84
8.1 接口 84
8.2 例子:词频 87
8.3 实现 91
8.4 扩展阅读 97
8.5 习题 97
第9章 集合 99
9.1 接口 99
9.2 例子:交叉引用列表 101
9.3 实现 107
9.3.1 成员操作 109
9.3.2 集合操作 111
9.4 扩展阅读 114
9.5 习题 115
第10章 动态数组 116
10.1 接口 116
10.2 实现 119
10.3 扩展阅读 122
10.4 习题 122
第11章 序列 123
11.1 接口 123
11.2 实现 125
11.3 扩展阅读 129
11.4 习题 129
第12章 环 131
12.1 接口 131
12.2 实现 134
12.3 扩展阅读 141
12.4 习题 141
第13章 位向量 142
13.1 接口 142
13.2 实现 144
13.2.1 成员操作 146
13.2.2 比较 150
13.2.3 集合操作 151
13.3 扩展阅读 152
13.4 习题 153
第14章 格式化 154
14.1 接口 154
14.1.1 格式化函数 155
14.1.2 转换函数 157
14.2 实现 160
14.2.1 格式化函数 161
14.2.2 转换函数 166
14.3 扩展阅读 170
14.4 习题 171
第15章 低级字符串 172
15.1 接口 173
15.2 例子:输出标识符 178
15.3 实现 179
15.3.1 字符串操作 180
15.3.2 分析字符串 184
15.3.3 转换函数 188
15.4 扩展阅读 189
15.5 习题 189
第16章 高级字符串 192
16.1 接口 192
16.2 实现 197
16.2.1 字符串操作 200
16.2.2 内存管理 204
16.2.3 分析字符串 205
16.2.4 转换函数 209
16.3 扩展阅读 210
16.4 习题 210
第17章 扩展精度算术 212
17.1 接口 212
17.2 实现 217
17.2.1 加减法 218
17.2.2 乘法 220
17.2.3 除法和比较 221
17.2.4 移位 226
17.2.5 字符串转换 228
17.3 扩展阅读 230
17.4 习题 230
第18章 任意精度算术 232
18.1 接口 232
18.2 例子:计算器 235
18.3 实现 240
18.3.1 取反和乘法 242
18.3.2 加减法 243
18.3.3 除法 246
18.3.4 取幂 247
18.3.5 比较 249
18.3.6 便捷函数 250
18.3.7 移位 251
18.3.8 与字符串和整数的转换 252
18.4 扩展阅读 254
18.5 习题 255
第19章 多精度算术 257
19.1 接口 257
19.2 例子:另一个计算器 263
19.3 实现 269
19.3.1 转换 272
19.3.2 无符号算术 275
19.3.3 有符号算术 277
19.3.4 便捷函数 280
19.3.5 比较和逻辑操作 285
19.3.6 字符串转换 288
19.4 扩展阅读 290
19.5 习题 291
第20章 线程 292
20.1 接口 294
20.1.1 线程 294
20.1.2 一般信号量 298
20.1.3 同步通信通道 301
20.2 例子 301
20.2.1 并发排序 302
20.2.2 临界区 305
20.2.3 生成素数 307
20.3 实现 311
20.3.1 同步通信通道 311
20.3.2 线程 313
20.3.3 线程创建和上下文切换 322
20.3.4 抢占 328
20.3.5 一般信号量 330
20.3.6 MIPS和ALPHA上的上下文
切换 332
20.4 扩展阅读 335
20.5 习题 336
附录A 接口摘要 339
参考书目 363
引 言
一个大程序由许多小的模块组成。这些模块提供了程序中使用的函数、过程和数据结构。理想情况下,这些模块中大部分都是现成的并且来自于库,只有那些特定于现有应用程序的模块需要从头开始编写。假定库代码已经全面测试过,而只有应用程序相关的代码会包含bug,那么调试就可以仅限于这部分代码。
遗憾的是,这种理论上的理想情况实际上很少出现。大多数程序都是从头开始编写,它们只对最低层次的功能使用库,如I/O和内存管理。即使对于此类底层组件,程序员也经常编写特定于应用程序的代码。例如,将C库函数malloc和free替换为定制的内存管理函数的应用程序也是很常见的。
造成这种情况的原因无疑有诸多方面。其中之一就是,很少有哪个普遍可用的库包含了健壮、设计良好的模块。一些可用的库相对平庸,缺少标准。虽然C库自1989年已经标准化,但直至现在才出现在大多数平台上。
另一个原因是规模问题:一些库规模太大,从而导致对库本身功能的掌握变成了一项沉重的任务。哪怕这项工作的工作量似乎稍逊于编写应用程序所需的工作量,程序员可能都会重新实现库中他们所需的部分功能。最近出现颇多的用户界面库,通常会有这种问题。
库的设计和实现是困难的。在通用性、简单性和效率这三个约束之间,设计者必须如履薄冰,审慎前行。如果库中的例程和数据结构过于通用,那么库本身可能难以使用,或因效率较低而无法达到预定目标。如果库的例程和数据结构过于简单,又可能无法满足应用程序的需求。如果库太难于理解,程序员干脆就不会使用它们。C库本身就提供了一些这样的例子,例如其中的realloc函数,其语义混乱到令人惊讶的地步。
库的实现者面临类似的障碍。即使设计做得很好,糟糕的实现同样会吓跑用户。如果某个实现太慢或太庞大,或只是感觉上如此,程序员都将自行设计替代品。最糟的是,如果实现有bug,它将使上述的理想状况彻底破灭,从而使库也变得无用。
本书描述了一个库的设计和实现,它适应以C语言编写的各种应用程序的需求。该库导出了一组模块,这些模块提供了用于小规模程序设计(programming-in-the-small)的函数和数据结构。在几千行长的应用程序或应用程序组件中,这些模块适于用作零部件。
在后续各章中描述的大部分编程工具,都涵盖在大学本科数据结构和算法课程中。但在本书中,我们更关注将这些工具打包的方式,以及如何使之健壮无错。各个模块都以一个接口及其实现的方式给出。这种设计方法学在第2章中进行了解释,它将模块规格说明与其实现相分离,以提高规格说明的清晰度和精确性,而这有助于提供健壮的实现。
1.1 文学程序
本书并不是以“处方”的形式来描述各个模块,而是通过例子描述。各章完整描述了一两个接口及其实现。这些描述以文学程序(literate program)的形式给出。接口及其实现的代码与对其进行解释的正文交织在一起。更重要的是,各章本身就是其描述的接口和实现的源代码。代码可以从本书的源文件文本中自动提取出来,所见即所得。
文学程序由英文正文和带标签的程序代码块组成。例如,
〈compute x • y〉≡
sum = 0;
for (i = 0; i < n; i++)
sum += x[i]*y[i];
定义了名为〈compute x • y〉的代码块,其代码计算了数组x和y的点积。在另一个代码块中使用该代码块时,直接引用即可:
〈function dotproduct〉≡
int dotProduct(int x[], int y[], int n) {
int i, sum;
〈compute x • y〉
return sum;
}
当〈function dotproduct〉代码块从本章对应的源文件中抽取出来时,将逐字复制其代码,用到代码块的地方都将替换为对应的代码。抽取〈function dotproduct〉的结果是一个只包含下述代码的文件:
int dotProduct(int x[], int y[], int n) {
int i, sum;
sum = 0;
for (i = 0; i < n; i++)
sum += x[i]*y[i];
return sum;
}
文学程序可以按各个小片段的形式给出,并附以完备的文档。英文正文包含了传统的程序注释,这些并不受程序设计语言的注释规范的限制。
代码块的这种特性将文学程序从编程语言强加的顺序约束中解放出来。代码可以按最适于理解的顺序给出,而不是按语言所硬性规定的顺序(例如,程序实体必须在使用前被定义)。
本书中使用的文学编程系统还有另外一些特性,它们有助于逐点对程序进行描述。为说明这些特性并提供一个完整的C语言文学程序的例子,本节其余部分将描述double程序,该程序检测输入中相邻的相同单词,如“the the”。
% double intro.txt inter.txt
intro.txt:10: the
inter.txt:110: interface
inter.txt:410: type
inter.txt:611: if
上述UNIX命令结果说明,“the”在intro.txt文件中出现了两次,第二次出现在第10行;而在inter.txt文件中,interface、type和if也分别在给出的行出现第二次。如果调用double时不指定参数,它将读取标准输入,并在输出时略去文件名。例如:
% cat intro.txt inter.txt | double
10: the
143: interface
343: type
544: if
在上述例子和其他例示中,由用户键入的命令显示为斜代码体,而输出则显示为通常的代码体。
我们先从定义根代码块来实现double,该代码块将使用对应于程序各个组件的其他代码块:
〈double.c 3〉≡
〈includes 4〉
〈data 4〉
〈prototypes 4〉
〈functions 3〉
按照惯例,根代码块的标签设置为程序的文件名,提取〈double.c 3〉代码块,即可提取整个程序。其他代码块的标签设置为double的各个顶层组件名。这些组件按C语言规定的顺序列出,但也可以按任意顺序给出。
〈double.c 3〉中的3是页码,表示该代码块的定义从书中哪一页开始。〈double.c 3〉中使用的代码块中的数字也是页码,表示该代码块的定义从书中哪一页开始。这些页码有助于读者浏览代码时定位。
main函数处理double的参数。它会打开各个文件,并调用doubleword扫描文件:
〈functions 3〉≡
int main(int argc, char *argv[]) {
int i;
for (i = 1; i < argc; i++) {
FILE *fp = fopen(argv[i], "r");
if (fp == NULL) {
fprintf(stderr, "%s: can't open '%s' (%s)\n",
argv[0], argv[i], strerror(errno));
return EXIT_FAILURE;
} else {
doubleword(argv[i], fp);
fclose(fp);
}
}
if (argc == 1) doubleword(NULL, stdin);
return EXIT_SUCCESS;
}
〈includes 4〉≡
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
doubleword函数需要从文件中读取单词。对于该程序来说,一个单词由一个或多个非空格字符组成,不区分大小写。getword从打开的文件读取下一个单词,复制到buf [0..size 1]中,并返回1;在到达文件末尾时该函数返回0。
〈functions 3〉+≡
int getword(FILE *fp, char *buf, int size) {
int c;
c = getc(fp);
〈scan forward to a nonspace character or EOF 5〉
〈copy the word into buf[0..size-1] 5〉
if (c != EOF)
ungetc(c, fp);
return〈found a word? 5〉;
}
〈prototypes 4〉≡
int getword(FILE *, char *, int);
该代码块说明了另一个文学编程特性:代码块标签〈functions 3〉后接的+≡表示将getword的代码附加到代码块〈functions 3〉的代码的后面,因此该代码块现在包含main和getword的代码。该特性允许分为多次定义一个代码块中的代码,每次定义一部分。对于一个“接续”代码块来说,其标签中的页码指向该代码块的第一次定义处,因此很容易找到代码块定义的开始处。
因为getword在main之后定义,在main中调用getword时就需要一个原型,这就是〈prototypes 4〉代码块的用处。该代码块在一定程度上是对C语言“先声明后使用”(declaration- before-use)规则的让步,但如果该代码定义得一致并在根代码块中出现在〈functions 3〉之前,那么函数可以按任何顺序给出。
getword除了从输入获取下一个单词之外,每当遇到一个换行字符时都对linenum加1。doubleword输出时将使用linenum。
〈data 4〉≡
int linenum;
〈scan forward to a nonspace character or EOF 5〉≡
for ( ; c != EOF && isspace(c); c = getc(fp))
if (c == '\n')
linenum++;
〈includes 4〉+≡
#include <ctype.h>
linenum的定义,也例证了代码块的顺序不必与C语言的要求相同。linenum在其第一次使用时定义,而不是在文件的顶部或getword定义之前,后两种做法才是合乎C语言要求的。
size的值限制了getword所能存储的单词的长度,getword函数会丢弃过多的字符并将大写字母转换为小写:
〈copy the word into buf[0..size-1] 5〉≡
{
int i = 0;
for ( ; c != EOF && !isspace(c); c = getc(fp))
if (i < size - 1)
buf[i++] = tolower(c);
if (i < size)
buf[i] = '\0';
}
索引i与size-1进行比较,以保证单词末尾有空间存储一个空字符。在size为0时,if语句保护了对缓存的赋值操作。在double中不会出现这种情况,但这种防性程序设计(defensive programming)有助于捕获“不可能发生的bug”。
剩下的代码逻辑是,如果buf中保存了一个单词则返回1,否则返回0:
〈found a word? 5〉≡
buf[0] != '\0'
该定义表明,代码块不必对应于C语言中的语句或任何其他语法单位,代码块只是文本而已。
doubleword读取各个单词,并将其与前一个单词比较,发现重复时输出。它只查看以字母开头的单词:
〈functions 3〉+≡
void doubleword(char *name, FILE *fp) {
char prev[128], word[128];
linenum = 1;
prev[0] = '\0';
while (getword(fp, word, sizeof(word)) {
if (isalpha(word[0]) && strcmp(prev, word)==0)
〈word is a duplicate 6〉
strcpy(prev, word);
}
}
〈prototypes 4〉+≡
void doubleword(char *, FILE *);
〈includes 4〉+≡
#include <string.h>
输出是很容易的,但仅当name不为NULL时才输出文件名及后接的冒号:
〈word is a duplicate 6〉≡
{
if (name)
printf("%s:", name);
printf("%d: %s\n", linenum, word);
}
该代码块被定义为一个复合语句,因而可以作为结果用在它所处的if语句中。
1.2 程序设计风格
double说明了本书中程序所使用的风格惯例。程序能否更容易被阅读并理解,比使程序更容易被计算机编译更为重要。编译器并不在意变量的名称、代码的布局或程序的模块划分方式。但这种细节对程序员阅读以及理解程序的难易程度有很大影响。
本书代码遵循C程序的一些既定的风格惯例。它使用一致的惯例来命名变量、类型和例程,并在本书的排版约定下,采用一致的缩进风格。风格惯例并非是一种必须遵循的刚性规则,它们表示的是程序设计的一种哲学方法,力求最大限度地增加程序的可读性和可理解性。因而,凡是改变惯例能有助于强调代码的重要方面或使复杂的代码更可读时,你完全可以违反“规则”。
一般来说,较长且富于语义的名称用于全局变量和例程,而数学符号般的短名称则用于局部变量。代码块〈compute x • y〉中的循环索引i属于后一种惯例。对索引和变量使用较长的名称通常会使代码更难阅读,例如下述代码中
sum = 0;
for (theindex = 0; theindex < numofElements; theindex++)
sum += x[theindex]*y[theindex];
长变量名反而使代码的语义含混不清。
变量的声明应该靠近于其第一次使用的地方(可能在代码块中)。linenum的声明很靠近在getword中首次使用该变量的地方,这就是个例子。在可能的情况下,局部变量的声明在使用变量的复合语句的开始处。例如,代码块〈copy the word into buf[0..size-1] 5〉中对i的声明。
一般来说,过程和函数的名称,应能反映过程完成的工作及函数的返回值。因而,getword应当返回输入中的下一个单词,而doubleword则找到并显示出现两次或更多次的单词。大多数例程都比较简单,不会超过一页代码,代码块更短,通常少于十二行。
代码中几乎没有注释,因为围绕对应代码块的正文代替了注释。有关注释风格的建议几乎会引发程序员间的战争。本书将效法C程序设计方面的典范,最低限度地使用注释。如果代码很清晰,且使用了良好的命名和缩进惯例,则这样的代码通常是自明的。仅当进行解释时(例如,解释数据结构的细节、算法的特例以及异常情况)才需要注释。编译器无法检查注释是否与代码一致,误导的注释通常比没有注释更糟糕。最后,有些注释只不过是种干扰,其中的噪音和过多的版式掩盖了注释内容,从而使这些注释只会掩盖代码本身的含义。
文学编程避免了注释战争中的许多争论,因为它不受程序设计语言注释机制的约束。程序员可以使用最适合于表达其意图的任何版式特性,如表、方程、图片和引文。文学编程似乎提倡准确、精确和清晰。
本书中的代码以C语言编写,它所使用的大多数惯用法通常已被有经验的C程序员所接受并希望采用。其中一些惯用法可能使不熟悉C语言的程序员困惑,但为了能用C语言流利地编程,程序员必须掌握这些惯用法。涉及指针的惯用法通常是最令人困惑的,因为C语言为指针的操作提供了几种独特且富有表达力的运算符。库函数strcpy将一个字符串复制到另一个字符串中并返回目标字符串,对该函数的不同实现就说明了“地道的C语言”和新手C程序员编写的代码之间的差别,后一种代码通常使用数组:
char *strcpy(char dst[], const char src[]) {
int i;
for (i = 0; src[i] != '\0'; i++)
dst[i] = src[i];
dst[i] = '\0';
return dst;
}
“地道”的版本则使用指针:
char *strcpy(char *dst, const char *src) {
char *s = dst;
while (*dst++ = *src++)
;
return s;
}
这两个版本都是strcpy的合理实现。指针版本使用通常的惯用法将赋值、指针递增和测试赋值操作的结果合并为单一的赋值表达式。它还修改了其参数dst和src,这在C语言中是可接受的,因为所有参数都是传值的,实际上参数只不过是已初始化的局部变量。
还可以举出很好的例子,来表明使用数组版本比指针版本更好。例如,所有程序员都更容易理解数组版本,无论他们能否使用C语言流畅地编程。但指针版本是最有经验的C程序员会编写的那种代码,因而程序员阅读现存代码时最有可能遇到它。本书可以帮助读者学习这些惯用法、理解C语言的优点、并避免易犯的错误。
1.3 效率
程序员似乎被效率问题困扰着。他们可能花费数小时来微调代码,使之运行得更快。遗憾的是,大部分这种工作都是无用功。当猜测程序的运行时间花费在何处时,程序员的直觉非常糟糕。
微调程序是为了使之更快,但通常总是会使之更大、更难理解、更可能包含错误。除非对执行时间的测量表明程序太慢,否则这样的微调没有意义。程序只需要足够快即可,不一定要尽可能快。
微调通常在“真空”中完成。如果一个程序太慢,找到其瓶颈的唯一途径就是测量它。程序的瓶颈很少出现在预期位置或者因你所怀疑的原因导致,而且在错误位置上微调程序是没有意义的。在找到正确的位置后,仅当该处花费的时间确实占运行时间的很大比例时,才有必要进行微调。如果I/O占了程序运行时间的60%,在搜索例程中节省1%是无意义的。
微调通常会引入错误。最快崩溃的程序绝非胜者。可靠性比效率更重要;与交付足够快的可靠软件相比,交付快速但会崩溃的软件,从长远看来代价更高。
微调经常在错误的层次上进行。快速算法的直接简明的实现,比慢速算法的手工微调实现要好得多。例如,减少线性查找的内层循环的指令数,注定不如直接使用二分查找。
微调无法修复低劣的设计。如果程序到处都慢,这种低效很可能是设计导致的。当基于编写得很糟糕或不精确的问题说明给出设计时,或者根本就没有总体设计时,就会发生这种令人遗憾的情况。
本书中大部分代码都使用了高效的算法,具有良好的平均情况性能,其最坏情形性能也易于概括。对大多数应用程序来说,这些代码对典型输入的执行时间总是足够快速的。当某些程序的代码性能可能会导致问题时,书中自会明确注明。
一些C程序员在寻求提高效率的途径时,大量使用宏和条件编译。只要有可能,本书将避免使用这两种方法。使用宏来避免函数调用基本上是不必要的。仅当客观的测量结果表明有问题的调用的开销大大超出其余代码的运行时间时,使用宏才有意义。操作I/O是较适宜采用宏的少数情况之一。例如,标准的I/O函数getc、putc、getchar和putchar通常实现为宏。
条件编译通常用于配置特定平台或环境的代码,或者用于代码调试的启用/禁用。这些问题是实际存在的,但条件编译通常只是解决问题的较为容易的方法,而且总会使代码更难于阅读。而重写代码以便在执行期间选择平台依赖关系通常则更为有用。例如,一个编译器可以在执行时选择多种(比如说六种)体系结构中的一个来生成代码,这样的一种交叉编译器要比必须配置并搭建六个不同的编译器更有用,而且可能更易于维护。
如果应用程序必须在编译时配置,与C语言的条件编译工具相比,版本控制工具更擅长完成该工作。这样,代码中就不必充斥着预处理器指令,因为那会使代码难于阅读,并模糊被编译和未被编译的代码之间的界限。使用版本控制工具,你看到的代码即为被执行的代码。对于跟踪性能改进情况来说,这些工具也是理想的选择。
1.4 扩展阅读
对于标准C库来说,ANSI标准 [ANSI 1990]和技术上等效的ISO标准 [ISO 1990]是权威的参考文献,但 [Plauger,1992]一书给出了更详细的描述和完整的实现。同样,C语言相关问题的定论就在于这些标准,但[Kernighan and Ritchie,1988]一书却可能是最广为使用的参考。[Harbison and Steele,1995]一书的最新版本或许是C语言标准的最新的资料,它还描述了如何编写“干净的C”,即可以用C++编译器编译的C代码。[Jaeschke,1991]一书将标准C语言的精华浓缩为紧凑的词典格式,这份资料对C程序员来说也很有用。
[Kernighan and Plauger,1976]一书给出了文学程序的早期例子,当然作者对文学编程没太多认识,只是使用了专门开发的工具将代码集成到书中。WEB是首批明确为文学编程设计的工具之一。[Knuth,1992]一书描述了WEB和它的一些变体及用法,[Sewell,1989]一书是WEB的入门介绍。更简单的工具([Hanson,1987],[Ramsey,1994])发展了很长时间才提供WEB的大部分基本功能。本书使用notangle来提取代码块,它是Ramsey的noweb系统中的程序之一。[Fraser and Hanson,1995]一书也使用了noweb,该书以文学程序的形式给出了一个完整的C语言编译器。该编译器也是一个交叉编译器。
double取自 [Kernighan and Pike,1984],在该书中double是用AWK [Aho, Kernighan and Weinberger,1988]程序设计语言实现的。尽管年龄老迈,但[Kernighan and Pike,1984]仍然是UNIX程序设计哲学方面的最佳书籍之一。
学习良好的程序设计风格,最好的方法是阅读风格良好的程序。本书将遵循 [Kernighan and Pike,1984]和 [Kernighan and Ritchie,1988]中的风格,这种风格经久而不衰。[Kernighan and Plauger,1978]一书是程序设计风格方面的经典著作,但该书并不包含C语言的例子。Ledgard的小书[Ledgard,1987]提供了类似的建议,而 [Maguire,1993]从PC程序设计的角度阐述了程序设计风格问题。[Koenig,1989]一书暴露的C语言的黑暗角落,强调了那些应该避免的东西。[McConnell,1993]一书在与程序构建相关的许多方面提供了明智的建议,并针对使用goto语句的利弊两方面进行了不偏不倚的讨论。
学习编写高效的代码,最好的方法是在算法方面有扎实的基础,并阅读其他高效的代码。[Sedgewick,1990]一书纵览了大多数程序员都必须知道的所有重要算法,而 [Knuth,1973a]一书对算法基础进行了至为详细的讨论。[Bentley,1982]一书有170页,给出了编写高效代码方面的一些有益的建议和常识。
1.5 习题
1.1 在一个单词结束于换行符时,getword在〈scan forward to a nonspace or EOF 5〉代码块中将linenum加1,而不是在〈copy the word into buf[0..size-1] 5〉代码块之后。解释这样做的原因。如果在本例中,linenum的加1操作是在〈copy the word into buf[0..size-1] 5〉代码块之后进行,会发生什么情况?
1.2 当double在输入中发现三个或更多相同单词时会显示什么?修改double来改掉这个“特性”。
1.3 许多有经验的C程序员会在strcpy的循环中加入一个显式的比较操作:
char *strcpy(char *dst, const char *src) {
char *s = dst;
while ((*dst++ = *src++) != '\0')
;
return s;
}
显式比较表明赋值操作并非笔误。一些C编译器和相关工具,如Gimpel Software的PC-Lint和LCLint[Evans,1996],在发现赋值操作的结果用作条件表达式时会发出警告,因为这种用法是一个常见的错误来源。如果读者有PC-Lint或LCLint,可以在一些“测试”过的程序上进行试验。
接口与实现
模块分为两个部分,即模块的接口与实现。接口规定了模块做什么。接口会声明标识符、类型和例程,提供给使用模块的代码。实现指明模块如何完成其接口规定的目标。对于给定的模块,通常只有一个接口,但可能有许多实现提供了接口规定的功能。每个实现可能使用不同的算法和数据结构,但它们都必须合乎接口的规定。
客户程序(client)是使用模块的一段代码。客户程序导入接口,实现则导出接口。客户程序只需要看到接口即可。实际上,它们可能只有实现的目标码。多个客户程序共享接口和实现,因而避免了不必要的代码重复。这种方法学也有助于避免bug,接口和实现编写并调试一次后,可以经常使用。
2.1 接口
接口仅规定客户程序可能使用的那些标识符,而尽可能隐藏不相关的表示细节和算法。这有助于客户程序避免依赖特定实现的具体细节。客户程序和实现之间的这种依赖性称之为耦合(coupling),在实现改变时耦合会导致bug,当依赖性被与实现相关的隐藏或隐含的假定掩盖时,这种bug可能会特别难于改正。设计完善且陈述准确的接口可以减少耦合。
对于接口与实现相分离,C语言只提供了最低限度的支持,但通过一些简单的约定,我们即可获得接口/实现方法学的大多数好处。在C语言中,接口通过一个头文件指定,头文件的扩展名通常为.h。这个头文件会声明客户程序可能使用的宏、类型、数据结构、变量和例程。客户程序用C预处理器指令#include导入接口。
以下例子说明了本书中的接口使用的约定。下述接口
〈arith.h〉≡
extern int Arith_max(int x, int y);
extern int Arith_min(int x, int y);
extern int Arith_div(int x, int y);
extern int Arith_mod(int x, int y);
extern int Arith_ceiling(int x, int y);
extern int Arith_floor (int x, int y);
声明了六个整数算术运算函数。该接口的实现需要为上述每一个函数提供定义。
该接口命名为Arith,接口头文件命名为arith.h。在接口中,接口名称表现为每个标识符的前缀。这种约定并不优美,但C语言几乎没有提供其他备选方案。所有文件作用域中的标识符,包括变量、函数、类型定义和枚举常数,都共享同一个命名空间。所有的全局结构、联合和枚举标记则共享另一个命名空间。在一个大程序中,在本来无关的模块中,很容易使用同一名称表示不同的目的。避免这种名称碰撞(name collision)的一个方法是使用前缀,如模块名。一个大程序很容易有数千全局标识符,但通常只有几百个模块。模块名不仅提供了适当的前缀,还有助于使客户程序代码文档化。
Arith接口中的函数提供了标准C库缺失的一些有用功能,并对除法和模运算提供了良定义的结果,而标准则将这些操作的行为规定为未定义(undefined)或由具体实现来定义(implementation-defined)。
Arith_min和Arith_max函数分别返回其整型参数的最小值和最大值。
Arith_div返回x除以y获得的商,而Arith_mod则返回对应的余数。当x和y都为正或都为负时,Arith_div(x,y)等于x/y,而Arith_mod(x,y)等于x%y。然而当两个操作数符号不同时,由C语言内建运算符所得出的返回值取决于具体编译器的实现。当y为零时,Arith_div和Arith_mod的行为与x/y和x%y相同。
C语言标准只是强调,如果x/y是可表示的,那么(x/y)*y + x%y必须等于x。当一个操作数为负数时,这种语义使得整数除法可以向零舍入,也可以向负无穷大舍入。例如,如果13/5的结果定义为2,那么标准指出,13%5必须等于13 (13/5)*5 13 (2)*5 3。但如果13/5定义为3,那么13%5的值必须是13 (3)*5 2。
因而内建的运算符只对正的操作数有用。标准库函数div和ldiv以两个整数或长整数为输入,并计算二者的商和余数,在一个结构的quot和rem字段中返回。这两个函数的语义是良定义的:它们总是向零舍入,因此div(-13,5).quot总是等于2。Arith_div和Arith_mod同样是良定义的。它们总是向数轴的左侧舍入,当其操作数符号相同时向零舍入,当其符号不同时向负无穷大舍入,因此Arith_div(-13,5)返回3。
Arith_div和Arith_mod的定义可以用更精确的数学术语来表达。Arith_div(x,y)定义为不超过实数z的最大整数,而z*y=x。因而,对x=-13和y=5(或者x = 13和y = 5),z为2.6,因此Arith_div(-13,5)为3。Arith_mod(x,y)定义为等于x - y*Arith_div(x,y),因此Arith_mod(-13,5)为13 5*(3) 2。
Arith_ceiling和Arith_floor函数遵循类似的约定。Arith_ceiling(x,y)返回不小于x/y的实数商的最小整数,而Arith_floor(x,y)返回不大于x/y的实数商的最大整数。对所有操作数x和y来说,Arith_ceiling返回数轴在x/y对应点右侧的整数,而Arith_floor返回x/y对应点左侧的整数。例如:
Arith_ceiling( 13,5) = 13/5 = 2.6 = 3
Arith_ceiling(-13,5) =-13/5 = -2.6 = -2
Arith_floor ( 13,5) = 13/5 = 2.6 = 2
Arith_floor (-13,5) =-13/5 = -2.6 = -3
即便简单如Arith这种程度的接口仍然需要这么费劲的规格说明,但对大多数接口来说,Arith的例子很有代表性和必要性(很让人遗憾)。大多数编程语言的语义中都包含漏洞,某些操作的精确含义定义得不明确或根本未定义。C语言的语义充满了这种漏洞。设计完善的接口会塞住这些漏洞,将未定义之处定义完善,并对语言标准规定为未定义或由具体实现定义的行为给出明确的裁决。
Arith不仅是一个用来显示C语言缺陷的人为范例。它也是有用的,例如对涉及模运算的算法,就像是哈希表中使用的那些算法。假定i从零到N - 1,其中N大于1,并对i加1和i减1的结果模N。即,如果i为N-1,i+1为0,而如果i为0,i-1为N-1。下述表达式
i = Arith_mod(i + 1, N);
i = Arith_mod(i - 1, N);
正确地对i进行了加1模N和减1模N的操作。表达式i = (i+1) % N可以工作,但i = ( i-1) % N无法工作,因为当i为0时,(i-1) % N可能是1或N-1。程序员在(-1) % N返回N-1的计算机上可以使用(i-1) % N,但如果依赖这种由具体实现定义的行为,那么在将代码移植到(-1) % N返回1的计算机上时,就可能遭遇到非常出人意料的行为。库函数div(x,y)也无济于事。它返回一个结构,其quot和rem字段分别保存x/y的商和余数。在i为零时,div(i-1, N).rem总是1。使用i = (i-1+N) % N是可以的,但仅当i-1+N不造成溢出时才行。
2.2 实现
实现会导出接口。它定义了必要的变量和函数,以提供接口规定的功能。实现具体解释了接口的语义,并给出其表示细节和算法,但在理想情况下,客户程序从来都不需要看到这些细节。不同的客户程序可以共享实现的目标码,通常是从(动态)库加载实现的目标码。
一个接口可以有多个实现。只要实现遵循接口的规定,完全可以在不影响客户程序的情况下改变实现。例如,不同的实现可能会提供更好的性能。设计完善的接口会避免对特定机器的依赖,但也可能强制实现依赖于机器,因此对用到接口的每种机器,可能都需要一个不同的实现(也可能是实现的一部分)来支持。
在C语言中,一个实现通过一个或多个.c文件来提供。实现必须提供其导出的接口规定的功能。实现会包含接口的.h文件,以确保其定义与接口的声明一致。但除此之外,C语言中没有其他语言机制来检查实现与接口是否符合。
如同本书中的接口,本书描述的实现也具有一种风格化的格式,如arith.c所示:
〈arith.c〉≡
#include "arith.h"
〈arith.c functions 14〉
〈arith.c functions 14〉≡
int Arith_max(int x, int y) {
return x > y ? x : y;
}
int Arith_min(int x, int y) {
return x > y ? y : x;
}
除了〈arith.c functions 14〉,更复杂的实现可能包含名为〈data〉、〈types〉、〈macros〉、〈prototypes〉等的代码块。在不会造成混淆时,代码块中的文件名(如arith.c)将略去。
在Arith_div的参数符号不同时,它必须处理除法的两种可能行为。如果除法向零舍入,而y不能整除x,那么Arith_div(x,y)的结果为x/y 1,否则,返回x/y即可:
〈arith.c functions 14〉+≡
int Arith_div(int x, int y) {
if (〈division truncates toward 0 14〉
&& 〈x and y have different signs 14〉 && x%y != 0)
return x/y - 1;
else
return x/y;
}
前一节的例子,即将13除以5,可以测试除法所采用的舍入方式。首先判断x和y是否小于0,然后比较两个判断结果是否相等,即可检查符号问题:
〈division truncates toward 0 14〉≡
-13/5 == -2
〈x and y have different signs 14〉≡
(x < 0) != (y < 0)
Arith_mod可以按其定义实现:
int Arith_mod(int x, int y) {
return x - y*Arith_div(x, y);
}
如果Arith_mod也像Arith_div那样进行判断,那么也可以使用%运算符实现。在相应的条件为真时,
Arith_mod(x,y) = x - y*Arith_div(x, y)
= x - y*(x/y - 1)
= x - y*(x/y) + y
加下划线的子表达式是标准C对x%y的定义,因此Arith_mod可定义为:
〈arith.c functions 14〉+≡
int Arith_mod(int x, int y) {
if (〈division truncates toward 0 14〉
&& 〈x and y have different signs 14〉 && x%y != 0)
return x%y + y;
else
return x%y;
}
Arith_floor刚好等于Arith_div,而Arith_ceiling等于Arith_div加1,除非y能整除x:
〈arith.c functions 14〉+≡
int Arith_floor(int x, int y) {
return Arith_div(x, y);
}
int Arith_ceiling(int x, int y) {
return Arith_div(x, y) + (x%y != 0);
}
2.3 抽象数据类型
一个抽象数据类型是一个接口,它定义了一个数据类型和对该类型的值所进行的操作。一个数据类型是一个值的集合。在C语言中,内建的数据类型包括字符、整数、浮点数等。而结构本身也能定义新的类型,因而可用于建立更高级类型,如列表、树、查找表等。
高级类型是抽象的,因为其接口隐藏了相关的表示细节,并只规定了对该类型值的合法操作。理想情况下,这些操作不会暴露类型的表示细节,因为那样可能使客户程序隐含地依赖于具体的表示。抽象数据类型或ADT的标准范例是栈。其接口定义了栈类型及其五个操作:
〈initial version of stack.h〉≡
#ifndef STACK_INCLUDED
#define STACK_INCLUDED
typedef struct Stack_T *Stack_T;
extern Stack_T Stack_new (void);
extern int Stack_empty(Stack_T stk);
extern void Stack_push (Stack_T stk, void *x);
extern void *Stack_pop (Stack_T stk);
extern void Stack_free (Stack_T *stk);
#endif
上述的typedef定义了Stack_T类型,这是一个指针,指向一个同名结构。该定义是合法的,因为结构、联合和枚举的名称(标记)占用了一个命名空间,该命名空间不同于变量、函数和类型名所用的命名空间。这种惯用法的使用遍及本书各处。类型名Stack_T,是这个接口中我们关注的名称,只有对实现来说,结构名才比较重要。使用相同的名称,可以避免用太多罕见的名称污染代码。
宏STACK_INCLUDED也会污染命名空间,但_INCLUDED后缀有助于避免冲突。另一个常见的约定是为此类名称加一个下划线前缀,如_STACK或_STACK_INCLUDED。但标准C将下划线前缀保留给实现者和未来的扩展使用,因此避免使用下划线前缀看起来是谨慎的做法。
该接口透露了栈是通过指向结构的指针表示的,但并没有给出结构的任何信息。因而Stack_T是一个不透明指针类型,客户程序可以自由地操纵这种指针,但无法反引用不透明指针,即无法查看指针所指向结构的内部信息。只有接口的实现才有这种特权。
不透明指针隐藏了表示细节,有助于捕获错误。只有Stack_T类型值可以传递给上述的函数,试图传递另一种指针,如指向其他结构的指针,将产生编译错误。唯一的例外是参数中的一个void指针,该该参数可以传递任何类型的指针。
条件编译指令#ifdef和#endif以及定义STACK_INCLUDED的#define,使得stack.h可以被包含多次,在接口又导入了其他接口时可能出现这种情况。如果没有这种保护,第二次和后续的包含操作,将因为typedef中的Stack_T重定义而导致编译错误。
在少数可用的备选方案中,这种约定似乎是最温和的。禁止接口包含其他接口,可以完全避免重复包含,但这又强制接口用某种其他方法指定必须导入的其他接口,如注释,也强迫程序员来提供包含指令。将条件编译指令放在客户程序而不是接口中,可以避免编译时不必要地读取接口文件,但代价是需要在许多地方衍生出很多乱七八糟的条件编译指令,不像只放在接口中那样清洁。上文说明的约定,需要编译器来完成所谓的“脏活”。
按约定,定义ADT的接口X可以将ADT类型命名为X_T。本书中的接口在这个约定基础上更进一步,在接口内部使用宏将X_T缩写为T。使用该约定时,stack.h如下:
〈stack.h〉≡
#ifndef STACK_INCLUDED
#define STACK_INCLUDED
#define T Stack_T
typedef struct T *T;
extern T Stack_new (void);
extern int Stack_empty(T stk);
extern void Stack_push (T stk, void *x);
extern void *Stack_pop (T stk);
extern void Stack_free (T *stk);
#undef T
#endif
该接口在语义上与前一个是等效的。缩写只是语法糖(syntactic sugar),使得接口稍微容易阅读一些。T指的总是接口中的主要类型。但客户程序必须使用Stack_T,因为stack.h末尾的#undef指令删除了上述的缩写。
该接口提供了可用于任意指针的容量无限制的栈。Stack_new创建新的栈,它返回一个类型为T的值,可以作为参数传递给其他四个函数。Stack_push将一个指针推入栈顶,Stack_pop在栈顶删除一个指针并返回该指针,如果栈为空,Stack_empty返回1,否则返回0。Stack_free以一个指向T的指针为参数,释放该指针所指向的栈,并将类型为T的变量设置为NULL指针。这种设计有助于避免悬挂指针(dangling pointer),即指针指向已经被释放的内存。例如,如果names通过下述代码定义并初始化:
#include "stack.h"
Stack_T names = Stack_new();
下述语句
Stack_free(&names);
将释放names指向的栈,并将names设置为NULL指针。
当ADT通过不透明指针表示时,导出的类型是一个指针类型,这也是Stack_T通过typedef定义为指向struct Stack_T的指针的原因。本书中大部分ADT都使用了类似的typedef。当ADT披露了其表示细节,并导出可接受并返回相应结构值的函数时,接口会将该结构类型定义为导出类型。第16章中的Text接口说明了这种约定,该接口将Text_T声明为struct Text_T的一个typedef。无论如何,接口中的主要类型总是缩写为T。
2.4 客户程序的职责
接口是其实现和其客户程序之间的一份契约。实现必须提供接口中规定的功能,而客户程序必须根据接口中描述的隐式和显式的规则来使用这些功能。程序设计语言提供了一些隐式规则,来支配接口中声明的类型、函数和变量的使用。例如,C语言的类型检查规则可以捕获接口函数的参数的类型和数目方面的错误。
C语言的用法没有规定的或编译器无法检查的规则,必须在接口中详细说明。客户程序必须遵循这些规则,实现必须执行这些规则。接口通常会规定未检查的运行时错误(unchecked runtime error)、已检查的运行时错误(checked runtime error)和异常(exception)。未检查的和已检查的运行时错误是非预期的用户错误,如未能打开一个文件。运行时错误是对客户程序和实现之间契约的破坏,是无法恢复的程序bug。异常是指一些可能的情形,但很少发生。程序也许能从异常恢复。内存耗尽就是一个例子。异常在第4章详述。
未检查的运行时错误是对客户程序与实现之间契约的破坏,而实现并不保证能够发现这样的错误。如果发生未检查的运行时错误,可能会继续执行,但结果是不可预测的,甚至可能是不可重复的。好的接口会在可能的情况下避免未检查的运行时错误,但必须规定可能发生的此类错误。例如,Arith必须指明除以零是一个未检查的运行时错误。Arith虽然可以检查除以零的情形,但却不加处理使之成为未检查的运行时错误,这样接口中的函数就模拟了C语言内建的除法运算符的行为(即,除以零时其行为是未定义的)。使除以零成为一种已检查的运行时错误,也是一种合理的方案。
已检查的运行时错误是对客户程序与实现之间契约的破坏,但实现保证会发现这种错误。这种错误表明,客户程序未能遵守契约对它的约束,客户程序有责任避免这类错误。Stack接口规定了三个已检查的运行时错误:
(1) 向该接口中的任何例程传递空的Stack_T类型的指针;
(2) 传递给Stack_free的Stack_T指针为NULL指针;
(3) 传递给Stack_pop的栈为空。
接口可以规定异常及引发异常的条件。如第4章所述,客户程序可以处理异常并采取校正措施。未处理的异常(unhandled exception)被当做是已检查的运行时错误。接口通常会列出自身引发的异常及其导入的接口引发的异常。例如,Stack接口导入了Mem接口,它使用后者来分配内存空间,因此它规定Stack_new和Stack_push可能引发Mem_Failed异常。本书中大多数接口都规定了类似的已检查的运行时错误和异常。
在向Stack接口添加这些之后,我们可以继续进行其实现:
〈stack.c〉≡
#include <stddef.h>
#include "assert.h"
#include "mem.h"
#include "stack.h"
#define T Stack_T
〈types 18〉
〈functions 18〉
#define指令又将T定义为Stack_T的缩写。该实现披露了Stack_T的内部结构,它是一个结构,一个字段指向一个链表,链表包含了栈上的各个指针,另一个字段统计了指针的数目。
〈types 18〉≡
struct T {
int count;
struct elem {
void *x;
struct elem *link;
} *head;
};
Stack_new分配并初始化一个新的T:
〈functions 18〉≡
T Stack_new(void) {
T stk;
NEW(stk);
stk->count = 0;
stk->head = NULL;
return stk;
}
NEW是Mem接口中一个用于分配内存的宏。NEW(p)为p指向的结构分配一个实例,因此Stack_ new中使用它来分配一个新的Stack_T结构实例。
如果count字段为0,Stack_empty返回1,否则返回0:
〈functions 18〉+≡
int Stack_empty(T stk) {
assert(stk);
return stk->count == 0;
}
assert(stk)实现了已检查的运行时错误,即禁止对Stack接口函数中的Stack_T类型参数传递NULL指针。assert(e)是一个断言,声称对任何表达式e,e都应该是非零值。如果e非零,它什么都不做,否则将中止程序执行。assert是标准库的一部分,但第4章的Assert接口定义了自身的assert,其语义与标准库类似,但提供了优雅的程序终止机制。assert用于所有已检查的运行时错误。
Stack_push和Stack_pop分别在stk->head链表头部添加和删除元素:
〈functions 18〉+≡
void Stack_push(T stk, void *x) {
struct elem *t;
assert(stk);
NEW(t);
t->x = x;
t->link = stk->head;
stk->head = t;
stk->count++;
}
void *Stack_pop(T stk) {
void *x;
struct elem *t;
assert(stk);
assert(stk->count > 0);
t = stk->head;
stk->head = t->link;
stk->count--;
x = t->x;
FREE(t);
return x;
}
FREE是Mem用于释放内存的宏,它释放其指针参数指向的内存空间,并将该参数设置为NULL指针,这与Stack_free的做法同理,都是为了避免悬挂指针。Stack_free也调用了FREE:
〈functions 18〉+≡
void Stack_free(T *stk) {
struct elem *t, *u;
assert(stk && *stk);
for (t = (*stk)->head; t; t = u) {
u = t->link;
FREE(t);
}
FREE(*stk);
}
该实现披露了一个未检查的运行时错误,本书中所有的ADT接口都会受到该错误的困扰,因而并没有在接口中指明。我们无法保证传递到Stack_push、Stack_pop、Stack_empty的Stack_T值和传递到Stack_free的Stack_T*值都是Stack_new返回的有效的Stack_T值。习题2.3针对该问题进行了探讨,给出一个部分解决方案。
还有两个未检查的运行时错误,其效应可能更为微妙。本书中许多ADT通过void指针通信,即存储并返回void指针。在任何此类ADT中,存储函数指针(指向函数的指针)都是未检查的运行时错误。void指针是一个类属指针(generic pointer,通用指针),类型为void *的变量可以容纳指向一个对象的任意指针,此类指针可以指向预定义类型、结构和指针。但函数指针不同。虽然许多C编译器允许将函数指针赋值给void指针,但不能保证void指针可以容纳函数指针 。
通过void指针传递任何对象指针都不会损失信息。例如,在执行下列代码之后,
S *p, *q;
void *t;
...
t = p;
q = t;
对任何非函数的类型S,p和q都将是相等的。但不能用void指针来破坏类型系统。例如,在执行下列代码之后,
S *p;
D *q;
void *t;
...
t = p;
q = t;
我们不能保证q与p是相等的,或者根据类型S和D的对齐约束,也不能保证q是一个指向类型D对象的有效指针。在标准C语言中,void指针和char指针具有相同的大小和表示。但其他指针可能小一些,或具有不同的表示。因而,如果S和D是不同的对象类型,那么在ADT中存储一个指向S的指针,将该指针返回到一个指向类型D的指针中,这是一个未检查的运行时错误。
在ADT函数并不修改被指向的对象时,程序员可能很容易将不透明指针参数声明为const。例如,Stack_empty可能有下述编写方式。
int Stack_empty(const T stk) {
assert(stk);
return stk->count == 0;
}
const的这种用法是不正确的。这里的意图是将stk声明为一个“指向struct T的常量实例的指针”,因为Stack_empty并不修改*stk。但const T stk将stk声明为一个“常量指针,指向一个struct T实例”,对T的typedef将struct T *打包到一个类型中,这一个指针类型成为了const的操作数 。无论对Stack_empty还是其调用者,const T stk都是无用的,因为在C语言中,所有的标量包括指针在函数调用时都是传值的。无论有没有const限定符,Stack_empty都无法改变调用者的实参值。
用struct T *代替T,可以避免这个问题:
int Stack_empty(const struct T *stk) {
assert(stk);
return stk->count == 0;
}
这个用法说明了为什么不应该将const用于传递给ADT的指针:const披露了有关实现的一些信息,因而限制了可能性。对于Stack的这个实现而言,使用const不是问题,但它排除了其他同样可行的方案。假定某个实现预期可重用栈中的元素,因而延迟对栈元素的释放操作,但会在调用Stack_empty时释放它们。Stack_empty的这种实现需要修改* stk,但因为*stk声明为const而无法进行修改。本书中的ADT都不使用const。
2.5 效率
本书中的接口的大多数实现所使用的算法和数据结构,其平均情况运行时间不会超过N(输入规模)的线性函数,大多数算法都能够处理大量的输入。无法处理大量输入的接口,或者性能可能成为重要影响因素的接口,可以规定性能标准(performance criteria)。实现必须满足这些标准,客户程序可以预期性能能够达到标准的规定(但不会比标准好上多少)。
本书中所有的接口都使用了简单但高效的算法。在N较大时,更复杂的算法和数据结构可能有更好的性能,但N通常比较小。大多数实现都只使用基本的数据结构,如数组、链表、哈希表、树和这些数据结构的组合。
本书中的ADT,除少量之外全部使用了不透明指针,因此需要使用诸如Stack_empty之类的函数来访问隐藏在实现背后的字段。调用函数而不是直接访问字段会带来开销,但它对实际应用程序性能的影响通常都是可忽略的。这种做法在可靠性和捕获运行时错误的机会方面带来的改进是可观的,远超性能方面的轻微代价。
如果客观的测量表明确实有必要改进性能,那么这种改进不应该改变接口,例如,可通过定义宏进行。当这种方法不可行时,最好创建一个新接口并说明其性能方面的优势,而不是改变现存的接口(这将使所有的客户程序无效)。
2.6 扩展阅读
自20世纪50年代以来,过程和函数库的重要性已经是公认的。[Parnas 1972]一文是一篇典型的论文,讨论了如何将程序划分为模块。该论文的历史已经将近40年,但当今的程序员仍然面临着该文所考虑的问题。
C程序员每天都使用接口:C库是15个接口的集合。标准输入输出接口,即stdio.h,定义了一个ADT FILE,以及对FILE指针的操作。[Plauger,1992]一书详细描述了这15个接口及适当的实现,其叙述方式大体上类似于本书讨论一组接口和实现的方式。
Modula-3是一种相对较新的语言,从语言层面支持接口与实现相分离,本书中使用的基于接口的术语即源自该语言[Nelson,1991]。未检查和已检查的运行时错误的概念,和ADT的T表示法,都是借鉴Modula-3。[Harbison,1992]是介绍Modula-3的一本教科书。[Horning等人,1993]一书描述了其Modula-3系统中的核心接口。本书中一些接口改编自该书中的接口。[Roberts,1995]一书使用了基于接口的设计,作为讲授计算机科学入门课程的编排方式。
断言的重要性是公认的,在一些语言如Modula-3和Eiffel [Meyer,1992]中,断言机制是内建在语言中的。[Maguire,1993]一书用一整章的篇幅讨论C程序中断言的使用。
熟悉面向对象编程的程序员可能认为,本书中大部分ADT都可以用面向对象程序设计语言中的对象实现(可能实现得更好),如C++ [Ellis and Stroustrup,1990]和Modula-3。[Budd,1991]一书是面向对象程序设计方法学的入门介绍,还包括一些面向对象程序设计语言如C++的内容。本书中说明的接口设计原理同样适用于面向对象语言。例如,用C++语言重写本书中的ADT,对从C语言切换到C++的程序员来说是一个很有用的练习过程。
STL(C++标准模板库,Standard Template Library)提供了与本书所述类似的ADT。STL充分利用了C++模板来针对具体类型实例化ADT(参见 [Musser and Saini,1996])。例如,STL为vector类型提供了一个模板,可针对int、string等类型分别实例化出对应的vector类型。STL还提供一套函数,来处理由模板生成的类型。
2.7 习题
2.1 原本可使用预处理器宏和条件编译指令如#if,来指定Arith_div和Arith_mod中如何处理除法的舍入操作。解释为什么对-13/5 == -2的显式测试是实现上述判断的更好的方法。
2.2 对于Arith_div和Arith_mod来说,仅当用于编译arith.c的编译器执行算术操作的方式与Arith_div和Arith_mod被调用时的目标机器相同时,这两个函数中所用的-13/5 == -2测试才是有效的。但这个条件可能会不成立,例如,如果arith.c由运行在机器X上交叉编译器编译,针对机器Y生成代码。不使用条件编译指令,请改正arith.c,使得交叉编译生成的代码也保证可以工作。
2.3 如同本书中所有的ADT,Stack接口也省略了下述规格说明:“将外部的Stack_T传递给本接口中任何例程,都是未检查的运行时错误”。外部的Stack_T,意味着不是由Stack_new产生的Stack_T。修正stack.c,使其可以在某些情况下检查到这种错误。例如,一种方法是向Stack_T结构添加一个字段,对于Stack_new返回的Stack_T,该字段包含一个特有的位模式。
2.4 通常有可能会检测到某些无效指针。例如,如果一个非空指针指定的地址在客户程序地址空间之外,那么该指针就是无效的,而且指针通常会受到对齐约束,例如,在某些系统上,指向double的指针,指向的地址必定是8的倍数。请设计一个特定于系统的宏isBadPtr(p),在p为无效指针时为1,这样assert(ptr)之类的断言都可以替换为类似assert (!isBadPtr(ptr))的断言。
2.5 对栈来说,有许多可行的接口。为Stack接口设计并实现一些备选方案。例如,一种方案是在为Stack_new增加一个参数,用于指定栈的最大容量。
如今的程序员忙于应付大量关于API(Application Programming Interface)的信息。但是,大多数程序员都会在其所写的几乎每一个应用程序中使用API并实现API的库,只有少数程序员会创建或发布新的能广泛应用的API。事实上,程序员似乎更喜欢使用自己搞的东西,而不愿意查找能满足他们要求的程序库,这或许是因为写特定应用程序的代码要比设计可广泛使用的API容易。
不好意思,我也未能免俗:lcc(我和Chris Fraser为ANSI/ISO C编写的编译器)就是从头开始编写的API。(在A Retargetable C Compiler: Design and Implementation一书中有关于lcc的介绍。)编译器是这样一类应用程序:可以使用标准接口,并且能够创建在其他地方也可以使用的接口。这类程序还有内存管理、字符串和符号表以及链表操作等。但是lcc仅使用了很少的标准C库函数的例程,并且它的代码几乎都无法直接应用到其他应用程序中。
本书提倡的是一种基于接口及其实现的设计方法,并且通过对24个接口及其实现的描述详细演示了该方法。这些接口涉及很多计算机领域的知识,包括数据结构、算法、字符串处理和并发程序。这些实现并不是简单的玩具,而是为在产品级代码中使用而设计的。实现的代码是可免费提供的。
C编程语言基本不支持基于接口的设计方法,而C++和Modula-3这样的面向对象的语言则鼓励将接口与实现分离。基于接口的设计跟具体的语言无关,但是它要求程序员对像C一样的语言有更强的驾驭能力和更高的警惕性,因为这类语言很容易破坏带有隐含实现信息的接口,反之亦然。
然而,一旦掌握了基于接口的设计方法,就能够在服务于众多应用程序的通用接口基础上建立应用程序,从而加快开发速度。在一些C++环境中的基础类库就体现了这种效果。增加对现有软件(接口实现库)的重用,能够降低初始开发成本,同时还能降低维护成本,因为应用程序的更多部分都建立在通用接口的实现之上,而这些实现无不经过了良好的测试。
本书中的24个接口引自几本参考书,并且针对本书特别做了修正。一些数据结构(抽象数据类型)中的接口源于lcc代码和20世纪70年代末到80年代初所做的Icon编程语言的实现代码(参见R. E. Griswold和M. T. Griswold所著的The Icon Programming Language)。其他的接口来自另外一些程序员的著作,我们将会在每一章的“扩展阅读”部分给出详细信息。
书中提供的一些接口是针对数据结构的,但本书不是介绍数据结构的,本书的侧重点在算法工程(包装数据结构以供应用程序使用),而不在数据结构算法本身。然而,接口设计的好坏总是取决于数据结构和算法是否合适,因此,本书可算是传统数据结构和算法教材(如Robert Sedgewick所著的Algorithms in C)的有益补充。
大多数章节会只介绍一个接口及其实现,少数章节还会描述与其相关的接口。每一章的“接口”部分将会单独给出一个明确而详细的接口描述。对于兴趣仅在于接口的程序员来说,这些内容就相当于一本参考手册。少数章节还会包含“例子”部分,会说明在一个简单的应用程序中接口的用法。
每章的“实现”部分将会详细地介绍本章接口的实现代码。有些例子会给出一个接口的多种实现方法,以展示基于接口设计的优点。这些内容对于修改或扩展一个接口或是设计一个相关的接口将大有裨益。许多练习题会进一步探究一些其他可行的设计与实现的方法。如果仅是为了理解如何使用接口,可以不用阅读“实现”一节。
接口、示例和实现都以文学(literate)程序的方式给出,换句话说,源代码及其解释是按照最适合理解代码的顺序交织出现的。代码可以自动地从本书的文本文件中抽取,并按C语言所规定的顺序组合起来。其他也用文学程序讲解C语言的图书有A Retargetable C Compiler和D.E.Knuth写的The Stanford GraphBase: A Platform for Combinatorial Computing。
本书架构
本书材料可分成下面的几大类:
基础 1. 引言
2. 接口与实现
4. 异常与断言
5. 内存管理
6. 再谈内存管理
数据结构 7. 链表
8. 表
9. 集合
10. 动态数组
11. 序列
12. 环
13. 位向量
字符串 3. 原子
14. 格式化
15. 低级字符串
16. 高级字符串
算法 17. 扩展精度算术
18. 任意精度算术
19. 多精度算术
线程 20. 线程
建议大多数读者通读第1章至第4章的内容,因为这几章形成了本书其余部分的框架。对于第5章至第20章,虽然某些章会参考其前面的内容,但影响不大,读者可以按任何顺序阅读。
第1章介绍了文学程序设计和编程风格与效率。第2章提出并描述了基于接口的设计方法,定义了相关的术语,并演示了两个简单的接口及其实现。第3章描述了Atom接口的实现原型,这是本书中最简单的具有产品质量的接口。第4章介绍了在每一个接口中都会用到的异常与断言。第5章和第6章描述了几乎所有的实现都会用到的内存管理接口。其余各章都分别描述了一个接口及其实现。
教学使用建议
我们假设本书的读者已经在大学介绍性的编程课程中了解了C语言,并且都实际了解了类似《C算法》一书中给出的基本数据结构。在普林斯顿,本书是大学二年级学生到研究生一年级的系统编程课程的教材。许多接口使用的都是高级C语言编程技巧,比如说不透明的指针和指向指针的指针等,因此这些接口都是学习这些内容非常好的实例,对于系统编程和数据结构课程非常有用。
这本书可以以多种方式在课堂上使用,最简单的就是用在面向项目的课程中。例如,在编译原理课程中,学生通常需要为一个玩具语言编写一个编译器。在图形学课程中同样也经常有一些实际的项目。本书中许多接口消除了新建项目所需要的一些令人厌烦的编程工作,从而简化了这类课程中的项目。这种用法可以帮助学生认识到在项目中重用代码可以节省大量劳动,并且引导学生在其项目中对自己所做的部分尝试使用基于接口的设计。后者在团队项目中特别有用,因为“现实世界”中的项目通常都是团队项目。
普林斯顿大学二年级系统编程课程的主要内容是接口与实现,其课外作业要求学生成为接口的用户、实现者和设计者。例如其中的一个作业是这样的,我给出了8.1节中描述的Table接口、它的实现的目标代码以及8.2节中描述的单词频率程序wf的说明,让学生只使用我们为Table设计的目标代码来实现wf。在下一个作业中,wf的目标代码就有了,他们必须实现Table。有时我会颠倒这些作业的顺序,但是这两种顺序对大部分学生来说都是很新颖的。他们不习惯在大部分程序中只使用目标代码,并且这些作业通常都是他们第一次接触到在接口和程序说明中使用半正式表示法。
最初布置的作业也介绍了作为接口说明必要组成部分的可检查的运行时错误和断言。同样,只有做过几次这样的作业之后,学生们才开始理解这些概念的意义。我禁止了突发性崩溃,即不是由断言错误的诊断所宣布的崩溃。运行崩溃的程序将被判为零分,这样做似乎过于苛刻,但是它能够引起学生们的注意,而且也能够让学生理解安全语言的好处,例如ML和Modula-3,在这些语言中,不会出现突发性崩溃。(这种评分方法实际上没有那么苛刻,因为在分成多个部分的作业中,只有产生冲突的那部分作业才会判为错误,而且不同的作业权重也不同。我给过许多0分,但是从来没有因此导致任何一个学生的课程总成绩降低达1分。)
一旦学生们有了自己的几个接口后,接下来就让他们设计新的接口并沿用以前的设计选择。例如,Andrew Appel最喜欢的一个作业是一个原始的测试程序。学生们以组为单位设计一个作业需要的任意算术精度的接口,作业的结果类似于第17章到第19章中描述的接口。不同的组设计的接口不同,完成后对这些接口进行比较,一个组对另一个组设计的接口进行评价,这样做很有启迪作用。Kai Li的那个需要一个学期来完成的项目也达到了同样的学习实践效果,该项目使用Tcl/Tk系统(参见J. K. Ousterhout所著的Tcl and the Tk Toolkit)以及学生们设计和实现的编辑程序专用的接口,构建了一个基于X的编辑程序。Tk本身就是一个很好的基于接口的设计。
在高级课程中,我通常把作业打包成接口,学生可以自行修改和改进,甚至改变作业的目标。给学生设置一个起点可以减少他们完成作业所需的时间,允许他们做一些实质性的修改鼓励了有创造性的学生去探索新的解决办法。通常,那些不成功的方法比成功的方法更让学生记忆深刻。学生不可避免地会走错路,为此也付出了更多的开发时间。但只有当他们事后再回过头来看,才会了解所犯的错误,也才会知道设计一个好的接口虽然很困难,但是值得付出努力,而且到最后,他们几乎都会转到基于接口的设计上来。
目 录
第1章 引言 1
1.1 文学程序 2
1.2 程序设计风格 6
1.3 效率 8
1.4 扩展阅读 9
1.5 习题 9
第2章 接口与实现 11
2.1 接口 11
2.2 实现 13
2.3 抽象数据类型 15
2.4 客户程序的职责 17
2.5 效率 21
2.6 扩展阅读 22
2.7 习题 22
第3章 原子 24
3.1 接口 24
3.2 实现 25
3.3 扩展阅读 30
3.4 习题 31
第4章 异常与断言 33
4.1 接口 35
4.2 实现 38
4.3 断言 44
4.4 扩展阅读 46
4.5 习题 47
第5章 内存管理 49
5.1 接口 50
5.2 产品实现 54
5.3 稽核实现 55
5.4 扩展阅读 62
5.5 习题 63
第6章 再谈内存管理 65
6.1 接口 65
6.2 实现 67
6.3 扩展阅读 72
6.4 习题 73
第7章 链表 75
7.1 接口 75
7.2 实现 79
7.3 扩展阅读 83
7.4 习题 83
第8章 表 84
8.1 接口 84
8.2 例子:词频 87
8.3 实现 91
8.4 扩展阅读 97
8.5 习题 97
第9章 集合 99
9.1 接口 99
9.2 例子:交叉引用列表 101
9.3 实现 107
9.3.1 成员操作 109
9.3.2 集合操作 111
9.4 扩展阅读 114
9.5 习题 115
第10章 动态数组 116
10.1 接口 116
10.2 实现 119
10.3 扩展阅读 122
10.4 习题 122
第11章 序列 123
11.1 接口 123
11.2 实现 125
11.3 扩展阅读 129
11.4 习题 129
第12章 环 131
12.1 接口 131
12.2 实现 134
12.3 扩展阅读 141
12.4 习题 141
第13章 位向量 142
13.1 接口 142
13.2 实现 144
13.2.1 成员操作 146
13.2.2 比较 150
13.2.3 集合操作 151
13.3 扩展阅读 152
13.4 习题 153
第14章 格式化 154
14.1 接口 154
14.1.1 格式化函数 155
14.1.2 转换函数 157
14.2 实现 160
14.2.1 格式化函数 161
14.2.2 转换函数 166
14.3 扩展阅读 170
14.4 习题 171
第15章 低级字符串 172
15.1 接口 173
15.2 例子:输出标识符 178
15.3 实现 179
15.3.1 字符串操作 180
15.3.2 分析字符串 184
15.3.3 转换函数 188
15.4 扩展阅读 189
15.5 习题 189
第16章 高级字符串 192
16.1 接口 192
16.2 实现 197
16.2.1 字符串操作 200
16.2.2 内存管理 204
16.2.3 分析字符串 205
16.2.4 转换函数 209
16.3 扩展阅读 210
16.4 习题 210
第17章 扩展精度算术 212
17.1 接口 212
17.2 实现 217
17.2.1 加减法 218
17.2.2 乘法 220
17.2.3 除法和比较 221
17.2.4 移位 226
17.2.5 字符串转换 228
17.3 扩展阅读 230
17.4 习题 230
第18章 任意精度算术 232
18.1 接口 232
18.2 例子:计算器 235
18.3 实现 240
18.3.1 取反和乘法 242
18.3.2 加减法 243
18.3.3 除法 246
18.3.4 取幂 247
18.3.5 比较 249
18.3.6 便捷函数 250
18.3.7 移位 251
18.3.8 与字符串和整数的转换 252
18.4 扩展阅读 254
18.5 习题 255
第19章 多精度算术 257
19.1 接口 257
19.2 例子:另一个计算器 263
19.3 实现 269
19.3.1 转换 272
19.3.2 无符号算术 275
19.3.3 有符号算术 277
19.3.4 便捷函数 280
19.3.5 比较和逻辑操作 285
19.3.6 字符串转换 288
19.4 扩展阅读 290
19.5 习题 291
第20章 线程 292
20.1 接口 294
20.1.1 线程 294
20.1.2 一般信号量 298
20.1.3 同步通信通道 301
20.2 例子 301
20.2.1 并发排序 302
20.2.2 临界区 305
20.2.3 生成素数 307
20.3 实现 311
20.3.1 同步通信通道 311
20.3.2 线程 313
20.3.3 线程创建和上下文切换 322
20.3.4 抢占 328
20.3.5 一般信号量 330
20.3.6 MIPS和ALPHA上的上下文
切换 332
20.4 扩展阅读 335
20.5 习题 336
附录A 接口摘要 339
参考书目 363
引 言
一个大程序由许多小的模块组成。这些模块提供了程序中使用的函数、过程和数据结构。理想情况下,这些模块中大部分都是现成的并且来自于库,只有那些特定于现有应用程序的模块需要从头开始编写。假定库代码已经全面测试过,而只有应用程序相关的代码会包含bug,那么调试就可以仅限于这部分代码。
遗憾的是,这种理论上的理想情况实际上很少出现。大多数程序都是从头开始编写,它们只对最低层次的功能使用库,如I/O和内存管理。即使对于此类底层组件,程序员也经常编写特定于应用程序的代码。例如,将C库函数malloc和free替换为定制的内存管理函数的应用程序也是很常见的。
造成这种情况的原因无疑有诸多方面。其中之一就是,很少有哪个普遍可用的库包含了健壮、设计良好的模块。一些可用的库相对平庸,缺少标准。虽然C库自1989年已经标准化,但直至现在才出现在大多数平台上。
另一个原因是规模问题:一些库规模太大,从而导致对库本身功能的掌握变成了一项沉重的任务。哪怕这项工作的工作量似乎稍逊于编写应用程序所需的工作量,程序员可能都会重新实现库中他们所需的部分功能。最近出现颇多的用户界面库,通常会有这种问题。
库的设计和实现是困难的。在通用性、简单性和效率这三个约束之间,设计者必须如履薄冰,审慎前行。如果库中的例程和数据结构过于通用,那么库本身可能难以使用,或因效率较低而无法达到预定目标。如果库的例程和数据结构过于简单,又可能无法满足应用程序的需求。如果库太难于理解,程序员干脆就不会使用它们。C库本身就提供了一些这样的例子,例如其中的realloc函数,其语义混乱到令人惊讶的地步。
库的实现者面临类似的障碍。即使设计做得很好,糟糕的实现同样会吓跑用户。如果某个实现太慢或太庞大,或只是感觉上如此,程序员都将自行设计替代品。最糟的是,如果实现有bug,它将使上述的理想状况彻底破灭,从而使库也变得无用。
本书描述了一个库的设计和实现,它适应以C语言编写的各种应用程序的需求。该库导出了一组模块,这些模块提供了用于小规模程序设计(programming-in-the-small)的函数和数据结构。在几千行长的应用程序或应用程序组件中,这些模块适于用作零部件。
在后续各章中描述的大部分编程工具,都涵盖在大学本科数据结构和算法课程中。但在本书中,我们更关注将这些工具打包的方式,以及如何使之健壮无错。各个模块都以一个接口及其实现的方式给出。这种设计方法学在第2章中进行了解释,它将模块规格说明与其实现相分离,以提高规格说明的清晰度和精确性,而这有助于提供健壮的实现。
1.1 文学程序
本书并不是以“处方”的形式来描述各个模块,而是通过例子描述。各章完整描述了一两个接口及其实现。这些描述以文学程序(literate program)的形式给出。接口及其实现的代码与对其进行解释的正文交织在一起。更重要的是,各章本身就是其描述的接口和实现的源代码。代码可以从本书的源文件文本中自动提取出来,所见即所得。
文学程序由英文正文和带标签的程序代码块组成。例如,
〈compute x • y〉≡
sum = 0;
for (i = 0; i < n; i++)
sum += x[i]*y[i];
定义了名为〈compute x • y〉的代码块,其代码计算了数组x和y的点积。在另一个代码块中使用该代码块时,直接引用即可:
〈function dotproduct〉≡
int dotProduct(int x[], int y[], int n) {
int i, sum;
〈compute x • y〉
return sum;
}
当〈function dotproduct〉代码块从本章对应的源文件中抽取出来时,将逐字复制其代码,用到代码块的地方都将替换为对应的代码。抽取〈function dotproduct〉的结果是一个只包含下述代码的文件:
int dotProduct(int x[], int y[], int n) {
int i, sum;
sum = 0;
for (i = 0; i < n; i++)
sum += x[i]*y[i];
return sum;
}
文学程序可以按各个小片段的形式给出,并附以完备的文档。英文正文包含了传统的程序注释,这些并不受程序设计语言的注释规范的限制。
代码块的这种特性将文学程序从编程语言强加的顺序约束中解放出来。代码可以按最适于理解的顺序给出,而不是按语言所硬性规定的顺序(例如,程序实体必须在使用前被定义)。
本书中使用的文学编程系统还有另外一些特性,它们有助于逐点对程序进行描述。为说明这些特性并提供一个完整的C语言文学程序的例子,本节其余部分将描述double程序,该程序检测输入中相邻的相同单词,如“the the”。
% double intro.txt inter.txt
intro.txt:10: the
inter.txt:110: interface
inter.txt:410: type
inter.txt:611: if
上述UNIX命令结果说明,“the”在intro.txt文件中出现了两次,第二次出现在第10行;而在inter.txt文件中,interface、type和if也分别在给出的行出现第二次。如果调用double时不指定参数,它将读取标准输入,并在输出时略去文件名。例如:
% cat intro.txt inter.txt | double
10: the
143: interface
343: type
544: if
在上述例子和其他例示中,由用户键入的命令显示为斜代码体,而输出则显示为通常的代码体。
我们先从定义根代码块来实现double,该代码块将使用对应于程序各个组件的其他代码块:
〈double.c 3〉≡
〈includes 4〉
〈data 4〉
〈prototypes 4〉
〈functions 3〉
按照惯例,根代码块的标签设置为程序的文件名,提取〈double.c 3〉代码块,即可提取整个程序。其他代码块的标签设置为double的各个顶层组件名。这些组件按C语言规定的顺序列出,但也可以按任意顺序给出。
〈double.c 3〉中的3是页码,表示该代码块的定义从书中哪一页开始。〈double.c 3〉中使用的代码块中的数字也是页码,表示该代码块的定义从书中哪一页开始。这些页码有助于读者浏览代码时定位。
main函数处理double的参数。它会打开各个文件,并调用doubleword扫描文件:
〈functions 3〉≡
int main(int argc, char *argv[]) {
int i;
for (i = 1; i < argc; i++) {
FILE *fp = fopen(argv[i], "r");
if (fp == NULL) {
fprintf(stderr, "%s: can't open '%s' (%s)\n",
argv[0], argv[i], strerror(errno));
return EXIT_FAILURE;
} else {
doubleword(argv[i], fp);
fclose(fp);
}
}
if (argc == 1) doubleword(NULL, stdin);
return EXIT_SUCCESS;
}
〈includes 4〉≡
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
doubleword函数需要从文件中读取单词。对于该程序来说,一个单词由一个或多个非空格字符组成,不区分大小写。getword从打开的文件读取下一个单词,复制到buf [0..size 1]中,并返回1;在到达文件末尾时该函数返回0。
〈functions 3〉+≡
int getword(FILE *fp, char *buf, int size) {
int c;
c = getc(fp);
〈scan forward to a nonspace character or EOF 5〉
〈copy the word into buf[0..size-1] 5〉
if (c != EOF)
ungetc(c, fp);
return〈found a word? 5〉;
}
〈prototypes 4〉≡
int getword(FILE *, char *, int);
该代码块说明了另一个文学编程特性:代码块标签〈functions 3〉后接的+≡表示将getword的代码附加到代码块〈functions 3〉的代码的后面,因此该代码块现在包含main和getword的代码。该特性允许分为多次定义一个代码块中的代码,每次定义一部分。对于一个“接续”代码块来说,其标签中的页码指向该代码块的第一次定义处,因此很容易找到代码块定义的开始处。
因为getword在main之后定义,在main中调用getword时就需要一个原型,这就是〈prototypes 4〉代码块的用处。该代码块在一定程度上是对C语言“先声明后使用”(declaration- before-use)规则的让步,但如果该代码定义得一致并在根代码块中出现在〈functions 3〉之前,那么函数可以按任何顺序给出。
getword除了从输入获取下一个单词之外,每当遇到一个换行字符时都对linenum加1。doubleword输出时将使用linenum。
〈data 4〉≡
int linenum;
〈scan forward to a nonspace character or EOF 5〉≡
for ( ; c != EOF && isspace(c); c = getc(fp))
if (c == '\n')
linenum++;
〈includes 4〉+≡
#include <ctype.h>
linenum的定义,也例证了代码块的顺序不必与C语言的要求相同。linenum在其第一次使用时定义,而不是在文件的顶部或getword定义之前,后两种做法才是合乎C语言要求的。
size的值限制了getword所能存储的单词的长度,getword函数会丢弃过多的字符并将大写字母转换为小写:
〈copy the word into buf[0..size-1] 5〉≡
{
int i = 0;
for ( ; c != EOF && !isspace(c); c = getc(fp))
if (i < size - 1)
buf[i++] = tolower(c);
if (i < size)
buf[i] = '\0';
}
索引i与size-1进行比较,以保证单词末尾有空间存储一个空字符。在size为0时,if语句保护了对缓存的赋值操作。在double中不会出现这种情况,但这种防性程序设计(defensive programming)有助于捕获“不可能发生的bug”。
剩下的代码逻辑是,如果buf中保存了一个单词则返回1,否则返回0:
〈found a word? 5〉≡
buf[0] != '\0'
该定义表明,代码块不必对应于C语言中的语句或任何其他语法单位,代码块只是文本而已。
doubleword读取各个单词,并将其与前一个单词比较,发现重复时输出。它只查看以字母开头的单词:
〈functions 3〉+≡
void doubleword(char *name, FILE *fp) {
char prev[128], word[128];
linenum = 1;
prev[0] = '\0';
while (getword(fp, word, sizeof(word)) {
if (isalpha(word[0]) && strcmp(prev, word)==0)
〈word is a duplicate 6〉
strcpy(prev, word);
}
}
〈prototypes 4〉+≡
void doubleword(char *, FILE *);
〈includes 4〉+≡
#include <string.h>
输出是很容易的,但仅当name不为NULL时才输出文件名及后接的冒号:
〈word is a duplicate 6〉≡
{
if (name)
printf("%s:", name);
printf("%d: %s\n", linenum, word);
}
该代码块被定义为一个复合语句,因而可以作为结果用在它所处的if语句中。
1.2 程序设计风格
double说明了本书中程序所使用的风格惯例。程序能否更容易被阅读并理解,比使程序更容易被计算机编译更为重要。编译器并不在意变量的名称、代码的布局或程序的模块划分方式。但这种细节对程序员阅读以及理解程序的难易程度有很大影响。
本书代码遵循C程序的一些既定的风格惯例。它使用一致的惯例来命名变量、类型和例程,并在本书的排版约定下,采用一致的缩进风格。风格惯例并非是一种必须遵循的刚性规则,它们表示的是程序设计的一种哲学方法,力求最大限度地增加程序的可读性和可理解性。因而,凡是改变惯例能有助于强调代码的重要方面或使复杂的代码更可读时,你完全可以违反“规则”。
一般来说,较长且富于语义的名称用于全局变量和例程,而数学符号般的短名称则用于局部变量。代码块〈compute x • y〉中的循环索引i属于后一种惯例。对索引和变量使用较长的名称通常会使代码更难阅读,例如下述代码中
sum = 0;
for (theindex = 0; theindex < numofElements; theindex++)
sum += x[theindex]*y[theindex];
长变量名反而使代码的语义含混不清。
变量的声明应该靠近于其第一次使用的地方(可能在代码块中)。linenum的声明很靠近在getword中首次使用该变量的地方,这就是个例子。在可能的情况下,局部变量的声明在使用变量的复合语句的开始处。例如,代码块〈copy the word into buf[0..size-1] 5〉中对i的声明。
一般来说,过程和函数的名称,应能反映过程完成的工作及函数的返回值。因而,getword应当返回输入中的下一个单词,而doubleword则找到并显示出现两次或更多次的单词。大多数例程都比较简单,不会超过一页代码,代码块更短,通常少于十二行。
代码中几乎没有注释,因为围绕对应代码块的正文代替了注释。有关注释风格的建议几乎会引发程序员间的战争。本书将效法C程序设计方面的典范,最低限度地使用注释。如果代码很清晰,且使用了良好的命名和缩进惯例,则这样的代码通常是自明的。仅当进行解释时(例如,解释数据结构的细节、算法的特例以及异常情况)才需要注释。编译器无法检查注释是否与代码一致,误导的注释通常比没有注释更糟糕。最后,有些注释只不过是种干扰,其中的噪音和过多的版式掩盖了注释内容,从而使这些注释只会掩盖代码本身的含义。
文学编程避免了注释战争中的许多争论,因为它不受程序设计语言注释机制的约束。程序员可以使用最适合于表达其意图的任何版式特性,如表、方程、图片和引文。文学编程似乎提倡准确、精确和清晰。
本书中的代码以C语言编写,它所使用的大多数惯用法通常已被有经验的C程序员所接受并希望采用。其中一些惯用法可能使不熟悉C语言的程序员困惑,但为了能用C语言流利地编程,程序员必须掌握这些惯用法。涉及指针的惯用法通常是最令人困惑的,因为C语言为指针的操作提供了几种独特且富有表达力的运算符。库函数strcpy将一个字符串复制到另一个字符串中并返回目标字符串,对该函数的不同实现就说明了“地道的C语言”和新手C程序员编写的代码之间的差别,后一种代码通常使用数组:
char *strcpy(char dst[], const char src[]) {
int i;
for (i = 0; src[i] != '\0'; i++)
dst[i] = src[i];
dst[i] = '\0';
return dst;
}
“地道”的版本则使用指针:
char *strcpy(char *dst, const char *src) {
char *s = dst;
while (*dst++ = *src++)
;
return s;
}
这两个版本都是strcpy的合理实现。指针版本使用通常的惯用法将赋值、指针递增和测试赋值操作的结果合并为单一的赋值表达式。它还修改了其参数dst和src,这在C语言中是可接受的,因为所有参数都是传值的,实际上参数只不过是已初始化的局部变量。
还可以举出很好的例子,来表明使用数组版本比指针版本更好。例如,所有程序员都更容易理解数组版本,无论他们能否使用C语言流畅地编程。但指针版本是最有经验的C程序员会编写的那种代码,因而程序员阅读现存代码时最有可能遇到它。本书可以帮助读者学习这些惯用法、理解C语言的优点、并避免易犯的错误。
1.3 效率
程序员似乎被效率问题困扰着。他们可能花费数小时来微调代码,使之运行得更快。遗憾的是,大部分这种工作都是无用功。当猜测程序的运行时间花费在何处时,程序员的直觉非常糟糕。
微调程序是为了使之更快,但通常总是会使之更大、更难理解、更可能包含错误。除非对执行时间的测量表明程序太慢,否则这样的微调没有意义。程序只需要足够快即可,不一定要尽可能快。
微调通常在“真空”中完成。如果一个程序太慢,找到其瓶颈的唯一途径就是测量它。程序的瓶颈很少出现在预期位置或者因你所怀疑的原因导致,而且在错误位置上微调程序是没有意义的。在找到正确的位置后,仅当该处花费的时间确实占运行时间的很大比例时,才有必要进行微调。如果I/O占了程序运行时间的60%,在搜索例程中节省1%是无意义的。
微调通常会引入错误。最快崩溃的程序绝非胜者。可靠性比效率更重要;与交付足够快的可靠软件相比,交付快速但会崩溃的软件,从长远看来代价更高。
微调经常在错误的层次上进行。快速算法的直接简明的实现,比慢速算法的手工微调实现要好得多。例如,减少线性查找的内层循环的指令数,注定不如直接使用二分查找。
微调无法修复低劣的设计。如果程序到处都慢,这种低效很可能是设计导致的。当基于编写得很糟糕或不精确的问题说明给出设计时,或者根本就没有总体设计时,就会发生这种令人遗憾的情况。
本书中大部分代码都使用了高效的算法,具有良好的平均情况性能,其最坏情形性能也易于概括。对大多数应用程序来说,这些代码对典型输入的执行时间总是足够快速的。当某些程序的代码性能可能会导致问题时,书中自会明确注明。
一些C程序员在寻求提高效率的途径时,大量使用宏和条件编译。只要有可能,本书将避免使用这两种方法。使用宏来避免函数调用基本上是不必要的。仅当客观的测量结果表明有问题的调用的开销大大超出其余代码的运行时间时,使用宏才有意义。操作I/O是较适宜采用宏的少数情况之一。例如,标准的I/O函数getc、putc、getchar和putchar通常实现为宏。
条件编译通常用于配置特定平台或环境的代码,或者用于代码调试的启用/禁用。这些问题是实际存在的,但条件编译通常只是解决问题的较为容易的方法,而且总会使代码更难于阅读。而重写代码以便在执行期间选择平台依赖关系通常则更为有用。例如,一个编译器可以在执行时选择多种(比如说六种)体系结构中的一个来生成代码,这样的一种交叉编译器要比必须配置并搭建六个不同的编译器更有用,而且可能更易于维护。
如果应用程序必须在编译时配置,与C语言的条件编译工具相比,版本控制工具更擅长完成该工作。这样,代码中就不必充斥着预处理器指令,因为那会使代码难于阅读,并模糊被编译和未被编译的代码之间的界限。使用版本控制工具,你看到的代码即为被执行的代码。对于跟踪性能改进情况来说,这些工具也是理想的选择。
1.4 扩展阅读
对于标准C库来说,ANSI标准 [ANSI 1990]和技术上等效的ISO标准 [ISO 1990]是权威的参考文献,但 [Plauger,1992]一书给出了更详细的描述和完整的实现。同样,C语言相关问题的定论就在于这些标准,但[Kernighan and Ritchie,1988]一书却可能是最广为使用的参考。[Harbison and Steele,1995]一书的最新版本或许是C语言标准的最新的资料,它还描述了如何编写“干净的C”,即可以用C++编译器编译的C代码。[Jaeschke,1991]一书将标准C语言的精华浓缩为紧凑的词典格式,这份资料对C程序员来说也很有用。
[Kernighan and Plauger,1976]一书给出了文学程序的早期例子,当然作者对文学编程没太多认识,只是使用了专门开发的工具将代码集成到书中。WEB是首批明确为文学编程设计的工具之一。[Knuth,1992]一书描述了WEB和它的一些变体及用法,[Sewell,1989]一书是WEB的入门介绍。更简单的工具([Hanson,1987],[Ramsey,1994])发展了很长时间才提供WEB的大部分基本功能。本书使用notangle来提取代码块,它是Ramsey的noweb系统中的程序之一。[Fraser and Hanson,1995]一书也使用了noweb,该书以文学程序的形式给出了一个完整的C语言编译器。该编译器也是一个交叉编译器。
double取自 [Kernighan and Pike,1984],在该书中double是用AWK [Aho, Kernighan and Weinberger,1988]程序设计语言实现的。尽管年龄老迈,但[Kernighan and Pike,1984]仍然是UNIX程序设计哲学方面的最佳书籍之一。
学习良好的程序设计风格,最好的方法是阅读风格良好的程序。本书将遵循 [Kernighan and Pike,1984]和 [Kernighan and Ritchie,1988]中的风格,这种风格经久而不衰。[Kernighan and Plauger,1978]一书是程序设计风格方面的经典著作,但该书并不包含C语言的例子。Ledgard的小书[Ledgard,1987]提供了类似的建议,而 [Maguire,1993]从PC程序设计的角度阐述了程序设计风格问题。[Koenig,1989]一书暴露的C语言的黑暗角落,强调了那些应该避免的东西。[McConnell,1993]一书在与程序构建相关的许多方面提供了明智的建议,并针对使用goto语句的利弊两方面进行了不偏不倚的讨论。
学习编写高效的代码,最好的方法是在算法方面有扎实的基础,并阅读其他高效的代码。[Sedgewick,1990]一书纵览了大多数程序员都必须知道的所有重要算法,而 [Knuth,1973a]一书对算法基础进行了至为详细的讨论。[Bentley,1982]一书有170页,给出了编写高效代码方面的一些有益的建议和常识。
1.5 习题
1.1 在一个单词结束于换行符时,getword在〈scan forward to a nonspace or EOF 5〉代码块中将linenum加1,而不是在〈copy the word into buf[0..size-1] 5〉代码块之后。解释这样做的原因。如果在本例中,linenum的加1操作是在〈copy the word into buf[0..size-1] 5〉代码块之后进行,会发生什么情况?
1.2 当double在输入中发现三个或更多相同单词时会显示什么?修改double来改掉这个“特性”。
1.3 许多有经验的C程序员会在strcpy的循环中加入一个显式的比较操作:
char *strcpy(char *dst, const char *src) {
char *s = dst;
while ((*dst++ = *src++) != '\0')
;
return s;
}
显式比较表明赋值操作并非笔误。一些C编译器和相关工具,如Gimpel Software的PC-Lint和LCLint[Evans,1996],在发现赋值操作的结果用作条件表达式时会发出警告,因为这种用法是一个常见的错误来源。如果读者有PC-Lint或LCLint,可以在一些“测试”过的程序上进行试验。
接口与实现
模块分为两个部分,即模块的接口与实现。接口规定了模块做什么。接口会声明标识符、类型和例程,提供给使用模块的代码。实现指明模块如何完成其接口规定的目标。对于给定的模块,通常只有一个接口,但可能有许多实现提供了接口规定的功能。每个实现可能使用不同的算法和数据结构,但它们都必须合乎接口的规定。
客户程序(client)是使用模块的一段代码。客户程序导入接口,实现则导出接口。客户程序只需要看到接口即可。实际上,它们可能只有实现的目标码。多个客户程序共享接口和实现,因而避免了不必要的代码重复。这种方法学也有助于避免bug,接口和实现编写并调试一次后,可以经常使用。
2.1 接口
接口仅规定客户程序可能使用的那些标识符,而尽可能隐藏不相关的表示细节和算法。这有助于客户程序避免依赖特定实现的具体细节。客户程序和实现之间的这种依赖性称之为耦合(coupling),在实现改变时耦合会导致bug,当依赖性被与实现相关的隐藏或隐含的假定掩盖时,这种bug可能会特别难于改正。设计完善且陈述准确的接口可以减少耦合。
对于接口与实现相分离,C语言只提供了最低限度的支持,但通过一些简单的约定,我们即可获得接口/实现方法学的大多数好处。在C语言中,接口通过一个头文件指定,头文件的扩展名通常为.h。这个头文件会声明客户程序可能使用的宏、类型、数据结构、变量和例程。客户程序用C预处理器指令#include导入接口。
以下例子说明了本书中的接口使用的约定。下述接口
〈arith.h〉≡
extern int Arith_max(int x, int y);
extern int Arith_min(int x, int y);
extern int Arith_div(int x, int y);
extern int Arith_mod(int x, int y);
extern int Arith_ceiling(int x, int y);
extern int Arith_floor (int x, int y);
声明了六个整数算术运算函数。该接口的实现需要为上述每一个函数提供定义。
该接口命名为Arith,接口头文件命名为arith.h。在接口中,接口名称表现为每个标识符的前缀。这种约定并不优美,但C语言几乎没有提供其他备选方案。所有文件作用域中的标识符,包括变量、函数、类型定义和枚举常数,都共享同一个命名空间。所有的全局结构、联合和枚举标记则共享另一个命名空间。在一个大程序中,在本来无关的模块中,很容易使用同一名称表示不同的目的。避免这种名称碰撞(name collision)的一个方法是使用前缀,如模块名。一个大程序很容易有数千全局标识符,但通常只有几百个模块。模块名不仅提供了适当的前缀,还有助于使客户程序代码文档化。
Arith接口中的函数提供了标准C库缺失的一些有用功能,并对除法和模运算提供了良定义的结果,而标准则将这些操作的行为规定为未定义(undefined)或由具体实现来定义(implementation-defined)。
Arith_min和Arith_max函数分别返回其整型参数的最小值和最大值。
Arith_div返回x除以y获得的商,而Arith_mod则返回对应的余数。当x和y都为正或都为负时,Arith_div(x,y)等于x/y,而Arith_mod(x,y)等于x%y。然而当两个操作数符号不同时,由C语言内建运算符所得出的返回值取决于具体编译器的实现。当y为零时,Arith_div和Arith_mod的行为与x/y和x%y相同。
C语言标准只是强调,如果x/y是可表示的,那么(x/y)*y + x%y必须等于x。当一个操作数为负数时,这种语义使得整数除法可以向零舍入,也可以向负无穷大舍入。例如,如果13/5的结果定义为2,那么标准指出,13%5必须等于13 (13/5)*5 13 (2)*5 3。但如果13/5定义为3,那么13%5的值必须是13 (3)*5 2。
因而内建的运算符只对正的操作数有用。标准库函数div和ldiv以两个整数或长整数为输入,并计算二者的商和余数,在一个结构的quot和rem字段中返回。这两个函数的语义是良定义的:它们总是向零舍入,因此div(-13,5).quot总是等于2。Arith_div和Arith_mod同样是良定义的。它们总是向数轴的左侧舍入,当其操作数符号相同时向零舍入,当其符号不同时向负无穷大舍入,因此Arith_div(-13,5)返回3。
Arith_div和Arith_mod的定义可以用更精确的数学术语来表达。Arith_div(x,y)定义为不超过实数z的最大整数,而z*y=x。因而,对x=-13和y=5(或者x = 13和y = 5),z为2.6,因此Arith_div(-13,5)为3。Arith_mod(x,y)定义为等于x - y*Arith_div(x,y),因此Arith_mod(-13,5)为13 5*(3) 2。
Arith_ceiling和Arith_floor函数遵循类似的约定。Arith_ceiling(x,y)返回不小于x/y的实数商的最小整数,而Arith_floor(x,y)返回不大于x/y的实数商的最大整数。对所有操作数x和y来说,Arith_ceiling返回数轴在x/y对应点右侧的整数,而Arith_floor返回x/y对应点左侧的整数。例如:
Arith_ceiling( 13,5) = 13/5 = 2.6 = 3
Arith_ceiling(-13,5) =-13/5 = -2.6 = -2
Arith_floor ( 13,5) = 13/5 = 2.6 = 2
Arith_floor (-13,5) =-13/5 = -2.6 = -3
即便简单如Arith这种程度的接口仍然需要这么费劲的规格说明,但对大多数接口来说,Arith的例子很有代表性和必要性(很让人遗憾)。大多数编程语言的语义中都包含漏洞,某些操作的精确含义定义得不明确或根本未定义。C语言的语义充满了这种漏洞。设计完善的接口会塞住这些漏洞,将未定义之处定义完善,并对语言标准规定为未定义或由具体实现定义的行为给出明确的裁决。
Arith不仅是一个用来显示C语言缺陷的人为范例。它也是有用的,例如对涉及模运算的算法,就像是哈希表中使用的那些算法。假定i从零到N - 1,其中N大于1,并对i加1和i减1的结果模N。即,如果i为N-1,i+1为0,而如果i为0,i-1为N-1。下述表达式
i = Arith_mod(i + 1, N);
i = Arith_mod(i - 1, N);
正确地对i进行了加1模N和减1模N的操作。表达式i = (i+1) % N可以工作,但i = ( i-1) % N无法工作,因为当i为0时,(i-1) % N可能是1或N-1。程序员在(-1) % N返回N-1的计算机上可以使用(i-1) % N,但如果依赖这种由具体实现定义的行为,那么在将代码移植到(-1) % N返回1的计算机上时,就可能遭遇到非常出人意料的行为。库函数div(x,y)也无济于事。它返回一个结构,其quot和rem字段分别保存x/y的商和余数。在i为零时,div(i-1, N).rem总是1。使用i = (i-1+N) % N是可以的,但仅当i-1+N不造成溢出时才行。
2.2 实现
实现会导出接口。它定义了必要的变量和函数,以提供接口规定的功能。实现具体解释了接口的语义,并给出其表示细节和算法,但在理想情况下,客户程序从来都不需要看到这些细节。不同的客户程序可以共享实现的目标码,通常是从(动态)库加载实现的目标码。
一个接口可以有多个实现。只要实现遵循接口的规定,完全可以在不影响客户程序的情况下改变实现。例如,不同的实现可能会提供更好的性能。设计完善的接口会避免对特定机器的依赖,但也可能强制实现依赖于机器,因此对用到接口的每种机器,可能都需要一个不同的实现(也可能是实现的一部分)来支持。
在C语言中,一个实现通过一个或多个.c文件来提供。实现必须提供其导出的接口规定的功能。实现会包含接口的.h文件,以确保其定义与接口的声明一致。但除此之外,C语言中没有其他语言机制来检查实现与接口是否符合。
如同本书中的接口,本书描述的实现也具有一种风格化的格式,如arith.c所示:
〈arith.c〉≡
#include "arith.h"
〈arith.c functions 14〉
〈arith.c functions 14〉≡
int Arith_max(int x, int y) {
return x > y ? x : y;
}
int Arith_min(int x, int y) {
return x > y ? y : x;
}
除了〈arith.c functions 14〉,更复杂的实现可能包含名为〈data〉、〈types〉、〈macros〉、〈prototypes〉等的代码块。在不会造成混淆时,代码块中的文件名(如arith.c)将略去。
在Arith_div的参数符号不同时,它必须处理除法的两种可能行为。如果除法向零舍入,而y不能整除x,那么Arith_div(x,y)的结果为x/y 1,否则,返回x/y即可:
〈arith.c functions 14〉+≡
int Arith_div(int x, int y) {
if (〈division truncates toward 0 14〉
&& 〈x and y have different signs 14〉 && x%y != 0)
return x/y - 1;
else
return x/y;
}
前一节的例子,即将13除以5,可以测试除法所采用的舍入方式。首先判断x和y是否小于0,然后比较两个判断结果是否相等,即可检查符号问题:
〈division truncates toward 0 14〉≡
-13/5 == -2
〈x and y have different signs 14〉≡
(x < 0) != (y < 0)
Arith_mod可以按其定义实现:
int Arith_mod(int x, int y) {
return x - y*Arith_div(x, y);
}
如果Arith_mod也像Arith_div那样进行判断,那么也可以使用%运算符实现。在相应的条件为真时,
Arith_mod(x,y) = x - y*Arith_div(x, y)
= x - y*(x/y - 1)
= x - y*(x/y) + y
加下划线的子表达式是标准C对x%y的定义,因此Arith_mod可定义为:
〈arith.c functions 14〉+≡
int Arith_mod(int x, int y) {
if (〈division truncates toward 0 14〉
&& 〈x and y have different signs 14〉 && x%y != 0)
return x%y + y;
else
return x%y;
}
Arith_floor刚好等于Arith_div,而Arith_ceiling等于Arith_div加1,除非y能整除x:
〈arith.c functions 14〉+≡
int Arith_floor(int x, int y) {
return Arith_div(x, y);
}
int Arith_ceiling(int x, int y) {
return Arith_div(x, y) + (x%y != 0);
}
2.3 抽象数据类型
一个抽象数据类型是一个接口,它定义了一个数据类型和对该类型的值所进行的操作。一个数据类型是一个值的集合。在C语言中,内建的数据类型包括字符、整数、浮点数等。而结构本身也能定义新的类型,因而可用于建立更高级类型,如列表、树、查找表等。
高级类型是抽象的,因为其接口隐藏了相关的表示细节,并只规定了对该类型值的合法操作。理想情况下,这些操作不会暴露类型的表示细节,因为那样可能使客户程序隐含地依赖于具体的表示。抽象数据类型或ADT的标准范例是栈。其接口定义了栈类型及其五个操作:
〈initial version of stack.h〉≡
#ifndef STACK_INCLUDED
#define STACK_INCLUDED
typedef struct Stack_T *Stack_T;
extern Stack_T Stack_new (void);
extern int Stack_empty(Stack_T stk);
extern void Stack_push (Stack_T stk, void *x);
extern void *Stack_pop (Stack_T stk);
extern void Stack_free (Stack_T *stk);
#endif
上述的typedef定义了Stack_T类型,这是一个指针,指向一个同名结构。该定义是合法的,因为结构、联合和枚举的名称(标记)占用了一个命名空间,该命名空间不同于变量、函数和类型名所用的命名空间。这种惯用法的使用遍及本书各处。类型名Stack_T,是这个接口中我们关注的名称,只有对实现来说,结构名才比较重要。使用相同的名称,可以避免用太多罕见的名称污染代码。
宏STACK_INCLUDED也会污染命名空间,但_INCLUDED后缀有助于避免冲突。另一个常见的约定是为此类名称加一个下划线前缀,如_STACK或_STACK_INCLUDED。但标准C将下划线前缀保留给实现者和未来的扩展使用,因此避免使用下划线前缀看起来是谨慎的做法。
该接口透露了栈是通过指向结构的指针表示的,但并没有给出结构的任何信息。因而Stack_T是一个不透明指针类型,客户程序可以自由地操纵这种指针,但无法反引用不透明指针,即无法查看指针所指向结构的内部信息。只有接口的实现才有这种特权。
不透明指针隐藏了表示细节,有助于捕获错误。只有Stack_T类型值可以传递给上述的函数,试图传递另一种指针,如指向其他结构的指针,将产生编译错误。唯一的例外是参数中的一个void指针,该该参数可以传递任何类型的指针。
条件编译指令#ifdef和#endif以及定义STACK_INCLUDED的#define,使得stack.h可以被包含多次,在接口又导入了其他接口时可能出现这种情况。如果没有这种保护,第二次和后续的包含操作,将因为typedef中的Stack_T重定义而导致编译错误。
在少数可用的备选方案中,这种约定似乎是最温和的。禁止接口包含其他接口,可以完全避免重复包含,但这又强制接口用某种其他方法指定必须导入的其他接口,如注释,也强迫程序员来提供包含指令。将条件编译指令放在客户程序而不是接口中,可以避免编译时不必要地读取接口文件,但代价是需要在许多地方衍生出很多乱七八糟的条件编译指令,不像只放在接口中那样清洁。上文说明的约定,需要编译器来完成所谓的“脏活”。
按约定,定义ADT的接口X可以将ADT类型命名为X_T。本书中的接口在这个约定基础上更进一步,在接口内部使用宏将X_T缩写为T。使用该约定时,stack.h如下:
〈stack.h〉≡
#ifndef STACK_INCLUDED
#define STACK_INCLUDED
#define T Stack_T
typedef struct T *T;
extern T Stack_new (void);
extern int Stack_empty(T stk);
extern void Stack_push (T stk, void *x);
extern void *Stack_pop (T stk);
extern void Stack_free (T *stk);
#undef T
#endif
该接口在语义上与前一个是等效的。缩写只是语法糖(syntactic sugar),使得接口稍微容易阅读一些。T指的总是接口中的主要类型。但客户程序必须使用Stack_T,因为stack.h末尾的#undef指令删除了上述的缩写。
该接口提供了可用于任意指针的容量无限制的栈。Stack_new创建新的栈,它返回一个类型为T的值,可以作为参数传递给其他四个函数。Stack_push将一个指针推入栈顶,Stack_pop在栈顶删除一个指针并返回该指针,如果栈为空,Stack_empty返回1,否则返回0。Stack_free以一个指向T的指针为参数,释放该指针所指向的栈,并将类型为T的变量设置为NULL指针。这种设计有助于避免悬挂指针(dangling pointer),即指针指向已经被释放的内存。例如,如果names通过下述代码定义并初始化:
#include "stack.h"
Stack_T names = Stack_new();
下述语句
Stack_free(&names);
将释放names指向的栈,并将names设置为NULL指针。
当ADT通过不透明指针表示时,导出的类型是一个指针类型,这也是Stack_T通过typedef定义为指向struct Stack_T的指针的原因。本书中大部分ADT都使用了类似的typedef。当ADT披露了其表示细节,并导出可接受并返回相应结构值的函数时,接口会将该结构类型定义为导出类型。第16章中的Text接口说明了这种约定,该接口将Text_T声明为struct Text_T的一个typedef。无论如何,接口中的主要类型总是缩写为T。
2.4 客户程序的职责
接口是其实现和其客户程序之间的一份契约。实现必须提供接口中规定的功能,而客户程序必须根据接口中描述的隐式和显式的规则来使用这些功能。程序设计语言提供了一些隐式规则,来支配接口中声明的类型、函数和变量的使用。例如,C语言的类型检查规则可以捕获接口函数的参数的类型和数目方面的错误。
C语言的用法没有规定的或编译器无法检查的规则,必须在接口中详细说明。客户程序必须遵循这些规则,实现必须执行这些规则。接口通常会规定未检查的运行时错误(unchecked runtime error)、已检查的运行时错误(checked runtime error)和异常(exception)。未检查的和已检查的运行时错误是非预期的用户错误,如未能打开一个文件。运行时错误是对客户程序和实现之间契约的破坏,是无法恢复的程序bug。异常是指一些可能的情形,但很少发生。程序也许能从异常恢复。内存耗尽就是一个例子。异常在第4章详述。
未检查的运行时错误是对客户程序与实现之间契约的破坏,而实现并不保证能够发现这样的错误。如果发生未检查的运行时错误,可能会继续执行,但结果是不可预测的,甚至可能是不可重复的。好的接口会在可能的情况下避免未检查的运行时错误,但必须规定可能发生的此类错误。例如,Arith必须指明除以零是一个未检查的运行时错误。Arith虽然可以检查除以零的情形,但却不加处理使之成为未检查的运行时错误,这样接口中的函数就模拟了C语言内建的除法运算符的行为(即,除以零时其行为是未定义的)。使除以零成为一种已检查的运行时错误,也是一种合理的方案。
已检查的运行时错误是对客户程序与实现之间契约的破坏,但实现保证会发现这种错误。这种错误表明,客户程序未能遵守契约对它的约束,客户程序有责任避免这类错误。Stack接口规定了三个已检查的运行时错误:
(1) 向该接口中的任何例程传递空的Stack_T类型的指针;
(2) 传递给Stack_free的Stack_T指针为NULL指针;
(3) 传递给Stack_pop的栈为空。
接口可以规定异常及引发异常的条件。如第4章所述,客户程序可以处理异常并采取校正措施。未处理的异常(unhandled exception)被当做是已检查的运行时错误。接口通常会列出自身引发的异常及其导入的接口引发的异常。例如,Stack接口导入了Mem接口,它使用后者来分配内存空间,因此它规定Stack_new和Stack_push可能引发Mem_Failed异常。本书中大多数接口都规定了类似的已检查的运行时错误和异常。
在向Stack接口添加这些之后,我们可以继续进行其实现:
〈stack.c〉≡
#include <stddef.h>
#include "assert.h"
#include "mem.h"
#include "stack.h"
#define T Stack_T
〈types 18〉
〈functions 18〉
#define指令又将T定义为Stack_T的缩写。该实现披露了Stack_T的内部结构,它是一个结构,一个字段指向一个链表,链表包含了栈上的各个指针,另一个字段统计了指针的数目。
〈types 18〉≡
struct T {
int count;
struct elem {
void *x;
struct elem *link;
} *head;
};
Stack_new分配并初始化一个新的T:
〈functions 18〉≡
T Stack_new(void) {
T stk;
NEW(stk);
stk->count = 0;
stk->head = NULL;
return stk;
}
NEW是Mem接口中一个用于分配内存的宏。NEW(p)为p指向的结构分配一个实例,因此Stack_ new中使用它来分配一个新的Stack_T结构实例。
如果count字段为0,Stack_empty返回1,否则返回0:
〈functions 18〉+≡
int Stack_empty(T stk) {
assert(stk);
return stk->count == 0;
}
assert(stk)实现了已检查的运行时错误,即禁止对Stack接口函数中的Stack_T类型参数传递NULL指针。assert(e)是一个断言,声称对任何表达式e,e都应该是非零值。如果e非零,它什么都不做,否则将中止程序执行。assert是标准库的一部分,但第4章的Assert接口定义了自身的assert,其语义与标准库类似,但提供了优雅的程序终止机制。assert用于所有已检查的运行时错误。
Stack_push和Stack_pop分别在stk->head链表头部添加和删除元素:
〈functions 18〉+≡
void Stack_push(T stk, void *x) {
struct elem *t;
assert(stk);
NEW(t);
t->x = x;
t->link = stk->head;
stk->head = t;
stk->count++;
}
void *Stack_pop(T stk) {
void *x;
struct elem *t;
assert(stk);
assert(stk->count > 0);
t = stk->head;
stk->head = t->link;
stk->count--;
x = t->x;
FREE(t);
return x;
}
FREE是Mem用于释放内存的宏,它释放其指针参数指向的内存空间,并将该参数设置为NULL指针,这与Stack_free的做法同理,都是为了避免悬挂指针。Stack_free也调用了FREE:
〈functions 18〉+≡
void Stack_free(T *stk) {
struct elem *t, *u;
assert(stk && *stk);
for (t = (*stk)->head; t; t = u) {
u = t->link;
FREE(t);
}
FREE(*stk);
}
该实现披露了一个未检查的运行时错误,本书中所有的ADT接口都会受到该错误的困扰,因而并没有在接口中指明。我们无法保证传递到Stack_push、Stack_pop、Stack_empty的Stack_T值和传递到Stack_free的Stack_T*值都是Stack_new返回的有效的Stack_T值。习题2.3针对该问题进行了探讨,给出一个部分解决方案。
还有两个未检查的运行时错误,其效应可能更为微妙。本书中许多ADT通过void指针通信,即存储并返回void指针。在任何此类ADT中,存储函数指针(指向函数的指针)都是未检查的运行时错误。void指针是一个类属指针(generic pointer,通用指针),类型为void *的变量可以容纳指向一个对象的任意指针,此类指针可以指向预定义类型、结构和指针。但函数指针不同。虽然许多C编译器允许将函数指针赋值给void指针,但不能保证void指针可以容纳函数指针 。
通过void指针传递任何对象指针都不会损失信息。例如,在执行下列代码之后,
S *p, *q;
void *t;
...
t = p;
q = t;
对任何非函数的类型S,p和q都将是相等的。但不能用void指针来破坏类型系统。例如,在执行下列代码之后,
S *p;
D *q;
void *t;
...
t = p;
q = t;
我们不能保证q与p是相等的,或者根据类型S和D的对齐约束,也不能保证q是一个指向类型D对象的有效指针。在标准C语言中,void指针和char指针具有相同的大小和表示。但其他指针可能小一些,或具有不同的表示。因而,如果S和D是不同的对象类型,那么在ADT中存储一个指向S的指针,将该指针返回到一个指向类型D的指针中,这是一个未检查的运行时错误。
在ADT函数并不修改被指向的对象时,程序员可能很容易将不透明指针参数声明为const。例如,Stack_empty可能有下述编写方式。
int Stack_empty(const T stk) {
assert(stk);
return stk->count == 0;
}
const的这种用法是不正确的。这里的意图是将stk声明为一个“指向struct T的常量实例的指针”,因为Stack_empty并不修改*stk。但const T stk将stk声明为一个“常量指针,指向一个struct T实例”,对T的typedef将struct T *打包到一个类型中,这一个指针类型成为了const的操作数 。无论对Stack_empty还是其调用者,const T stk都是无用的,因为在C语言中,所有的标量包括指针在函数调用时都是传值的。无论有没有const限定符,Stack_empty都无法改变调用者的实参值。
用struct T *代替T,可以避免这个问题:
int Stack_empty(const struct T *stk) {
assert(stk);
return stk->count == 0;
}
这个用法说明了为什么不应该将const用于传递给ADT的指针:const披露了有关实现的一些信息,因而限制了可能性。对于Stack的这个实现而言,使用const不是问题,但它排除了其他同样可行的方案。假定某个实现预期可重用栈中的元素,因而延迟对栈元素的释放操作,但会在调用Stack_empty时释放它们。Stack_empty的这种实现需要修改* stk,但因为*stk声明为const而无法进行修改。本书中的ADT都不使用const。
2.5 效率
本书中的接口的大多数实现所使用的算法和数据结构,其平均情况运行时间不会超过N(输入规模)的线性函数,大多数算法都能够处理大量的输入。无法处理大量输入的接口,或者性能可能成为重要影响因素的接口,可以规定性能标准(performance criteria)。实现必须满足这些标准,客户程序可以预期性能能够达到标准的规定(但不会比标准好上多少)。
本书中所有的接口都使用了简单但高效的算法。在N较大时,更复杂的算法和数据结构可能有更好的性能,但N通常比较小。大多数实现都只使用基本的数据结构,如数组、链表、哈希表、树和这些数据结构的组合。
本书中的ADT,除少量之外全部使用了不透明指针,因此需要使用诸如Stack_empty之类的函数来访问隐藏在实现背后的字段。调用函数而不是直接访问字段会带来开销,但它对实际应用程序性能的影响通常都是可忽略的。这种做法在可靠性和捕获运行时错误的机会方面带来的改进是可观的,远超性能方面的轻微代价。
如果客观的测量表明确实有必要改进性能,那么这种改进不应该改变接口,例如,可通过定义宏进行。当这种方法不可行时,最好创建一个新接口并说明其性能方面的优势,而不是改变现存的接口(这将使所有的客户程序无效)。
2.6 扩展阅读
自20世纪50年代以来,过程和函数库的重要性已经是公认的。[Parnas 1972]一文是一篇典型的论文,讨论了如何将程序划分为模块。该论文的历史已经将近40年,但当今的程序员仍然面临着该文所考虑的问题。
C程序员每天都使用接口:C库是15个接口的集合。标准输入输出接口,即stdio.h,定义了一个ADT FILE,以及对FILE指针的操作。[Plauger,1992]一书详细描述了这15个接口及适当的实现,其叙述方式大体上类似于本书讨论一组接口和实现的方式。
Modula-3是一种相对较新的语言,从语言层面支持接口与实现相分离,本书中使用的基于接口的术语即源自该语言[Nelson,1991]。未检查和已检查的运行时错误的概念,和ADT的T表示法,都是借鉴Modula-3。[Harbison,1992]是介绍Modula-3的一本教科书。[Horning等人,1993]一书描述了其Modula-3系统中的核心接口。本书中一些接口改编自该书中的接口。[Roberts,1995]一书使用了基于接口的设计,作为讲授计算机科学入门课程的编排方式。
断言的重要性是公认的,在一些语言如Modula-3和Eiffel [Meyer,1992]中,断言机制是内建在语言中的。[Maguire,1993]一书用一整章的篇幅讨论C程序中断言的使用。
熟悉面向对象编程的程序员可能认为,本书中大部分ADT都可以用面向对象程序设计语言中的对象实现(可能实现得更好),如C++ [Ellis and Stroustrup,1990]和Modula-3。[Budd,1991]一书是面向对象程序设计方法学的入门介绍,还包括一些面向对象程序设计语言如C++的内容。本书中说明的接口设计原理同样适用于面向对象语言。例如,用C++语言重写本书中的ADT,对从C语言切换到C++的程序员来说是一个很有用的练习过程。
STL(C++标准模板库,Standard Template Library)提供了与本书所述类似的ADT。STL充分利用了C++模板来针对具体类型实例化ADT(参见 [Musser and Saini,1996])。例如,STL为vector类型提供了一个模板,可针对int、string等类型分别实例化出对应的vector类型。STL还提供一套函数,来处理由模板生成的类型。
2.7 习题
2.1 原本可使用预处理器宏和条件编译指令如#if,来指定Arith_div和Arith_mod中如何处理除法的舍入操作。解释为什么对-13/5 == -2的显式测试是实现上述判断的更好的方法。
2.2 对于Arith_div和Arith_mod来说,仅当用于编译arith.c的编译器执行算术操作的方式与Arith_div和Arith_mod被调用时的目标机器相同时,这两个函数中所用的-13/5 == -2测试才是有效的。但这个条件可能会不成立,例如,如果arith.c由运行在机器X上交叉编译器编译,针对机器Y生成代码。不使用条件编译指令,请改正arith.c,使得交叉编译生成的代码也保证可以工作。
2.3 如同本书中所有的ADT,Stack接口也省略了下述规格说明:“将外部的Stack_T传递给本接口中任何例程,都是未检查的运行时错误”。外部的Stack_T,意味着不是由Stack_new产生的Stack_T。修正stack.c,使其可以在某些情况下检查到这种错误。例如,一种方法是向Stack_T结构添加一个字段,对于Stack_new返回的Stack_T,该字段包含一个特有的位模式。
2.4 通常有可能会检测到某些无效指针。例如,如果一个非空指针指定的地址在客户程序地址空间之外,那么该指针就是无效的,而且指针通常会受到对齐约束,例如,在某些系统上,指向double的指针,指向的地址必定是8的倍数。请设计一个特定于系统的宏isBadPtr(p),在p为无效指针时为1,这样assert(ptr)之类的断言都可以替换为类似assert (!isBadPtr(ptr))的断言。
2.5 对栈来说,有许多可行的接口。为Stack接口设计并实现一些备选方案。例如,一种方案是在为Stack_new增加一个参数,用于指定栈的最大容量。