抽象数据类型
- 抽象数据类型简介
抽象数据类型是数据结构的一种应用形式。与普通数据类型相似,抽象数据类型由类型描述(例如取值范围)和操作集合共同定义。例如,普通的整型数据类型由整数的取值范围和整数能参与的一系列加减乘除运算共同定义:unsinged char的取值范围是0~255,其操作集合为“+、-、*、/、&、|、~……”。抽象数据类型是由一类数据结构及相关操作函数构成。例如,我们可以将存储和表达环形队列的数据缓冲以及访问指针封装成结构体类型,此时该结构体类型与相关的队列操作函数就构成了环形队列的抽象数据类型。
值得一提的是,如果我们将队列初始化、释放和访问的函数以函数指针的形式存储在抽象数据类型的结构体中,则完成了面向对象的封装。此时的抽象数据类型就可以被称之为类(Class);具体的结构体变量就可以被称之为对象的实例(Instance);结构体中所有的函数指针构成了虚函数表(Virtual Function Table);队列的初始化函数和释放函数可以分别被称之为构造(Constructor)/析构函数(Destructor);其它队列的操作函数称之为方法(Method)。结构体中允许外部直接访问的变量称之为成员变量(Member)或属性(Property),其公有或是私有属性由类型的掩码结构体决定:掩码结构体中公开的成员变量具有公有(Public)属性;被屏蔽的部分具有私有(Private)属性。
- 抽象数据类型 (Abstract Data Type)
一类数据结构,其实现形式和内部操作过程对用户是不透明的,我们称这类数据结构及其操作为抽象数据类型。
- 面向对象编程 (Object-Oriented Programming)
将数据以及对其进行操作的函数封装在一起构成抽象数据类型的编程方式,称为面向对象编程。
- 面向对象C语言开发 (Object-Oriented Programming with ANSI-C)
程序员使用C语言手工构造抽象数据类型实现面向对象的开发,称为面向对象C语言开发。C语言本身是面向过程的(Procedure-Oriented),并不能很好的支持面向对象的开发。C++是面向对象的编程语言(Object-Oriented Language),编译器本身会根据用户定义的类去自动构造抽象数据类型。
- 掩码结构体 (Masked Structure)
在定义抽象数据类型时,为了向外界屏蔽结构体内的某些信息而定义的与实际抽象数据类型占用相同存储器空间、仅公开某些成员而屏蔽大部分信息的结构体类型,称之为掩码结构体。这种结构常用于使用C语言实现的类封装结构中。
- 抽象数据类型构成要素
使用C语言构建抽象数据类型主要借助以下手段:
i) 使用结构体来封装数据与成员变量;
ii) 使用typedef来定义抽象数据类型;
iii) 如果需要将抽象数据类型封装成对象,还需要借助函数指针构成虚函数表;
iv) 如果需要在不同的对象操作中使用不同的参数,还需要借助可变参数列表的支持。
- 函数指针
指向函数入口地址的指针称为函数指针。与普通指针类似,函数指针的本质也是一个整型变量;定义函数指针时需要详细的制定其指向函数的参数表和返回值类型。下面的代码就定义了一个指向函数void Example(unsigned char *pstrData)的函指针:
1 /* 测试函数 */ 2 void ExampleA(unsigned char *pstrData) { }; 3 void ExampleB(unsigned char *pstrData) { }; 4 …… 5 /* 声明一个函数指针fnPointToFunction 并对其进行初始化 */ 6 void (* fnPointToFunction)(unsigned char *pstrData) = &ExampleA; 7 …… 8 void main(void) 9 { 10 …… 11 /* 通过函数指针调用函数 */ 12 (*fnPointToFunction)(“Hello world”); 13 /* 直接调用函数 */ 14 Example(“Hello world”); 15 /* 更改函数指针的值 */ 16 fnPointToFunction = ExampleB; /* “&”运算符不是必须的,可以省略*/ 17 /* 函数的名称就代表它的入口地址 */ 18 /* 使用函数指针调用ExampleB */ 19 fnPointToFunction(“Bye!”); /* “*“运算符也不是必须的,可以省略 */ 20 …… 21 }
函数指针可以像普通指针那样作为函数的参数或返回值,也可以用来构建结构体和数组。下面的代码声明了一个使用函数指针作为参数和返回值的函数,其中,函数名为Example,下划线指示的部分为函数名以及参数列表,其余部分为函数返回值类型说明:
/* 一个使用函数指针作为参数和返回值的函数例子 */
void (*Example (void (* fnParameter)(unsigned char *pchData)))(unsigned char *pchData);
这样的函数声明可读性非常的糟糕,因此我们使用typedef来逃离这一窘境:
1 /* 使用typedef 定义函数指针 */ 2 typedef void (* P_FUN)(unsigned char *pchData); /* 定义了一个函数指针类型P_FUN */ 3 4 /* 测试函数 */ 5 void ExampleA(unsigned char *pstrData) { }; /*假设这个函数在LCD上输出字符 */ 6 void ExampleB(unsigned char *pstrData) { }; /*假设这个函数在打印机上输出字符 */ 7 /* 使用新定义的函数指针类型声明函数指针和数组 */ 8 P_FUN fnFunctionToPoint; /* 声明了一个函数指针 */ 9 P_FUN fnFunctionTable[] = /* 声明了一个函数指针数组 */ 10 { 11 &ExampleA, &ExampleB /* 初始化数组 */ 12 }; 13 /* 使用新定义的类型声明了一个和前面代码片断相同的函数: 14 使用函数指针作为输入参数和返回值 */ 15 P_FUN Example(P_FUN fnParameter) 16 { 17 …… 18 } 19 void main(void) 20 { 21 /* 使用范例 */ 22 unsigned char n = 0; 23 /* 在所有有效的输出设备上都输出 Hello world */ 24 for (n = 0;n < (sizeof(fnFuntionTable) / sizeof(fnFunctionTable[0])); n++) 25 { 26 /* 依次遍历函数数组 */ 27 fnFunctionTable[n](“Hello world!”); 28 } 29 }
可变参数列表
可变参数是指某一函数被调用时,并不知道具体传递进来的参数类型和参数的数目,例如大家熟知的函数printf():
1 /* 一个使用可变参数的例子 */ 2 printf(“Hello! \n”); /* 只有一个字符串参数 */ 3 printf(“Today is the %dth day of this week. \n”,Week); /* 有两个参数 */ 4 printf(“ %d + %d = %d”,a,b,a * b); /* 三个参数 */
C语言是通过软件堆栈的方式进行参数传递的,对于下面的函数,从左至右依次压入栈中的变量为:a、b、c。如果存在更多的参数,只要在函数在真正被调用前按照同样的顺序依次压入栈中就可以完成任意数量参数的传递。这就是可变参数传递的原理,在函数声明时,在参数列表最右边加入一个省略号“...”作为参数就可以将一个函数声明为可变参数传递。例如:
1 /* 一个使用可变参数的例子 */ 2 void printf(char *pString,…); /* 使用可变参数作为函数参数 */
可变参数实际上是具有参数类型va_list。在函数内部必须要首先声明一个可变参数变量,以便依次取出所有传入的数据,例如:
1 va_list Example; /* 定义一个可变参数列表 */
va_list可以像普通变量类型一样充当函数的参数和返回值,例如:
1 /* 定义一个函数,需要上级函数传递一个va_list型变量的指针 */ 2 void FunctionExample(va_list *pva);
我们可以通过宏va_start()告知函数准备从堆栈中取数据。其中,使用va_start()需要传递两个参数,分别为va_list变量及函数参数列表中“…”左边的第一个形参的名称。例如:
1 va_start(Example,pString); /* 告知函数准备开始从可变参数列表Example中取数据 */
与va_start()对应,我们可以通过宏va_end()告知函数不再继续进行参数的提取。例如:
1 va_end(Example); /* 结束参数提取 */
在va_start()和va_end()所划定的范围内,我们可以通过va_arg()依次提取所需的参数,其中提取参数的顺序与调用函数时传送参数的顺序相同。例如:
1 unsigned int A = va_arg(Example,unsigned int); /* 提取一个unsigned int型的数据 */
也可以通过va_copy为当前的参数列表做一个备份(备份当前的参数读取位置),例如:
/* 保存当前的参数栈 */ va_list ExampleB; /* 定一个新的可变参数列表 */ va_copy(ExampleB,Example); /* 复制当前的参数栈信息到ExampleB中 */
- 综合演示
该范例用于实现向指定的设备输出可变数量的字符串。我们首先需要利用函数指针构造一个输出设备驱动函数表,将所有的输出设备已数组的形式组织在一起:
/* 定义输出设备驱动函数的原形 */ typedef void OUTPUT_DRV(unsigned char *pstr,va_list *pArg); /* 注意这里不是定义函数指针 */ /* 而是定义了一个函数原形 */ OUTPUT_DRV LCD_Drv; /* 定义了一个函数LCD_Drv() */ OUTPUT_DRV PRN_Drv; /* 定义了一个函数PRN_Drv() */ /* 定义指向OUTPUT_DRV类型函数的函数指针 */ typedef OUTPUT_DRV * P_DRV; /* 这里定义了一个函数指针 */ /* 使用函数指针构造了一个驱动函数表 */ P_DRV OutputDrivers[ ] = { &LCD_Drv, & PRN_Drv };
接下来我们将通过可变参数实现一个向指定设备输出类似printf格式字符串的函数,具体的设备需要用户通过字符串的形式给出,例如“LCD”或者“PRN”。该函数将根据用户输入的字符串决定输出的设备和字符串:
#include <stdarg.h> #include <string.h> /* 定义输出设备驱动函数的原形 */ int Print(unsigned char *DrvNAME,…) { unsigned char *pstr = NULL; P_DRV fnDrv = NULL; va_list Arg; /* 定义可变参数列表 */ /* 健壮性检测 */ if (DrvNAME == NULL) { return -1; } /* 确定使用哪个设备进行输出 */ if (strcmp(DrvNAME,”LCD”) == 0) /* 如果输入的第一个字符串为LCD */ { fnDrv = OutputDrivers [0]; /* 使用LCD驱动 */ } else if (strcmp(DrvNAME,”PRN”) == 0) /* 如果输入的第一个字符串为PRN */ { fnDrv = OutputDrivers [1]; /* 使用打印机驱动 */ } else /* 未知的设备 */ { return -1; } va_start(Arg, DrvNAME); /* 开始取参数 */ pstr = va_arg(Arg,unsigned char *); /* 获取一个字符串 */ fnDrv(pstr,&Arg); /* 调用指定的设备驱动 */ va_end(Arg); /* 结束取参数 */ } /* 驱动函数实体 */ void LCD_Drv(unsigned char *pstr,va_list *pArg) { …… /* 在函数中可以通过 va_arg(*pArg,类型) 来依次提取参数,不需要 通过va_end(*pArg)来标注取参数结束,如果通过va_copy生成了 一个新的va_list变量,则需要在取出参数后通过va_end()将该变 量关闭。*/ …… } void PRN_Drv(unsigned char *pstr,va_list *pArg) { …… }
可以使用下面的方式调用函数Print():
1 /* Print() 的操作范例 */ 2 unsigned char Day = 3; 3 Print(“LCD”, “It`s the %dth day of this week.\n”,Day);
如果LCD驱动编写无误,我们将在LCD设备上看到以下的内容:
It`s the 3th day of this week.
- 构建抽象数据类型
抽象数据类型就是对数据结构和相关操作的封装。以前面介绍的字符串输出系统为例,通过改造和对比,你可以很明显找到抽象数据类型和普通数据结构的区别。首先,我们需要分析所需抽象的对象在数据结构上有那些共同的特质,或者说我们如何将对象的共性和特型区别开来。
以字符串输出设备为例,LCD、打印机、显示器、超级终端……这些设备都可以通过数据流的方式进行输出,都在一定程度上兼容流控制,因此可以使用类似的函数接口形式,以“print打印”格式字符串的形式进行输出,基于以上条件我们定义如下的驱动接口函数原型(Prototype):
1 /* 定义驱动函数原型 OUTPUT_DRV */ 2 typedef int OUTPUT_DRV(unsigned char *pstr,va_list *pArg); 3 /* 定义指向OUTPUT_DRV 的函数指针 */ 4 typedef OUTPUT_DRV * P_DRV;
接下来,我们可以为不同的驱动指定一个唯一的ID,并将这些信息同驱动接口函数(指针)封装在一起,我们称之为一个输出设备 (Output Device):
1 # include <stdint.h> 2 /* 定义输出设备(Output Device) */ 3 typedef struct 4 { 5 uint16_t ID; /* 设备ID */ 6 P_DRV fnDriver; /* 驱动函数指针 */ 7 }OUTPUT_DEV;
这就初步完成了对抽象数据类型的封装。OUTPUT_DEV是一个自定义类型,包含了设备ID和设备的驱动函数指针——这是一个典型的抽象数据类型。当用户使用我们下面将要定义的通用接口函数来操作设备时,所有的具体细节都是透明的,这就是抽象(Abstract)一词的精髓所在:
1 # include <stdint.h> 2 typedef uint16_t ERR_CODE; 3 # define ERROR_NONE_ERROR 0x0000 4 # define ERROR_ILLEGAL_PARAMETER 0xFFFF 5 /* 设备通用接口函数 */ 6 ERR_CODE Print_Device(OUTPUT_DEV *pDev,unsigned char *pStr,…) 7 { 8 ERR_CODE Err; 9 va_list Arg; 10 if ((pDev == NULL) || (pStr == NULL)) /* 强壮性检测 */ 11 { 12 return ERROR_ILLEGAL_PARAMETER; 13 } 14 va_start(Arg,pStr); 15 Err = (pDev -> fnDriver)(pStr,&Arg); 16 va_end(Arg); 17 return Err; /* 无错误产生 */ 18 }
如果我们将上面的代码存在在一个叫OutputDevice.c的文件中,将前面的定义部分(前一个代码片断)连同设备通用接口函数的声明一起存放到OutputDevice.h中,我们事实上就完成了一个抽象数据类型的函数库封装,这也是利用抽象数据类型完成系统模块化的一个典型范例。当然,其中还包含一些函数封装的细节问题,比如包含、接口描述、头文件保护等等
- 面向对象
前面我们通过一个简单的例子说明了如何构建和使用抽象数据类型,也许例子中的驱动结构已经相当实用了,但是这种程度的抽象与面向对象技术相比仍然显得不够“强大”。在C语言中应用面向对象技术并不仅仅是利用结构体将数据和函数封装在一起那么简单,完成了抽象数据类型的定义只是万里长征的第一步。如何充分利用抽象数据类型带来的优势,是这一小结将要讨论的内容。
在前面的内容中,我们演示了一种通过字符串指定设备,并完成输出的功能。示例代码只给出了针对两种设备的操作,用户如果想添加新的设备,必须修改函数Print。显然Print是封装在OutputDevice.c中的一个函数,不应该要求用户对其进行修改,为了实现扩展,需要借助两个面向对象的概念:继承(Inherite)和派生(Derive)。
在给出示例代码之前,我们应该强调一点:以下的示例也许看起来显得笨拙,但是它演示了在C语言中实现面向对象技术的一些手法,毕竟使用C语言实现的面向对象都是近似的,或者说类和对象的概念仅仅存在于开发人员的大脑中。从这一点出发,你就会明白,很多时候不应该执着于让程序看起来“像”面向对象,而是应该让程序运行起来与面向对象“效果相同”。这也是使用C语言实现面向对象技术的精髓。
首先,我们定义一个新的抽象数据类型,用以将所有的设备组织起来,并能通过一个唯一的名称来识别他们(很显然,用户不应该被要求知晓设备内部ID编号):
1 #ifndef _OUTPUT_DEVICE_H_ 2 #define _OUTPUT_DEVICE_H_ 3 …… 4 typedef uint16_t ERR_CODE; 5 # define ERROR_NONE_ERROR 0x0000 6 # define ERROR_DEVICE_NOT_FOUND 0x0001 7 # define ERROR_ILLEGAL_PARAMETER 0xFFFF 8 …… 9 /* 新的数据类型 */ 10 typedef struct Output_Dev_Item OUTPUT_DEV_ITEM; 11 struct Output_Dev_Item 12 { 13 OUTPUT_DEV Device; /* 继承了OUTPUT_DEV */ 14 unsigned char DevName[4]; /* 增加了一个3个字符长度的名称 */ 15 OUTPUT_DEV_ITEM * pNext; /* 构成链表 */ 16 }; 17 /* 我们需要为新派生的类添加三个成员函数 */ 18 19 /* 向设备链上增加一个新设备,并为其指定一个名称*/ 20 extern OUTPUT_DEV_ITEM *Add_Device 21 ( 22 OUTPUT_DEV *pDevice, 23 unsigned char chDevName[4] 24 ); 25 /* 从设备链上删除一个指定名称的设备 */ 26 extern OUTPUT_DEV_ITEM *Remove_Device(unsigned char chDevName[4]); 27 /* 向指定的设备输出格式字符串 */ 28 extern ERR_CODE Print(unsigned char chDevName[4],unsigned char *pStr,…); 29 …… 30 #endif
经过一次对原有抽象数据类型OUTPUT_DEV的继承,我们至少在OutputDevice.h中派生出了一个看起来非常强大的新“类”OUTPUT_DEV_ITEM,所有针对其进行的操作对用户都是透明的:添加一个设备、删除一个设备甚至直呼其名的在设备上输出格式字符串。这一切都是通过在继承老对象的同时,增加了新的成员变量Name和用于构建链表的指针pNext实现的。具体的函数,我们不再一一给出,仅就核心的操作函数Print给出其代码:
1 /* OutputDevice.c */ 2 #include <stdarg.h> 3 #include <stdint.h> 4 #include <string.h> 5 #include “OutputDevice.h” 6 …… 7 static OUTPUT_DEV_ITEM *Find_Device(unsigned char chDevName[4]); 8 …… 9 static OUTPUT_DEV_ITEM *s_pChainRoot = NULL; 10 …… 11 ERR_CODE Print(unsigned char chDevName[4],unsigned char *pStr,…) 12 { 13 va_list Arg; 14 ERR_CODE Err; 15 OUTPUT_DEV_ITEM *pDeviceItem = NULL; 16 /* 系统强壮性检测 */ 17 if ((pStr == NULL) || (chDevName == NULL)) 18 { 19 return ERROR_ILLEGAL_PARAMETER; 20 } 21 22 pDeviceItem = Find_Device(chDevName); /* 查找指定的设备 */ 23 if (pDeviceItem == NULL) 24 { 25 return ERROR_DEVICE_NOT_FOUND; /* 设备没有找到 */ 26 } 27 /* 提取可变参数列表 */ 28 va_start(Arg, chDevName[4]); 29 Err = (pDeviceItem -> Device). fnDriver(pStr,&Arg); 30 va_end(Arg); 31 return Err; /* 返回操作结果 */ 32 }
值得一提的是OutputDevice.c中使用到了一个静态函数Find_Device(),由于函数的静态特性与私有成员很类似,我们可以将该其视作私有(Private)成员函数,而其它在模块外部通过头文件OutputDevice.h可以找到接口并调用的函数则称为公有(Public)成员函数。同理,全局静态变量s_pChainRoot可以被视作私有成员变量。Add_Device()和Remove_Device()分别充当了构造函数和析构函数的角色,只不过这些函数都不是初始化时由系统自动执行的。抽象数据类型OUTPUT_DEV_ITEM接口函数Add_Device()、Remove_Device()和Print()完成了对整个“类”的封装。更为完善和复杂的C语言面向对象技术,可以通过查阅文献《Object-Oriented Programming with ANSI-C》获得。
1 /* 使用范例 */ 2 #include “OutputDevice.h” 3 # define DEVICE_ID_LCD 0x0001 4 /* 声明一个LCD驱动函数 */ 5 OUTPUT_DRV LCDDrv; 6 /* 声明一个LCD设备 */ 7 OUTPUT_DEV LCDDev = 8 { 9 DEVICE_ID_LCD, &LCDDrv; 10 }; 11 void main(void) 12 { 13 uint8_t Day = 3; 14 Add_Device(&LCDDev,”LCD”); /* 添加一个LCD设备,名为“LCD”*/ 15 Print(“LCD”, “It`s the %dth day of this week.\n”,Day); 16 }