前面我们写了不少函数:排序int的冒泡、交换int的swap、求int数组的最大值……但你有没有发现,这些函数都被死死绑在了具体的类型上——排序只能排int,交换只能换int,如果哪天想排double或struct Student,就得把几乎相同的逻辑重新写一遍。
这显然不够优雅。C 语言虽然没有模板和泛型,但它有一个“万能指针”——void *。配合函数指针,你可以写出类型无关的通用代码,写一次,到处用。
一、void *:可以指向任何类型的指针
void *是一种特殊的指针类型,它能存储任意类型对象的地址,但不携带“指向什么类型”的信息。
inta=10;doubleb=3.14;charc='X';void*vp;vp=&a;// 指向 intvp=&b;// 指向 doublevp=&c;// 指向 char你可以把void *理解为“一个单纯的地址”,丢掉了类型标签。正因如此,编译器不知道它指向的数据是什么类型、占多少字节,所以:
限制 1:不能直接解引用
inta=10;void*vp=&a;printf("%d\n",*vp);// 编译错误!不能解引用 void*必须先把它强制转换成正确的类型指针,再解引用:
printf("%d\n",*(int*)vp);// 正确限制 2:不能做指针算术
vp+1;// 编译错误!不知道每次跳多少字节GCC 扩展允许void*的算术(当成char*),但标准 C 不允许。要移动void*,先转成char*。
二、void *的典型用途:通用接口
尽管有这些限制,void *却成为了 C 语言实现“泛型”的基石。标准库中大量函数都使用它:
| 函数 | 用途 |
|---|---|
malloc/calloc/realloc | 返回void*,可赋给任何指针 |
memcpy/memset/memmove | 操作任意类型的内存块 |
qsort/bsearch | 排序和查找任何类型的数据 |
| 线程创建函数 | 传递任意类型的参数 |
它们的共同点是:操作的是“一段内存”,不关心里面存的是什么类型。只要我们告诉它这块内存的起始地址和大小,剩下的事情就能做。
三、实现一个通用的swap函数
以前我们写的swap只能交换两个int。用void*加上元素大小参数,就能交换任意类型的值。
#include<stdio.h>#include<string.h>// 交换任意两块内存voidswap_generic(void*a,void*b,size_tsize){chartemp[size];// VLA,临时存储memcpy(temp,a,size);memcpy(a,b,size);memcpy(b,temp,size);}intmain(void){intx=10,y=20;swap_generic(&x,&y,sizeof(int));printf("x=%d, y=%d\n",x,y);// x=20, y=10doubled1=1.5,d2=3.5;swap_generic(&d1,&d2,sizeof(double));printf("d1=%.1f, d2=%.1f\n",d1,d2);// d1=3.5, d2=1.5return0;}关键:我们必须传入sizeof(类型),因为void*不知道元素多大。函数内部用memcpy(也是void*接口)逐字节拷贝,char temp[size]是变长数组,大小由参数决定。
变长数组在某些场合有争议(栈空间风险),但它让这个例子简洁。实际项目中若拷贝大对象,可改用动态分配或循环按机器字长拷贝。原理一样。
四、模仿qsort:写一个泛型冒泡排序
qsort是 C 标准库的通用排序函数,原型如下:
voidqsort(void*base,size_tnmemb,size_tsize,int(*compar)(constvoid*,constvoid*));参数:
base:数组首地址(void*)nmemb:元素个数size:每个元素的字节数compar:比较函数,接收两个const void*,返回负数/零/正数
它的思想是:把数据和操作分离。排序逻辑不需要知道数据是什么类型,只要知道每个元素多大,并由调用者提供一个比较函数。
我们参照这个设计,实现自己的generic_bubble_sort:
#include<stdio.h>#include<string.h>#include<stddef.h>// 交换 size 字节的两块内存staticvoidswap_bytes(void*a,void*b,size_tsize){char*pa=(char*)a;char*pb=(char*)b;for(size_ti=0;i<size;i++){chartemp=pa[i];pa[i]=pb[i];pb[i]=temp;}}// 泛型冒泡排序voidgeneric_bubble_sort(void*base,size_tnmemb,size_tsize,int(*compar)(constvoid*,constvoid*)){char*arr=(char*)base;// 转换为 char* 用于地址算术for(size_ti=0;i<nmemb-1;i++){intswapped=0;for(size_tj=0;j<nmemb-1-i;j++){// 计算相邻元素的地址void*elem_j=arr+j*size;void*elem_next=arr+(j+1)*size;if(compar(elem_j,elem_next)>0){swap_bytes(elem_j,elem_next,size);swapped=1;}}if(!swapped)break;}}核心点:
base被转成char*,这样arr + j * size就能准确定位到第j个元素的起始地址。- 调用者提供的
compar负责比较逻辑,它拿到的是两个void*,内部再转成实际类型比较。
五、使用我们的泛型排序:对int、double、结构体排序
1. 排序int数组
intcompare_int(constvoid*a,constvoid*b){intia=*(constint*)a;intib=*(constint*)b;return(ia>ib)-(ia<ib);// 简洁的差值法,避免溢出}intmain(void){intarr[]={5,2,9,1,5,6};size_tn=sizeof(arr)/sizeof(arr[0]);generic_bubble_sort(arr,n,sizeof(int),compare_int);for(size_ti=0;i<n;i++)printf("%d ",arr[i]);printf("\n");return0;}2. 排序double数组
intcompare_double(constvoid*a,constvoid*b){doubleda=*(constdouble*)a;doubledb=*(constdouble*)b;if(da<db)return-1;if(da>db)return1;return0;}注意浮点数不能直接用减法返回,因为 NaN 和极值可能使减法失效。
3. 排序结构体(按成绩)
typedefstruct{charname[20];intid;floatscore;}Student;intcompare_student_by_score(constvoid*a,constvoid*b){constStudent*sa=(constStudent*)a;constStudent*sb=(constStudent*)b;if(sa->score<sb->score)return-1;if(sa->score>sb->score)return1;return0;}intmain(void){Student class[]={{"Alice",1,92.5},{"Bob",2,85.0},{"Carol",3,78.5},};size_tn=sizeof(class)/sizeof(class[0]);generic_bubble_sort(class,n,sizeof(Student),compare_student_by_score);for(size_ti=0;i<n;i++){printf("%s: %.1f\n",class[i].name,class[i].score);}return0;}你看到了吗?同一个generic_bubble_sort函数,通过传入不同的比较函数,可以排序任何类型——这就是 C 语言泛型编程的核心模式:void*+ 元素大小 + 函数指针。
六、void *的更多场景
除了排序,void*还在以下地方大显身手:
- 动态内存管理:
malloc返回void*,你就是用它来申请任意类型的内存。 - 回调参数:线程创建、定时器回调等,通常接受一个
void*参数,让你传递任意上下文。 - 通用容器:简单的泛型栈、队列、链表可以通过
void*存储数据(配合大小或用指针指向数据)。
例如,一个泛型栈可以这样声明:
typedefstruct{void**data;// 存的是各种类型的指针inttop;intcapacity;}Stack;这种“存指针”的方式省去了元素大小的麻烦,但需要注意数据生命周期。更完善的通用容器库(如 GLib、sys/queue.h)正是建立在这些概念上。
七、常见错误与陷阱
1. 忘记传递size或写错
voidbad_sort(void*base,size_tnmemb,int(*cmp)(constvoid*,constvoid*)){// 缺少 size 参数,无法知道元素多大!}没有size,就无法计算第 n 个元素的地址,排序逻辑寸步难行。
2.compar中强制转换错误
intcompare_int(constvoid*a,constvoid*b){return*(int*)a-*(int*)b;// 未加 const,且解引用 void* 时忘了 const}应该*(const int*)a。虽然编译器对const丢失可能只是警告,但保持 const 正确性是好习惯。
3. 对void*做算术
void*vp=...;vp++;// GCC 扩展允许,但标准 C 不允许,可移植性差统一用(char*)vp + offset。
4. 比较函数返回值逻辑错误
qsort风格的比较函数要求返回负数表示a < b,零表示相等,正数表示a > b。简单减法虽然简便,但对大整数可能溢出。对浮点数务必用 if-else 结构。
八、小结
void *是 C 语言实现通用编程的秘密武器。它丢掉了类型标签,换来了操作任意数据的能力,但也要求程序员必须小心翼翼地管理类型转换和大小信息。
今天你学到了:
void*可以指向任何类型,但不能直接解引用或做算术。- 配合
size参数和函数指针,可以写出类型无关的swap、排序、查找等算法。 - 自己实现了一个泛型冒泡排序,理解了
qsort的底层原理。 - 这是 C 语言中“数据和算法分离”的经典范式。
这套模式是后面实现链表、树、哈希表等通用数据结构的基础。下一篇文章,我们就要用void*和动态内存,构建一个经典的动态数据结构——单向链表,让数据不在编译时固定大小,而是可以自由地增长和收缩。真正的数据结构之旅,现在开始。
课后小练习
- 实现一个泛型的数组逆置函数
void generic_reverse(void *base, size_t nmemb, size_t size),将数组中元素的顺序颠倒。用它逆置一个int数组和一个double数组,验证结果。 - 利用
qsort(标准库函数)对字符串数组char *words[] = {"banana", "apple", "cherry"};按字典序排序并打印。提示:比较函数中解引用void*得到char**,再用strcmp。 - 实现一个泛型的
find_max函数:void *find_max(const void *base, size_t nmemb, size_t size, int (*compar)(const void *, const void *)),返回指向最大元素的指针。测试对int数组和struct Student数组的查找。 - (小挑战)用
void*和函数指针设计一个简单的回调式foreach函数:void array_foreach(void *base, size_t nmemb, size_t size, void (*func)(void *))。该函数遍历数组,对每个元素调用func。写一个打印int和double的回调,并在main中测试。
我们下期见!
💡获取本系列示例代码请访问 GitCode 仓库。