首页 / 资讯中心 / 文章详情

C语言函数指针与qsort:实现轻量级泛型编程

C语言函数指针与qsort:实现轻量级泛型编程 ★ FEATURED ARTICLE
这个系列写到第四篇前几篇聊了指针的本质、指针与数组、指针与结构体/字符串今天要聊的是指针最“高级”的一类用途函数指针、回调函数以及 C 语言如何借助它们实现轻量级的泛型编程。如果你写过某个模块希望把“行为”作为参数传来传去或者想用一个排序函数统一处理整数、浮点、字符串甚至结构体数组那这篇内容正好是为你准备的。qsort 这个名字大家可能在教科书上见过可真正用明白、用透还是得先把函数指针和类型擦除这两件事搞清楚。1. 函数指针先把“函数的地址”这件事想透1.1 函数名本身就是一个地址C 语言里函数和变量一样编译之后也会落在一个内存地址上。这个地址就是函数体机器码的起始位置。你完全可以把某个函数的地址保存下来在之后的某个时刻、某个地方通过这个地址去调用它。这和用指针保存变量的地址然后间接访问底层逻辑完全一致。看这段代码#include stdio.h void hello(void) { printf(hello\n); } int main(void) { void (*fp)(void) hello; printf(hello %p\n, (void *)hello); printf(fp %p\n, (void *)fp); fp(); return 0; }这里hello在赋值表达式里不是“调用函数”而是自动变成了一个指向函数的指针值。你可以直接把函数名赋给函数指针也可以写hello两者的值一样。数组名在多数场景会退化成首元素指针函数名在表达式里则是变成了“指向函数的指针”理解这个退化规则再看函数指针就不玄了。1.2 声明语法括号里的星号是灵魂函数指针声明让人头大核心原因是函数调用运算符()的优先级高于解引用*。看两个声明int *f(int x); /* 一个函数参数是 int返回值是 int* */ int (*fp)(int x); /* 一个指针指向“参数是 int返回值是 int”的函数 */如果少写括号int *f(int x)表示 f 是一个函数它接收 int返回 int*。这和我们想要的“函数指针”完全是两回事。声明int (*fp)(int x)时*fp被括号先框住意思是先把 fp 解引用成一个函数然后再看参数和返回类型。读复杂声明的技巧从变量名开始往外看。fp是一个指针它指向的东西带有(int x)说明它指向一个接受 int 参数的函数左边还有int说明这个函数返回 int。为了不让每个回调函数参数都写一长串最好用 typedeftypedef int (*BinaryOp)(int, int); int add(int a, int b) { return a b; } int main(void) { BinaryOp op add; printf(%d\n, op(2, 3)); return 0; }typedef 之后BinaryOp就是一个“函数指针类型”。每次声明只需要写BinaryOp op漂亮很多。要注意函数指针类型必须精确匹配包括每个参数类型和返回值类型。一个返回 int、接收 int 的函数指针不能直接塞进“接收两个 int 再返回 int”的回调位置编译器会警告强行跑起来可能直接栈乱掉。1.3 第一个回调把行为传进函数回调函数的原始形态就是把一个函数指针作为普通参数传给另一个函数在合适时机通过指针调用。写一个最简单的遍历函数#include stdio.h void for_each(int *arr, size_t n, void (*action)(int *)) { for (size_t i 0; i n; i) { action(arr[i]); } } void print_one(int *p) { printf(%d , *p); } void add_one(int *p) { (*p); } int main(void) { int a[] {1, 2, 3}; for_each(a, 3, print_one); printf(\n); for_each(a, 3, add_one); for_each(a, 3, print_one); printf(\n); return 0; }for_each 只关心“怎么遍历”不关心“每个元素要做什么”。print_one 和 add_one 是调用者注入的具体行为。这就是回调最核心的价值控制逻辑和业务逻辑分离。以后想增加一个“把每个元素翻倍”的功能不需要动 for_each只需要再写一个函数调用时传进去即可。2. qsort 使用精讲为什么标准库只问字节数2.1 拆解 qsort 的五个参数qsort 在 stdlib.h 中声明原型是void qsort(void *base, size_t nmemb, size_t size, int (*compar)(const void *, const void *));参数含义不复杂base数组起始地址。用 void* 是为了接收任意类型的数组指针它只记录地址不记类型。nmemb元素个数。size单个元素占用的字节数通常用sizeof(arr[0])获得。compar比较函数指针决定排序规则。为什么 qsort 不需要知道具体类型因为排序过程只做两件事比较两个元素、交换两个元素。比较规则交给回调交换靠 size 做内存搬运。qsort 内部把数组当作一整块字节按 size 计算每个元素的位置完全不需要理解元素本身的含义。这里有个细节void* 可以接收任意“对象指针”但函数指针并不保证能安全转换到 void*。qsort 的第一个参数是对象数组所以没问题如果你想排一个“函数指针数组”就得确认平台支持把函数指针转成 void*这在移植性上是有讲究的。2.2 比较器到底怎么写比较器函数必须遵守一个约定当第一个参数应该排在第二个参数前面时返回负值两者相等时返回 0第一个参数应该排在后面时返回正值。这个返回值不一定要是 1 或 -1只需要符号正确。整数数组排序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, 8, 1, 9, 3}; qsort(arr, sizeof(arr) / sizeof(arr[0]), sizeof(arr[0]), compare_int); return 0; }为什么先转成const int *再解引用因为比较器拿到的是 void*不能直接解引用。你先把 void* 转换成“指向真实元素类型的指针”再解引用才能得到值。加 const 是为了不修改原数组qsort 本来也不承诺比较器能改数据。为什么不用return *(int *)a - *(int *)b;因为如果两个 int 相差太大减法可能溢出。溢出是未定义行为出现负数转正数、正数转负数都有可能排序结果就乱了。(ia ib) - (ia ib)是安全写法永远返回 -1、0、1。比较 double 时注意一个坑int compare_double(const void *a, const void *b) { double da *(const double *)a; double db *(const double *)b; return (da db) - (da db); }千万不能写return (int)(*(double *)a - *(double *)b);。两个很接近的小数相减比如 0.4 和 0.3差值是 0.1强转成 int 会变成 0等于告诉 qsort 这两个数相等排序自然不对。字符串数组的写法是另一个高发错误区。如果你要排一个char *arr[]数组元素本身是字符串指针qsort 的比较器收到的 void* 指向的是一个“char *”元素所以要先取出字符串指针再交给 strcmpint compare_string(const void *a, const void *b) { const char * const * psa a; const char * const * psb b; return strcmp(*psa, *psb); }这一步绕了一层指针很多人在这里卡住。可以画一张图arr是数组数组元素是char *比较器参数 a 指向数组中的某个元素也就是说 a 是指向char *的指针类型上类似char **。只有再解引用一次才能拿到真正的字符串地址。结构体排序也类似接收到 void* 后先转换成结构体指针再访问成员typedef struct { int id; char name[32]; } Item; int compare_item_id(const void *a, const void *b) { const Item *pa a; const Item *pb b; return (pa-id pb-id) - (pa-id pb-id); } qsort(items, n, sizeof(items[0]), compare_item_id);如果要在同 id 的前提下再按 name 排就依次比较 id 和 strcmp(name)。谁为主谁为次完全由比较器决定。2.3 从 qsort 抽象出“泛型编程”泛型编程的目标是让算法逻辑与具体数据类型解耦。排序算法最需要知道的是“比较两个元素”和“搬运两个元素”qsort 把这两件事都抽象出去了比较通过函数指针注入搬运通过 size 和内存拷贝完成。这就是一种类型擦除。和 C 模板相比C 的这套方式并不编译期生成多份代码而是只有一份排序代码用 void* 忽略元素类型运行时靠回调拿到比较逻辑。好处是代码量小坏处是编译期几乎没有任何类型保护比较器里写错指针类型很容易运行期踩内存。和 Java/C# 的泛型相比C 没有运行时类型检查全靠程序员自觉。理解了 qsort 之后你会明白为什么 C 的老手总强调“一切对象指针都可以换成 void*”以及“指针运算一定要在类型化指针上进行”。这些细节不是书呆子式的严谨而是类型擦除方案的底层基石。3. 手写一个“泛型排序”把类型擦除的原理跑通3.1 实现一个 qsort 风格的插入排序纸上谈兵没用自己动手写一个泛型排序印象会深得多。下面这个函数模仿 qsort 的参数设计用插入排序算法实现#include stdio.h #include stdlib.h #include string.h static void memswap(unsigned char *pa, unsigned char *pb, unsigned char *tmp, size_t size) { memcpy(tmp, pa, size); memcpy(pa, pb, size); memcpy(pb, tmp, size); } static int compare_int_default(const void *a, const void *b) { int ia *(const int *)a; int ib *(const int *)b; return (ia ib) - (ia ib); } void array_sort(void *base, size_t n, size_t size, int (*compare)(const void *, const void *)) { if (base NULL || n 2 || size 0 || compare NULL) { return; } unsigned char *p base; unsigned char *tmp malloc(size); if (tmp NULL) { return; } for (size_t i 1; i n; i) { size_t j i; while (j 0 compare(p j * size, p (j - 1) * size) 0) { memswap(p j * size, p (j - 1) * size, tmp, size); j--; } } free(tmp); } int main(void) { int arr[] {5, 2, 8, 1, 9, 3}; array_sort(arr, sizeof(arr) / sizeof(arr[0]), sizeof(arr[0]), compare_int_default); for (size_t i 0; i sizeof(arr) / sizeof(arr[0]); i) { printf(%d , arr[i]); } printf(\n); return 0; }几个关键点不能用base i * size直接做指针算术因为 void* 没有“对象大小”概念。先把 base 转成unsigned char *再按字节偏移才能访问到第 i 个元素。比较器返回的是正负和零排序代码只需要判断 0不需要关心具体数值。交换时必须按 size 逐字节搬。临时空间用 malloc 分配避免在栈上声明一个不知道大小的数组。如果环境支持 C99 变长数组也可以改成unsigned char tmp[size]但 malloc 版本兼容性更广。这个排序是“冒泡式插入”相邻交换逻辑简单适合教学。真正生产环境直接用 qsort 就行没必要自己造排序轮子。3.2 为什么必须用字节交换而不是赋值因为你不知道元素类型。在 C 语言里*dst *src这种赋值要求两边都有确定的类型。拿到 void* 后编译器不知道目标是 int、double 还是结构体自然不能直接赋值。最通用的做法是把它当成一串无符号字符来搬运。C 标准明确保证任何对象都可以通过 unsigned char 类型的指针逐字节读取和修改其对象表示。所以用 memcpy 按 size 搬运是符合标准且安全的。memcpy 也不需要数据对齐保证它可以像逐字节复制一样工作。相比之下如果硬把 void* 转成某个强类型指针去操作可能会触发对齐问题或别名问题属于未定义行为的灰色地带。3.3 手写版本有什么用首先它帮你理解 qsort 内部到底在干什么。很多人用 qsort 一辈子不知道 base 参数为什么要配 size。手写一遍后你会对 void* 的“地址 字节数”这套思维有肌肉记忆。其次它给你提供了一种扩展思路。比如你想要“带额外上下文的排序”比较器除了比较两个元素还需要读取一个全局配置那 qsort 的固定签名就不好使了。你可以把接口扩展成void array_sort_ctx(void *base, size_t n, size_t size, void *ctx, int (*compare)(const void *, const void *, void *));每次比较时把 ctx 原样传给比较器。这种模式在成熟 C 项目里非常常见很多回调接口都会预留void *user_data参数就是为了避免用全局变量传上下文。4. C11 _Generic 与宏代码生成让“泛型”动作更顺手4.1 _Generic编译期按类型挑分支C11 引入了_Generic可以根据控制表达式的静态类型在编译期选择一个表达式结果。它不算完整泛型只是一个类型分派器但配合宏用起来很顺手。看一个“泛型打印”的例子#include stdio.h void print_int(int v) { printf(%d\n, v); } void print_double(double v) { printf(%f\n, v); } void print_string(const char *s) { printf(%s\n, s); } void print_unknown(const void *p) { printf(unknown: %p\n, p); } #define print_value(x) _Generic((x), \ int: print_int, \ double: print_double, \ char *: print_string, \ const char *: print_string, \ default: print_unknown)(x) int main(void) { int a 10; double b 3.14; const char *name hello; print_value(a); print_value(b); print_value(name); return 0; }_Generic先看(x)的类型找到匹配分支后把它后面的函数名和原始参数组合成一次函数调用。整个过程发生在编译期不会运行时判断。这个工具有几个坑控制表达式不会执行所以写复杂表达式也不会有运行时开销但类型匹配非常严格修饰符、数组衰减、字符常量的类型都要心里有数。比如A在 C 语言里是 int 类型不是 char 类型所以print_value(A)会走进 int 分支。字符串字面量是char[N]数组作为控制表达式会衰减成char *所以要单独给char *分支。_Generic适合做“API 入口”把不同函数按类型汇聚成一个宏但它不能根据类型生成大段逻辑模板引擎的活它干不了。4.2 用宏展开批量生成“类型专属函数”当你需要为 int、double、float 各写一套完全相同的排序函数时重复粘贴会让人崩溃。C 预处理器可以像代码模板一样展开宏批量生成函数。看这个宏#include stdio.h #define DEFINE_SORT_FOR(type) \ static void sort_##type(type *arr, size_t n) { \ for (size_t i 1; i n; i) { \ type tmp arr[i]; \ size_t j i; \ while (j 0 tmp arr[j - 1]) { \ arr[j] arr[j - 1]; \ j--; \ } \ arr[j] tmp; \ } \ } DEFINE_SORT_FOR(int) DEFINE_SORT_FOR(double) int main(void) { int int_arr[] {3, 1, 4, 1, 5}; double dbl_arr[] {2.5, 0.3, 1.2}; sort_int(int_arr, sizeof(int_arr) / sizeof(int_arr[0])); sort_double(dbl_arr, sizeof(dbl_arr) / sizeof(dbl_arr[0])); return 0; }预处理之后宏会展开成两个独立函数sort_int和sort_double。宏内部用##拼接标识符用\续行。注意行尾不能有多余空格否则续行会失败。这个方案有几个限制类型名如果是多个单词比如unsigned longsort_##type拼接出来是sort_unsigned long中间有空格无法形成一个合法标识符。解决办法是先 typedef 一个单名再传进去。类型名如果是指针比如char *拼接同样会出问题。一般也要先用 typedef 包一层。宏没有类型检查生成大量函数容易造成代码膨胀。适合“按需定义”不要把所有类型全列出来。调试困难。好在可以看预处理输出用gcc -E 文件.c就能看到宏展开后的真实代码。4.3 把 _Generic 和生成宏组合成一个对外接口生成了一堆sort_int、sort_double后调用端还是需要自己选择名字。可以用_Generic包一层统一入口#define sort_array(arr, n) _Generic((arr), \ int *: sort_int, \ double *: sort_double)(arr, n)这样sort_array(int_arr, n)会自动映射到sort_intsort_array(dbl_arr, n)映射到sort_double。调用方式统一了编译器在编译期就帮我们选好具体函数不需要函数指针间接调用性能也比 qsort 那种回调方式好。这算是 C 语言“原生泛型”的一种组合拳宏负责代码生成_Generic 负责类型分派typedef 负责把复杂类型收拢成短名字。整体没有 C 模板那么强大但对很多嵌入式、系统级场景足够用了。5. 实战经验函数指针数组、上下文与排查清单5.1 函数指针数组命令表与状态机函数指针不仅能当参数传来传去还能放进数组里。数组元素都是同一种函数指针类型配上名字就构成一张命令表。#include stdio.h #include string.h typedef void (*cmd_func_t)(const char *arg); static void cmd_help(const char *arg) { (void)arg; printf(help\n); } static void cmd_version(const char *arg) { (void)arg; printf(v1.0\n); } typedef struct { const char *name; cmd_func_t func; } cmd_entry; static const cmd_entry commands[] { {help, cmd_help}, {version, cmd_version}, }; void execute_cmd(const char *name, const char *arg) { for (size_t i 0; i sizeof(commands) / sizeof(commands[0]); i) { if (strcmp(commands[i].name, name) 0) { commands[i].func(arg); return; } } printf(unknown command\n); }新增一个命令只需要在commands表里加一行不用动 execute_cmd 的框架代码。如果业务命令多到几十个用if-else if写出来会非常长查表法要清爽得多。状态机也是同样的套路当前状态作为数组下标当前事件作为另一个下标查表得到处理函数。协议解析、按键处理、通信控制这些场景都爱用这种结构。函数指针数组要求所有函数签名一致如果不同命令需要不同的参数形态通常统一成void (*)(const char *arg)或void (*)(void *ctx)参数解析放到函数内部自己处理。5.2 回调的上下文和生命周期问题qsort 的比较器没有额外上下文参数想传递配置只能靠全局变量。全局变量在多线程、嵌套调用时很容易出问题所以项目里自定义回调接口时几乎都会预留一个void *ctx参数。比如遍历链表void list_foreach(Node *head, void *ctx, void (*visit)(Node *node, void *ctx));visit 回调触发时链表框架会把 ctx 原样传回给调用者。这个 ctx 可以是一个结构体指针里面装着你需要的任何状态不需要全局变量。这里最大的坑是生命周期。如果回调是同步触发的ctx 在函数内用完即可。如果回调会被保存下来等异步事件发生时才调用那 ctx 指向的内存必须保证“活到回调执行那一刻”。一不留神用一个已释放的栈内存作为 ctx回调一触发就是悬垂指针问题。这是我在很多项目里看到的高频 bug。如果回调运行在中断或高频线程上下文回调函数里不要做耗时操作也不要去调用可能不可重入的库函数。正确做法是回调只做“记录事件、塞队列、快速返回”真正耗时的处理交给主循环去消费。5.3 常见问题速查表现象可能原因处理建议编译警告 incompatible function pointer比较器参数没写成 const void *严格按 qsort 签名写比较器qsort 排序结果乱序比较器返回值溢出或逻辑写反用(a b) - (a b)先想清楚升序还是降序程序崩溃base 地址错误或 size 写错用sizeof(arr[0])不要用sizeof(arr)或手写数字结构体数组排序很慢结构体大qsort 频繁整体复制排索引数组或指针数组避免移动大对象函数指针数组越界调用状态号或命令号超出表长查表前先校验索引表大小用常量定义_Generic 选错分支类型修饰符或数组衰减不匹配显式转换控制表达式类型或增加对应分支5.4 一个模拟排错场景结构体排序为什么在目标板上乱序某开发者在一个模拟项目X里把一组日志结构体交给 qsort按时间戳字段排序。开发机上一跑结果完全正确交叉编译到另一套嵌入式环境后偶尔出现相邻两条日志顺序颠倒。排查过程先从比较器入手发现时间戳比较函数的写法是long diff pa-timestamp - pb-timestamp; return (int)diff;timestamp 是 unsigned long 类型。两个时间戳相减先按 unsigned long 计算差值可能是很大的正数或负数之后强转成 int高位被截断符号就可能翻转。比如真实大小关系是 A 在 B 前但由于差值截断后变成负数正数颠倒排序自然不稳定。改成逻辑比较return (pa-timestamp pb-timestamp) - (pa-timestamp pb-timestamp);问题消失。这个例子说明最短的写法往往不是最安全的写法。比较器函数里任何运算都必须保证返回值的符号和“真实大小关系”完全一致不能依赖有符号溢出或强转截断后的结果。6. 一些使用体会函数指针、回调、qsort 和 _Generic 这四件事本质上都在围绕同一个主题能不能把“类型”或“行为”从一个更大的逻辑中抽出来。函数指针抽的是“行为”void* 抽的是“类型”回调是把“行为”当成值传来传去宏和 _Generic 则是在编译期辅助完成这种抽象。我的个人体会是函数指针语法说难也难说简单也简单。先把它拆开看明白再亲手写一遍泛型排序包装最后用 _Generic 包一层对外接口。这三个练习做完你对“指针也是一种数据”的理解会完全不一样后面再去看事件驱动、状态机、插件化设计都会感觉顺很多。C 语言没有那么多花架子它的力量恰恰来自这些最基础的类型系统和内存模型而回调函数与泛型编程是这条路上绕不开的一个里程碑。
阅读完成 · 觉得有帮助?
咨询建站