看到这个标题,你可能非常惊讶,C语言也能实现泛型链表?我们知道链表是我们非常常用的数据结构,但是在C中却没有像C++中的STL那样有一个list的模板类,那么我们是否可以用C语言实现一个像STL中的list那样的泛型链表呢?答案是肯定的。下面就以本人的一个用C语言设计的链表为例子,来分析说明一下本人的设计和实现要点,希望能给你一点有用的帮助。
一、所用的链表类型的选择
我们知道,链表也有非常多的类型,包括单链表、单循环链表、双链表、双向循环链表等。在我的设计中,我的链表使用的类型是双向循环链表,并带一个不保存真实数据的头结点。其原因如下: 1)单链表由于不能从后继定位到前驱,在操作时较为不方便 2)双链表虽然能方便找到前驱,但是如果总是在其尾部插入或删除结点,为了定位的方便和操作的统一(所有的删除和插入操作,都跟在中间插入删除结点的操作一样),还要为其增加一个尾结点,并且程序还要保存一个指向这个尾结点的指针,并管理这个指针,从而增加程序的复杂性。而使用带头结点的循环双向链表,就能方便的定位(其上一个元素为链表的最后一个元素,其下一个元素为链表的第0个元素),并使所有的插入和删除的操作统一,因为头结点也是尾结点。注:结点的下标从0开始,头结点不算入下标值。 3)接口的使用与C++中stl中list和泛型算法的使用大致相同。
二、list类型的定义
为了让大家一睹为快,下面就给出这个用C语言实现的“泛型”的定义,再来说明,我这样设计的原因及要点,其定义如下: 其定义在文件list_v2.c中
1 | typedef struct node |
其声明在文件list_v2.h中
1 | //泛型循环双链表,带头结点,结点下标从0开始,头结点不计入下标值 |
三、如何实现隐藏链表的成员变量(即封装)
首先,我们为什么需要封装呢?我觉得封装主要有三大好处。
- 隔离变化,在程序中需要封装的通常是程序中最容易发生变化的地方,例如成员变量等,我们可以把它们封装起来,从而让它们的变化不会影响到系统的其他部分,也就是说,封装的是变化。
- 降低复杂度,因为我们把一个对象是如何实现的等细节封装起来,只留给用户一个最小依赖的接口,从而让系统变量简单明了,在一定程度降低了系统的复杂性,方便了用户的使用。
- 让用户只能按照我们设计好的接口来操作一个对象或类型,而不能自己直接对一个对象进行操作,从而减少了用户的误操作,提高了系统的稳定性。
在面向对象的设计中,如果我们想要隐藏一个类的成员变量,我们可以把这些成员变量声明为私有的,而在C语言中,我们可以怎么实现呢?其实其实现是很简单的,我们在C语言中,当我们要使用一个自己定义的类型或函数时,我们会把声明它的头文件包含(include)过来,只要我们在文件中只声明其类型是一个结构体,而把它的实现写在.c文件中即可。
在本例子中,我把struct list
和struct node
定义在.c文件中,而在头文件中,只声明其指针类型,即typedef struct node* Iterator
和typedef struct list* List;
当我们要使用该类型时,只需要在所在的文件中,include该头文件即可。因为在编译时,编译器只要知道List和Iterator是一个指针类型就能知道其所占的内存大小,也就能为其分配内存,所以能够编译成功。而又因为该头文件中并没有该类型(struct list和struct node)的定义,所以我们在使用该类型时,只能通过我们提供的接口来操作对象。例如,我们并不能使用List list; list->data
等等的操作,而只能通过已定义的接口GetData来获得。
###四、如何实现泛型
泛型,第一时间想起的可能是模板,但是在C语言中却没有这个东西。但是C语言中却有一个可以指向任何类型,在使用时,再根据具体的指针类型进行类型转换的指针类型,它就是void*
。
为什么void可以指向任何类型的数据?这还得从C语言对于数据类型的处理方式来说明。在C语言中,我们使用malloc
等函数来申请内存,而从内存的角度来看,数据是没有类型的,它们都是一串的0或1,而程序则根据不同的类型来解释这个内存单元中的数据的意义,例如对于内存中的数据,FFFFFFFF
,如果它是一个有符号整型数据,它代表的是-1
,而如果它是一个无符号整型数据,它代表的则是2^32-1
。进一步说,如果你用一个int
的指针变量p指向该内存,则p就是-1,如果你用unsigned int
的指针p
指向该内存,则*p = 2^32-1
。
而我们使用malloc
等函数时,也只需要说明申请的内存的大小即可,也不用说明申请的内存空间所存放的数据的类型,例如,我们申请一块内存空间来存放一个整型数据,则只需要malloc(sizeof(int))
,即可,当然你完全可以把它当作一个具有4个单位的char数组来使用。所以我们可以使用void指针来指向我们申请的内存,申请内存的大小由链表中的成员data_size定义,它也是真正的data所占的内存大小。
五、为什么需要赋值函数指针assign
这里来说明一下,该链表的数据的插入方式,我们的插入方式是,新建一个结点,把data指向的数据复制到结点中,并把该结点插入到链表中。插入的函数定义如下:
1 | Iterator Insert(List list, void *data, Iterator it_before, |
从上面的解说中,我们可以看到链表中的成员data_size指示了链表中的数据所占的内存大小,那我们们就可以使用函数memcpy把data指向的数据复制到新建的结点的data所指向的内存即可。为什么还需要一个函数指针assign,来指向一个定义数据之间如何赋值的函数呢?其实这和面向对象语言中常说到的深复制和浅复制有关。
注:memcpy函数的原型为:void * memcpy ( void * destination, const void * source, size_t num );
试想一下,假如你的链表的数据类型不是int型等基本类型,也不是不含有指针的结构体,而是一个这样的结构体,例如:
1 | struct student |
学生的姓名和学号都是能过动态分配内存而来的,并由student结构体中的name和no指针指向,那么当我们使用memcpy时,只能复制其指针,而不能复制其指向的数据,这样在很多情况下都会带来一定的问题。这个跟在C++中什么时候需要自己定义复制构造函数的情况类似。因为这种情况下,默认的复制构造函数并不能满足我们的需要,只能自己定义复制构造函数。
所以在插入一个结点时,需要assign函数指针的原理与C++中自己定义复制构造函数的原理一样。它用于定义如何根据一个已有的对象生成一个该对象的拷贝对象。当然,可能在大多数的情况下,我们需要用到的数据类型都没有包含指针,所以在Insert函数的实现中,其实我也是有用到memcpy函数的,就是当assign为NULL时,就使用memcpy函数进行数据对象间的赋值,它其实就相当于C++中的默认复制构造函数或默认赋值操作函数。assign为NULL表示使用默认的逐位复制方式,即浅复制。
六、为什么不用typedef
对于这个问题,其实很好回答。很多人实现一个通用链表是这样实现的,它们把node结构的实现如下:
1 | typedef struct node |
然后,当需要使用整型的链表时,就把DataType用typedef为int。其实这样做的一个最大的缺陷就是一个程序中只能存在着一个数据类型的链表,例如,如果我需要一个int型的链表和一个float型的链表,那么该把DataType定义为int呢还是float呢?所以这种看似可行的方式,其实只是虚有其表,在现象中是行不能的,虽然不少的数据结构的书都是这样实现的,但是它却没有什么实用价值。
而其本质的原因是把结点的数据域的数据类型与某一种特定的数据类型DataType绑定在一起,从而让链表不能独立地变化。
七、为什么只把结点的指针定义为Iterator
在C++中iterator是一个类,为什么在这里,我只把结点的指针声明为一个Iterator呢?其实受STL的影响,我在一开始时,也是把Iterator实现为一个结构体,它只有一个数据成员,就是一个指向Node的指针。但在后来的实践中,发现其实并没有必要。在C++中为什么把iterator定义为一个类,是为了重载*,->等运行符,让iterator使用起来跟普通的指针一样。但是在C语言中,并没有重载运行符的做法,所以直接把Ierator声明为一个Node的指针最为方便、直接和好用,所有的比较运算都可以直接进行,而无需要借助函数。而把它声明为一个结构体反而麻烦、累赘。
八、为什么查找需要两个Iterator
其实这是参考了STL中的泛型算法的思想。而且本人觉得这是一种比较好的实现。为什么FindFirst的函数原型不是
1 | Iterator FindFirst(List list, int (*condition)(const void*, const void*)); |
而是
1 | Iterator FindFirst(Iterator begin, Iterator end, void *data,int (*condition)(const void*, const void*)); |
们可以试想一下,这个链表的为char链表,链表的元素为ABCBCBC,我们要在链表中找出所有的B,如果查找算法是使用第一种定义的话,它只能找出第一个B,而后面的两个B就无能为力了,而第二种定义,则可以通过循环改变其始末迭代器来在不同的序列段间查找目标字符B的位置。