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指针可以容纳函数指针[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。