在电影的例子中,数据形成了一个项目列表,其中每一项包含一个片名(C的字符串)和等级(int值)。C中没有符合这个需求的基本类型,所以需要定义一个结构来表示每个项目,然后设计一此方法来把一系列结构链接成一个列表。
实际上 ,我们使用C的功能设计了一种符合需要的新的数据类型。但是我们的做法并不系统。
现在我们将用更为系统的方法来定义数据类型。
类型由什么组成?一个类型(type)指定两类信息:一个属性集和一个操作集。比如,int类型的属性是它表示一个整数值,因此它拥有整数的属性。它允许的算术操作是改变一个int数的符号、两个int数相加、相减、相乘、相除,以及一个int数对另一个取模。在声明一个变量为int时,您的意思是这些操作且只有这些操作可以对其起作用。
假设您想定义一个新的数据类型。首先,您需要提供存储数据的方式,可能是通过设计一个结构。第二,需要提供操作数据的方式。比如,考虑films2.c程序。它用一系列链接在一起的结构来保存信息,还提供了添加信息和显示信息的代码。但是这个程序并没有明确的表明我们在创建一个新的类型。应该怎么做呢?
计算机科学已经研究出一种定义新类型的成功的方法。这种方法使用3个步骤来完成从抽象到具体的过程:
1、为类型的属性和可对类型执行的操作提供一个抽象的描述。这个描述不应受任何特定实现的约束,甚至不应受任何特定编程语言的约束。这样一种正式的抽象数据描述被称为抽象数据类型(ADT)。
2、开发一个实现该ADT的编程接口。即说明如何存储数据并描述用于执行所需操作的函数集合。比如,在C中您可能同时提供一个结构的定义和用来操作该结构的函数的原型。这些函数对用户自定义类型的作用和C的内置运算符对C的基本类型的作用一样。想要使用新类型的人,可以使用这个接口来进行编程。
3、编写代码来实现这个接口。当然, 这一步至关重要,但是使用这种新类型的程序员无须了解实现的细节。
我们通过一个例子来了解这个过程。让我们重新完成电影列表的例子。
17.3.1 变得抽象
基本上关于电影工程所需的就是一个项目列表,每个项目包含一个影片名和一个等级。您需要能向列表末尾添加新的项目,并且能显示列表的内容。让我们把满足这些需求的抽象类型称为“列表(list)”。一个列表应有哪些属性呢?显然,列表应该能够保存项目序列。即,列表能够保存多个项目,并且这些项目以某种方式排列,从而能够谈及列表中的第一项或第二个项目或最后一个项目。而且,列表类型应该支持诸如向列表添加一个项目之类的操作。下面是一些有用的操作:
*把列表初始化为空列表。
*向列表末尾添加一个项目。
*确定列表是否为空。
*确定列表是否已满。
*确定列表中有多少个项目。
*访问列表中每一个项目以执行某些任务,比如显示项目。
对此工程来说,不需要对列表进行其他操作,但是通常的列表操作还包括如下内容:
*在列表的任何位置插入一个项目。
*从列表中删除一个项目。
*取出列表中的一个项目(不改变列表)。
*替换列表中的一个项目。
*在列表中搜索一个项目。
非正式但抽象的列表定义是:它是一个能够保存项目序列并且可以对其应用任何前面的操作的数据对象。这个定义没有说明什么样的项目才能存储在列表中。它并未指出是否应该使用数组或链接的结构集或其他数据形式来保存这些项目。它并未指出使用何种方法来实现诸如获取列表中的元素个数之类的操作。这些都是留给实现的细节。
为了使例子更简单,我们采用一种简化的列表作为抽象数据类型,它只包含电影工作所需的属性。表17.1是此类型的一个总结 :
列表类型总结
类型名称: | 简单列表 |
类型属性: | 可保存一个项目序列 |
类型操作: | 把列表初始化为空列表 |
确定列表是否为空 | |
确定列表是否已满 | |
确定列表中有多少个项目 | |
向列表末尾添加一个项目 | |
访问列表中每一个项目 | |
清空列表 |
下一步是为简单列表ADT开发一个C语言接口。
17.3.2 构造接口
简单列表的接口有两个部分。第一部分描述数据如何表示,第二部分描述实现ADT操作的函数。接口的设计应尽可能的与ADT描述密切保持一致。因此,应该用某种通用的Item类型来进行表达,而不是用诸如int或struct film之类的专用类型。这样做的方法之一就是使用C的typedef工具将Item定义为所需类型:
#define TSIZE 45 /*存放片名的数组大小*/
struct film {
char title[TSIZE];
int rating;
};
typedef struct film Item;
然后可以在其余的定义中使用Item类型。如果以后需要其他形式数据的列表,您可以重新定义Item类型,而使其余接口定义保持不变。
定义了Item之后,您需要决定如何存储这种类型的项目。这一步确实应属于实现阶段,但是现在做出决定可以使例子更简单一些。在film2.c程序中,链接结构工作的很好,所以我们采用它,如下所示:
typedef struct node
{
Item item;
struct node *next;
} Node;
typedef Node *List;
在链表的实现中,每一个链接被称为一个节点(node)。每一个节点包含形成列表内容的信息和指向下一节点的指针。为了强调这个术语,我们对节点结构使用标记node,并且通过typedef使Node成为struct node结构的类型名。最后,为了管理链表,需要一个指向其开始处的指针,我们通过使用typedef使List成为指向Node结构的指针的名称。因此:
List movies;
的声明表明movies是一个适合指向链表的指针。
这是定义List类型的惟一的方法吗?不是的,比如,可以加入一个变量来保存列表中项目的数量:
typedef struct list
{
Node *head; /*指向列表头的指针*/
int size; /*列表中项目的数量*/
} List; /*另一种定义列表的方法*/
可以添加另一个指针来保存列表的末尾。稍后,您可以看到一个这样做的例子。现在还是让我们使用List类型的第一种定义。重要的一点是要考虑清楚如下的声明:
List movies;
是在建立一个列表,而不是在建立一个指向节点的指针或是建立一个结构。movies的确切数据表示是应该在接口层上不可见的实现细节。
比如,启动后程序应该把头指针初始化为NULL。但是不应使用这们的代码:
movies = NULL;
为什么不能这样做?因为稍后您也许会发现您更喜欢List类型的结构实现,那将需要下面的初始化语句:
movies.next = NULL;
movies.size = 0;
任何使用List类型的人都无须担心这些细节,而应能够使用下面的代码:
InitializeList (movies);
程序员只需要知道他们应该使用InitializeList()函数来初始化列表,不需要知道List变量的确切的数据实现。这是数据隐藏(data hiding)的一个例子。数据隐藏是一种对更高级编程隐藏数据表示细节的艺术。
为了引导用户,您可以用下面的行来提供函数原型:
/*操作:初始化一个列表*/
/*操作前:plist指向一个列表*/
/*操作后:该列表被初始化为一个空列表*/
void InitializeList(List *plist);
有三点需要注意:第一,注释“操作前(precondition)”是调用函数之前应具有的情形。例如, 这里需要一个待初始化的列表。第二,注释概要“操作后(postcondition)"是执行函数后应具有的情形。最后,函数使用一个指向列表的指针(而不是一个列表)作为其参数,所以函数调用应该像下面这样:
InitializeList (&movies);
原因是C按值来传递参数,所以C函数要想改变调用程序中的变量,惟一的途径就是使用指向该变量的指针。
C语言把所有的类型和函数信息集成到一个包的方法是将类型定义和函数原型(包括”操作前“和”操作后“注释)放入一个头文件中。这个文件将提供程序员使用该类型所需的全部信息。程序清单17.3显示了简单列表类型的头文件。它定义了一个特定结构作为Item类型,然后根据Item类型定义了Node,又根据Node类型定义了List类型。然后,代表列表操作的函数用Item类型的List类型作为它们的参数。如果一个函数需要修改参数,它使用指向相关类型的指针,而不是直接使用类型。文件大写每个函数名,以表示其为接口包的一部分。另外,文件使用#ifndef技术对多次包含一个文件提供保护。如果您的编译器不支持C99布尔类型,您可以头文件中使用:
enum bool {false,true}; /*把bool定义为类型,false true是它的值*/
替换:
#include <stdbool.h> /*C99功能*/
程序清单 17.3 list.h接口头文件
/*list.h--简单列表类型的头文件*/
#ifndef LIST_H_
#define LIST_H_
#include <stdbool.h> /*C99特性*/
/*特定于程序的声明*/
#define TSIZE 45 /*存放片名的数组大小*/
struct film
{
char title[TSIZE];
int rating;
};
/*一般类型定义*/
typedef struct film Item;
typedef struct node
{
Item item;
struct node *next;
} Node;
typedef Node *List;
/*函数原型*/
/*操作:初始化一个列表*/
/*操作前:plist指向一个列表*/
/*操作后:该列表被初始化为空列表*/
void InitializeList(List *plist);
/*操作:确定列表是否为空列表*/
/*操作前:plist指向一个已经初始化的列表*/
/*操作后:如果列表为空则返回true,否则返回false*/
bool ListIsEmpty(const List *plist);
/*操作:确定列表是否已满*/
/*操作前:plist 指向已经初始化的列表*/
/*操作后:如果列表已满则返回true,否则返回false*/
bool ListIsFull(const List *plist);
/*操作:确定列表中项目的个数*/
/*操作前:plist 指向一个已初始化的列表*/
/*操作后:返回列表中项目的个数*/
unsigned int ListItemCount(const List *plist);
/*操作:在列表末尾添加一个项目*/
/*操作前:item是要被增加到列表的项目
plist指向一个初始化的列表*/
/*操作后:如果可能的话,在列表末尾添加一个新项目,
函数返回true,否则函数返回false*/
bool AddItem(Item item,List *plist);
/*操作:把一个函数作用于列表中的每一个项目*/
/*操作前:plist 指向初始化的列表
pfun指向一个函数,该函数接受一个Item参数并且无返回值*/
/*操作后:pfun指向的函数被作用到列表中的每个项目一次*/
void Traverse(const List *plist,void (*pfun)(Item item));
/*操作:释放已分配的内存(如果有)*/
/*操作前:plist指向已初始化的列表*/
/*操作后:为该列表分配的内存已被释放
并且该列表被置为空列表*/
void EmptyTheList(List *plist);
#endif // LIST_H_
只有InitializeList()、AddItem()和EmptyTheList()函数修改列表,因此,从技术上讲只有这些方法需要一个指针参数。然而,如果用户必须记住把一个List参数传递给某个函数并把一个List的地址作为参数传递给另外一个函数,那么这很容易混淆。因此,为了使用户的责任简单化,所有函数都使用指针参数。
头文件中的一个原型比其他原型略复杂:
/*操作:把一个函数作用于列表中的每一个项目*/
/*操作前:plist 指向初始化的列表
pfun指向一个函数,该函数接受一个Item参数并且无返回值*/
/*操作后:pfun指向的函数被作用到列表中的每个项目一次*/
void Traverse(const List *plist,void (*pfun)(Item item));
参数pfun是指向函数的指针。具体地,它是指向将一个项目作为参数且没有返回值的函数的指针。可以将指向函数的指针作为一个参数传递给另一个函数,然后这个函数就可以使用被指向的函数。例如,可以让pfun指向用于显示一个项目的函数。然后,Traverse()函数将此函数作用于列表中每一个项目,从而显示整个列表。
17.3.3 使用接口
我们的要求是应该能使用这个接口编写程序而不必知道太多的细节,比如不必知道函数如何编写。
在编写支持函数之前,我们编写电影程序的新版本。因为接口使用List和Item类型,所以程序也应该使用这些类型。下面是一种可能的伪代码方案:
Creat a List variable.
Craet an Item variable.
Initialize the list to empty.
While the list isn't full and while there's more input:
Read the input into the Item variable.
Add the item to the end of the list.
Vist each item in the list and display it .
程序清单17.4 显示的程序遵循了这个伪代码方案,其中加入了一些错误检查的代码。注意它如何利用list.h文件中描述的接口。还要注意程序清单中含有与Traverse()函数要求的原型一致的showmovies( )函数的代码。因此,程序能够把指针showmovies(函数名是指向该函数的指针)传递给Traverse(),从而使Traverse()能够对列表中的每一个项目应用showmovies()函数。
程序清单17.4 film3.c程序
/*films3.c --使用ADT风格的链表*/
/*和list.c一同编译*/
#include <stdio.h>
#include <stdlib.h> /*为exit()提供原型*/
#include "list.h" /*定义LIst,Item*/
void showmovies(Item item);
int main(void)
{
List movies;
Item temp;
/*初始化*/
InitializeList(&movies);
if(ListIsFull(movies))
{
fprintf(stderr,"No memory available!Bye!\n");
exit(1);
}
/*收集并存储*/
puts("Enter first movie title: ");
while(gets(temp.title)!=NULL && temp.title[0]!='\0')
{
puts("Enter your rating <0-10>: ");
scanf("%d",&temp.rating);
while(getchar()!='\n')
continue;
if(AddItem(temp,&movies)==false)
{
fprintf(stderr,"Problem allocating memory\n");
break;
}
if(ListIsFull(movies))
{
puts("The list is now full.");
break;
}
puts("Enter next movie title(empty line to stop): ");
}
/*显示*/
if(ListIsEmpty(movies))
printf("No data entered. ");
else
{
printf("Here is the movies list: ")
Traverse(movies,showmovies);
}
printf("You entered %d movies.\n",ListItemCount(movies));
/*清除*/
EmptyTheList(&movies);
printf("Bye!\n");
return 0;
}
showmovies(Item item)
{
printf("Movie: %s Rating: %d\n",item.title,item.rating);
}
17.3.4 实现接口
当然,还需要实现List接口。C的方法是在list.c文件中集中进行函数定义。整个程序由三个文件组成:
list.h,定义数据结构并为用户接口提供原型;
list.c,提供函数代码以实现接口;
film3.c,将列表接口应用于具体编程问题的源代码文件。
程序清单17.5显示了list.c的一种可能实现。要运行这个程序,必须编译并链接film3.c和list.c。文件list.h、list.c和film3.c共同组成了完整的程序。
程序清单17.5 list.c实现文件
/*list.c --支持列表操作的函数*/
#include <stdio.h>
#include <stdlib.h>
#include "list.h"
/*局部函数原型*/
static void CopyToNode(Item item,Node *pnode);
/*接口函数*/
/*把列表设置为空列表*/
void InitializeList(List *plist)
{
*plist = NULL;
}
/*确定列表是否为空,如果是则返回真*/
bool ListIsEmpty(const List *plist)
{
if(*plist == NULL)
return true;
else
return false;
}
/*确定列表是否已满,如果是则返回真*/
bool ListIsFull(const List * plist)
{
Node *pt;
boo full;
pt=(Node *)malloc(sizeof(Node));
if(pt==NULL)
full = true;
else
full = false;
free(pt);
return full;
}
/*返回节点数*/
unsigned int ListItemCount(const List *plist)
{
unsigned int count = 0;
Node * pnode = *plist; /*设置到列表开始处*/
while(pnode != NULL)
{
++count;
pnode = pnode->next; /*把1设置为下一个节点*/
}
return count;
}
/*创建存放项目的节点,并把它添加到由plist指向的列表(较慢的实现方法)尾部*/
bool AddItem(Item item,List *plist)
{
Node * pnew;
Node * scan = *plist;
pnew = (Node *)malloc(sizeof(Node));
if(pnew==NULL)
return false;
CopyToNode(item,pnew);
pnew->next = NULL;
if(scan==NULL) /*空列表,因此把pnew放在列表头部*/
*plist = pnew;
else
{
while(scan->next != NULL)
scan = scan->next; /*找到列表尾部*/
scan->next = pnew; /*把pnew添加到结尾处*/
}
return true;
}
/*访问每个节点并对它们分别执行由pfun指向的函数*/
void Traverse(const List *plist,void (*pfun)(Item item))
{
Node * pnode = *plist; /*设置到列表开始处*/
while(pnode!=NULL)
{
(*pfun)(pnode->item); /*把函数作用于列表中的项目*/
pnode = pnode->next; /*前进到下一项*/
}
}
/*释放由malloc()分配的内存*/
/*把列表指针设置为NULL*/
void EmptyTheList(List * plist)
{
Node * psave;
while(*plist != NULL)
{
psave = (*plist)->next; /*保存下一个节点的地址*/
free(*plist); /*释放当前节点*/
*plist=psave; /*前进到下一个节点*/
}
}
/*局部函数定义*/
/*把一个项目复制到一个节点中*/
static void CopyToNode(Item item,Node *pnode)
{
pnode->item = item; /*结构复制*/
}
一、程序注释
list.c文件有许多有趣的地方。其一,它说明了何时该使用内部链接函数。如12章中所述,内部链接函数只在定义它的文件中可见。在实现接口时,您有时会发现编写不作为正式接口一部分的辅助函数很方便。比如,示例程序中使用CopyToNode( )函数把一个Item类型复制到一个Item类型变量。因为这个函数是实现的一部分而不是接口的一部分,所以我们使用static存储类限定词将其隐藏在list.c文件中。
InitializeList()函数将列表初始化为空列表。在我们的实现中,这意味着把一个List类型的变量初始化为NULL。如前所述,这要求向函数传递一个指向List变量的指针。
ListIsEmpty()函数很简单,其前提条件是当列表为空时,列表变量被设置为NULL。因此,在使用ListIsEmpty()函数之前初始化列表很重要。而且,要扩展接口以包含删除项目的操作,就需要确保当列表的最后一项被删除后,删除函数将列表重置为空列表。因为这个函数不改变列表,不需要传递指针参数,所以参数的类型是List而不是指向List的指针。
对于链表而言,列表的大小受可用内存数量的限制。ListIsFull()函数尝试为一个新项目分配足够的空间。如果这一操作失败,则列表已满;如果成功,就需要释放其刚分配的内存以使其为真正的项目所用。
ListItemCount()函数使用常用的 链表算法 来遍历列表,同时统计项目个数:
unsigned int ListItemCount(const List *plist)
{
Node * pnode = *plist; /*设置到列表的开始处*/
unsigned int count = 0;
while(pnode != NULL)
{
++count;
pnode = pnode->next; /*设置下一个节点*/
}
return count;
}
AddItem()是这些函数中所做工作最多的:
BOOL AddItem(Item item,List *plist)
{
Node * pnew;
Node * scan = *plist;
pnew = (Node *)malloc(sizeof(Node));
if(pnew==NULL)
return false; /*失败时退出函数*/
CopyToNode(item,pnew);
pnew->next = NULL;
if(scan==NULL) /*空列表,因此把pnew放在列表头部*/
*plist = pnew;
else
{
while(scan->next != NULL)
scan = scan->next; /*找到列表末尾*/
scan->next = pnew; /*把pnew添加到结尾处*/
}
return true;
}
AddItem()函数首先为新节点分配空间。如果成功,用CopyToNode()把项目复制到新节点,然后设置节点的next成员为NULL。这表明该节点是链表中的最后一个节点。最后,在创建节点和对节点成员正确赋值以后,函数将该节点添加到列表结尾处。如果此项目是添加到列表中的第一项,程序把头指针设置为指向第一项(记住,头指针地址是传递给AddItem()的第二个参数,所以*plist是头指针的值)。否则,代码继续在链表中前进,直到发现其next成员被设置为NULL的项目。这个节点就是当前的最后节点,所以函数重置其next成员以指向新的节点。
按照良好的编程习惯,您应该在向列表添加项目之前调用ListIsFull()函数。但是用户可能未能遵守这一惯例,所以AddItem()自己检查malloc( )是否成功。而且,用户还可能在调用ListIsFull()和调用AddItem()之间做其他事情时分配内存,所以最好检查malloc()是否成功。
Traverse()函数和LIstItemCount()函数类似,但它还将一个函数作用于列表中的每一项:
void Traverse(const List *plist,void(*pfun)(Item item))
{
Node *pnode = *plist; /*设置到列表的开始处*/
while(pnode != NULL)
{
(*pfun)(pnode->item); /*把函数作用于列表中的项目*/
pnode = pnode->next; /*前进到下一个项目*/
}
}
回忆一下,pnode->item表示节点中存储的数据,而pnode->next表示链表中的下一节点。比如,下面的函数调用将showmovies()函数作用于列表中的每一项:
Terverse(movies,showmovies);
最后,EmptyTheList()函数释放先前由malloc()分配的内存:
void EmptyTheList (List *plist)
{
Node *psave;
while(*plist != NULL)
{
psave = (*plist)->next; /*保存下一个节点的地址*/
free(*plist); /*释放当前节点*/
*plist = psave; /*前进到下一个节点*/
}
}
实现通过把List变量设置为NULL来指示一个空列表。因此需要向这个函数传递List变量地址以使其能重置它。因为List已经是一个指针,所以Plist就是一个指向指针的指针。因此在代码中,表达式*plist的类型是指向Node的指针。当列表到达结尾处时,*plist是Null,这意味着原来的实际参数现在已设为NULL。
代码保存下一个节点的地址,因为原则上调用free()可能使当前节点(*plist指向的那个节点)的内容不再可用。
二、思考您的工作
首先,比较程序清单17.2和17.4。两个程序使用同样的方法(动态分配的链接结构)来解决电影列表问题。但是程序清单17.2暴露了所有编程细节,将malloc()和prev->next置于公共视野中。而程序清单17.4隐藏了这些细节,并用与任务直接相关的语言来表达程序。即它讨论的是创建列表和向列表添加项目这样的任务,而不是调用内存函数或者重置指针。简言之,程序清单17.4表达程序的方式是根据需要解决的问题,而不是根据解决问题所需的低级工具。ADT版本是针对最终用户的,并且可读性更好。
其次,list.h和list.c文件共同组成可重用的资源。如果需要另一个简单列表,仍可以使用这些文件。
有时候,还需要重新定义CopyToNode()函数。比如,如果项目是一个数组,就不能通过赋值进行复制。
另一个要点是用户接口根据抽象列表操作来定义,而不是根据某些专门的数据表示和算法定义的。这使您能够自由的修改实现而不用改动最后的程序。比如,当前的AddItem()函数的效率不是很高,因为它总是从列表首端开始,然后去搜索尾端。您可以通过保存列表结尾处的地址来解决这个问题。比如,可以这样重新定义List类型:
typedef struct list
{
Node *head; /*指向列表头*/
Node *end; /*指向列表尾*/
}
当然,您得使用这个新的定义来重新编写列表处理函数,但是不需要改变程序清单17.4中的任何代码。
这种把实现和最终接口相隔离的做法对于大型编程工程来说尤其有用。这称为数据隐藏,因为详细的数据表示对终端用户是不可见的。
注意,这个具体的ADT甚至不要求您以链表的方法来实现简单列表。下面是另一种可以使用的方法:
#define MAXSIZE 100
typedef struct list
{
Item entries[MAXSIZE]; /*项目数据*/
int items; /*列表中项目的数量*/
}
这也将需要重新编写list.c文件,但是使用列表的程序不需要改动。
最后,考虑这种方法为程序开发带来的益处。如果有些功能运行不正常,可以将问题集中到一个函数上。如果想用更好的办法来完成一个任务,比如添加项目,您只需要重写那一个函数。如果需要新的属性,您可以考虑向包中添加一个新的函数。如果您觉得使用数组或双向链接可能更好一些,您可以重写实现的代码而不用修改使用实现的程序。