C-Style “泛型”:以 swap/lsearch/stack 为例
在C语言中,没有提供任何泛型能力,但是,有神奇 void*
,只要运用恰当,能写出通用的『泛型』函数。在这里,『泛型』打了引号,表示并非真的是 C# 这种支持泛型语言中的含义,而是表示一种通用的意思。
这篇文章以函数和结构体及其方法为例,实现了两个函数 swap
和 lsearch
,以此说明如何利用 void*
写出通用的函数,以及一个通用的 Stack
来说明在 C 语言里面也能写出通用的数据结构。
首先来看第一个例子,swap
函数不难写,很多人在刚开始学习 C 语言时候就写过。比如想要交换两个 int
,可以写一个函数,函数声明大致如下:
现在又想交换两个 double
,交换两个自定义的结构体,怎么办呢?再写两个类似的函数,显然是不合适的。
我们可以写一个通用的函数来做这个事情。
我们传入两个要交换对象的地址。对于 int
版本的 swap
函数,编译器知道拿 4 个字节做交换,double
版本的 swap
函数是 8 个字节,但是 void*
不包含类似的信息,所以我们要传入一个 int
来表示要交换对象的大小。下面是通用版的 swap
函数
void swap(void *p1, void *p2, int size)
{
char buffer[size];
memcpy(buffer, p1, size);
memcpy(p1, p2, size);
memcpy(p2, buffer, size);
}
p1
放到 buffer
里面,p2
放到 p1
里面,最后把 buffer
里面的放到 p2
里面。
这里需要注意一下,函数第一句需要编译器的支持,幸好,现代编译器基本都支持了。
可以写几行代码简单的测试一下:
下面来写个线性搜索函数。
搜索,要有关键字,所以我们要传入待比较对象的地址,还要传入一个数组和它的大小,和前面说的一样,void*
几乎不包含什么信息,所以要传入待搜索对象的大小,最后,要能比较两个对象,需要传入一个比较函数。对于线性搜索来说,可以不传,因为用 memcmp
比较就能知道两个对象是否一样,但是对于二分搜索就不行了。虽然只是一个简单的示例,还是要充分的考虑通用性的。另外还有一种可能性,就是对于一个自定义结构体,内存可能不是完全一样的,但是针对这个类型而言,含义是一样的,这样也需要一个自定义的比较函数。
void* lsearch(void *key, void *base, int n, int eleSize, int(*cmp)(void*, void*))
{
for (int i = 0; i < n; ++i)
{
void *eleAddr = (char*)base + i * eleSize;
if (cmp(key, eleAddr) == 0)
{
return eleAddr;
}
}
return NULL;
}
key
进行比较。void*
是不能做指针的加减运算的,我们转成 char*
,然后根据当前遍历的 i
和对象大小,向后移动若干个字节,获得待比较对象的地址。然后就是比较,调用传入的比较函数比较,如果一致,就返回该地址。最后,遍历完成还没有找到则返回 NULL
。
这次写一个稍微复杂的代码来测试:找相同的字符串。我们需要一个比较函数:
int strCmp(void *p1, void *p2)
{
char *s1 = *(char**)p1;
char *s2 = *(char**)p2;
return strcmp(s1, s2);
}
char*
,对于搜索函数而言,要比较的就是 char*
,lsearch
需要的对象的地址,那么传入的就是 char*
的地址,也就是说,key
的类型是 char**
,base
数组里面是 char*
。所以我们的比较函数的 p1
p2
是 char**
,先强转再解引用。
如果写成 char *s1 = (char*)p1;
,那么 s1
的含义就和预期的不一致,把 p1
当做 char*
,解析地址,跳转到对应的地方,鬼知道那里是什么,很可能压根你就没权访问,假定能访问,从那里开始一直找到 \0
结束,没人知道 s1
是个什么鬼字符串。
简单地说,这里需要理解指针,要理解跳一次和跳两次的区别。
char *notes[] = {"Ab", "F#", "B", "Gb", "D"};
char *favoriteNote = "Eb";
char **found = (char**)lsearch(&favoriteNote, notes, 5, sizeof(char*), strCmp);
std::cout << (found == NULL) << std::endl;
favoriteNote = "Gb";
found = (char**)lsearch(&favoriteNote, notes, 5, sizeof(char*), strCmp);
std::cout << *found << std::endl;
char**
了,倒数第二参数是 char*
的大小,说明要比较的对象类型是 char*
。如前所述,第一个参数是对象的地址,favoriteNote
是 char*
,我们要比较它(char*
),所以再取地址传进去。
下面实现一个通用的 Stack
结构体及其方法。这里还会讨论为什么会内存泄漏以及如何修复这个问题。
首先,我们定义头文件。
struct Stack {
void *elements;
int eleSize;
int logicalLength;
int allocLength;
};
void StackNew(Stack *s, int eleSize);
void StackDispose(Stack *s);
void StackPush(Stack *s, void *eleAddr);
void StackPop(Stack *s, void *eleAddr);
void*
来保存内容;由于 void*
不包含其他信息,所以需要 eleSize
来指定要进出栈的对象的大小;内部两个量,用于记录有多少个元素在栈里面,分配了多少空间来存放这些对象。
接下来定义的几个函数很容易理解,新初始化一个栈,销毁处理栈,进栈、出栈操作,eleAddr
有着不同的含义,进栈操作指的是待进栈对象的地址,出栈操作指的是会把待出栈对象拷贝到的地址。
下面我们来实现 New
这个操作,初始化各个变量,为 elements
分配默认大小的空间。
void StackNew(Stack *s, int eleSize)
{
assert(s->eleSize > 0);
s->eleSize = eleSize;
s->logicalLength = 0;
s->allocLength = 4;
s->elements = malloc(s->allocLength * eleSize);
assert(s->elements);
}
Dispose
这个函数很简单,释放 elements
占用的内存。
Push
函数稍微复杂一点,把要进栈的对象拷贝到栈空间里面,同时,要考虑到分配的空间可能已经不够大了,要动态增加。
void StackPush(Stack *s, void *eleAddr)
{
if (s->logicalLength == s->allocLength)
{
StackGrow(s);
}
void *target = (char*)s->elements + s->eleSize * s->logicalLength;
memcpy(target, eleAddr, s->eleSize);
s->logicalLength++;
}
Grow
函数如下:
static void StackGrow(Stack *s)
{
s->allocLength *= 2;
s->elements = realloc(s->elements, s->allocLength * s->eleSize);
assert(s->elements);
}
realloc
函数重新分配内存,它的基本实现是如果当前内存后面有足够的空间,直接扩大内存,修改这块内存前的四字节或者八字节的标记块,返回与传入指针相同的地址;如果不够,开辟另一块内存,拷贝内容过去,释放前一块内存,返回新内存地址。
最后是 Pop
函数,要先判断栈里面是不是有东西,如果有,把最上面的对象拷贝到给定的地址去。
void StackPop(Stack *s, void *eleAddr)
{
assert(s->logicalLength > 0);
s->logicalLength--;
void *source = (char*)s->elements + s->eleSize * s->logicalLength;
memcpy(eleAddr, source, s->eleSize);
}
Stack
实现像模像样,我们写个简单的函数来测试一下:
Stack s;
StackNew(&s, 4);
int x = 12;
int y = 1;
int z = 15;
StackPush(&s, &x);
StackPush(&s, &z);
int output;
StackPop(&s, &output);
std::cout << output << std::endl;
StackPush(&s, &y);
StackPop(&s, &output);
std::cout << output << std::endl;
StackPop(&s, &output);
std::cout << output << std::endl;
StackDispose(&s);
但是这有一个严重的问题,比如传入的是 char*
,指针对应的地址是在 Stack
外 malloc
的内存,绝大多数情况,当时指向的指针早就过了生命周期或者指向了其他地方,那么,栈里面这个指针副本是唯一指向当初被分配的那块内存的指针。如果外部没有把所有指针对象都 Pop
出来并且释放(外部调用程序往往也没有这个义务做这些事情),那么,内存泄露就此产生了。所以,我们 Stack
实现要负责这个事情。
我们给 Stack
增加一个字段,同时修改以下 New
接口,让外界传入释放内存的函数,然后保存起来。
Dispose
函数,如果 Stack
里面还有对象,且 freeFn
不是 NULL
的话,释放内存,以防内存泄漏。