C语言的接口与实现

 

模块分为两个部分,即模块的接口与实现。接口规定了模块做什么。接口会声明标识符、类型和例程,提供给使用模块的代码。实现指明模块如何完成其接口规定的目标。对于给定的模块,通常只有一个接口,但可能有许多实现提供了接口规定的功能。...





模块分为两个部分,即模块的接口与实现。接口规定了模块做什么。接口会声明标识符、类型和例程,提供给使用模块的代码。实现指明模块如何完成其接口规定的目标。对于给定的模块,通常只有一个接口,但可能有许多实现提供了接口规定的功能。每个实现可能使用不同的算法和数据结构,但它们都必须合乎接口的规定。

客户程序(client)是使用模块的一段代码。客户程序导入接口,实现则导出接口。客户程序只需要看到接口即可。实际上,它们可能只有实现的目标码。多个客户程序共享接口和实现,因而避免了不必要的代码重复。这种方法学也有助于避免bug,接口和实现编写并调试一次后,可以经常使用。

2.1接口
接口仅规定客户程序可能使用的那些标识符,而尽可能隐藏不相关的表示细节和算法。这有助于客户程序避免依赖特定实现的具体细节。客户程序和实现之间的这种依赖性称之为耦合(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不造成溢出时才行。

2.2实现
实现会导出接口。它定义了必要的变量和函数,以提供接口规定的功能。实现具体解释了接口的语义,并给出其表示细节和算法,但在理想情况下,客户程序从来都不需要看到这些细节。不同的客户程序可以共享实现的目标码,通常是从(动态)库加载实现的目标码。

一个接口可以有多个实现。只要实现遵循接口的规定,完全可以在不影响客户程序的情况下改变实现。例如,不同的实现可能会提供更好的性能。设计完善的接口会避免对特定机器的依赖,但也可能强制实现依赖于机器,因此对用到接口的每种机器,可能都需要一个不同的实现(也可能是实现的一部分)来支持。

在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 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。

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

本书中所有的接口都使用了简单但高效的算法。在N较大时,更复杂的算法和数据结构可能有更好的性能,但N通常比较小。大多数实现都只使用基本的数据结构,如数组、链表、哈希表、树和这些数据结构的组合。

本书中的ADT,除少量之外全部使用了不透明指针,因此需要使用诸如Stack_empty之类的函数来访问隐藏在实现背后的字段。调用函数而不是直接访问字段会带来开销,但它对实际应用程序性能的影响通常都是可忽略的。这种做法在可靠性和捕获运行时错误的机会方面带来的改进是可观的,远超性能方面的轻微代价。

如果客观的测量表明确实有必要改进性能,那么这种改进不应该改变接口,例如,可通过定义宏进行。当这种方法不可行时,最好创建一个新接口并说明其性能方面的优势,而不是改变现存的接口(这将使所有的客户程序无效)。

2.6拓展阅读
自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还提供一套函数,来处理由模板生成的类型。

[1] C语言中数据指针和函数指针的位宽应该是相同的,但C++中的成员函数指针可能有不同。——译者注

[2] const修饰指针,指针就是常量;const修饰结构,结构实例就是常量。——译者注

【美】David R. Hanson(戴维 R.汉森) (作者)  郭旭 (译者)

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

本文链接:http://www.epubit.com.cn/book/onlinechapter/37652

非商业转载请注明作译者、出处,并保留本文的原始链接(见上)。


    关注 编程爱好者读书笔记


微信扫一扫关注公众号

0 个评论

要回复文章请先登录注册