前面我们写了不少函数:排序 int 的冒泡、交换 intswap、求 int 数组的最大值……但你有没有发现,这些函数都被死死绑在了具体的类型上——排序只能排 int,交换只能换 int,如果哪天想排 doublestruct 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*,内部再转成实际类型比较。

五、使用我们的泛型排序:对 intdouble、结构体排序

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* 和动态内存,构建一个经典的动态数据结构——单向链表,让数据不在编译时固定大小,而是可以自由地增长和收缩。真正的数据结构之旅,现在开始。


课后小练习

  1. 实现一个泛型的数组逆置函数 void generic_reverse(void *base, size_t nmemb, size_t size),将数组中元素的顺序颠倒。用它逆置一个 int 数组和一个 double 数组,验证结果。
  2. 利用 qsort(标准库函数)对字符串数组 char *words[] = {"banana", "apple", "cherry"}; 按字典序排序并打印。提示:比较函数中解引用 void* 得到 char**,再用 strcmp
  3. 实现一个泛型的 find_max 函数:void *find_max(const void *base, size_t nmemb, size_t size, int (*compar)(const void *, const void *)),返回指向最大元素的指针。测试对 int 数组和 struct Student 数组的查找。
  4. (小挑战)用 void* 和函数指针设计一个简单的回调式 foreach 函数:void array_foreach(void *base, size_t nmemb, size_t size, void (*func)(void *))。该函数遍历数组,对每个元素调用 func。写一个打印 intdouble 的回调,并在 main 中测试。

我们下期见!

💡获取本系列示例代码请访问 GitCode 仓库

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐