3、两道“有趣”的代码题O(∩_∩)O
通过函数指针的学习,我们来看看下面两道很有趣的代码
下面两题均来自《C陷阱与缺陷》
< 第一题 >
代码:
(*(void (*)())0)();
解析:
💬如果你是头一次看上面这段代码的话,心里一定是一个大大的问号???现在我就来解释一下
- 本题的突破口在于这个0,仔细观察可以发现,0前面有一个括号
()
,括号里面的这种形式若是你自己去看的话就是一个函数指针,那相当于就是对0进行一个强制类型转换,把它变成一个函数地址,然后前面的*
我们刚才讲过,就是对这个函数进行解引用,获取到这个函数。那么最后一步便是去调用这个函数
具体的分解可以看看下图👇
分步细说:
void (*)()
—— 》一个没有形参,返回类型为void的函数指针
(void (*)())0
——》 对0进行强制类型转换,使其被解释成为一个函数的地址
*(void (*)())0
——》对0地址处的函数进行解引用,获取到这个函数
(*(void (*)())0)()
——》调用0地址处的函数
原文现身:
< 第二题 >
代码:
void (*signal(int, void(*)(int)))(int);
解析:
💬同理,若是第一次见一定会被它绕晕了😵了
- 本题真的可以说是在套娃了,首先你看到的一定是
signal
,它呢是C语言中的一个信号函数,有兴趣可以去了解一下,我们知道()
的优先级高于*
,所以signal会和后面的内容先结合,那其实已经可以看出这是一个函数声明了。进到里面再来看看这个函数有两个参数,一个是int
,一个是函数指针
,那么外层的又是什么呢? - 仔细看下图,我将内部的signal()函数声明抽离了出来,只剩下了头和尾,你可以做一个视觉上的合并,那其实又是一个
void (*)(int)
的函数指针,其实这就是signal函数的返回类型,是一个函数指针
同样地,我们再来捋一遍
分步细说:
void (*)(int)
—— 》是一个函数指针,为signal函数的形参
signal(int, void(*)(int))
——》 是一个函数声明,signal与右侧的()
率先结合,内部有两个形参
void (*)(int)
——》也是一个函数指针,不过是作为signal函数返回类型
优化:
对于上面的这种写法你是否觉得很冗余,其实可以再度进行一个优化,那么你可能很快就看得懂了
- 因为
void (*)(int)
是出现了两次,之前我们在C语言中有学习过typedef
这个关键字,可以用来对一个很长的数据类型或者变量进行重命名,那么在这里我们也可以这样做 - 不过呢,你要把重命名后的名字放在
(*)
里面,因为语法这么规定了,去掉变量名后就是它的类型
typedef void(*ptr_t)(int);
- 于是这句代码就可以简化为下面这种形式👇注意解引用那个
*
不要了,函数指针这里是可以省略的
//void (*signal(int, void(*)(int)))(int); ptr_t signal(int, ptr_t);
原文现身:
4、函数指针数组
指针可以存放在一个数组中,那函数指针可以吗?来看看【函数指针数组】吧
概念明细
- 还记得我们学习完【数组指针】后的这道练习题吗,最后我们判定它的类型为数组指针数组,它是一个数组,里面存放的都是数组指针
int (*parr3[10])[5];
- 那对于函数指针来说,和这个其实存在异曲同工之妙,只需要把后面的
[]
改为()
即可,当然你也可以改个名字
int (*pfArr[10]();
- 再来对比我们前面学习过的【函数指针】,你有发现区别在哪吗?没错,就是多了个
[10]
,因为[]
的优先级较高,所以pArr会和它先结合,那其实就可以肯定它为一个数组了
int (*pfArr)();
声明知道了,那具体怎么使用呢?怎么去接收多个函数的地址呢?再来看看
int Add(int x, int y) { return x + y; } int Sub(int x, int y) { return x - y; } int main(void) { int (* pfArr[2])(int, int) = {Add, Sub}; int ret1 = pfArr[0](5, 3); int ret2 = pfArr[1](5, 3); printf("ret1 = %d\n", ret1); printf("ret2 = %d\n", ret2); return 0; }
- 很简单,上面有Add和Sub两个加与减的函数,那将它们存放到一个数组中,首先用花括号把它们括起来
{Add, Sub}
,然后还是和函数指针一样的声明,只需要在指针变量后加上一个[2]
即可,那么这就是一个【函数指针数组】 - 接着去调用的话其实和要结合函数和数组的调用形式,既要控制数组的下标,还要考虑调用函数时传入相应的参数,如下所示👇
对于【函数指针数组】,我想你应该感受到了它的强大,竟然可以存放多个数组的地址然后根据不同的下标索引找到不同的函数进行调用,如果使用得当,那一定可以事半而功倍
具体引用:转移表✔
对于函数指针数组而言,有一个很经典的应用就是转移表,简单来说就是计算器
- 首先我使用分支循环实现了简易的功能计算,代码如下
void menu() { printf("**************************\n"); printf("***** 1.Add 2.Sub *****\n"); printf("***** 3.Mul 4.Div *****\n"); printf("***** 5.Cls 0.Exit*****\n"); printf("**************************\n"); } int main(void) { int input = 0; int x = 0, y = 0; int ret = 0; do { menu(); printf("请输入你的选择:>"); scanf("%d", &input); switch (input) { case 1: printf("请输入两个运算数:>"); scanf("%d %d", &x, &y); ret = Add(x, y); printf("结果为:%d\n", ret); break; case 2: printf("请输入两个运算数:>"); scanf("%d %d", &x, &y); ret = Sub(x, y); printf("结果为:%d\n", ret); break; case 3: printf("请输入两个运算数:>"); scanf("%d %d", &x, &y); ret = Mul(x, y); printf("结果为:%d\n", ret); break; case 4: printf("请输入两个运算数:>"); scanf("%d %d", &x, &y); ret = Div(x, y); printf("结果为:%d\n", ret); break; case 5: system("cls"); break; case 0: break; default: printf("请输入正确的内容:"); break; } } while (input); return 0; }
但是仔细观察可以发现,每一条case
语句中,都有重复的工作,就显得很冗余,为什么每个case里都要放一个输入呢,这是我后来发现的问题,若是把这个输入放在外面的话,就会造成按下0想要退出的时候还会出现输入运算数的情况,因为这是处于一个do...while的循环之中
但是此处我若是利用函数指针数组的话,就会很方便了
- 函数声明如下,将这四个加、减、乘、除的函数的地址放到数组中存起来,通过下标的方式来进行访问
int (*pfArr[5])(int, int) = {0, Add, Sub, Mul, Div};
- 于是内部的逻辑就可以写成下面这样,通过去判断输入的
input
来实现不同的功能,只有当input >= 1 && input <= 4
时,才进行运算,此时把输入操作符的逻辑放在这里即可,便不会影响其他功能了
do { menu(); printf("请输入你的选择:>"); scanf("%d", &input); int (*pfArr[5])(int, int) = {0, Add, Sub, Mul, Div}; if (input == 0) { break; } if (input >= 1 && input <= 4){ printf("请输入两个运算数:>"); scanf("%d %d", &x, &y); int ret = pfArr[input](x, y); printf("结果为:%d\n", ret); } else if (input == 5) { system("cls"); } else { printf("输入有误,请重新输入\n"); } } while (input);
5、指向函数指针数组的指针
学习了函数指针数组后,你是否有联想到取出这个数组的地址再存放到指针里去呢?这不,它来了
- 仿照前面的写法,若现在要是一个指针的话,那你应该想要又需要
*
和()
了,因为存在优先级的问题,指针变量会和[]
相结合,所以我们可以取出函数指针数组的地址,给到一个指针作为接收,这个指针即为ptr
- 分解着来细说一下,首先说明一下,有些同学直接拿函数指针的
*
作为指针符,这是不对的, 那是用来对函数指针所指向函数的地址进行解引用的,可不能混淆,所以我们要另外再加一个*
,与ptr进行结合 - 那么此时ptr就一定是一个指针,然后朝外一看有一个数组,那它便指向一个数组,这个数组的有5个元素,每个元素的类型我们只需要拿到数组名即
(*ptr)[5]
即可,便发现里面存放的都是函数指针。这么分析下来这个【ptr】确实是一个==指向函数指针数组的指针==
再来看一组练习巩固一下
- 【pfun】是一个指针,它指向一个形参类型为
const char*
,返回类型为void
的函数 - 【pfunArr】是一个数组,数组大小为5,里面存放的均是指向指向一个形参类型为
const char*
,返回类型为void
的函数指针 - 【ppfunArr】是一个指针,它指向一个数组,数组里面的都是函数指针。。。同上
void test(const char* str) { printf("%s\n", str); } int main() { //函数指针pfun void (*pfun)(const char*) = test; //函数指针的数组pfunArr void (*pfunArr[5])(const char* str); pfunArr[0] = test; //指向函数指针数组pfunArr的指针ppfunArr void (*(*ppfunArr)[5])(const char*) = &pfunArr; return 0; }
研究到这块就可以了,如果上面的这些你全搞懂了的话,那么指针这一块相当于学得还可以了,不过缺乏实战, 【炼狱篇】会有大量的实战,虽然题量很多而言很难,但这是提升自己最好的机会!
五、回调函数
1、回调函数的概念
回调函数就是一个通过【函数指针】调用的函数。如果你把函数的指针(
地址
)作为参数传递给另一个函数,当这个指针被用来调用其所指向的函数时,我们就说这是回调函数。回调函数不是由该函数的实现方直接调用,而是在特定的事件或条件发生时由另外的一方调用的,用于对该事件或条件进行响应。
2、为什么要使用回调函数?
👉最大的一个目的,就是为了实现:解耦!
- 在主入口程序中,把回调函数像参数一样传入库函数。这样一来,只要我们改变传进库函数的参数,就可以实现不同的功能,且不需要修改库函数的实现,变的很灵活,这就是解耦
- 主函数和回调函数是在同一层的,而库函数在另外一层。如果库函数对我们不可见,我们修改不了库函数的实现,也就是说不能通过修改库函数让库函数调用普通函数那样实现,那我们就只能通过传入不同的回调函数了,这也就是在日常工作中常见的情况
注:使用回调函数会有间接调用,因此,会有一些额外的传参与访存开销,对于MCU代码中对时间要求较高的代码要慎用
3、回调函数使用场景
场景一:模拟计算器的加减乘除
- 在函数指针章节,我有介绍了如何使用【函数指针数组】去模拟计算器的加减乘除,现在我们使用回调函数来试试
==功能与菜单==
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; }
通过画图来看一下是如何通过函数指针来实现的回调
- 可以看出,回调函数它不会自己调用,而是将自己的函数名传递给到另一个函数(此处的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、原理分析
仔细看一下这张图,你就清楚整个调用过程了
场景三:模拟任务下载进度
本代码来自我的Linux基础入门篇之进度条小程序,也很好地展现了回调函数的魅力之所在:rose:
==processBar.h==
1 #include <stdio.h> 2 #include <unistd.h> 3 #include <string.h> 4 5 #define TOP 100 6 #define BODY '=' 7 #define RIGHT '>' 8 9 extern void processBar(int rate); 10 extern void initBar();
==processBar.c==
1 #include "processBar.h" 2 3 const char* label = "|/-\\"; 4 char bar[TOP]; 5 6 void initBar() 7 { 8 memset(bar, '\0', sizeof(bar)); 9 } 10 11 // 单次的进度推进 12 void processBar(int rate) 13 { 14 if(rate < 0 || rate > 100) return; 15 16 int len = strlen(label); 17 printf("[%-100s][%d%%][%c]\r", bar, rate, label[rate % len]); 18 fflush(stdout); // 刷新缓冲区 19 bar[rate++] = BODY; 20 if(rate < 100) 21 bar[rate] = RIGHT; 22 else 23 initBar(); 24 }
==main.c==
1 #include "processBar.h" 2 3 // 函数指针类型 4 typedef void (callback_t)(int rate); 5 6 // 模拟一种安装或下载的场景(回调函数) 7 void downLoad(callback_t cb) 8 { 9 int total = 1000; // 1000B 10 int curr = 0; // 0B 11 12 while(curr <= total) 13 { 14 /* 进行某种下载任务 */ 15 usleep(50000); // 模拟下载时间 16 17 // 计算下载速率 18 int rate = curr * 100 / total; 19 cb(rate); // 通过函数指针去调用对应的函数 20 21 // 循环下载了一部分 22 curr += 10; 23 } 24 printf("\n"); 25 } 26 27 int main(void) 28 { 29 // 将所需要的调用的函数地址传递给回调函数 30 printf("download 1:\n"); 31 downLoad(processBar); 32 33 printf("download 2:\n"); 34 downLoad(processBar); 35 36 printf("download 3:\n"); 37 downLoad(processBar); 38 }
场景四:模拟文件下载模块
我们为什么要用回调函数呢?
记得在一次C++开发面试的时候被被一位主面官问到过这个问题,现在再回答一遍。
- 我们对回调函数的使用无非是对函数指针的应用,函数指针的概念本身很简单,但是把函数指针应用于回调函数就体现了一种解决问题的策略,一种设计系统的思想。
- 在解释这种思想前我想先说明一下,回调函数固然能解决一部分系统架构问题但是绝不能再系统内到处都是,如果你发现你的系统内到处都是回调函数,那么你一定要重构你的系统。回调函数本身是一种破坏系统结构的设计思路,回调函数会绝对的变化系统的运行轨迹,执行顺序,调用顺序。回调函数的出现会让读到你的代码的人非常的懵头转向。
- 那么什么是回调函数呢,那是不得以而为之的设计策略,想象一种系统实现:在一个下载系统中有一个文件下载模块和一个下载文件当前进度显示模块,系统要求实时的显示文件的下载进度,想想很简单在面向对象的世界里无非是实现两个类而已。但是问题恰恰出在这里,显示模块如何驱动下载进度条?显示模块不知道也不应该知道下载模块所知道的文件下载进度(面向对象设计的封装性,模块间要解耦,模块内要内聚),文件下载进度是只有下载模块才知道的事情,解决方案很简单给下载模块传递一个函数指针作为回调函数驱动显示模块的显示进度。
下面是模拟实现这个文件下载模块的代码,仅供参考【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"); }