28. 【C语言】通用数据操作:`void *` 与类型无关编程
前面我们写了不少函数:排序 int 的冒泡、交换 int 的 swap、求 int 数组的最大值……但你有没有发现,这些函数都被死死绑在了具体的类型上——排序只能排 int,交换只能换 int,如果哪天想排 double 或 struct Student,就得把几乎相同的逻辑重新写一遍。
这显然不够优雅。C 语言虽然没有模板和泛型,但它有一个“万能指针”——void *。配合函数指针,你可以写出类型无关的通用代码,写一次,到处用。
一、void *:可以指向任何类型的指针
void * 是一种特殊的指针类型,它能存储任意类型对象的地址,但不携带“指向什么类型”的信息。
int a = 10;
double b = 3.14;
char c = 'X';
void *vp;
vp = &a; // 指向 int
vp = &b; // 指向 double
vp = &c; // 指向 char
你可以把 void * 理解为“一个单纯的地址”,丢掉了类型标签。正因如此,编译器不知道它指向的数据是什么类型、占多少字节,所以:
限制 1:不能直接解引用
int a = 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>
// 交换任意两块内存
void swap_generic(void *a, void *b, size_t size) {
char temp[size]; // VLA,临时存储
memcpy(temp, a, size);
memcpy(a, b, size);
memcpy(b, temp, size);
}
int main(void) {
int x = 10, y = 20;
swap_generic(&x, &y, sizeof(int));
printf("x=%d, y=%d\n", x, y); // x=20, y=10
double d1 = 1.5, d2 = 3.5;
swap_generic(&d1, &d2, sizeof(double));
printf("d1=%.1f, d2=%.1f\n", d1, d2); // d1=3.5, d2=1.5
return 0;
}
关键:我们必须传入 sizeof(类型),因为 void* 不知道元素多大。函数内部用 memcpy(也是 void* 接口)逐字节拷贝,char temp[size] 是变长数组,大小由参数决定。
变长数组在某些场合有争议(栈空间风险),但它让这个例子简洁。实际项目中若拷贝大对象,可改用动态分配或循环按机器字长拷贝。原理一样。
四、模仿 qsort:写一个泛型冒泡排序
qsort 是 C 标准库的通用排序函数,原型如下:
void qsort(void *base, size_t nmemb, size_t size,
int (*compar)(const void *, const void *));
参数:
base:数组首地址(void*)nmemb:元素个数size:每个元素的字节数compar:比较函数,接收两个const void*,返回负数/零/正数
它的思想是:把数据和操作分离。排序逻辑不需要知道数据是什么类型,只要知道每个元素多大,并由调用者提供一个比较函数。
我们参照这个设计,实现自己的 generic_bubble_sort:
#include <stdio.h>
#include <string.h>
#include <stddef.h>
// 交换 size 字节的两块内存
static void swap_bytes(void *a, void *b, size_t size) {
char *pa = (char*)a;
char *pb = (char*)b;
for (size_t i = 0; i < size; i++) {
char temp = pa[i];
pa[i] = pb[i];
pb[i] = temp;
}
}
// 泛型冒泡排序
void generic_bubble_sort(void *base, size_t nmemb, size_t size,
int (*compar)(const void *, const void *)) {
char *arr = (char*)base; // 转换为 char* 用于地址算术
for (size_t i = 0; i < nmemb - 1; i++) {
int swapped = 0;
for (size_t j = 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 数组
int compare_int(const void *a, const void *b) {
int ia = *(const int*)a;
int ib = *(const int*)b;
return (ia > ib) - (ia < ib); // 简洁的差值法,避免溢出
}
int main(void) {
int arr[] = {5, 2, 9, 1, 5, 6};
size_t n = sizeof(arr) / sizeof(arr[0]);
generic_bubble_sort(arr, n, sizeof(int), compare_int);
for (size_t i = 0; i < n; i++) printf("%d ", arr[i]);
printf("\n");
return 0;
}
2. 排序 double 数组
int compare_double(const void *a, const void *b) {
double da = *(const double*)a;
double db = *(const double*)b;
if (da < db) return -1;
if (da > db) return 1;
return 0;
}
注意浮点数不能直接用减法返回,因为 NaN 和极值可能使减法失效。
3. 排序结构体(按成绩)
typedef struct {
char name[20];
int id;
float score;
} Student;
int compare_student_by_score(const void *a, const void *b) {
const Student *sa = (const Student*)a;
const Student *sb = (const Student*)b;
if (sa->score < sb->score) return -1;
if (sa->score > sb->score) return 1;
return 0;
}
int main(void) {
Student class[] = {
{"Alice", 1, 92.5},
{"Bob", 2, 85.0},
{"Carol", 3, 78.5},
};
size_t n = sizeof(class) / sizeof(class[0]);
generic_bubble_sort(class, n, sizeof(Student), compare_student_by_score);
for (size_t i = 0; i < n; i++) {
printf("%s: %.1f\n", class[i].name, class[i].score);
}
return 0;
}
你看到了吗? 同一个 generic_bubble_sort 函数,通过传入不同的比较函数,可以排序任何类型——这就是 C 语言泛型编程的核心模式:void* + 元素大小 + 函数指针。
六、void * 的更多场景
除了排序,void* 还在以下地方大显身手:
- 动态内存管理:
malloc返回void*,你就是用它来申请任意类型的内存。 - 回调参数:线程创建、定时器回调等,通常接受一个
void*参数,让你传递任意上下文。 - 通用容器:简单的泛型栈、队列、链表可以通过
void*存储数据(配合大小或用指针指向数据)。
例如,一个泛型栈可以这样声明:
typedef struct {
void **data; // 存的是各种类型的指针
int top;
int capacity;
} Stack;
这种“存指针”的方式省去了元素大小的麻烦,但需要注意数据生命周期。更完善的通用容器库(如 GLib、sys/queue.h)正是建立在这些概念上。
七、常见错误与陷阱
1. 忘记传递 size 或写错
void bad_sort(void *base, size_t nmemb, int (*cmp)(const void*, const void*)) {
// 缺少 size 参数,无法知道元素多大!
}
没有 size,就无法计算第 n 个元素的地址,排序逻辑寸步难行。
2. compar 中强制转换错误
int compare_int(const void *a, const void *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 仓库。
更多推荐

所有评论(0)