
在本篇文章中,我们将探讨 C 语言中一个强大而灵活的机制:回调函数。我们将从回调函数的基本概念入手,并通过一个实际例子——模拟实现标准库中的 qsort 排序函数(使用冒泡排序算法),来深入理解它的用法和 void* 指针的威力。
回调函数本质上是一个通过函数指针调用的函数。
核心思想: 将变化的部分(如不同数据的比较逻辑)从不变的部分(如排序算法的骨架)中分离出来。
在 C 语言中,qsort 函数是一个非常重要的标准库函数,它使用快速排序算法来对数组进行排序。它的强大之处在于可以使用回调函数来定义任意类型的比较规则。
在通用排序中的作用: 它实现了 排序的通用逻辑,但它不知道如何比较不同类型的数据(如整数 vs. 结构体)。它通过接收一个比较函数指针(即回调函数)作为参数,将具体的比较任务回调给使用者来实现。
qsort 函数定义在 <stdlib.h> 头文件中:
void qsort(void *base, size_t nmemb, size_t size, int (*compar)(const void *, const void *));
参数 (Parameters) | 解释 (Meaning) |
|---|---|
void* base | 指向待排序数组的起始地址。void* 表示可指向任何类型数据。 |
size_t num | 数组中元素的数量。size_t 是一个无符号整型。 |
size_t size | 数组中每个元素的大小(以字节为单位)。size_t 是一个无符号整型。 |
int (*compar)(const void*, const void*) | 指向一个用于比较两个元素的函数(即回调函数)的指针。这个函数会被 qsort 重复调用。 |
回调函数 compar 的返回值要求
compar 函数定义了元素的排序顺序。它接收两个 const void* 指针作为参数。
返回值 (Return Value) | 含义 (Meaning) |
|---|---|
< 0 | p1 所指向的元素应在 p2 所指向的元素之前。 |
0 | p1 所指向的元素与 p2 所指向的元素等价。 |
> 0 | p1 所指向的元素应在 p2 所指向的元素之后。 |
这段代码演示了如何使用标准库 qsort 函数分别对整数数组、以及按结构体成员变量对结构体数组进行排序。
// 结构体定义
struct Stu //学生
{
char name[20]; //名字
int age; //年龄
};
// 1. 比较整数的回调函数 (int_cmp)
// 实现升序排序
int int_cmp(const void* p1, const void* p2)
{
// 将void*指针强制转换为int*,然后解引用相减。
return (*(int*)p1 - *(int*)p2);
}
// 2. 比较结构体年龄的回调函数 (cmp_stu_by_age)
// 实现按年龄升序排序
int cmp_stu_by_age(const void* e1, const void* e2)
{
// 转换为struct Stu*,访问age成员后相减
return ((struct Stu*)e1)->age - ((struct Stu*)e2)->age;
}
// 3. 比较结构体姓名的回调函数 (cmp_stu_by_name)
// 实现按姓名(字符串)升序排序
int cmp_stu_by_name(const void* e1, const void* e2)
{
// 转换为struct Stu*,访问name成员,使用strcmp比较字符串大小
return strcmp(((struct Stu*)e1)->name, ((struct Stu*)e2)->name);
}
// 打印结构体数组的辅助函数
void PrintStu(struct Stu s[], int sz)
{
for (int i = 0; i < sz; i++)
{
printf("{%s, %d} ", s[i].name, s[i].age);
}
printf("\n");
}
// 测试整数排序
void test_int_sort()
{
int arr[] = { 1, 23, 15, 73, 29, 72, 24, 6, 8, 90 };
int sz = sizeof(arr) / sizeof(arr[0]);
printf("Original array: ");
for (int i = 0; i < sz; i++) {
printf("%d ", arr[i]);
}
printf("\n");
// 调用 qsort 排序整数
qsort(arr, sz, sizeof(int), int_cmp);
printf("Sorted array (int): ");
for (int i = 0; i < sz; i++)
{
printf("%d ", arr[i]);
}
printf("\n\n");
}
// 测试结构体排序
void test_struct_sort()
{
struct Stu s[] = { {"zhangsan", 20}, {"lisi", 30}, {"wangwu", 15} };
int sz = sizeof(s) / sizeof(s[0]);
printf("Original Structs: ");
PrintStu(s, sz);
// 1. 按年龄排序
// 注意:qsort是in-place排序,会修改原数组s
qsort(s, sz, sizeof(s[0]), cmp_stu_by_age);
printf("Sorted Structs (by age): ");
PrintStu(s, sz);
// 2. 按姓名排序 (需要重新初始化或使用一个新数组进行演示)
struct Stu s_name[] = { {"extreme35", 20}, {"jack", 21}, {"daiwei", 17} };
printf("Original Structs (for name sort): ");
PrintStu(s_name, sz);
qsort(s_name, sz, sizeof(s_name[0]), cmp_stu_by_name);
printf("Sorted Structs (by name): ");
PrintStu(s_name, sz);
printf("\n");
}结果展示:

运行结果有力地证明了回调函数机制在实现通用排序中的作用:
int)还是复杂数据类型(struct Stu),qsort(或其模拟实现)的底层排序逻辑保持不变。void* 指针在 C 语言中处理异构数据和实现通用接口的强大能力。冒泡排序是一种重复遍历待排序列表,比较相邻元素并根据需要交换位置,直到所有元素都按指定顺序排列好的排序方法。
核心思想:
C语言经典实现:
void bubble_sort(int* arr, int n)
{
for (int i = 0; i < n - 1; i++)
{
// 最后i个元素已经排好序
for (int j = 0; j < n - 1 - i; j++)
{
if (arr[j] > arr[j + 1])
{
// 交换元素
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
}这里还可以进行优化,核心优化思路是通过引入交换标志位来检测单轮遍历中是否发生元素交换,如果某一轮没有发生任何交换,说明数组已经有序,即可提前终止排序过程。这里就不写了,重点在模拟实现回调函数qsort。
结果展示:

动图演示:

现在来到本文最核心部分:让冒泡排序能够像 qsort 一样排序任意类型的数据,必须将上述代码中的数据访问、比较和交换操作抽象化。
为了便于实现,把最普通的冒泡排序参数也修改到与qsort库函数一致,并且将原来写死的部分进行抽象成普遍适用的。
void bubble_sort_qsort(void* base, size_t num, size_t size, int (*compar)(const void*, const void*))这是实现通用性的基础,即函数必须能够接收和处理任何类型的数据。之前只是接收了整型数组,现在我们不仅要接收内置数据类型的数组,还要接收用户千姿百态的自定义结构体数据类型,那将参数类型可以修改为接受任何数据类型的指针就可以了,为了与库函数保持一致,也顺便将元素个数与元素大小直接定义为size_t类型。
关键参数:
参数 | 作用 | 抽象原理 |
|---|---|---|
void* base | 数组的起始地址。 | void* 是 C 语言中"万能"的指针类型,它可以指向任何数据类型的地址。通用函数不知道要排序的是整数还是结构体,因此使用 void* 来接收一切数据地址。 |
size_t size | 数组中每个元素占据的字节大小。 | void* 失去了类型信息,也就失去了"步长"信息。size 参数告诉通用函数:一个元素占多少字节,这是计算下一个元素地址所必需的。 |
这一步需要保证通过通用排序函数需要能够定位数组中的任意第
个元素,这需要克服 void* 不能直接进行指针算术运算的问题。
寻址原理:
void*,编译器不知道一个元素占多少字节,无法确定 base + 1 应该跳过多少内存。void* 强制转换为 char*。char 在 C 语言中总是占用 1 个字节。char*,每移动一步(加 1),就精确地移动 1 个字节。要找到第 j 个元素,需要跳过 j 个元素,总共移动的字节数是:
这里为什么不转成别的,可以假设一下,如果转换为比内存存储中最小单位的类型,那每一次跳过字节数的时候,就不能保证一个一个字节去比较了,也就达不到一开始期望这个函数实现的功能了。
有了上述理解后,在函数内部就可以进行具体实现了,参考代码如下:
void* current_ele = (char*)base + j * size;
void* next_ele = (char*)base + (j + 1) * size;
// 示例:如果 size=4(int),j=2,则 (char*)base + 2 * 4,即跳过 8 个字节找到第三个元素。
// 示例:如果 size=32(struct),j=1,则 (char*)base + 1 * 32,即跳过 32 个字节找到第二个元素。这是通过回调函数实现的抽象,将“如何比较”的决定权交给使用者。
通用排序函数只负责冒泡排序的循环和交换逻辑。每当需要比较两个相邻元素时,它就调用 compar 函数:
bubble_sort_gsort 传入两个元素的地址(仍是 void*)。compar 函数接收这两个 void* 指针。compar 函数内部,使用者将 void* 强制转换为原始数据类型(例如 int* 或 struct Student*),然后执行特定于该类型的比较操作(例如相减或调用 strcmp)。compar 返回比较结果(负数、零、正数),与库函数一致,这里参考上面库函数的返回值含义进行实现即可。这一步应该是整个函数实现最难的部分了,由于元素可能是任何类型(从 1 字节的 char 到几百字节的复杂结构体),交换操作必须是通用的。
可以先进行简单思考一下:
OK,有了这些大胆的假设,现在开始上手:
swap 函数原理
交换函数 swap(或 _swap)接收两个元素的地址(p1,p2)和元素大小(size):
void* 转换为 char*。size - 1。void swap(void* p1, void* p2, size_t size)
{
for (int i = 0; i < size; i++)
{
// 1. 读取 p1 的第 i 个字节到临时变量 temp
char temp = *((char*)p1 + i);
// 2. 将 p2 的第 i 个字节写入 p1 的第 i 个位置
*((char*)p1 + i) = *((char*)p2 + i);
// 3. 将临时变量 temp 写入 p2 的第 i 个位置
*((char*)p2 + i) = temp;
}
}
// 通过这个循环,无论元素是 int (size=4) 还是 struct (size=N),都能被完整地交换。直到这里,我们已经实现完所有的抽象,现在可以去进行测试了,这里给出所有抽象的源码:
//通用交换函数
void swap(void* p1, void* p2, size_t size)
{
for (int i = 0; i < size; i++)
{
char temp = *((char*)p1 + i);
*((char*)p1 + i) = *((char*)p2 + i);
*((char*)p2 + i) = temp;
}
}
//通用排序函数
void bubble_sort_qsort(void* base, size_t num, size_t size, int (*compar)(const void*, const void*)) // PDF 中为 bubble 函数
{
int i = 0;
for (i = 0; i < num - 1; i++) // 外层循环:趟数
{
int j = 0;
for (j = 0; j < num - 1 - i; j++) // 内层循环:比较未归位元素
{
// 元素寻址
void* current_ele = (char*)base + j * size;
void* next_ele = (char*)base + (j + 1) * size;
// 调用回调函数进行比较
if (compar(current_ele, next_ele) > 0)
{
// 执行通用交换
swap(current_ele, next_ele, size);
}
}
}
}这里只进行函数测试,那么为了验证函数逻辑是否正确,我们使用已经正确执行的比较函数(免得比较函数写错,半天以为自己函数实现错了),那就继续沿用使用qsort函数的示例,只需要把测试函数里的qsort换成我们自己实现的就好了:
bubble_sort_qsort(arr, sz, sizeof(int), int_cmp);
bubble_sort_qsort(s, sz, sizeof(s[0]), cmp_stu_by_age);
bubble_sort_qsort(s_name, sz, sizeof(s_name[0]), cmp_stu_by_name);结果展示:

这里可以看见跟之前调用qsort函数实现的效果一致,那就证明我们的大胆假设是对的。
本文通过对 qsort 库函数的学习和对 bubble_sort_qsort 的模拟实现,系统性地掌握了 C 语言中实现代码通用性的核心手段:回调函数与 void* 指针。
我们成功地将排序算法(冒泡排序)与数据类型细节(int、struct Stu)彻底解耦,这依赖于四个关键抽象的协同工作:
void* 和 size_t):使得排序函数可以接受任何数据的起始地址和大小。(char*)base + j * size 的方式,实现了精确的、按字节的内存地址定位。compar 回调函数,实现了排序规则的自定义。swap 函数通过逐字节(char*)操作,保证了无论元素是 4 字节还是 32 字节,都能正确、安全地完成数据交换。运行结果有力地证明了该模拟实现的正确性与通用性:
这套机制的成功运行,不仅是 C 语言指针操作的胜利,更是函数式编程思维在过程语言中的体现:将行为(比较)作为参数传递给逻辑(排序),极大地提升了代码的复用性和可扩展性。
掌握了 qsort 的原理,就掌握了 C 语言库函数设计中的精髓。在未来的编程实践中,可以用相同的思路,实现通用的查找函数、遍历函数等高级工具。