C生万物 | 剖析函数指针经典应用 —— 回调函数
不懂函数指针的老铁可以先看看这篇文章【指针函数与函数指针】,上车,准备出发:car:
@TOC
一、回调函数的概念
回调函数就是一个通过【函数指针】调用的函数。如果你把函数的指针(地址)作为参数传递给另一个函数,当这个指针被用来调用其所指向的函数时,我们就说这是回调函数。回调函数不是由该函数的实现方直接调用,而是在特定的事件或条件发生时由另外的一方调用的,用于对该事件或条件进行响应。
二、为什么要使用回调函数?
👉最大的一个目的,就是为了实现:解耦!
-
在主入口程序中,把回调函数像参数一样传入库函数。这样一来,只要我们改变传进库函数的参数,就可以实现不同的功能,且不需要修改库函数的实现,变的很灵活,这就是解耦
-
主函数和回调函数是在同一层的,而库函数在另外一层。如果库函数对我们不可见,我们修改不了库函数的实现,也就是说不能通过修改库函数让库函数调用普通函数那样实现,那我们就只能通过传入不同的回调函数了,这也就是在日常工作中常见的情况
注:使用回调函数会有间接调用,因此,会有一些额外的传参与访存开销,对于MCU代码中对时间要求较高的代码要慎用
三、回调函数使用场景
场景一:模拟计算器的加减乘除
- 在函数指针章节,我有介绍了如何使用【函数指针数组】去模拟计算器的加减乘除,现在我们使用回调函数来试试
==功能与菜单==
int Add(int x, int y)
{
return x + y;
}
int Sub(int x, int y)
{
return x - y;
}
int Mul(int x, int y)
{
return x * y;
}
int Div(int x, int y)
{
return x / y;
}
void menu()
{
printf("**************************\n");
printf("***** 1.Add 2.Sub *****\n");
printf("***** 3.Mul 4.Div *****\n");
printf("***** 5.Cls 0.Exit*****\n");
printf("**************************\n");
}
==主程序与回调函数==
void calc(int (*p)(int, int))
{
int x = 0, y = 0;
printf("请输入两个运算数:>");
scanf("%d %d", &x, &y);
int ret = p(x, y);
printf("结果为:%d\n", ret);
}
int main(void)
{
int input = 0;
do {
menu();
printf("请输入你的选择:>");
scanf("%d", &input);
switch (input)
{
case 1:
calc(Add);
break;
case 2:
calc(Sub);
break;
case 3:
calc(Mul);
break;
case 4:
calc(Div);
break;
case 5:
system("cls");
break;
case 0:
break;
default:
printf("请输入正确的内容:\n");
break;
}
} while (input);
return 0;
}
通过画图来看一下是如何通过函数指针来实现的回调
- [x] 可以看出,回调函数它不会自己调用,而是将自己的函数名传递给到另一个函数(此处的Add和Sub即为回调函数),然后在这个函数内部通过函数指针去调用这个函数。就是这样函数指针会接收来自不同函数的地址,继而实现计算器的加、减、乘、除各种功能
场景二:模拟qsort函数【⭐】
学习过数据结构的同学一定接触过【快速排序】,即QuickSort。不了解的可以看看 数据结构 | 十大排序超硬核八万字详解
1、qsort函数解读
- 在C语言中,也有一个关于快速排序的库函数,叫做qsort,来看一下官方文档是怎么说的
- 清楚了这个函数的基本作用后,那最想知道的就是它如何使用,既然是函数的话就需要传递参数,给个特写📷
base
—— 待排序元素的起始地址,类型为【void】表示可以传递任何类型的数组num
—— 表示待排序数据的元素个数size
—— 表示数组中每个元素所占的字节数int (*compar)(const void*, const void*)
—— 函数指针,用于接收回调函数
2、用用qsort
💬首先我们用它来排下整型数组试试
cmp_int(const void* e1, const void* e2)
{
return *(int*)e1 - *(int*)e2;
}
void test1()
{
int arr[10] = { 2,3,6,7,5,1,4,9,10,8 };
int sz = sizeof(arr) / sizeof(arr[0]);
printarray(arr, sz);
qsort(arr, sz, sizeof(arr[0]), cmp_int);
printarray(arr, sz);
}
运行结果:
解析:
cmp_int(const void* e1, const void* e2)
{
return *(int*)e1 - *(int*)e2;
}
- 主要来讲一下这个函数,这就是本文要讲解的回调函数,为什么它的形参是一个
void*
的指针呢?这种类型的指针一般被我们称作为【垃圾桶】,那垃圾桶我们平常都在用,不考虑垃圾分类的话,可以接收任何种类的垃圾,那么在这里就是==可以接收任何类型的数据==,即整型、字符型、浮点型,甚至是自定义类型它都可以接受 - 但是呢我们在使用的时候还是要去进行一个转换,此处就要使用到【强制类型转换】,将其转换为
int *
的指针,那么它就指向了我们要待排序的数组。但是要怎么比较和交换两个数据呢,这就要看qsort()函数内部的实现了,它是基于快速排序的思想,如果你懂快速排序的话,脑海里立马就能浮现出它们的比较的场景 - 还是来看一下官方文档,其实下面的这种比较思路很常见,像字符串函数
[strcmp]
也是这样的:- 前一个比后一个小,返回
-1
- 前一个和后一个相等返回,返回
0
- 前一个比后一个大,返回
1
- 前一个比后一个小,返回
当然,除了上面这种内置类型外,自定义类型的数据也是可以比较的,接下去我们来比较一下两个学生的信息
- 下面是结构体的初始化和定义,以及qsort函数的调用
typedef struct stu {
char name[20];
int age;
}stu;
void test2()
{
stu ss[3] = { {"zhangsan", 22}, {"lisi", 55}, {"wangwu", 33} };
qsort(ss, 3, sizeof(ss[0]), cmp_byname);
//qsort(ss, 3, sizeof(ss[0]), cmp_byage);
}
- 下面是两个回调函数的实现,在看了第一个后相信你已经很熟悉了,形参还是
void*
类型的指针,但是在比较的时候要转换为结构体指针,否则就无法访问到成员了。对于【姓名】的比较是按照首字母的ASCLL码值来的,这里我们直接使用库函数strcmp
即可,比较的规则和qsort()是一致的
Cmp_ByName(const void* e1, const void* e2)
{
return strcmp(((stu*)e1)->name, ((stu*)e2)->name);
}
Cmp_ByAge(const void* e1, const void* e2)
{
return ((stu*)e1)->age - ((stu*)e2)->age;
}
首先来看按照名字排序的结果
然后是按照年龄排序的结果
3、使用冒泡排序模拟qsort
- 普通的冒泡排序的话相信是个大学生应该都会写,这里就不解释了,如果不会的话看看我的排序文章
void BubbleSort(int* a, int n)
{
for (int i = 0; i < n - 1; ++i)
{
for (int j = 0; j < n - 1 - i; ++j)
{
if (a[j] > a[j + 1])
{
int t = a[j];
a[j] = a[j + 1];
a[j + 1] = t;
}
}
}
}
但此时我若是要用这个冒泡排序去排任意类型的数据呢?该如何进行修改
- 此时就需要使用到刚才所学习的
qsort()
函数了。我们可以仿照着它的参数来写写看
void bubblesort(void* base, int num, int sz, int(*cmp)(const void* e1, const void* e2))
- 既然参数做了,那么函数体内部我们也需要做一个大改动。例如对数组中的两个数据进行比较的时候,就不能单纯地使用关系运算符
>
、>
、==
了,此处函数指针就派上了用场,我们可是使用函数指针去接收不同的回调函数,继而去实现不同的类型数据的比较,也就是上面所写的Cmp_int
、Cmp_ByName
、Cmp_ByAge
- 而且对于内部的交换逻辑我们也要单独去实现,不同数据的交换方式是不一样的
那现在,我们就来实现一下上面说到的这两块内部逻辑
- 首先就是
j
和j + 1
这两个位置上的值要如何进行比较的问题,那既然base指向首元素地址,那有同学说不妨让它进行偏移,但是它的类型是void*
,虽然这种类型的指针可以接收各种各样的数据地址, 但是却无法进行偏移,因为它也不知道要偏移多少字节,所以我上面在回调函数内部对两个形参进行了强转才可以进行比较
- 我们知道,对于
char
类型的字符,在内存中只占有1个字节的大小,那么char*
的指针每次后移便会偏移一个字节,那既然在形参我们传入了数组中每个元素在内存中所占字节数的话,就可以使用起来了,和char*
的指针去做一个配合
- 所以两数比较的逻辑就可以写成下面这样
//判断两数是否需要交换
if (cmp((char*)base + j * sz, (char*)base + (j + 1) * sz) > 0)
{
//两数据交换的逻辑
}
接下去就来实现两数交换的逻辑
- 因为我们是使用的
char*
指针一个字节一个字节去访问数据的,所以交换的时候也需要按照字节来交换。单独封装一个Swap()函数,把要交换两个数的地址和单个数据所占的字节数传入
声明:
void Swap(char* buf1, char* buf2, int sz)
调用:
Swap((char*)base + j * sz, (char*)base + (j + 1) * sz, sz);
内部逻辑就是单个数据的交换【记住,这只是单个数据,所以循环sz次】
void Swap(char* buf1, char* buf2, int sz)
{
//两个数据按照字节一一交换
for (int i = 0; i < sz; ++i)
{
int t = *buf1;
*buf1 = *buf2;
*buf2 = t;
buf1++;
buf2++;
}
}
具体交换细节可以看下图
测试一下:
- 可以看到,整数类型的数据排序成功了
- 再看看内置类型
4、原理分析
仔细看一下这张图,你就清楚整个调用过程了
场景三:模拟文件下载模块
我们为什么要用回调函数呢?
记得在一次C++开发面试的时候被被一位主面官问到过这个问题,现在再回答一遍。
-
我们对回调函数的使用无非是对函数指针的应用,函数指针的概念本身很简单,但是把函数指针应用于回调函数就体现了一种解决问题的策略,一种设计系统的思想。
-
在解释这种思想前我想先说明一下,回调函数固然能解决一部分系统架构问题但是绝不能再系统内到处都是,如果你发现你的系统内到处都是回调函数,那么你一定要重构你的系统。回调函数本身是一种破坏系统结构的设计思路,回调函数会绝对的变化系统的运行轨迹,执行顺序,调用顺序。回调函数的出现会让读到你的代码的人非常的懵头转向。
-
那么什么是回调函数呢,那是不得以而为之的设计策略,想象一种系统实现:在一个下载系统中有一个文件下载模块和一个下载文件当前进度显示模块,系统要求实时的显示文件的下载进度,想想很简单在面向对象的世界里无非是实现两个类而已。但是问题恰恰出在这里,显示模块如何驱动下载进度条?显示模块不知道也不应该知道下载模块所知道的文件下载进度(面向对象设计的封装性,模块间要解耦,模块内要内聚),文件下载进度是只有下载模块才知道的事情,解决方案很简单给下载模块传递一个函数指针作为回调函数驱动显示模块的显示进度。
下面是模拟实现这个文件下载模块的代码,仅供参考
#include <iostream>
#include <random>
#include <ctime>
typedef void(*on_process_callback)(std::string data);
//处理完成的回调
void on_process_result(std::string data)
{
//根据返回消息进行处理
std::cout << data.c_str() << std::endl;
};
class TaskProcessing
{
public:
TaskProcessing(on_process_callback callback) : _callback(callback)
{};
void set_callback(on_process_callback callback)
{
_callback = callback;
};
void do_task()
{
//当文件传输完成
if (_callback)
{
srand((int)time(NULL));
if (rand() & 1)
{
(*_callback)(std::string("ftp succeed"));
}
else
{
(*_callback)(std::string("ftp failed"));
}
}
};
private:
on_process_callback _callback;
};
int main()
{
TaskProcessing* process = new TaskProcessing(on_process_result);
process->do_task();
system("pause");
}
四、语言对比
在看这个回调函数的时候,我也联想到了JS和C#中似乎也有类似的身影,这里对比分析一下
1、JavaScript回调函数
- 在JavaScrip中, function 是内置的类对象,也就是说它是一种类型的对象,可以和其它String、Array、Number、Object类的对象一样用于内置对象的管理。因为function实际上是一种对象,它可以“存储在变量中,通过参数传递给(别一个)函数(function),在函数内部创建,从函数中返回结果值”。
- 因为function是内置对象,我们可以将它作为参数传递给另一个函数,延迟到函数中执行,甚至执行后将它返回。这是在JavaScript中使用回调函数的精髓
例如在下面,有一个add函数,通过外界传入要运算的两个操作符以及一个回调函数的地址,就可以起到在add函数内部去调用
print()
函数的作用
- 可以看到我传递了
print
作为add()函数的形参,其为函数名,函数名即为函数的地址,此时add函数内部就获取到printf()函数的地址那便可以通过一定的条件去调用这个函数
<script>
function add(num1, num2, callback) {
var sum = num1 + num2;
callback(sum);
}
function print(num) {
console.log(num);
}
add(1, 2, print); //3
</script>
2、C#委托
如果有学习过C#的同学,说到【回调函数】的话,应该可以很快联想到委托,真的是异曲同工之妙
不清楚的同学可以先看看这个视频,讲得还可以
[video(video-iUzHqnNf-1681127177320)(type-bilibili)(url-https://player.bilibili.com/player.html?aid=651765386)(image-https://img-blog.csdnimg.cn/img_convert/71c8770f8bb222a2be0a483b024e224d.jpeg)(title-C#基础教程 delegate 帮你理解委托,知道委托的好处, 不懂委托一定要看下!)]
- C#里面有命名方法委托、多播委托、匿名委托,这里举一个简单点的小例子
class Program
{
public delegate void MyDelegate();
static void Main(string[] args)
{
MyDelegate myDelegate = new MyDelegate(new Test().SayHello);
myDelegate();
}
}
class Test
{
public void SayHello()
{
Console.WriteLine("Hello Delegate!");
}
}
💬 举了两个小小的例子,为了让读者了解到除了C语言其实还有其他语言中也有【回调函数】的声音,了解到什么叫做 ⇒ C生万物
五、总结与提炼
好,最后来总结一下本文所学习的内容:book:
- 在本文中,我们重点讲解了什么叫做【回调函数】,以及为什么要使用【回调函数】,它有什么用途?清楚了基本的概念后我们就去真正地接触了这个回调函数,模拟实现了三种回调函数的应用场景,分别是计算器的加减乘除、qsort函数、还有文件下载模块,其中==qsort函数的模拟实现==是我们本文的重点所在
- 除了C语言里面有回调函数之外,其实其他语言里面也存在这个东西,像JS中的回调函数、C#中的委托,如果有兴趣的老铁可以再去研究研究,回调函数这个东西在有些场景确实很管用
以上就是本文要介绍的所有内容,感谢您的阅读:rose:
- 点赞
- 收藏
- 关注作者
评论(0)