《C语言接口与实现:创建可重用软件的技术》一2.4 客户程序的职责

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。

时间: 2024-09-15 03:54:25

《C语言接口与实现:创建可重用软件的技术》一2.4 客户程序的职责的相关文章

《C语言接口与实现:创建可重用软件的技术》一导读

前言 C语言接口与实现:创建可重用软件的技术 如今的程序员忙于应付大量关于API(Application Programming Interface)的信息.但是,大多数程序员都会在其所写的几乎每一个应用程序中使用API并实现API的库,只有少数程序员会创建或发布新的能广泛应用的API.事实上,程序员似乎更喜欢使用自己搞的东西,而不愿意查找能满足他们要求的程序库,这或许是因为写特定应用程序的代码要比设计可广泛使用的API容易. 不好意思,我也未能免俗:lcc(我和Chris Fraser为ANS

《C语言接口与实现:创建可重用软件的技术》一第1章 引言1.1 文学程序

第1章 引言 C语言接口与实现:创建可重用软件的技术 一个大程序由许多小的模块组成.这些模块提供了程序中使用的函数.过程和数据结构.理想情况下,这些模块中大部分都是现成的并且来自于库,只有那些特定于现有应用程序的模块需要从头开始编写.假定库代码已经全面测试过,而只有应用程序相关的代码会包含bug,那么调试就可以仅限于这部分代码. 遗憾的是,这种理论上的理想情况实际上很少出现.大多数程序都是从头开始编写,它们只对最低层次的功能使用库,如I/O和内存管理.即使对于此类底层组件,程序员也经常编写特定于

《C语言接口与实现:创建可重用软件的技术》一2.2 实现

2.2 实现 实现会导出接口.它定义了必要的变量和函数,以提供接口规定的功能.实现具体解释了接口的语义,并给出其表示细节和算法,但在理想情况下,客户程序从来都不需要看到这些细节.不同的客户程序可以共享实现的目标码,通常是从(动态)库加载实现的目标码. 一个接口可以有多个实现.只要实现遵循接口的规定,完全可以在不影响客户程序的情况下改变实现.例如,不同的实现可能会提供更好的性能.设计完善的接口会避免对特定机器的依赖,但也可能强制实现依赖于机器,因此对用到接口的每种机器,可能都需要一个不同的实现(也

《C语言接口与实现:创建可重用软件的技术》一2.3 抽象数据类型

2.3 抽象数据类型 一个抽象数据类型是一个接口,它定义了一个数据类型和对该类型的值所进行的操作.一个数据类型是一个值的集合.在C语言中,内建的数据类型包括字符.整数.浮点数等.而结构本身也能定义新的类型,因而可用于建立更高级类型,如列表.树.查找表等. 高级类型是抽象的,因为其接口隐藏了相关的表示细节,并只规定了对该类型值的合法操作.理想情况下,这些操作不会暴露类型的表示细节,因为那样可能使客户程序隐含地依赖于具体的表示.抽象数据类型或ADT的标准范例是栈.其接口定义了栈类型及其5个操作: 〈

《C语言接口与实现:创建可重用软件的技术》一2.5 效率

2.5 效率 本书中的接口的大多数实现所使用的算法和数据结构,其平均情况运行时间不会超过N(输入规模)的线性函数,大多数算法都能够处理大量的输入.无法处理大量输入的接口,或者性能可能成为重要影响因素的接口,可以规定性能标准(performance criteria).实现必须满足这些标准,客户程序可以预期性能能够达到标准的规定(但不会比标准好上多少). 本书中所有的接口都使用了简单但高效的算法.在N较大时,更复杂的算法和数据结构可能有更好的性能,但N通常比较小.大多数实现都只使用基本的数据结构,

《C语言接口与实现:创建可重用软件的技术》一2.7 习题

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由运行在机器

《C语言接口与实现:创建可重用软件的技术》一1.2 程序设计风格

1.2 程序设计风格 double说明了本书中程序所使用的风格惯例.程序能否更容易被阅读并理解,比使程序更容易被计算机编译更为重要.编译器并不在意变量的名称.代码的布局或程序的模块划分方式.但这种细节对程序员阅读以及理解程序的难易程度有很大影响. 本书代码遵循C程序的一些既定的风格惯例.它使用一致的惯例来命名变量.类型和例程,并在本书的排版约定下,采用一致的缩进风格.风格惯例并非是一种必须遵循的刚性规则,它们表示的是程序设计的一种哲学方法,力求最大限度地增加程序的可读性和可理解性.因而,凡是改变

《C语言接口与实现:创建可重用软件的技术》一2.6 扩展阅读

2.6 扩展阅读 自20世纪50年代以来,过程和函数库的重要性已经是公认的.[Parnas 1972]一文是一篇典型的论文,讨论了如何将程序划分为模块.该论文的历史已经将近40年,但当今的程序员仍然面临着该文所考虑的问题. C程序员每天都使用接口:C库是15个接口的集合.标准输入输出接口,即stdio.h,定义了一个ADT FILE,以及对FILE指针的操作.[Plauger,1992]一书详细描述了这15个接口及适当的实现,其叙述方式大体上类似于本书讨论一组接口和实现的方式. Modula-3

《C语言接口与实现:创建可重用软件的技术》一1.3 效率

1.3 效率 程序员似乎被效率问题困扰着.他们可能花费数小时来微调代码,使之运行得更快.遗憾的是,大部分这种工作都是无用功.当猜测程序的运行时间花费在何处时,程序员的直觉非常糟糕. 微调程序是为了使之更快,但通常总是会使之更大.更难理解.更可能包含错误.除非对执行时间的测量表明程序太慢,否则这样的微调没有意义.程序只需要足够快即可,不一定要尽可能快. 微调通常在"真空"中完成.如果一个程序太慢,找到其瓶颈的唯一途径就是测量它.程序的瓶颈很少出现在预期位置或者是因你所怀疑的原因导致,而且