深入理解C指针(四):回调函数与qsort——指针实战的终极舞台
✨ 用 清晰易懂的图解 帮你建立直观认知 ,用通俗的 代码语言 帮你落地理解, 让每个知识点都能 轻松get !
🚀 个人主页 :0xCode小新 · CSDN
🌱 代码仓库 :0xCode小新· Gitee
📌 专栏系列
- 📖 《c语言》
💬 座右铭 : “ 积跬步,以致千里。”
在掌握了函数指针的基础后,我们现在来学习指针系列中最具有实战价值的一章——回调函数。如果说函数指针是"指向代码的指针",那么回调函数就是"让代码变得智能灵活的魔法"。
我将带你深入理解回调函数的设计思想,并通过C语言标准库中最经典的qsort函数,展示如何利用函数指针实现通用的排序算法。我们不仅会学习如何使用qsort对各种数据类型进行排序,还会利用冒泡排序亲手模拟实现一个自己的qsort,彻底理解其底层机制。
从理论到实践,从使用到底层实现,并配有丰富的图片详解,我来为你揭开C语言中高级的指针应用技巧,让你的编程能力和编程思维得到提高。
文章目录
- 1. 回调函数
- 1.1 什么是回调函数?
- 1.2 回调函数工作原理
- 2. qsort使用举例
- 2.1 qsort函数原型
- 2.2 排序整型数据
- 2.3 排序结构体数据
- 2.4 qsort的优势总结
- 注意事项
- 3. qsort函数的模拟实现
- 3.1 模拟qsort的核心思路
- 3.1.1设计函数接口
- 3.1.2 元素访问问题
- 核心问题:`void*` 的局限性
- **转换为 `char*`**
- **工作原理:地址计算**
- 解决方案:
- 3.1.3 比较问题
- 核心问题:类型未知,无法比较
- 将“如何比较”交给函数调用者
- 比较函数的定义与约定
- 3.1.4 交换问题
- 核心问题:类型未知,无法直接交换
- 解决方案:在内存层面进行操作
- 工作原理:逐字节交换
- 3.2 设计思路总结
- 结语
OK,废话不多讲,本章内容稍稍复杂,跟好车速,不要掉队,希望大家都可以理解本章内容
1. 回调函数
回调函数是C语言中基于函数指针的高级编程技术,它通过将函数指针作为参数传递,实现调用方与被调用方之间的灵活交互,是现代编程中一种重要的设计模式。
1.1 什么是回调函数?
回调函数是一个通过函数指针调用的函数。其核心机制是:将函数的指针(地址)作为参数传递给另一个函数,当这个指针被用来调用其所指向的函数时,被调用的函数就称为回调函数。
关键特性:
- 不是由函数实现方直接调用
- 在特定事件或条件发生时由另一方调用
- 用于对该事件或条件进行响应
在上一讲中我们写的计算机的实现的代码中,有一段代码是重复出现的,其中虽然执行计算的逻辑是区别的,但是输入输出操作是冗余的,有没有办法,简化⼀些呢?(如果不太清楚可以回顾深入理解C指针(三)),我把代码放到下面让大家看一下:
// 优化前的冗余代码
switch (input) {case 1:printf("输入操作数:");scanf("%d %d", &x, &y);ret = add(x, y);printf("ret = %d\n", ret);break;case 2:printf("输入操作数:");scanf("%d %d", &x, &y);ret = sub(x, y);printf("ret = %d\n", ret);break;// ...更多重复代码
}// 使用回调函数优化后
void calc(int (*func)(int, int)) {int x, y;printf("输入操作数:");scanf("%d %d", &x, &y);int ret = func(x, y);printf("ret = %d\n", ret);
}// 调用方式
switch (input) {case 1: calc(add); break;case 2: calc(sub); break;case 3: calc(mul); break;case 4: calc(div); break;
}
因为优化后的代码,对比优化前只有调用函数的逻辑是有差异的,我们可以把需要调用的函数的地址以参数的形式传递过去(也就是函数指针参数
),使用函数指针接收,函数指针指向什么函数就调用什么函数,这里其实使用的就是回调函数的功能。
1.2 回调函数工作原理
// 定义回调函数类型
typedef int (*OperationFunc)(int, int); //typedef是类型重命名,希望你们没有忘记这个关键字// 接收函数指针作为参数
void calculate(OperationFunc operation, int a, int b) {int result = operation(a, b); // 通过函数指针调用printf("结果: %d\n", result);
}calculate(add, 10, 20);
calculate(multiply, 5, 6);
2. qsort使用举例
在明白函数回调的基本知识之后,我们来看一个C语言标准库中最重要且最常用的函数之一(qsort),这个函数就是回调函数在实际编程中的应用。
这个函数可以对各种类型的数据进行快速排序,其灵活性完全得益于函数指针的应用。
2.1 qsort函数原型
void qsort(void* base, // 指向要排序的数组的起始地址的指针(void*类型,可接收任意类型数组的地址)size_t num, // 数组中元素的个数(size_t类型,无符号整数)size_t width, // 数组中每个元素的大小(以字节为单位,size_t类型)int(__cdecl* compare)(const void* elem1, const void* elem2) // 指向比较函数的指针,用于定义排序规则
);
文档链接地址: cpulsplus
2.2 排序整型数据
我们先用qsort
来排序整型数据来熟悉一下qsort
的使用方法
#include <stdio.h>
#include <stdlib.h> //使用qsort函数需要引入这个头文件// 比较函数:整型升序排序
int int_cmp(const void *p1, const void *p2)
{return (*(int*)p1 - *(int*)p2);
}int main()
{int arr[] = {1, 3, 5, 7, 9, 2, 4, 6, 8, 0};int i = 0;int sz = sizeof(arr) / sizeof(arr[0]);qsort(arr, sz, sizeof(int), int_cmp);for (i = 0; i < sz; i++) {printf("%d ", arr[i]);}printf("\n");return 0;
}
输出结果:
可能大家不太理解,我来解释一下:
qsort(arr, sz, sizeof(int), int_cmp);
- 参数1:
arr
- 要排序的数组起始地址 - 参数2:
sz
- 数组中元素个数(10个) - 参数3:
sizeof(int
) - 每个元素的大小(4字节) - 参数4:
int_cmp
- 比较函数的"地址"(告诉qsort如何比较元素)
int int_cmp(const void *p1, const void *p2)
{return (*(int*)p1 - *(int*)p2);
}
-
作用:告诉qsort如何比较两个元素的大小(此处是升序排序)
-
参数:
p1
和p2
是要比较的两个元素的指针 -
返回值规则:
- 返回负数:
p1
指向的元素 <p2
指向的元素 - 返回0:两个元素相等
- 返回正数:
p1
指向的元素 >p2
指向的元素
写成减法是因为我们待排序的元素是
int
类型,而这样的排序规则是qsort
函数定义的规则 - 返回负数:
-
技巧:
(*(int*)p1)
表示"先把p1当成int类型指针,然后取出它指向的值",因为我们的参数是void*
类型,所以我们要先强转成int*
类型,然后解引用获得他的值
2.3 排序结构体数据
qsort
的真正强大之处在于能够排序任意类型的数据,包括自定义结构体。
我们来实现一下:
定义学生结构体
struct Stu {char name[20]; int age;
};
按年龄排序
int cmp_stu_by_age(const void *e1, const void *e2)
{return ((struct Stu*)e1)->age - ((struct Stu*)e2)->age;
}void sort_by_age()
{struct Stu s[] = {{"zhangsan", 20}, {"lisi", 30}, {"wangwu", 15}};int sz = sizeof(s) / sizeof(s[0]);qsort(s, sz, sizeof(s[0]), cmp_stu_by_age);
}
此处大家可能看不懂的地方应该是->
和定义结构体
- 定义结构体
struct Stu
:创建了一个新类型,就像int
、float
一样,但它是我们自定义的name[20]
:字符数组,可以存储最多19个字符的姓名(留1位给结束符'\0'
)age
:整型变量,存储年龄- 创建好结构体之后,我们就可以使用他,也就是初始化,我们初始化了三个变量把他存放到了s数组(**tips:**结构体的初始化要加
{}
哦)
->
- 当你有结构体变量本身时,用
.
操作符来访问成员
struct Stu s = {"wangwu", 15};
printf("%s的年龄是%d岁\n", s.name, s.age);
- 当你有指向结构体的指针时,用
->
操作符来访问成员
struct Stu s = {"wangwu", 15};
struct Stu *p = &s; // p是指向结构体的指针printf("%s的年龄是%d岁\n", (*p).name, (*p).age); // 繁琐写法
printf("%s的年龄是%d岁\n", p->name, p->age); // 简洁写法(等价于上一行)
->
的作用就是:通过指针直接访问结构体成员,省去解引用和点操作符的繁琐步骤。
我们的变量e1,e2都是指针变量,所以使用->
会更加方便!
按姓名排序
#include <string.h>int cmp_stu_by_name(const void *e1, const void *e2)
{// 排序字符串我们需要使用strcmp函数return strcmp(((struct Stu*)e1)->name, ((struct Stu*)e2)->name);
}void sort_by_name()
{struct Stu s[] = {{"zhangsan", 20}, {"lisi", 30}, {"wangwu", 15}};int sz = sizeof(s) / sizeof(s[0]);qsort(s, sz, sizeof(s[0]), cmp_stu_by_name);
}
strcmp
函数使用需要包含头文件**#include <string.h>
**
return strcmp(((struct Stu*)e1)->name, ((struct Stu*)e2)->name);
上面的代码我来解读一下:
e1``e2
都是 const void*
类型(通用指针),(struct Stu*)e1
(struct Stu*)e2
将它们强制转换为指向学生结构体的特定类型指针,这是因为 void*
不知道指向什么类型,需要明确告知编译器
通过结构体指针访问学生的姓名,也就是strcmp
的两个参数
2.4 qsort的优势总结
- 通用性强:可以排序任何类型的数据
- 效率高:采用快速排序算法,平均时间复杂度为O(n log n)
- 灵活性好:通过不同的比较函数实现不同的排序规则
- 标准化:是C标准库函数,跨平台兼容性好
注意事项
- 比较函数的一致性:必须确保比较函数定义的顺序关系是一致的
- 空指针检查:在实际项目中,应该对指针参数进行有效性检查
- 类型安全:需要确保类型转换的正确性,避免未定义行为
qsort函数是回调函数理念的完美体现,它将排序算法的基础框架与具体元素的比较规则分离,使得同一个排序函数可以适用于无数种数据类型和排序需求。这种设计思想值得我们在日常编程中学习和借鉴。
接下来,我们将尝试模拟实现qsort函数,深入理解其内部工作机制。
3. qsort函数的模拟实现
在理解了qsort函数的使用方法后,我们现在来深入探索其内部实现机制。通过模拟实现qsort函数,我们不仅能加深对回调函数的理解,还能掌握void指针的高级用法和字节级操作技巧。
3.1 模拟qsort的核心思路
我们采用冒泡排序算法来模拟qsort函数,但要使其能够处理任意类型的数据,需要解决三个关键问题:
- 如何比较任意类型的元素? → 使用函数指针和void指针
- 如何交换任意类型的元素? → 逐字节交换
- 如何访问数组中的元素? → 通过计算字节偏移量
我先为大家展示完整的代码实现,大家可以先思考一下这样设计的思路。之后我会详细解析这种设计模式,当大家理解了这种设计思想,就能明白为什么 C 语言会成为系统级编程的首选语言了。
#include <stdio.h>
#include <string.h>// 比较函数:整型升序排序
int int_cmp(const void *p1, const void *p2)
{return (*(int*)p1 - *(int*)p2);
}// 交换函数:逐字节交换两个元素
void _swap(void *p1, void *p2, int size)
{int i = 0;for (i = 0; i < size; i++){char tmp = *((char*)p1 + i);*((char*)p1 + i) = *((char*)p2 + i);*((char*)p2 + i) = tmp;}
}void bubble(void *base, int count, int size, int(*cmp)(void*, void*))
{int i = 0;int j = 0;for (i = 0; i < count - 1; i++){for (j = 0; j < count - i - 1; j++){// 计算当前元素和下一个元素的地址// (char*)base + j*size → 第j个元素的起始地址// (char*)base + (j+1)*size → 第j+1个元素的起始地址if (cmp((char*)base + j * size, (char*)base + (j + 1) * size) > 0){_swap((char*)base + j * size, (char*)base + (j + 1) * size, size);}}}
}int main()
{int arr[] = {1, 3, 5, 7, 9, 2, 4, 6, 8, 0};int i = 0;int sz = sizeof(arr) / sizeof(arr[0]);bubble(arr, sz, sizeof(int), int_cmp);for (i = 0; i < sz; i++){printf("%d ", arr[i]);}printf("\n");return 0;
}
3.1.1设计函数接口
首先考虑参数列表。我们需要:
- 数据起始地址:
void* base
(因为不知道具体类型) - 元素数量:
int count
- 元素大小:
int size
(因为不知道类型大小) - 比较函数:
int(*cmp)(void*, void*)
(让调用者提供比较规则)
也就是:
void bubble_sort(void *base, int count, int size, int(*cmp)(void*, void*))
如果大家细心的话,可以看出它和qsort
的参数是一样的。
3.1.2 元素访问问题
在普通冒泡排序中,我们使用arr[j]
访问元素。但现在我们只有void* base
,不知道具体类型。
在C/C++中,void*
是一种“通用指针”,可以指向任何类型的数据。但正因为它“不知道”它指向的数据类型,编译器无法确定它指向的数据大小。因此,直接对 void*
指针进行算术运算(如 base + j * size
)是非法的,编译器会报错。
char*
是解决这个问题的“钥匙”,原因如下:
sizeof(char)
保证为 1:char
类型的大小始终是1个字节。因此,char*
指针进行算术运算时,每加1,地址就精确地增加1个字节。- 精确的字节级控制:我们需要操作的“单位”不是元素本身,而是构成元素的字节。
size
参数已经告诉了我们每个元素占多少字节。所以,我们要做的就是计算j * size
个字节的偏移量。
将 base
(void*
类型) 转换为 char*
后,我们就得到了一个“以字节为单位进行移动”的指针。
(char*)base
: 现在这是一个指向内存中某个字节的指针。(char*)base + j * size
: 从起始位置向后移动j * size
个字节。这正好指向第j
个元素的起始地址。(这部分内容理解起来可能稍有难度,建议大家多花些时间思考一下,仔细体会悟这种样样设计的精妙之处。)(char*)base + (j+1) * size
: 再向后移动size
个字节,指向第j+1
个元素的起始地址。
这个过程与 base
实际指向的数据类型(int, double, struct…)完全无关,我们只关心它们的大小(size
),从而实现了泛型。
// 计算第j个元素的地址
void arr[j] = (char*)base + j * size;// 计算第j+1个元素的地址
void arr[j+1] = (char*)base + (j + 1) * size;
3.1.3 比较问题
在原始的冒泡排序中,我们直接使用 arr[j] > arr[j+1]
进行比较。这是因为我们知道 arr
是 int
数组,编译器知道 >
操作符如何比较两个整数。
但在泛型的 qsort
中,base
是 void*
,我们完全不知道它指向的是整数、浮点数、字符串还是自定义结构体。编译器无法为我们生成任何有意义的比较代码。
既然函数的实现者(我们)不知道如何比较,那么唯一的方法就是让函数的使用者(调用者)来告诉我们。
这就是 cmp 的作用。它是一个由用户提供的函数,其唯一职责就是:比较两个数据的大小,并返回一个整数来表示比较结果。
qsort
要求比较函数必须遵循严格的格式:
int compar(const void *a, const void *b);
- 参数 (
a
,b
): 是两个const void*
指针。它们分别指向需要比较的两个元素的地址。(可以理解为arr[j]和arr[j+1]) - 返回值 (
int
): 一个整数,其含义有严格约定:- 返回值 < 0: 表示
a
所指向的元素 应该排在b
所指向的元素之前。 - 返回值 == 0: 表示
a
和b
所指向的元素相等,顺序无关紧要。 - 返回值 > 0: 表示
a
所指向的元素 应该排在b
所指向的元素之后。
- 返回值 < 0: 表示
3.1.4 交换问题
在原始的冒泡排序中,我们使用一个临时变量 tmp
来交换两个 int
:
int tmp = arr[j];
arr[j] = arr[j+1];
arr[j+1] = tmp;
这之所以可行,是因为:
- 我们知道数据类型是
int
。 - 我们知道如何声明一个同类型的临时变量 (
int tmp
)。 - 我们知道如何使用赋值操作符
=
来拷贝整个int
的值。
在泛型函数中,这三条我们一条都不满足。我们不知道 size
字节背后隐藏的是什么类型,因此无法声明一个“万能”的临时变量,也无法进行直接赋值。
既然我们在语言层面(也就是类型系统)无法解决问题,我们就下沉到最底层的内存层面。
无论数据类型多么复杂(int
, double
, struct
, …),它们在内存中最终都表现为一连串的字节(char
)。size
参数已经告诉了我们这串字节的长度。
交换两个元素,本质上就是将这两块长度为 size
的内存区域的内容完全互换。
_swap
函数的工作流程可以分解为:
- 将指针降级为字节指针:将
void* p1
和p2
转换为char*
。这样,p1 + i
就表示指向p1
所指向内存块的第i
个字节的指针。 - 循环
size
次:对内存块中的每一个字节进行操作。 - 交换单个字节:
char tmp = *((char*)p1 + i);
:读取p1
内存块的第i
个字节,存入临时变量tmp
(一个char
的大小正好是1字节)。*((char*)p1 + i) = *((char*)p2 + i);
:将p2
内存块的第i
个字节,拷贝到p1
内存块的第i
个字节。*((char*)p2 + i) = tmp;
:将临时保存的p1
的第i
个字节,拷贝到p2
的第i
个字节。
- 循环结束后,两个内存块的内容就完成了彻底的互换。(此时完成了一次任意类型的元素交换)
3.2 设计思路总结
- 抽象化:将数据类型抽象为
void*
和元素大小 - 回调机制:使用函数指针让调用者提供比较逻辑
- 字节操作:通过
char*
指针和字节偏移访问任意类型数据 - 通用交换:逐字节交换实现类型无关的数据交换
- 接口一致性:模仿标准库qsort的接口设计
这个设计过程展示了如何从具体到抽象,从特殊到一般的编程思维演进。通过将不变的部分(排序算法)与变化的部分(比较逻辑和数据类型)分离,我们创建了一个高度通用且灵活的函数。
本章内容到这里就告一段落了。在这一章中,我们学习了C 语言指针的重要应用 —— 回调函数机制,还通过亲手实现通用排序函数的实践,进一步巩固了这一核心概念。
或许我对知识点的讲解还有不够清晰、不够透彻的地方,但我的初衷始终是希望能真正带大家学会回调函数这种高级指针用法。所以,如果大家在理解过程中有任何疑问,或是觉得某个环节我没讲明白,都可以在评论区留言,我会逐一为大家解答。
下一章节再见哦!
- 抽象化:将数据类型抽象为
void*
和元素大小 - 回调机制:使用函数指针让调用者提供比较逻辑
- 字节操作:通过
char*
指针和字节偏移访问任意类型数据 - 通用交换:逐字节交换实现类型无关的数据交换
- 接口一致性:模仿标准库qsort的接口设计
这个设计过程展示了如何从具体到抽象,从特殊到一般的编程思维演进。通过将不变的部分(排序算法)与变化的部分(比较逻辑和数据类型)分离,我们创建了一个高度通用且灵活的函数。
结语
本章内容到这里就告一段落了。在这一章中,我们学习了C 语言指针的重要应用 —— 回调函数机制,还通过亲手实现通用排序函数的实践,进一步巩固了这一核心概念。
或许我对知识点的讲解还有不够清晰、不够透彻的地方,但我的初衷始终是希望能真正带大家学会回调函数这种高级指针用法。所以,如果大家在理解过程中有任何疑问,或是觉得某个环节我没讲明白,都可以在评论区留言,我会逐一为大家解答。
下一章节再见哦!