C语言的接口与实现
模块分为两个部份,即模块的接口与实现。接口规定了模块做甚么。接口会声明标识符、类型和例程,提供给使用模块的代码。实现指明模块如何完成其接口规定的目标。对给定的模块,通常只有一个接口,但可能有许多实现提供了接口规定的功能。每一个实现可能使用不同的算法和数据结构,但它们都必须合乎接口的规定。客户程序(client)是使用模块的一段代码。客户程序导入接口,实现则导出接口。客户程序只需要看到接口便可。实际上,它们可能只有实现的目标码。多个客户程序同享接口和实现,因此避免了不必要的代码重复。这种方法学也有助于避免bug,接口和实现编写并调试一次后,可以常常使用。2.1接口接口仅规定客户程序可能使用的那些标识符,而尽量隐藏不相关的表示细节和算法。这有助于客户程序避免依赖特定实现的具体细节。客户程序和实现之间的这类依赖性称之为耦合(coupling),在实现改变时耦合会致使bug,当依赖性被与实现相干的隐藏或隐含的假定掩盖时,这类bug可能会特别难于改正。设计完善且陈说准确的接口可以减少耦合。对接口与实现相分离,C语言只提供了最低限度的支持,但通过一些简单的约定,我们便可取得接口/实现方法学的大多数好处。在C语言中,接口通过1个头文件指定,头文件的扩展名通常为.h。这个头文件会声明客户程序可能使用的宏、类型、数据结构、变量和例程。客户程序用C预处理器指令#include导入接口。以下例子说明了本书中的接口使用的约定。下述接口〈arith.h〉≡ externintArith_max(intx,inty); externintArith_min(intx,inty); externintArith_div(intx,inty); externintArith_mod(intx,inty); externintArith_ceiling(intx,inty); externintArith_floor (intx,inty);
声明了6个整数算术运算函数。该接口的实现需要为上述每个函数提供定义。该接口命名为Arith,接口头文件命名为arith.h。在接口中,接口名称表现为每一个标识符的前缀。这类约定其实不优美,但C语言几乎没有提供其他备选方案。所有文件作用域中的标识符,包括变量、函数、类型定义和枚举常数,都同享同一个命名空间。所有的全局结构、联合和枚举标记则同享另一个命名空间。在一个大程序中,在本来无关的模块中,很容易使用同一名称表示不同的目的。避免这类名称冲突(namecollision)的一个方法是使用前缀,如模块名。一个大程序很容易有数千全局标识符,但通常只有几百个模块。模块名不但提供了适当的前缀,还有助于使客户程序代码文档化。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)为-*(-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= 3Arith_ceiling(-13,5)=-13/5=-2.6=-2Arith_floor (13,5)=13/5= 2.6= 2Arith_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" 〈unctions14〉〈unctions14〉≡ intArith_max(intx,inty){
returnxy?x:y; } intArith_min(intx,inty){
returnxy?y:x; }
除〈unctions14〉,更复杂的实现可能包括名为〈data〉、〈types〉、〈macros〉、〈prototypes〉等的代码块。在不会造成混淆时,代码块中的文件名(如arith.c)将略去。在Arith_div的参数符号不同时,它必须处理除法的两种可能行动。如果除法向零舍入,而y不能整除x,那末Arith_div(x,y)的结果为x/y-1,否则,返回x/y便可:〈arith.c_functions_14_〉+≡intArith_div(intx,inty){
if(〈divisiontruncatestoward〉
〈xandyhavedifferentsigns14〉x%y!=0)
returnx/y-1;
else
returnx/y;}
前一节的例子,行将-13除以5,可以测试除法所采取的舍入方式。首先判断x和y是不是小于0,然后比较两个判断结果是不是相等,便可检查符号问题:〈divisiontruncatestoward〉≡ -13/5==-2〈xandyhavedifferentsigns14〉≡ (x0)!=(y0)
Arith_mod可以按其定义实现:intArith_mod(intx,inty){
returnx-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可定义为:〈unctions14〉+≡ intArith_mod(intx,inty){
if(〈divisiontruncatestoward〉
〈xandyhavedifferentsigns14〉x%y!=0)
returnx%y+y;
else
returnx%y;}
Arith_floor恰好等于Arith_div,而Arith_ceiling等于Arith_div加1,除非y能整除x:〈unctions14〉+≡intArith_floor(intx,inty){returnArith_div(x,y);}intArith_ceiling(intx,inty){returnArith_div(x,y)+(x%y!=0);}
2.3抽象数据类型一个抽象数据类型是一个接口,它定义了一个数据类型和对该类型的值所进行的操作。一个数据类型是一个值的集合。在C语言中,内建的数据类型包括字符、整数、浮点数等。而结构本身也能定义新的类型,因此可用于建立更高级类型,如列表、树、查找表等。高级类型是抽象的,由于其接口隐藏了相干的表示细节,并只规定了对该类型值的合法操作。理想情况下,这些操作不会暴露类型的表示细节,由于那样可能使客户程序隐含地依赖于具体的表示。抽象数据类型或ADT的标准范例是栈。其接口定义了栈类型及其5个操作:〈initialversionofstack.h〉≡ #ifndefSTACK_INCLUDED #defineSTACK_INCLUDED typedefstructStack_T*Stack_T; externStack_TStack_new (void); externint
Stack_empty(Stack_Tstk); externvoid
Stack_push(Stack_Tstk,void*x); externvoid *Stack_pop (Stack_Tstk); externvoid
Stack_free(Stack_T*stk); #endif
上述的typedef定义了Stack_T类型,这是一个指针,指向一个同名结构。该定义是合法的,由于结构、联合和枚举的名称(标记)占用了一个命名空间,该命名空间不同于变量、函数和类型名所用的命名空间。这类惯用法的使用遍及本书各处。类型名Stack_T,是这个接口中我们
Stack_new (void); externint Stack_empty(Tstk); externvoid Stack_push(Tstk,void*x); externvoid*Stack_pop (Tstk); externvoid Stack_free(T*stk); #undefT #endif
该接口在语义上与前一个是等效的。缩写只是语法糖(syntacticsugar),使得接口略微容易浏览一些。T指的总是接口中的主要类型。但客户程序必须使用Stack_T,由于stack.h末尾的#undef指令删除了上述的缩写。该接口提供了可用于任意指针的容量无限制的栈。Stack_new创建新的栈,它返回一个类型为T的值,可以作为参数传递给其他四个函数。Stack_push将一个指针推入栈顶,Stack_pop在栈顶删除一个指针并返回该指针,如果栈为空,Stack_empty返回1,否则返回0。Stack_free以一个指向T的指针为参数,释放该指针所指向的栈,并将类型为T的变量设置为NULL指针。这类设计有助于避免悬挂指针(danglingpointer),即指针指向已被释放的内存。例如,如果names通过下述代码定义并初始化:#include"stack.h"Stack_Tnames=Stack_new();
下述语句Stack_free(names);
将释放names指向的栈,并将names设置为NULL指针。当ADT通过不透明指针表示时,导出的类型是一个指针类型,这也是Stack_T通过typedef定义为指向structStack_T的指针的缘由。本书中大部分ADT都使用了类似的typedef。当ADT表露了其表示细节,并导出可接受并返回相应结构值的函数时,接口会将该结构类型定义为导出类型。第16章中的Text接口说明了这类约定,该接口将Text_T声明为structText_T的一个typedef。无论如何,接口中的主要类型总是缩写为T。2.4客户程序的职责接口是其实现和其客户程序之间的一份契约。实现必须提供接口中规定的功能,而客户程序必须根据接口中描写的隐式和显式的规则来使用这些功能。程序设计语言提供了一些隐式规则,来安排接口中声明的类型、函数和变量的使用。例如,C语言的类型检查规则可以捕获接口函数的参数的类型和数目方面的毛病。C语言的用法没有规定的或编译器没法检查的规则,必须在接口中详细说明。客户程序必须遵守这些规则,实现必须履行这些规则。接口通常会规定未检查的运行时毛病(uncheckedruntimeerror)、已检查的运行时毛病(checkedruntimeerror)和异常(exception)。未检查的和已检查的运行时毛病是非预期的用户毛病,如未能打开一个文件。运行时毛病是对客户程序和实现之间契约的破坏,是没法恢复的程序bug。异常是指一些可能的情形,但很少产生。程序或许能从异常恢复。内存耗尽就是一个例子。异常在第4章详述。未检查的运行时毛病是对客户程序与实现之间契约的破坏,而实现其实不保证能够发现这样的毛病。如果产生未检查的运行时毛病,可能会继续履行,但结果是不可预测的,乃至可能是不可重复的。好的接口会在可能的情况下避免未检查的运行时毛病,但必须规定可能发生的此类毛病。例如,Arith必须指明除以零是一个未检查的运行时毛病。Arith虽然可以检查除以零的情形,但却不加处理使之成为未检查的运行时毛病,这样接口中的函数就摹拟了C语言内建的除法运算符的行动(即,除以零时其行动是未定义的)。使除以零成为一种已检查的运行时毛病,也是一种公道的方案。已检查的运行时毛病是对客户程序与实现之间契约的破坏,但实现保证会发现这类毛病。这类毛病表明,客户程序未能遵照契约对它的束缚,客户程序有避免这类毛病。Stack接口规定了三个已检查的运行时毛病:(1)向该接口中的任何例程传递空的Stack_T类型的指针;(2)传递给Stack_free的Stack_T指针为NULL指针;(3)传递给Stack_pop的栈为空。接口可以规定异常及引发异常的条件。如第4章所述,客户程序可以处理异常并采取校订措施。未处理的异常(unhandledexception)被当作是已检查的运行时毛病。接口通常会列出本身引发的异常及其导入的接口引发的异常。例如,Stack接口导入了Mem接口,它使用后者来分配内存空间,因此它规定Stack_new和Stack_push可能引发Mem_Failed异常。本书中大多数接口都规定了类似的已检查的运行时毛病和异常。在向Stack接口添加这些以后,我们可以继续进行其实现:〈stack.c〉≡ #includestddef.h #include"assert.h" #include"mem.h" #include"stack.h" #defineTStack_T〈types18〉〈functions18〉
#define指令又将T定义为Stack_T的缩写。该实现表露了Stack_T的内部结构,它是一个结构,一个字段指向一个链表,链表包括了栈上的各个指针,另一个字段统计了指针的数目。〈types18〉≡ structT{
intcount;
structelem{
void*x;
structelem*link;
}*head;};
Stack_new分配并初始化一个新的T:〈functions18〉≡ TStack_new(void){
Tstk;
NEW(stk);
stk-count=0;
stk-head=NULL;
returnstk;}
NEW是Mem接口中一个用于分配内存的宏。NEW(p)为p指向的结构分配一个实例,因此Stack_new中使用它来分配一个新的Stack_T结构实例。如果count字段为0,Stack_empty返回1,否则返回0:〈functions18〉+≡ intStack_empty(Tstk){
assert(stk);
returnstk-count==0; }
assert(stk)实现了已检查的运行时毛病,即制止对Stack接口函数中的Stack_T类型参数传递NULL指针。assert(e)是一个断言,宣称对任何表达式e,e都应当是非零值。如果e非零,它甚么都不做,否则将中断程序履行。assert是标准库的一部分,但第4章的Assert接口定义了本身的assert,其语义与标准库类似,但提供了优雅的程序终止机制。assert用于所有已检查的运行时毛病。Stack_push和Stack_pop分别在stk-head链表头部添加和删除元素:〈functions18〉+≡ voidStack_push(Tstk,void*x){
structelem*t;
assert(stk);
NEW(t);
t-x=x;
t-link=stk-head;
stk-head=t;
stk-count++; }void*Stack_pop(Tstk){
void*x;
structelem*t;
assert(stk);
assert(stk-count0);
t=stk-head;
stk-head=t-link;
stk-count--;
x=t-x;
FREE(t);
returnx; }
FREE是Mem用于释放内存的宏,它释放其指针参数指向的内存空间,并将该参数设置为NULL指针,这与Stack_free的做法同理,都是为了避免悬挂指针。Stack_free也调用了FREE:〈functions18〉+≡ voidStack_free(T*stk){
structelem*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指针是一个类属指针(genericpointer,通用指针),类型为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可能有下述编写方式。intStack_empty(constTstk){
assert(stk);
returnstk-count==0;}
const的这类用法是不正确的。这里的意图是将stk声明为一个“指向structT的常量实例的指针”,由于Stack_empty其实不修改*stk。但constTstk将stk声明为一个“常量指针,指向一个structT实例”,对T的typedef将structT*打包到一个类型中,这一个指针类型成为了const的操作数[2]。不管对Stack_empty还是其调用者,constTstk都是无用的,由于在C语言中,所有的标量包括指针在函数调用时都是传值的。不管有没有const限定符,Stack_empty都没法改变调用者的实参值。用structT*代替T,可以避免这个问题:intStack_empty(conststructT*stk){
assert(stk);
returnstk-count==0;}
这个用法说明了为何不应当将const用于传递给ADT的指针:const表露了有关实现的一些信息,因此限制了可能性。对Stack的这个实现而言,使用const不是问题,但它排除其他一样可行的方案。假定某个实现预期可重用栈中的元素,因此延迟对栈元素的释放操作,但会在调用Stack_empty时释放它们。Stack_empty的这类实现需要修改*stk,但由于*stk声明为const而没法进行修改。本书中的ADT都不使用const。2.5效力本书中的接口的大多数实现所使用的算法和数据结构,其平均情况运行时间不会超过N(输入范围)的线性函数,大多数算法都能够处理大量的输入。没法处理大量输入的接口,或性能可能成为重要影响因素的接口,可以规定性能标准(performancecriteria)。实现必须满足这些标准,客户程序可以预期性能能够到达标准的规定(但不会比标准好上多少)。本书中所有的接口都使用了简单但高效的算法。在N较大时,更复杂的算法和数据结构可能有更好的性能,但N通常比较小。大多数实现都只使用基本的数据结构,如数组、链表、哈希表、树和这些数据结构的组合。本书中的ADT,除少许以外全部使用了不透明指针,因此需要使用诸如Stack_empty之类的函数来访问隐藏在实现背后的字段。调用函数而不是直接访问字段会带来开消,但它对实际应用程序性能的影响通常都是可疏忽的。这类做法在可靠性和捕获运行时毛病的机会方面带来的改进是可观的,远超性能方面的轻微代价。如果客观的丈量表明确切有必要改进性能,那末这类改进不应当改变接口,例如,可通过定义宏进行。当这种方法不可行时,最好创建一个新接口并说明其性能方面的优势,而不是改变现存的接口(这将使所有的客户程序无效)。2.6拓展浏览自20世纪50年代以来,进程和函数库的重要性已是公认的。[Parnas]一文是一篇典型的论文,讨论了如何将程序划分为模块。该论文的历史已将近40年,但现今的程序员依然面临着该文所斟酌的问题。C程序员每天都使用接口:C库是15个接口的集合。标准输入输出接口,即stdio.h,定义了一个ADTFILE,和对FILE指针的操作。[Plauger,]1书详细描写了这15个接口及适当的实现,其叙述方式大体上类似于本书讨论一组接口和实现的方式。Modula-3是一种相对较新的语言,从语言层面支持接口与实现相分离,本书中使用的基于接口的术语即源自该语言[Nelson,]。未检查和已检查的运行时毛病的概念,和ADT的T表示法,都是鉴戒Modula-3。[Harbison,]是介绍Modula-3的1本教科书。[Horning等人,]1书描写了其Modula-3系统中的核心接口。本书中一些接口改编自该书中的接口。[Roberts,]1书使用了基于接口的设计,作为讲授计算机科学入门课程的编排方式。断言的重要性是公认的,在一些语言如Modula-3和Eiffel[Meyer,]中,断言机制是内建在语言中的。[Maguire,]一书用一整章的篇幅讨论C程序中断言的使用。熟习面向对象编程的程序员可能认为,本书中大部分ADT都可以用面向对象程序设计语言中的对象实现(可能实现得更好),如C++[EllisandStroustrup,]和Modula-3。[Budd,]一书是面向对象程序设计方法学的入门介绍,还包括一些面向对象程序设计语言如C++的内容。本书中说明的接口设计原理一样适用于面向对象语言。例如,用C++语言重写本书中的ADT,对从C语言切换到C++的程序员来说是一个很有用的练习进程。STL(C++标准模板库,StandardTemplateLibrary)提供了与本书所述类似的ADT。STL充分利用了C++模板来针对具体类型实例化ADT(参见[MusserandSaini,])。例如,STL为vector类型提供了一个模板,可针对int、string等类型分别实例化出对应的vector类型。STL还提供一套函数,来处理由模板生成的类型。
[1]C语言中数据指针和函数指针的位宽应该是相同的,但C++中的成员函数指针可能有不同。——译者注
[2]const修饰指针,指针就是常量;const修饰结构,结构实例就是常量。——译者注
nson(戴维R.汉森)(作者) 郭旭(译者)
节选自《C语言接口与实现:创建可重用软件的技术》
本文链接:
北京白癜风医院福州治疗白癜风专科医院哪家好