一个大程序由许多小的模块组成。这些模块提供了程序中使用的函数、过程和数据结构。理想情况下,这些模块中大部分都是现成的并且来自于库,只有那些特定于现有应用程序的模块需要从头开始编写。假定库代码已经全面测试过,而只有应用程序相关的代码会包含bug,那么调试就可以仅限于这部分代码。
遗憾的是,这种理论上的理想情况实际上很少出现。大多数程序都是从头开始编写,它们只对最低层次的功能使用库,如I/O和内存管理。即使对于此类底层组件,程序员也经常编写特定于应用程序的代码。例如,将C库函数malloc和free替换为定制的内存管理函数的应用程序也是很常见的。
造成这种情况的原因无疑有诸多方面。其中之一就是,很少有哪个普遍可用的库包含了健壮、设计良好的模块。一些可用的库相对平庸,缺少标准。虽然C库自1989年已经标准化,但直至现在才出现在大多数平台上。
另一个原因是规模问题:一些库规模太大,从而导致对库本身功能的掌握变成了一项沉重的任务。哪怕这项工作的工作量似乎稍逊于编写应用程序所需的工作量,程序员可能都会重新实现库中他们所需的部分功能。最近出现颇多的用户界面库,通常会有这种问题。
库的设计和实现是困难的。在通用性、简单性和效率这3个约束之间,设计者必须如履薄冰,审慎前行。如果库中的例程和数据结构过于通用,那么库本身可能难以使用,或因效率较低而无法达到预定目标。如果库的例程和数据结构过于简单,又可能无法满足应用程序的需求。如果库太难于理解,程序员干脆就不会使用它们。C库本身就提供了一些这样的例子,例如其中的realloc函数,其语义混乱到令人惊讶的地步。
库的实现者面临类似的障碍。即使设计做得很好,糟糕的实现同样会吓跑用户。如果某个实现太慢或太庞大,或只是感觉上如此,程序员都将自行设计替代品。最糟的是,如果实现有bug,它将使上述的理想状况彻底破灭,从而使库也变得无用。
本书描述了一个库的设计和实现,它适应以C语言编写的各种应用程序的需求。该库导出了一组模块,这些模块提供了用于小规模程序设计(programming-in-the-small)的函数和数据结构。在几千行长的应用程序或应用程序组件中,这些模块适于用作零部件。
在后续各章中描述的大部分编程工具,都涵盖在大学本科数据结构和算法课程中。但在本书中,我们更关注将这些工具打包的方式,以及如何使之健壮无错。各个模块都以一个接口 及其实现 的方式给出。这种设计方法学在第2章中进行了解释,它将模块规格说明与其实现相分离,以提高规格说明的清晰度和精确性,而这有助于提供健壮的实现。
本书并不是以“技巧”的形式来描述各个模块,而是通过例子描述。各章完整描述了一两个接口及其实现。这些描述以文学程序(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语句中。
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则找到并显示出现两次或更多次的单词。大多数例程都比较简单,不会超过一页代码,代码块更短,通常少于12行。
代码中几乎没有注释,因为围绕对应代码块的正文代替了注释。有关注释风格的建议几乎会引发程序员间的战争。本书将效法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语言的优点并避免易犯的错误。
程序员似乎被效率问题困扰着。他们可能花费数小时来微调代码,使之运行得更快。遗憾的是,大部分这种工作都是无用功。当猜测程序的运行时间花费在何处时,程序员的直觉非常糟糕。
微调程序是为了使之更快,但通常总是会使之更大、更难理解、更可能包含错误。除非对执行时间的测量表明程序太慢,否则这样的微调没有意义。程序只需要足够快即可,不一定要尽可能快。
微调通常在“真空”中完成。如果一个程序太慢,找到其瓶颈的唯一途径就是测量它。程序的瓶颈很少出现在预期位置或者是因你所怀疑的原因导致,而且在错误位置上微调程序是没有意义的。在找到正确的位置后,仅当该处花费的时间确实占运行时间的很大比例时,才有必要进行微调。如果I/O占了程序运行时间的60%,在搜索例程中节省1%是无意义的。
微调通常会引入错误。最快崩溃的程序绝非胜者。可靠性比效率更重要;与交付足够快的可靠软件相比,交付快速但会崩溃的软件,从长远看来代价更高。
微调经常在错误的层次上进行。快速算法的直接简明的实现,比慢速算法的手工微调实现要好得多。例如,减少线性查找的内层循环的指令数,注定不如直接使用二分查找。
微调无法修复低劣的设计。如果程序到处都慢,这种低效很可能是设计导致的。当基于编写得很糟糕或不精确的问题说明给出设计时,或者根本就没有总体设计时,就会发生这种令人遗憾的情况。
本书中大部分代码都使用了高效的算法,具有良好的平均情况性能,其最坏情形性能也易于概括。对大多数应用程序来说,这些代码对典型输入的执行时间总是足够快速的。当某些程序的代码性能可能会导致问题时,书中自会明确注明。
一些C程序员在寻求提高效率的途径时,大量使用宏和条件编译。只要有可能,本书将避免使用这两种方法。使用宏来避免函数调用基本上是不必要的。仅当客观的测量结果表明有问题的调用的开销大大超出其余代码的运行时间时,使用宏才有意义。操作I/O是较适宜采用宏的少数情况之一。例如,标准的I/O函数getc、putc、getchar和putchar通常实现为宏。
条件编译通常用于配置特定平台或环境的代码,或者用于代码调试的启用/禁用。这些问题是实际存在的,但条件编译通常只是解决问题的较为容易的方法,而且总会使代码更难于阅读。而重写代码以便在执行期间选择平台依赖关系通常则更为有用。例如,一个编译器可以在执行时选择多种(比如说6种)体系结构中的一个来生成代码,这样的一种交叉编译器要比必须配置并搭建6个不同的编译器更有用,而且可能更易于维护。
如果应用程序必须在编译时配置,与C语言的条件编译工具相比,版本控制工具更擅长完成该工作。这样,代码中就不必充斥着预处理器指令,因为那会使代码难于阅读,并模糊被编译和未被编译的代码之间的界限。使用版本控制工具,你看到的代码即为被执行的代码。对于跟踪性能改进情况来说,这些工具也是理想的选择。
对于标准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.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在输入中发现3个或更多相同单词时会显示什么?修改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,可以在一些“测试”过的程序上进行试验。