1. 为什么我要在C语言里折腾“泛型”
想必很多从Java、Python、C++转过来写C的人,第一感觉是“手被绑住了”。尤其在写数据结构的时候,比如写一个栈、队列、链表,C语言里最痛苦的一件事就是:类型。你写一个int栈,下一个场合要用float,再下一个场合要用结构体指针,每次都把同一套逻辑复制一遍,改一改类型名,重新编译,这种事情干上三五次,人是会暴躁的。我当初就是在这样一个“改类型改到怀疑人生”的晚上,开始琢磨C语言里到底能不能搞出类似泛型的东西。
先说明一点:C语言在语言层面是没有泛型的,这是它和C++的重要分水岭。C++的template是在编译期做类型替换,而C语言没有这套机制。但C语言给了我们两个非常关键的“工具”,一个是void*,一个是函数指针。前者可以在运行时丢掉类型信息,后者可以把“行为的差异”抽象成参数传递进来。两者一组合,就能在C语言里模拟出一套“编译期做不到、但运行期够用”的泛型方案。
需要澄清的是,这套方案并不是我的发明,C标准库里的qsort函数早就这么干了。它是C语言泛型编程最具代表性的一个例子:把数组首地址作为void*传入,把元素大小和个数传入,再把比较函数作为函数指针传入。qsort根本不知道数组里装的是什么类型,但它依然能把任何类型的数组排好序。这篇文章要讲的,就是把这个思路吃透,再亲手做出几个可以复用的泛型组件。
适合什么人看?如果你写过一点点C,对指针有一定感觉,但还没试过void*和函数指针的“组合拳”,这篇文章可以帮你打开思路。如果你已经写了很久C,但每次写数据结构都在复制粘贴,这里提供的一套做法也能直接拿去用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. void*和函数指针:两个被严重低估的抽象工具
2.1 void*的本质:类型擦除与“地址”的唯一形态
很多初学者见到void就害怕,觉得它“什么都没有”,其实恰恰相反,void是C语言里最灵活的一等公民。它翻译成大白话就是“一个地址,但我不告诉你它指向什么类型”。任何类型的指针,无论是int*、double还是某个结构体的指针,赋值给void都不会报警告(其实在C语言里是隐式转换,不需要强转;反过来从void*赋回具体类型指针,在C里也不需要强转,C++才需要)。
这就是类型擦除。你可以把一个结构体指针塞进void*,栈不知道它是个结构体,链表不知道它是个结构体,它们只知道“这是一个地址,有若干个字节”。等到要用的时候,再把它转回原来的类型,就像把行李寄存进柜子,柜子不关心里面是衣服还是电脑,你取的时候知道自己存了什么就行。
这里有一个核心代价:类型安全没了。你丢失了“这是一个login_t”的信息,如果哪天不小心把它当成一个int来用,编译器不会拦你,程序会在运行期给你一个难以排查的崩溃。所以用void做泛型,本质是“程序员自己扛起类型安全的责任”。
另外,void*还有一个常被忽略的细节:它只能存“指针”,不能直接存“值”。这引出了一个很重要的设计分支——泛型容器到底存什么?要么存指针(推荐,代价小),要么存“元素的字节拷贝”(比如泛型栈的底层用memcpy把元素按字节搬进一个char数组)。这两种策略后面会详细说。
2.2 函数指针:把“行为”当作参数传递
函数指针这个东西,刚接触时觉得绕,说白了就是把函数名当成一个值,可以放进变量、传进函数、放在结构体里。它的声明确实长得劝退,比如int (cmp)(const void, const void*),读法是:cmp是一个指针,指向一个函数,这个函数接收两个const void*参数,返回int。
为什么要用函数指针?为了把“差异行为”从函数体内部抽离出来。以排序为例,如果你写一个int数组的排序函数,函数体内需要写“比较两个int的大小”;如果你换成一个字符串数组的排序,比较逻辑就完全变了——你没法提前知道调用者想怎么比较。
泛型方案就是把“比较”这个行为独立成函数,通过函数指针传给排序函数。排序算法本身只需要知道一件事:怎么判断两个元素谁前谁后。至于元素是什么、比较规则是什么,排序函数不关心。这就是所谓的“控制反转”或者说“策略模式”的雏形,在C语言里用函数指针就能落地。
2.3 两者结合的设计思路:数据交给void*,行为交给函数指针
把void*和函数指针放在一起,就构成了C语言泛型编程的基本范式:
- 数据层面:用void*指向一块不知道类型的内存区域。
- 行为层面:所有需要“知道类型”的操作(比较、拷贝、打印、释放),都通过函数指针注入。
- 调用者层面:只有实际使用数据的代码,才需要了解真实类型,才能提供对应的操作函数。
这个范式在C标准库中遍地都是。qsort是排序,bsearch是二分查找,它们都长一个样:void* + 元素数量 + 元素大小 + 比较函数。这不是巧合,这就是C语言在缺乏 template 情况下,能够给出的最优雅的抽象形态。
我在实际使用中最大的感受是:一旦你接受了“void*负责数据、函数指针负责行为”这种分工,很多原来觉得不可能抽象的代码都能拆得干干净净。
3. 手写一个“通用”的泛型栈:从设计到编码
3.1 先定方案:为什么选“存储大小”而非“存储指针”
网上很多C语言泛型容器的例子,选的是“每层存储一个void指针”,代码写起来确实简单,push传入void,pop返回void*。但这样有一个隐含限制:每个元素必须是一个指针,或者至少能被转成指针。如果你要存一个int值,你得先把它强转成void*(当然int转指针在大多数平台上能跑,但这是未定义行为,而且存double就没法这么干了)。如果元素是一个结构体对象,你还得在外面再包一层指针,分配一堆堆内存,麻烦死了。
所以我的方案是:栈内部维护一个char数组或者malloc出来的字节缓冲区,每次push的时候,用memcpy把元素按sizeof(T)字节拷贝进去;pop的时候再memcpy出来。这样栈本身不关心元素类型,它只关心“每个元素多大”。
这个方案最关键的一个参数就是元素大小size。你必须保证,push进去的元素类型和size保持一致。比如你定义了一个存int的栈,size = sizeof(int),但是push的时候不小心传了一个double*进去,拷贝的字节数就不对,后面的数据全部错位。所以还是那句话:类型安全要靠自觉。
3.2 核心代码:泛型栈的完整实现
直接上代码。头文件部分:
c复制#ifndef GENERIC_STACK_H
#define GENERIC_STACK_H
#include <stddef.h>
typedef struct {
void *data;
size_t size; /* 每个元素的大小 */
size_t capacity; /* 当前容量(元素个数) */
size_t len; /* 栈内元素个数 */
} gstack_t;
void gstack_init(gstack_t *st, size_t elem_size);
void gstack_destroy(gstack_t *st);
int gstack_push(gstack_t *st, const void *elem);
int gstack_pop(gstack_t *st, void *out);
size_t gstack_size(const gstack_t *st);
int gstack_empty(const gstack_t *st);
void gstack_clear(gstack_t *st);
#endif
实现文件:
c复制#include "gstack.h"
#include <stdlib.h>
#include <string.h>
#define STACK_INIT_CAP 4
void gstack_init(gstack_t *st, size_t elem_size) {
if (elem_size == 0) {
st->data = NULL;
st->size = 0;
st->capacity = 0;
st->len = 0;
return;
}
st->data = malloc(STACK_INIT_CAP * elem_size);
st->size = elem_size;
st->capacity = st->data ? STACK_INIT_CAP : 0;
st->len = 0;
}
void gstack_destroy(gstack_t *st) {
free(st->data);
st->data = NULL;
st->size = st->capacity = st->len = 0;
}
int gstack_push(gstack_t *st, const void *elem) {
if (!st->size) return -1;
if (st->len == st->capacity) {
size_t new_cap = st->capacity * 2;
void *tmp = realloc(st->data, new_cap * st->size);
if (!tmp) return -1;
st->data = tmp;
st->capacity = new_cap;
}
memcpy((char *)st->data + st->len * st->size, elem, st->size);
st->len++;
return 0;
}
int gstack_pop(gstack_t *st, void *out) {
if (st->len == 0) return -1;
st->len--;
memcpy(out, (char *)st->data + st->len * st->size, st->size);
return 0;
}
size_t gstack_size(const gstack_t *st) {
return st->len;
}
int gstack_empty(const gstack_t *st) {
return st->len == 0;
}
void gstack_clear(gstack_t *st) {
st->len = 0;
}
补充解释一下几个关键点:
- 为什么扩容用2倍而不是逐个增长?这是常见的摊还分析结论:每次扩容翻倍,平均下来每个元素的拷贝次数是常数级,整体时间复杂度为O(1)摊还。
- 为什么memcpy的目标地址要强转成char*?因为void不能做指针算术运算。C语言里对void做加减是GNU扩展,不是标准C的稳定行为,所以我习惯转成char*,保证严格遵循C标准。
- realloc之后的tmp指针非常重要:如果realloc失败会返回NULL,直接赋给st->data会把原来的指针弄丢,造成内存泄漏。先存到tmp里,成功了再覆盖。
- gstack_init里elem_size==0的限制,是为了防止除零和无效的0字节拷贝。
3.3 使用示范:int栈和字符串栈
先来个简单的,int栈:
c复制#include <stdio.h>
#include "gstack.h"
int main(void) {
gstack_t st;
gstack_init(&st, sizeof(int));
for (int i = 0; i < 10; i++) {
gstack_push(&st, &i);
}
int val;
while (!gstack_empty(&st)) {
gstack_pop(&st, &val);
printf("%d ", val);
}
printf("\n");
gstack_destroy(&st);
return 0;
}
这里有一个很关键的习惯:push和pop都是把变量的地址传进去,而不是传变量本身。因为泛型栈是“按字节拷贝”的,它需要的是源地址和目的地址。如果写gstack_push(&st, i),那会把i的值当成地址,程序会炸。
再看字符串的情况。字符串有一个麻烦:你直接存char*,栈里存的是“指针的字节”,不是“字符串的字节”。这样其实也行,前提是你自己管理好字符串的生命周期。但如果你希望栈owns字符串,让pop出来之后字符串依然有效,就需要在压栈的时候单独拷贝字符串,并在弹栈时释放,这就要引入“自定义拷贝/释放回调”了。先展示简单的“存指针”版本:
c复制#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "gstack.h"
int main(void) {
gstack_t st;
gstack_init(&st, sizeof(char*));
const char *fruits[] = {"apple", "banana", "cherry", "date"};
for (int i = 0; i < 4; i++) {
gstack_push(&st, &fruits[i]);
}
char *p;
while (gstack_pop(&st, &p) == 0) {
printf("%s\n", p);
}
gstack_destroy(&st);
return 0;
}
注意压栈时传的是&fruits[i],它是指向“char指针”的地址,而栈的elem_size是sizeof(char),正好拷贝一个指针的字节。这样每次弹栈,拿到的p就是原来那个字符串的首地址。
如果你需要栈深拷贝字符串,可以更复杂一点,比如为栈增加“拷贝函数”和“释放函数”这两个函数指针。后面在进阶部分我会展示。
4. 用函数指针模拟“策略模式”:一个更高级的泛型例子
4.1 从泛型栈到泛型“容器论”:给结构体数组排序
qsort已经是现成的,但你亲手写一个“不知道类型却能排序”的函数,会极大地加深对函数指针的理解。下面这个例子,我们写一个泛型的插入排序:
c复制#include <stddef.h>
#include <string.h>
typedef int (*cmp_func)(const void *, const void *);
void ginsert_sort(void *base, size_t n, size_t size, cmp_func cmp) {
char *arr = (char *)base;
char tmp[256]; /* 注意:这个临时缓冲区只适合“元素比较小”的场景 */
for (size_t i = 1; i < n; i++) {
if (cmp(arr + i * size, arr + (i - 1) * size) < 0) {
memcpy(tmp, arr + i * size, size);
size_t j = i;
while (j > 0 && cmp(tmp, arr + (j - 1) * size) < 0) {
memcpy(arr + j * size, arr + (j - 1) * size, size);
j--;
}
memcpy(arr + j * size, tmp, size);
}
}
}
这个排序函数,只要知道三件事就能工作:
- 数组首地址base
- 元素个数n
- 每个元素多大size
拿它去排字符串数组:
c复制#include <stdio.h>
#include <string.h>
static int str_cmp(const void *a, const void *b) {
/* 注意:a和b指向的是数组里的“某个元素”,而数组元素本身是char*,
所以要先转成char**,再解引用拿到真正的char*,然后strcmp */
return strcmp(*(char * const *)a, *(char * const *)b);
}
int main(void) {
const char *words[] = {"pear", "apple", "orange", "banana"};
ginsert_sort(words, 4, sizeof(char*), str_cmp);
for (int i = 0; i < 4; i++) {
printf("%s\n", words[i]);
}
return 0;
}
这个str_cmp的写法很多人第一次都会写错。因为qsort/ginsert_sort传给比较函数的是“指向元素的指针”,而元素本身是char*,所以参数a其实是char**。比较的逻辑是“拿到两个char*,再交给strcmp”。写成:
c复制char * const *pa = a;
char * const *pb = b;
return strcmp(*pa, *pb);
这里用char * const 强调,我们不想修改指针本身,只想通过它读到char的值。
4.2 排序任意结构体:同一个函数,千变万化的类型
再演示一个结构体排序。假设你有一个学生成绩表:
c复制#include <stdio.h>
#include <string.h>
typedef struct {
char name[32];
int score;
} student_t;
static int cmp_by_score(const void *a, const void *b) {
const student_t *sa = (const student_t *)a;
const student_t *sb = (const student_t *)b;
return sa->score - sb->score;
}
static int cmp_by_name(const void *a, const void *b) {
const student_t *sa = (const student_t *)a;
const student_t *sb = (const student_t *)b;
return strcmp(sa->name, sb->name);
}
int main(void) {
student_t arr[] = {
{"Bob", 85},
{"Alice", 92},
{"Eve", 78},
{"Dave", 91},
};
size_t n = sizeof(arr) / sizeof(arr[0]);
ginsert_sort(arr, n, sizeof(student_t), cmp_by_score);
printf("按成绩:\n");
for (size_t i = 0; i < n; i++) {
printf("%s -> %d\n", arr[i].name, arr[i].score);
}
ginsert_sort(arr, n, sizeof(student_t), cmp_by_name);
printf("按名字:\n");
for (size_t i = 0; i < n; i++) {
printf("%s -> %d\n", arr[i].name, arr[i].score);
}
return 0;
}
整个排序函数一行代码都不用改,只换比较函数,就能实现不同的排序规则。这就是函数指针作为“策略模式”的最大价值。
有一点必须强调:上面的ginsert_sort用了一个局部的char tmp[256],这在元素大小超过256时会越界。所以更稳妥的写法是用malloc动态分配临时缓冲区:
c复制void ginsert_sort(void *base, size_t n, size_t size, cmp_func cmp) {
char *arr = (char *)base;
char *tmp = (char *)malloc(size);
if (!tmp) return;
for (size_t i = 1; i < n; i++) {
if (cmp(arr + i * size, arr + (i - 1) * size) < 0) {
memcpy(tmp, arr + i * size, size);
size_t j = i;
while (j > 0 && cmp(tmp, arr + (j - 1) * size) < 0) {
memcpy(arr + j * size, arr + (j - 1) * size, size);
j--;
}
memcpy(arr + j * size, tmp, size);
}
}
free(tmp);
}
5. 进阶玩法:把“拷贝、释放”也做成函数指针字段
5.1 带回调的结构体:让容器真正拥有元素
前面那个int栈和指针栈都太“浅”了。如果你要存一大堆动态分配的复杂结构体,并且希望栈弹出来之后,元素还能正常访问,一个更完整的设计是:让容器内部保存“拷贝函数”和“大小”,以及可选的“析构函数”。
设计一个带有回调字段的容器:
c复制typedef void (*copy_func)(void *dst, const void *src);
typedef void (*free_func)(void *elem);
typedef struct {
void *data;
size_t size;
size_t capacity;
size_t len;
copy_func copy;
free_func free;
} gcstack_t;
初始化的时候,除了大小之外,再传入拷贝和释放函数:
c复制static void copy_string(void *dst, const void *src) {
const char **dst_str = (const char **)dst;
const char *src_str = *(const char *const *)src;
*dst_str = malloc(strlen(src_str) + 1);
if (*dst_str) strcpy(*dst_str, src_str);
}
static void free_string(void *elem) {
char **str = (char **)elem;
free(*str);
*str = NULL;
}
push的时候,不再是简单的memcpy,而是调用copy回调:
c复制int gcstack_push(gcstack_t *st, const void *elem) {
if (st->len == st->capacity) {
size_t new_cap = st->capacity ? st->capacity * 2 : 4;
void *tmp = realloc(st->data, new_cap * st->size);
if (!tmp) return -1;
st->data = tmp;
st->capacity = new_cap;
}
st->copy((char *)st->data + st->len * st->size, elem);
st->len++;
return 0;
}
pop的时候,先把内容memcpy到外部out缓冲区,再调用free回收内部的那份:
c复制int gcstack_pop(gcstack_t *st, void *out) {
if (st->len == 0) return -1;
st->len--;
void *elem = (char *)st->data + st->len * st->size;
memcpy(out, elem, st->size);
st->free(elem);
return 0;
}
这样,外层用户只需要提供一个栈,push进去一份字符串,pop拿到的是一份独立的深拷贝副本,生命周期完全可控。这个设计在写一些“容器需要管理资源”的场景(比如动态字符串栈、结构体栈)时非常有用。
5.2 一个通用“哈希表”的设想
如果泛型栈能写,泛型哈希表也完全可以。基本思路是:哈希表的桶数组存的是void*,每个节点存“键”和“值”,键的哈希函数、比较函数都通过函数指针传入。这样你就能做出一个“支持任意键类型”的哈希表组件。
C语言社区里确实有很多这样的开源库,比如uthash,它的做法是通过宏来生成哈希表代码;也有用void*加函数指针来实现的库,比如glib的GHashTable。两种路线各有拥趸。
用宏实现的典型好处是:编译期展开,类型安全几乎和手写代码一样,性能最好。glib那种void*+函数指针的路线,灵活、可调试、类型的切换不用重新编译,但代价是丢失了编译期检查,而且函数指针间接调用会有很小一部分性能损失。实际项目里,怎么选取决于你的约束条件——性能要求高且类型集合固定,用宏派;类型变化频繁且对性能要求没那么苛刻,用函数指针派。我自己两种都写过,个人更喜欢函数指针派,因为调试和改起来都更直观。
6. 常见问题与排查技巧实录:这些坑我帮你踩过了
6.1 比较函数参数写错:最常见的翻车现场
第一次写qsort比较函数,十个人有九个会写错。你的比较函数收到的两个参数,不是数组元素本身,而是指向元素的指针。数组元素是int,参数就是int*;数组元素是char*,参数就是char**。
新手最容易犯的错误,是在比较函数里直接解引用一层就开干。等你看到排序结果完全不对、或者程序直接段错误的时候,多半就是这里的问题。我曾经调试过一个bug,一个结构体数组排序后数据全乱了,排查了大半天,最后发现是比较函数把参数类型写成了结构体指针,但实际收到的却是“结构体指针的指针”,两层的差异让逻辑彻底错乱。
排查技巧:在比较函数第一行加个printf,看看传入的地址到底指向什么,立刻就明白了。
6.2 元素大小不匹配:泛型容器的隐性炸弹
泛型栈初始化时说好了sizeof(int),但你push的时候传入的是一个double的地址,memcpy就会多拷贝4个字节,栈的len虽然+1了,底层数据却错位了。再pop的时候,取出来的数据永远是坏的。
这种错误编译器查不出来,只有运行期数据会逐渐变得离谱。我给的建议是,在调度阶段用assert断言:
c复制assert(st->size == sizeof(你要压入的类型));
这行代码能在debug版本里帮你拦住90%的误用。release版本可以把assert关掉,但逻辑上你至少有一次机会发现错误。
6.3 临时缓冲区的边界问题:malloc才是王道
前一版ginsert_sort用了一个char tmp[256],当时能写清楚是因为我在注释里写了“只适合小元素”。但如果你真的拿它去排一个300字节的结构体数组,栈缓冲区就会溢出,轻则数据损坏,重则直接踩坏返回地址,debug起来相当痛苦。
正确做法是动态分配:malloc(size),用完free。这不仅仅是“更规范”,它还能应对元素大小未知的场景,保证排序函数面对任何size都能正常工作。
6.4 内存泄漏:容器销毁时要想想元素的内存归谁
void*容器只管自己那块字节缓冲区的生命周期,元素内部如果还抱着动态分配的内存,容器销毁时根本不知道——它甚至不知道元素里有什么。所以,一旦你的元素类型包含malloc出来的字段,就必须通过析构回调来释放。
我见过太多人写了一个“存结构体指针的栈”,pop出来之后只free了结构体本身,结构体内部的字符串却泄漏了。解决思路很简单:要么容器持有free函数指针,要么调用方自己负责弹栈并释放。关键是设计时就要想清楚“谁拥有这块内存”,而不是等到valgrind报了一堆leak再一个个去猜。
6.5 类型安全永远缺失:从代码规范上补救
void*泛型最明显的短板,就是编译器不再帮你检查类型。Java/C++的泛型在编译期就能报错,你的C代码只能在运行期崩溃。我个人的补救习惯是:
- 每个泛型容器包一层“类型安全的薄封装”。比如定义一个int_stack_t结构体,内部包含gstack_t,然后提供int_stack_push、int_stack_pop等专用函数。
- 在薄封装里做断言校验。
- 滥用强制类型转换的地方,写注释说明为什么这里安全。
6.6 函数指针的性能损耗:什么时候不需要担心
函数指针是间接调用,大多数情况下比直接调用要慢一点,但现代CPU的分支预测和间接分支预测已经很成熟,这种开销通常微乎其微。如果你在写排序这种O(nlogn)的算法,比较函数本身的开销会被整体复杂度覆盖。但如果你在一个几十亿次的循环里逐个用它做很轻量的计算,那么可能值得考虑宏替代。
衡量标准很简单:先写清楚,再profile。数据证明函数指针是瓶颈了,再换方案不迟。为了几纳秒的差距牺牲可维护性,大多时候不划算。
7. 在C和C++之间:void*泛型与template的分水岭
有些读者可能会问:既然C++有template,为什么还要研究C语言的void*泛型?我的看法是这样:这两种技术解决的问题相同,但哲学完全不同。
C++的template是“完整的泛型”,它在编译期生成针对每种类型的专用代码,类型安全由编译器兜底,性能通常也更好。C语言的void*是“运行时的擦除式泛型”,一段代码适配所有类型,代价是类型安全靠自觉。两者的关系有点像“编译期多态”和“运行期多态”的对比。
但这里有一个常被忽略的重点:即便你在写C,void*+函数指针的思路也特别能训练抽象思维。因为缺少编译期帮忙,你会被迫思考“数据到底是什么形态”“哪些操作依赖于具体类型”“怎样才能把变化的部分抽出去”。这种思考方式放到C++里写template,放到面向对象语言里写策略模式、模板方法模式,都是相通的。
另外,很多人从C++转C,最不适应的就是“没有语法层面的泛型”。但如果你学会了这套“void*数据 + 函数指针行为”的组合,其实能写出非常干净、可复用的C代码,C标准库本身就是最好的范本。我在读qsort源码(或者glibc里各种实现)的时候,最大的收获不是排序算法本身,而是那种“怎么把类型无关的骨架搭出来,再把类型相关的血肉通过函数指针填充进去”的感觉。
8. 一个完整的综合示例:用泛型栈+函数指针做四则运算
说了这么多理论和分段示例,最后给一个能跑、能玩、还能说明问题的综合案例:用泛型栈做后缀表达式(逆波兰表达式)的计算。
思路简介:
- 后缀表达式如"3 4 + 2 *",计算方式是遇到数字就压栈,遇到运算符就弹出两个数,计算完再压回去。
- 我们的栈可以设计成支持“int”和“double”两种数值模式。为了演示泛型,这里用double。
代码:
c复制#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
#include "gstack.h"
static double perform_op(double a, double b, char op) {
switch (op) {
case '+': return a + b;
case '-': return a - b;
case '*': return a * b;
case '/': return a / b;
default: return 0.0;
}
}
int main(void) {
char expr[] = "3 4 + 2 * 5 -"; /* (3+4)*2 - 5 = 9 */
const char *p = expr;
gstack_t stack;
gstack_init(&stack, sizeof(double));
while (*p) {
while (*p == ' ') p++;
if (*p == '\0') break;
if (isdigit((unsigned char)*p)) {
double val = strtod(p, (char **)&p);
gstack_push(&stack, &val);
} else if (*p == '+' || *p == '-' || *p == '*' || *p == '/') {
double a, b;
gstack_pop(&stack, &b);
gstack_pop(&stack, &a);
double result = perform_op(a, b, *p);
gstack_push(&stack, &result);
p++;
} else {
p++;
}
}
double final_val;
gstack_pop(&stack, &final_val);
printf("结果: %.2f\n", final_val);
gstack_destroy(&stack);
return 0;
}
这个例子妙在计算器本身根本不关心栈里存的是int还是double——它只知道遇到数字就push,遇到运算符就pop两个“sizeof(double)”大小的字节块。泛型栈在整段代码里不起任何类型判断的作用,只有最终取值的时候,我们才把它当成double来用。
如果你想把计算器改造成“支持int”,把gstack_init(&stack, sizeof(int))以及所有double换成int,一行逻辑不用改。这就是泛型容器带来的爽快感:类型变了,算法骨架不变。
9. 我对这套方案的实际体会
用void*加函数指针在C语言里模拟泛型,说到底是“戴着镣铐跳舞”。它肯定没有C++的template那么优雅,也无法提供编译期的类型保障,但是它在纯C环境下确实能把大量重复的代码收拢成一份。C标准库的qsort、bsearch已经证明了这种模式的可靠性,我自己写的泛型栈、泛型链表、泛型哈希表也在好几个工程里跑得很稳。
最值得养成的好习惯,我认为是:写任何泛型组件之前,先明确“哪些信息是容器必须知道的”。容器不知道元素是int还是student_t,但它必须知道:
- 元素占多少个字节;
- 怎么比较两个元素;
- 怎么拷贝一个元素;
- 怎么释放一个元素。
把这四个问题想清楚,你的泛型组件就有了设计的方向。剩下的只是实现细节。
还有一个小技巧我反复用到:很多泛型容器看起来复杂,但你可以先把一个“只支持int的版本”写出来,跑通了以后,把int替换成“size + void*”,再把比较函数、拷贝函数依次抽成函数指针。这种从具体到抽象的演进路径,比一开始就硬写一个完美泛型版要容易得多,调试起来也更轻松。
最后,如果你要借鉴开源库,我个人推荐去读读glib的GArray、GHashTable实现思路,还有uthash这个宏库的源码,两种风格都吃透了,你在C语言“泛型”这个话题上基本就算通了。这篇文章写到的内容,都只是这条路上的起点。
