本节书摘来自异步社区《C语言接口与实现:创建可重用软件的技术》一书中的第1章,第1.1节,作者 傅道坤,更多章节内容可以访问云栖社区“异步社区”公众号查看
第1章 引言
C语言接口与实现:创建可重用软件的技术
一个大程序由许多小的模块组成。这些模块提供了程序中使用的函数、过程和数据结构。理想情况下,这些模块中大部分都是现成的并且来自于库,只有那些特定于现有应用程序的模块需要从头开始编写。假定库代码已经全面测试过,而只有应用程序相关的代码会包含bug,那么调试就可以仅限于这部分代码。
遗憾的是,这种理论上的理想情况实际上很少出现。大多数程序都是从头开始编写,它们只对最低层次的功能使用库,如I/O和内存管理。即使对于此类底层组件,程序员也经常编写特定于应用程序的代码。例如,将C库函数malloc和free替换为定制的内存管理函数的应用程序也是很常见的。
造成这种情况的原因无疑有诸多方面。其中之一就是,很少有哪个普遍可用的库包含了健壮、设计良好的模块。一些可用的库相对平庸,缺少标准。虽然C库自1989年已经标准化,但直至现在才出现在大多数平台上。
另一个原因是规模问题:一些库规模太大,从而导致对库本身功能的掌握变成了一项沉重的任务。哪怕这项工作的工作量似乎稍逊于编写应用程序所需的工作量,程序员可能都会重新实现库中他们所需的部分功能。最近出现颇多的用户界面库,通常会有这种问题。
库的设计和实现是困难的。在通用性、简单性和效率这3个约束之间,设计者必须如履薄冰,审慎前行。如果库中的例程和数据结构过于通用,那么库本身可能难以使用,或因效率较低而无法达到预定目标。如果库的例程和数据结构过于简单,又可能无法满足应用程序的需求。如果库太难于理解,程序员干脆就不会使用它们。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的点积。在另一个代码块中使用该代码块时,直接引用即可:
〈functiondotproduct〉≡
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.c3〉≡
〈includes4〉
〈data 4〉
〈prototypes4〉
〈functions 3〉
按照惯例,根代码块的标签设置为程序的文件名,提取〈double.c3〉代码块,即可提取整个程序。其他代码块的标签设置为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 intobuf[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 intobuf[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语句中。