模块分为两个部分,即模块的接口与实现。接口规定了模块做什么。接口会声明标识符、类型和例程,提供给使用模块的代码。实现指明模块如何完成其接口规定的目标。对于给定的模块,通常只有一个接口,但可能有许多实现提供了接口规定的功能。每个实现可能使用不同的算法和数据结构,但它们都必须合乎接口的规定。
客户程序(client)是使用模块的一段代码。客户程序导入接口,实现则导出接口。客户程序只需要看到接口即可。实际上,它们可能只有实现的目标码。多个客户程序共享接口和实现,因而避免了不必要的代码重复。这种方法学也有助于避免bug,接口和实现编写并调试一次后,可以经常使用。
接口仅规定客户程序可能使用的那些标识符,而尽可能隐藏不相关的表示细节和算法。这有助于客户程序避免依赖特定实现的具体细节。客户程序和实现之间的这种依赖性称之为耦合(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);
声明了6个整数算术运算函数。该接口的实现需要为上述每一个函数提供定义。
该接口命名为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不造成溢出时才行。
实现会导出接口。它定义了必要的变量和函数,以提供接口规定的功能。实现具体解释了接口的语义,并给出其表示细节和算法,但在理想情况下,客户程序从来都不需要看到这些细节。不同的客户程序可以共享实现的目标码,通常是从(动态)库加载实现的目标码。
一个接口可以有多个实现。只要实现遵循接口的规定,完全可以在不影响客户程序的情况下改变实现。例如,不同的实现可能会提供更好的性能。设计完善的接口会避免对特定机器的依赖,但也可能强制实现依赖于机器,因此对用到接口的每种机器,可能都需要一个不同的实现(也可能是实现的一部分)来支持。
在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);
}
一个抽象数据类型是一个接口,它定义了一个数据类型和对该类型的值所进行的操作。一个数据类型是一个值的集合。在C语言中,内建的数据类型包括字符、整数、浮点数等。而结构本身也能定义新的类型,因而可用于建立更高级类型,如列表、树、查找表等。
高级类型是抽象的,因为其接口隐藏了相关的表示细节,并只规定了对该类型值的合法操作。理想情况下,这些操作不会暴露类型的表示细节,因为那样可能使客户程序隐含地依赖于具体的表示。抽象数据类型或ADT的标准范例是栈。其接口定义了栈类型及其5个操作:
〈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。
接口是其实现和其客户程序之间的一份契约。实现必须提供接口中规定的功能,而客户程序必须根据接口中描述的隐式和显式的规则来使用这些功能。程序设计语言提供了一些隐式规则,来支配接口中声明的类型、函数和变量的使用。例如,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指针可以容纳函数指针 [1] 。
通过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的操作数 [2] 。无论对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。
本书中的接口的大多数实现所使用的算法和数据结构,其平均情况运行时间不会超过N(输入规模)的线性函数,大多数算法都能够处理大量的输入。无法处理大量输入的接口,或者性能可能成为重要影响因素的接口,可以规定性能标准(performance criteria)。实现必须满足这些标准,客户程序可以预期性能能够达到标准的规定(但不会比标准好上多少)。
本书中所有的接口都使用了简单但高效的算法。在N较大时,更复杂的算法和数据结构可能有更好的性能,但N通常比较小。大多数实现都只使用基本的数据结构,如数组、链表、哈希表、树和这些数据结构的组合。
本书中的ADT,除少量之外全部使用了不透明指针,因此需要使用诸如Stack_empty之类的函数来访问隐藏在实现背后的字段。调用函数而不是直接访问字段会带来开销,但它对实际应用程序性能的影响通常都是可忽略的。这种做法在可靠性和捕获运行时错误的机会方面带来的改进是可观的,远超性能方面的轻微代价。
如果客观的测量表明确实有必要改进性能,那么这种改进不应该改变接口,例如,可通过定义宏进行。当这种方法不可行时,最好创建一个新接口并说明其性能方面的优势,而不是改变现存的接口(这将使所有的客户程序无效)。
自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.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增加一个参数,用于指定栈的最大容量。
[1] C语言中数据指针和函数指针的位宽应该是相同的,但C++中的成员函数指针可能有不同。——译者注
[2] const修饰指针,指针就是常量;const修饰结构,结构实例就是常量。——译者注