如果你写过一段时间的C代码,基本不可能绕开一个名字——qsort。它是C标准库自带的排序接口,一个函数搞定任意类型数组的排序,整型、浮点型、结构体、字符串随便来,只要你自己提供一个比较规则。但说句实在话,很多人用它用得很溜,可真要自己动手把这样一套通用排序逻辑模拟实现出来,往往会卡壳:该用什么类型接收数组地址?怎么在不拷贝数据的前提下比较两个元素?元素交换怎么处理不同大小?这背后其实是C语言几个最核心的机制在协同工作——函数指针、void*泛型、按字节寻址。这篇内容我打算把自己手写一份qsort模拟实现的全过程拆开来讲,包括设计思路、每一行代码的意图、踩过的坑、以及针对结构体和浮点数的实战写法,适合对指针和内存模型已经有了基本概念、想进一步搞清楚泛型接口怎么设计的朋友。
1. 项目概述与需求拆解
1.1 qsort到底解决了什么问题
qsort是C标准库中定义在stdlib.h里的排序函数,它的原型长这样:
void qsort(void* base, size_t num, size_t size, int (*compar)(const void*, const void*));第一眼看上去就很劝退,一堆void*、一个函数指针。但把它的作用翻译成人话就是:我不管你的数组里装的是什么类型的数据,也不管这个数组有多少个元素、每个元素占几个字节,只要你告诉我这四项信息,我就能帮你把数组排好序。
这四项信息分别是:
base:数组起始地址。num:元素个数。size:单个元素占用的字节数。compar:你自定义的比较规则。
这里最关键的思路在于,排序算法本身并不关心元素类型。它只把每个存储单元看作一块连续的内存,然后用你给的元素大小来定位"第几个元素"到底在哪个字节偏移量上,用回调函数来获取"谁大谁小"。这个抽象做得极其干净,稍微体会一下就能感受到标准库设计者的功力。
1.2 为什么值得手动模拟一遍
很多人觉得qsort就是一个排序函数,直接调用就好了,模拟实现纯属浪费时间。实际上,把qsort重新实现一遍,等于把下面这些知识点全部串了起来:
void*为什么能接收任意类型地址,但为什么不能直接解引用。- 为什么
base + index * size这个地址运算这么重要。 - 函数指针作为回调是怎样在库代码和用户代码之间建立协作关系的。
- 字节交换如何抹平不同类型元素之间的差异。
- 比较器返回值约定为什么是"正、负、零"而不是"大、小、等"。
这些问题在工作中写代码时不一定天天遇到,但一旦遇到内存池、通用容器、插件机制、回调框架这类场景,底层全是这些机制。手动实现qsort就是一次性把这些机制全部打通的最佳练习题。
1.3 这次模拟实现的整体设计思路
正规的C标准库为了性能,内部实现的是快速排序,所以名字才叫qsort。但我们的目标是搞懂它的通用接口设计原理,所以我推荐先把排序主体用冒泡排序来实现。为什么用冒泡?因为它结构最简单,双层循环的判断和交换点非常直观,方便把关注点全部集中在"通用参数怎么处理"上。等彻底吃透了这套接口模型,再回头用快排去替换排序主体,也是顺水推舟的事情。
先把我最后完成的代码整体贴出来,后面一节一节拆开去讲。
#include <stdio.h> #include <string.h> void Swap(char* buf1, char* buf2, size_t size) { for (size_t i = 0; i < size; i++) { char tmp = buf1[i]; buf1[i] = buf2[i]; buf2[i] = tmp; } } void my_qsort(void* base, size_t num, size_t size, int (*compar)(const void*, const void*)) { if (base == NULL || compar == NULL || num < 2) return; for (size_t i = 0; i < num - 1; i++) { for (size_t j = 0; j < num - 1 - i; j++) { char* p1 = (char*)base + j * size; char* p2 = (char*)base + (j + 1) * size; if (compar(p1, p2) > 0) { Swap(p1, p2, size); } } } }核心逻辑其实就三件事:定位元素地址、调用比较器、交换内存数据。接下来我逐个击破这三件事背后的原理。
2. 核心原理拆解:函数指针、void* 与字节操作
2.1 函数指针与回调机制
函数指针是C语言里一个很容易被忽略但又极其强大的特性。一个函数的名字在表达式里会被自动转换成这个函数的地址,你可以把这个地址存起来,然后在另一个函数内部通过这个地址去调用那个函数。
qsort里的int (*compar)(const void*, const void*)就是这样一个函数指针。它声明了compar是一个指针,指向一个参数是两个const void*、返回值是int的函数。排序函数内部在需要比较两个元素时,就通过compar(p1, p2)来调用外部的比较逻辑。
这种设计把"如何比较"这个决定权完全交给了调用者。排序算法只负责比较和交换的流程,不负责具体的比较策略。这套机制在工程上就叫回调。现在很多框架里的事件处理、定时器触发、中间件钩子,本质都是回调机制。理解了qsort里的函数指针,你再去看那些框架源码里的回调注册函数,会顺畅很多。
提示:函数指针的声明语法确实别扭。一个实用记忆法是先看括号:
(*compar)说明compar先被解引用,所以它是指针;再和int (...)组合,说明它指向的是一个返回整型的函数。平时多写几次typedef int (*CompareFunc)(const void*, const void*);这种别名声明,会顺手很多。
2.2 void* 与强制类型转换的必要性
void*被称为无类型指针,它可以接收任意类型的指针而不会触发编译告警。也就是说,整型数组的地址、结构体数组的地址、甚至字符串指针,都可以直接传给qsort的base参数。
但void*有个限制:它只是一个"地址"概念,没有携带任何"对象大小"的语义。因此C语言不允许对void*做指针算术。你写base + 1编译器会直接报错,因为编译器不知道加1到底应该跨越几个字节。
解决方式是把void*转换成char*。char固定占1字节,char*的指针算术步长是1字节。所以(char*)base + j * size计算的就是第j个元素的起始字节地址。这一步是整个模拟实现里最重要的地址定位手段,千万不能省,也千万别转成int*——一旦转成int*,指针加1就变成了加4字节,跟元素实际大小完全脱钩了。
还有一点容易忽略:base用void*接收并不意味着你可以把它直接丢给用户比较器。用户比较器要比较两个具体类型的元素,它需要把这些地址转回自己知道的具体指针类型,比如int*或Stu*。所以void*在整个链条中扮演的角色是"中立缓冲区地址",真正要访问数据时必须做一次强制类型转换。
2.3 按字节交换的内存操作
排序必然牵扯到交换两个元素。常规写法是:
int tmp = a; a = b; b = tmp;但这只适用于int。如果排序对象是double,char,或者一个几十字节的结构体,怎么办?泛型接口的答案非常朴素:把数据块当成一堆字节,逐字节交换。
Swap函数接收两个起始地址和元素字节数size,然后循环size次,每次都交换一个字节。底层其实就是把内存里的一段数据搬到另一段,跟数据本身是什么含义完全无关。这也是为什么Swap的参数类型是char*而不是void*,因为我们需要对这串地址做1字节步长的遍历。
这种按字节交换的方式在元素较大时效率会偏低,比如结构体有几百字节,一个交换就要循环几百次。真实的标准库qsort内部会根据元素大小做针对性优化,比如适当批量搬运。但从正确性和教学角度,逐字节交换是最低风险、最直观的实现,因为它不存在对齐问题,也永远不会因为类型大小假设错误而出错。
2.4 元素定位的计算模型
用char*作为基础步长后,我们可以推出一个通用的元素地址公式:
元素地址 = (char*)base + index * size其中index是元素下标,size是单个元素大小。这个公式对所有线性存储的数组都成立。在冒泡排序中,我们要比较相邻的两个元素,所以第j个和第j+1个元素的地址分别是:
char* p1 = (char*)base + j * size; char* p2 = (char*)base + (j + 1) * size;这里有一个非常重要的细节:如果你对p1和p2取sizeof,结果只会是1,因为它们是char*。千万不要在排序函数内部尝试用sizeof(*p1)去推断元素大小,那是做不到的。元素大小只能依靠调用者传入的size参数。这也是通用接口和具体类型接口之间的本质区别:具体类型接口用编译器推导大小,通用接口用参数显式传递大小。
3. 完整实现与代码解析
3.1 排序函数骨架搭建
先看一下完整的排序主体:
void my_qsort(void* base, size_t num, size_t size, int (*compar)(const void*, const void*)) { if (base == NULL || compar == NULL || num < 2) return; for (size_t i = 0; i < num - 1; i++) { for (size_t j = 0; j < num - 1 - i; j++) { char* p1 = (char*)base + j * size; char* p2 = (char*)base + (j + 1) * size; if (compar(p1, p2) > 0) { Swap(p1, p2, size); } } } }函数体开头那段空指针和边界判断是必须的。base和compar如果为NULL,后面必然解引用崩溃,提前返回既是防御也是礼仪。num < 2的判断也很关键,如果数组只有一个元素或没有元素,num - 1会用到无符号数下溢,直接跳过排序才能避免出现一个巨大的循环次数。
排序部分就是典型的冒泡排序:外层循环控制"冒泡"的总趟数,内层循环负责每一趟头的相邻扫描比较,比较结果大于0就交换。由于我们的目标是通用接口,排序算法本身的复杂度不是重点,重点是每一步都通过size和compar来屏蔽类型差异。
3.2 字节交换函数实现细节
交换函数前面已经提过,这里再补充一些工程细节:
void Swap(char* buf1, char* buf2, size_t size) { while (size--) { char tmp = *buf1; *buf1++ = *buf2; *buf2++ = tmp; } }这种while (size--)配合指针自增的写法稍微紧凑一点,但可读性不如for循环版本。我更推荐初学阶段使用for循环配合数组下标,因为buf1[i]和buf2[i]的写法更直观,不容易把指针自增和取值混在一起看错。
另一个值得思考的优化点:对于常见的4字节int或8字节double,逐字节交换其实可以包装成按更大的单元搬移。比如针对4字节对齐的内存,用循环内一次交换4字节的方式能减少循环次数。但这样做的前提是调用者传入的内存地址已经对齐,真实qsort内部确实会做类似优化。作为模拟实现,没必要一开始就卷进对齐问题,逐字节是最稳的。
注意:交换是按字节进行的,所以它不关心元素内部是否有指针、结构体里有没有嵌套数组,只要
size传对,数据从A地址整体搬到B地址是精确且完整的。这一点比用memcpy配合临时缓冲区还要简单,因为它不需要额外申请内存,也不会有内存分配失败的风险。
3.3 整型数组排序测试
写完了排序函数,当然要拿来排真实的数组。最基本的测试是整型数组:
int cmp_int(const void* e1, const void* e2) { return *(int*)e1 - *(int*)e2; } void test_int(void) { int arr[] = { 9, 3, 7, 1, 4, 6, 2, 8, 5, 0 }; size_t num = sizeof(arr) / sizeof(arr[0]); printf("排序前: "); for (size_t i = 0; i < num; i++) printf("%d ", arr[i]); printf("\n"); my_qsort(arr, num, sizeof(int), cmp_int); printf("排序后: "); for (size_t i = 0; i < num; i++) printf("%d ", arr[i]); printf("\n"); }这里比较器cmp_int的两个参数类型必须是const void*,内部再把它们转回const int*并解引用。返回值用两者相减:如果第一个元素大于第二个,返回正数;小于,返回负数;相等,返回0。这个正负零约定要和排序函数里的判定条件compar(p1, p2) > 0配合,也就是说,只有前一个元素大于后一个元素才交换,得到的是升序排列。
有一点要提醒:sizeof(arr) / sizeof(arr[0])这个写数组元素个数的惯用技巧很好用,但要注意它只能在数组定义的作用域内使用。如果数组已经退化成指针传到了另一个函数里,这个写法就失效了。
3.4 浮点、结构体等复杂类型的排序
整型数组只是热身。qsort最爽的地方在于,它排结构体数组同样简单。比如我有一个学生结构体:
typedef struct Stu { char name[32]; int age; double score; } Stu;想按成绩排序,只需要写一个比较成绩的比较器:
int cmp_stu_by_score(const void* e1, const void* e2) { double s1 = ((const Stu*)e1)->score; double s2 = ((const Stu*)e2)->score; if (s1 > s2) return 1; if (s1 < s2) return -1; return 0; }这里我没有直接写((Stu*)e1)->score - ((Stu*)e2)->score,因为浮点数相减的结果是个double,如果把它直接塞进int返回值里,精度会丢失,而且对于两个非常接近的浮点数,相减得到的小数强行转成int往往全是0。用这种分支比较返回值的方式才规范。
按姓名排序则更简单,直接用字符串函数:
int cmp_stu_by_name(const void* e1, const void* e2) { return strcmp(((const Stu*)e1)->name, ((const Stu*)e2)->name); }strcmp的返回值本身就是正、负、零,天然满足qsort比较器的约定。这是字符串排序最自然的写法。但这里藏着一个常见的坑,稍后我在问题排查部分单独说。
测试结构体数组排序的代码和整型类似,只要把sizeof(int)换成sizeof(Stu),比较器换成对应的函数即可。整个排序过程对结构体内部字段一无所知,它只知道把每个Stu大小的内存块整体搬来搬去。这也是为什么Stu里可以放心地嵌套数组和结构体,排序不会破坏数据完整性。
4. 实操过程中的问题排查与避坑实录
4.1 空指针与无效参数的保护
模拟qsort的初期版本我没有加参数保护,结果用空指针测试时直接段错误。排查后发现崩溃点在compar(p1, p2)这一行,因为base为NULL时计算出的p1、p2也都是野地址,访问必然违规。
后来我在函数入口加了三个判断:
if (base == NULL || compar == NULL) return; if (num < 2) return;其中num < 2这个判断解决了一个隐蔽的问题:当num为0时,外层循环条件里的num - 1因为无符号整数的特性会变成一个大得离谱的正数,如果不拦截,整个循环会失控。很多新手在这里栽跟头,所以一定要记住,跟size_t打交道时,先判断有没有可能小于等于0,然后再考虑做减法运算。
4.2 比较器返回值的符号问题
比较器的核心约定是返回值的正负零。但我在测试一个包含负数和大数的整型数组时发现,直接用return *(int*)e1 - *(int*)e2;有可能产生错误的排序结果。原因很简单:整数减法可能溢出。比如INT_MAX - (-1)的结果在数学上是超出int表示范围的,编译器得到的结果很可能被截断成一个负数,于是判断逻辑就反了。
正确的写法是:
int cmp_int(const void* e1, const void* e2) { int a = *(const int*)e1; int b = *(const int*)e2; if (a > b) return 1; if (a < b) return -1; return 0; }虽然代码行数变多了,但彻底规避了溢出风险。这也是标准库实现时普遍采用的稳妥策略。在真实的工程代码里,宁可多写几行,也不要让一个看似简洁的表达式在极端数据下翻车。
4.3 strcmp使用的典型误伤
有个朋友在写按姓名排序时,比较器是这么写的:
int cmp_bad(const void* e1, const void* e2) { return strcmp(((const Stu*)e1)->name, ((const Stu*)e2)->name) == 1; }他把strcmp的返回值当成逻辑真值来用,结果排序结果时好时坏。原因也好懂:strcmp返回的是一个可正可负可零的完整整数,而不是简单的布尔真假。strcmp == 1只在两个字符串恰好相差1个ASCII码时成立,绝大多数情况返回的是其它数值,比如负的-3或者正的5,于是比较器经常返回0,排序就基本不动。正确的用法是直接返回strcmp的原始返回值,一句话搞定。
4.4 地址偏移量计算的边界问题
冒泡排序的内层循环里有一个特别需要注意的边界点:当j达到num - 1 - i时,j + 1不能越界。因为内层循环每次要同时访问第j个和第j+1个元素,所以最大的j值必须等于num - 1 - i,这样才能保证j + 1最大为num - 1,刚好是最后一个元素下标。
我曾贪图简洁把内层循环写成j < num - 1,结果每一趟都比到最后一个元素,然后j + 1就越界访问了。这种越界不会立刻崩溃,但会读到一个未知的"第num个元素"内容,比较运算后的交换行为就完全随机了,排序结果自然错误。排查这类问题的最好方法是把数组元素个数缩减到3个,然后逐步打印每次比较的元素下标,很快就能看出循环条件写错了。
4.5 数组元素个数与字节大小的匹配
sizeof(int)在不同平台是4字节,在个别嵌入式平台可能有差异,但用sizeof来求就永远不会错。需要注意的反而是不要手写4或者8这种魔数。比如结构体里有double,它的对齐要求可能导致sizeof(Stu)比字段之和多几个填充字节。手写大小必然出错,用sizeof才能在编译期自动算出真实大小。
4.6 交换函数传参时的地址一致性
调用Swap时,一定要传元素的起始地址,也就是p1和p2。有个容易犯的错误是写成了Swap(&p1, &p2, size),这时交换的不是数组元素,而是排序函数内部的局部指针变量。这种错误通常表现为排序后数组完全没变,但程序也不报错,排查起来比较郁闷。所以看到交换后数据没变化,第一反应就是检查交换函数的实参到底传的是元素地址还是指针变量的地址。
5. 扩展应用:从模拟到真实项目的致用技巧
5.1 用typedef简化函数指针声明
函数指针声明比较复杂,每次在函数参数里写完整版容易出错。在真实项目里我习惯先定义一个类型别名:
typedef int (*CompareFunc)(const void*, const void*); void my_qsort(void* base, size_t num, size_t size, CompareFunc cmp);这样声明排序函数时参数看着清爽,调用者写比较器时也只需要遵循int (*)(const void*, const void*)这一个签名模板。更重要的是,以后在实际工程中写回调注册函数、状态机转换表时,这个typedef模式可以直接复用。
5.2 多字段结构体排序的灵活应用
结构体排序经常遇到"先按成绩降序,成绩相同按姓名升序"的需求。可以这样写:
int cmp_stu_score_then_name(const void* e1, const void* e2) { const Stu* s1 = (const Stu*)e1; const Stu* s2 = (const Stu*)e2; if (s1->score > s2->score) return -1; if (s1->score < s2->score) return 1; return strcmp(s1->name, s2->name); }注意这里成绩降序的处理方式:分数高的返回负数,表示它应该排在前面;分数低的返回正数,表示靠后。这种用手写正负来控制升降序的方法,比简单地翻转数组要干净得多。第二排序字段直接在返回语句上一行接住即可,逻辑一目了然。
5.3 从模拟实现回归标准库qsort
把模拟实现跑通之后,再回头看标准的qsort,你会发现你已经在使用层面上完全理解它了。唯一剩下的区别是内部排序算法:标准库用的是快速排序的变种,在数据量大的时候性能更好,但接口语义完全一致。
从工程使用层面,有几条经验是通用的:
- 比较器一定要用
const修饰参数,避免在比较时意外修改元素内容。 - 数组量极大时,qsort的性能优势才明显;量小的时候冒泡模拟版也能用,但不要在生产环境长期依赖冒泡。
- 如果排序的是结构体数组且结构体很大,交换开销会比较高,这时候也可以考虑排序一份下标数组或指针数组,把大块内存的搬移变成指针的搬移。
我自己在做某跨平台系统时,就曾经需要按不同业务字段对同一份配置结构体数组排序。直接调用标准qsort,换了几个比较器函数就搞定了,整个过程根本没改动任何排序逻辑。当时我就觉得,模拟实现qsort那几天花得值,因为理解了这个泛型接口的底层逻辑,业务层设计才能做到如此游刃有余。
5.4 一个完整的综合示例
最后放一个我在本地测试时用过的完整示例,把结构体排序、函数指针、字符串比较全部揉在一起:
#include <stdio.h> #include <string.h> typedef struct Stu { char name[32]; int age; double score; } Stu; void Swap(char* buf1, char* buf2, size_t size) { for (size_t i = 0; i < size; i++) { char tmp = buf1[i]; buf1[i] = buf2[i]; buf2[i] = tmp; } } void my_qsort(void* base, size_t num, size_t size, int (*compar)(const void*, const void*)) { if (base == NULL || compar == NULL || num < 2) return; for (size_t i = 0; i < num - 1; i++) { for (size_t j = 0; j < num - 1 - i; j++) { char* p1 = (char*)base + j * size; char* p2 = (char*)base + (j + 1) * size; if (compar(p1, p2) > 0) Swap(p1, p2, size); } } } int cmp_stu_by_score(const void* e1, const void* e2) { const Stu* s1 = (const Stu*)e1; const Stu* s2 = (const Stu*)e2; if (s1->score > s2->score) return 1; if (s1->score < s2->score) return -1; return 0; } void print_stu_array(const Stu* arr, size_t num) { for (size_t i = 0; i < num; i++) { printf("%s %d %.1f\n", arr[i].name, arr[i].age, arr[i].score); } } int main(void) { Stu arr[] = { {"Alice", 20, 85.5}, {"Bob", 21, 92.0}, {"Cris", 19, 77.5}, {"David", 22, 88.0} }; size_t num = sizeof(arr) / sizeof(arr[0]); printf("排序前:\n"); print_stu_array(arr, num); my_qsort(arr, num, sizeof(Stu), cmp_stu_by_score); printf("按成绩升序排序后:\n"); print_stu_array(arr, num); return 0; }这段代码在我本地的测试环境里编译运行完全正常。如果你想快速验证自己写的模拟版,直接拿这个例子替换掉比较器函数和数组类型就行。
最后说点个人体会。我最早学qsort的时候也是死记硬背函数原型,能调用但完全不懂里面发生了什么。后来硬着头皮把冒泡模拟版一行行写出来,又把结构体排序、浮点比较、strcmp陷阱这些坑逐个踩了一遍,才真正敢说自己熟练掌握了这个接口。其实C语言的很多"高级用法"并没有那么玄幻,核心就几条:地址是编号、类型决定步长、回调决定策略。qsort把这些概念浓缩到一个函数里,确实是练手价值极高的一个题目。如果你也想检验自己对指针和内存的理解程度,手写一个qsort模拟实现,绝对比看十遍书来得管用。