学C语言那阵子,指针把我虐得够呛。直到有一天,我照着库函数的实现思路,自己动手把qsort的底层逻辑完整模拟了一遍,很多之前一知半解的东西才真正串起来了。qsort是C标准库提供的排序函数,它的独特之处在于:一个函数能排序任意类型的数组,而这一切靠的是void指针和函数指针。今天这篇就完整记录一下我模拟qsort函数的过程,把函数指针、回调机制、泛型设计、内存操作这几个点一次讲透。如果你学过C语言但还没写过库函数级代码,或者正在准备面试,这篇应该能帮你省不少劲。
1. 先弄清楚:qsort到底解决了什么问题
1.1 从一次“万能排序”需求说起
先说个场景。假设你要排序一个int数组,写个冒泡排序很简单。几天后要排序double数组,怎么办?把冒泡排序复制一份,把int换成double,再改一遍比较逻辑。如果这个需求每周变一次,今天排字符串,明天排结构体,你手上就会堆满三四个几乎一模一样、只改了个类型的排序函数。
qsort的出现就是为了终结这种重复劳动。它的设计思路非常朴素:排序过程无非就是“比较两个元素”和“交换两个元素”,其中“比较”是唯一跟类型强相关的步骤。如果我把“怎么比”这个策略从函数里拿出来,交给调用者通过函数指针传入,剩下的比较、遍历、交换逻辑全部用通用的方式处理,就能写出一个“你给它什么类型,它就排什么类型”的万能排序函数。
这就是回调机制的核心思想,也是我个人认为C语言里最值得反复琢磨的设计模式之一。你不需要在qsort里知道元素的类型,你只需要知道每个元素占多大空间、相邻元素在内存里隔着多远,就可以通过地址偏移找到任意位置的元素,再交给你传入的比较函数去判断大小。
1.2 函数指针:把比较规则交出去
很多初学者一看到函数指针就头大,其实没那么玄乎。函数名本身就是函数的入口地址,函数指针就是用来保存这个地址、然后通过这个地址去调用函数的变量。它的声明语法确实有点绕:int (*compar)(const void *, const void *)。读法建议从中间往外读:先看到*compar,说明compar是个指针;往左遇到int,说明这个指针指向一个返回int的函数;右边括号里的两个参数都是const void *,说明被指向的函数接收两个常量空指针。
为什么要用const void *而不是int *或者char *?因为qsort是库函数,它根本不知道你要排什么类型。void *就是“任意类型指针”的意思,而const告诉比较函数:你这个比较操作只读不改,别把原始数据给我动了。这是库函数设计里的一个重要约定,也是很多面试官喜欢追问的细节。
类比一下,qsort就像是工厂里的一条流水线,它能处理规格不同的货品,但分拣规则由你制定。比较函数就是那张“分拣规则表”,你写好之后把规则表的地址交给流水线,流水线按你的规则一件件比对、摆放。规则表本身不是流水线的一部分,所以你想换规则,随时换。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 模拟前的功课:原型与内存细节
2.1 逐项拆解qsort的函数签名
先看一眼标准库里的qsort原型:
c复制void qsort(void *base, size_t nmemb, size_t size,
int (*compar)(const void *, const void *));
一共四个参数,每个都值得琢磨:
base:数组的首地址。类型是void *,这是泛型的关键,任何类型的数组指针都能直接传进来,编译器不会报警。nmemb:数组中元素的个数。类型是size_t,也就是无符号整数,注意它永远不会是负数。size:每个元素占的字节数。比如int数组传sizeof(int),结构体数组传sizeof(struct xxx)。传给qsort一个元素大小,它才能把void指针按“步长”往前走。compar:比较函数的指针,这是灵魂参数。
这段签名里有个极容易忽略的细节:既然有了base,为什么还要一个nmemb?因为C语言数组不会携带长度信息,你在函数内部无法通过指针反推数组大小,所以必须由调用者明确告知。同理,为什么有了base和nmemb,还要size?因为void *做不了指针运算,编译器不知道这个指针移动一格会跨多少个字节。如果把数组比作一栋楼,元素就是楼层,size就是层高,没有层高你连上下楼都没法走。
2.2 比较函数的返回契约与类型转换
比较函数是qsort的“裁判”,它的契约非常严格:
- 返回值大于0,表示第一个参数排在第二个参数后面;
