回调函数,相信大家多多少少都有听过,有些小伙伴已经深刻理解其精髓,并在项目中用得游刃有余,当然还有一些小伙伴,处于一知半解甚至完全懵逼的情况。到底什么是回调函数?和普通函数有啥区别?为啥要用回调函数?
什么是回调函数?
首先我们看下到底什么是回调函数。维基百科上对于回调(callback)有这么一段描述:
直接翻译如下:
在计算机编程中,回调是一个函数,其存储为一个数据(或引用),并且被设计用来被另一个函数调用,一般来说会调回到原始的抽象层。
解释得非常专业,但好像没看懂,更懵了!相信很多对回调函数一知半解的小伙伴都是被这种专业而晦涩的定义给劝退了,实际上我们换一种 “接地气” 说法,理解起来就非常简单了:
如果一个函数 A,被作为参数传入了函数 B,并且函数 B 在执行过程中又回过来调用了函数 A,那么这个函数 A 就是一个回调函数。
是不是很简单,我们再结合 C 语言的一个代码示例来理解:
#include <stdio.h>
void print_hello() {
printf("Hello, World!\n");
}
void execute_callback(void (*callback)()) {
callback();
}
int main() {
execute_callback(print_hello);
return 0;
}
上例中我们定义了一个 print_hello 函数,其作用是打印 “hello world”,接着我们又定义了一个 execute_callback 函数,其接受一个函数指针作为参数,并且在其运行的时候,又会通过这个函数指针调用其中存储的函数。最后我们在 main 中调用 execute_callback ,并将 print_hello 作为参数传入其中。此时,print_hello 就成为了一个回调函数。
和普通函数的区别?
如果 print_hello 作为一个普通函数,是什么样的?我们来看下:
#include <stdio.h>
void print_hello() {
printf("Hello, World!\n");
}
void execute_callback(void) {
print_hello();
}
int main() {
execute_callback();
return 0;
}
正如上面的代码所示,execute_callback 中直接调用了 print_hello,此时 print_hello 形式上就成为了一个普通函数。然而其实现的功能,和一开始的 “回调函数版本” 是完全相同的!
很明显,一个函数是不是回调函数与它本身的实现没有任何关系,而是与其被调用的形式有关。如果是在一个函数中以这个函数的参数形式被调用,那么就称之为回调函数,如果是在一个函数中直接通过函数名调用,那么就称之为普通函数。
为什么要用回调函数?
那么问题来了,既然使用普通函数也能实现同样的功能,那为什么要用回调函数,考虑到回调函数还涉及到函数指针这种相对高级一点的 C 语言概念,普通函数就非常直观,非常易于理解,那使用回调函数的意义在哪里?仅仅是因为它看起来很 “高级” 才用吗?
首先说结论,当然不仅仅是因为它高级才用,回调函数在特定的使用场合有普通函数无法替代的优势(注意此处不是说普通函数就无法实现对应的功能,而是指普通函数没有回调函数的那种优势)。
在详细解释之前我们需要明确一个很重要的点:
不存在完美的技术,只有在特定环境下最合适的技术。
对于我们上面的示例程序来说,整个执行逻辑很简单,运行过程也很简单,因此直接调用函数更为合适,反而如果用回调函数,就会显得画蛇添足。
那什么情况下需要回调函数呢?
假设我要实现对一个整形数组排序的功能,一开始要求按照升序来进行排列,那么此时代码可能如下所示:
void sort(int* array, int size) {
for (int i = 0; i < size - 1; ++i) {
for (int j = 0; j < size - 1 - i; ++j) {
if (array[j] > array[j + 1]) {
int temp = array[j];
array[j] = array[j + 1];
array[j + 1] = temp;
}
}
}
}
代码的实现逻辑无需详细介绍,在这种情况下,我们使用普通函数的调用方式完全没有任何问题:
#include <stdio.h>
// 通用的排序函数,使用回调函数进行比较
void sort(int* array, int size) {
for (int i = 0; i < size - 1; ++i) {
for (int j = 0; j < size - 1 - i; ++j) {
if (array[j] > array[j + 1]) {
int temp = array[j];
array[j] = array[j + 1];
array[j + 1] = temp;
}
}
}
}
// 打印数组
void print_array(int* array, int size) {
for (int i = 0; i < size; ++i) {
printf("%d ", array[i]);
}
printf("\n");
}
int main() {
int data[] = {3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5};
int size = sizeof(data) / sizeof(data[0]);
printf("Original array: ");
print_array(data, size);
sort(data, size); // 调用排序函数
printf("Sorted: ");
print_array(data, size);
return 0;
}
这时候甲方来新需求了,不仅需要升序排序,还需要加一个降序排序。此时按照普通函数的实现方式,你可能会这么实现:
#include <stdio.h>
// 升序排序函数
void sort_ascending(int* array, int size) {
for (int i = 0; i < size - 1; ++i) {
for (int j = 0; j < size - 1 - i; ++j) {
if (array[j] > array[j + 1]) {
int temp = array[j];
array[j] = array[j + 1];
array[j + 1] = temp;
}
}
}
}
// 降序排序函数
void sort_descending(int* array, int size) {
for (int i = 0; i < size - 1; ++i) {
for (int j = 0; j < size - 1 - i; ++j) {
if (array[j] < array[j + 1]) {
int temp = array[j];
array[j] = array[j + 1];
array[j + 1] = temp;
}
}
}
}
// 打印数组
void print_array(int* array, int size) {
for (int i = 0; i < size; ++i) {
printf("%d ", array[i]);
}
printf("\n");
}
int main() {
int data[] = {3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5};
int size = sizeof(data) / sizeof(data[0]);
printf("Original array: ");
print_array(data, size);
// 使用升序排序
sort_ascending(data, size);
printf("Sorted in ascending order: ");
print_array(data, size);
// 恢复原始数组顺序以便重新排序
int data2[] = {3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5};
// 使用降序排序
sort_descending(data2, size);
printf("Sorted in descending order: ");
print_array(data2, size);
return 0;
}
此时我们单独实现了一个升序排序函数 sort_ascending,又单独实现了一个降序排序函数 sort_descending。然后在对应需要执行排序操作的地方(main 函数中模拟)直接调用了这两个函数。
功能是实现了,但细心的小伙伴应该也能发现有点小问题:
-
sort_ascending 和 sort_descending 好像有很多相同的代码,感觉做了很多重复工作。如果后续又有新的排序逻辑,就又会增加其中的重复工作量。
-
基于上一点,一旦在某个排序逻辑运行的时候发现共用的代码有问题(这一点在一些复杂逻辑的实现过程中会比较常见),那么所有已实现的排序代码都要修改。
-
如果你是在写一个模块或者库供他人或自己使用,那么很显然每次增加排序逻辑都需要修改这个库本身的代码(在库添加对应的排序实现逻辑)。
对于上面的第一第二点,抽象意识强一点的小伙伴可能会说我可以将排序逻辑的共同点抽象出来,将所有的排序逻辑融合成一个函数,并通过参数方式,选择我对应需要哪种排序。这样不会存在重复工作,同时一旦出现问题也只需要改一个地方。
没毛病,但这种方式仍然需要你在外部需求增加的时候修改你那个融合后的函数,为那个融合后的函数增加对应的参数判断逻辑和具体的实现逻辑。这种方式没有办法完美解决第三个问题点。
如果是嵌入式工程师,你应该觉得上面的这个场景似曾相识 —— 业务逻辑需要调用到硬件接口的时候。如果你的业务逻辑中直接调用了硬件接口函数,如串口发送等,那么一旦要对接的硬件接口发生了变化,如变成了 SPI 接口,或是网口,再退一步说同样是串口,但对接的芯片又发生了变化,如公司为了降本,使用了更便宜的芯片,而其串口操作接口又和原来的不一样,那很不幸,所有直接调用的地方都需要进行修改!
这,就是耦合。
首先肯定,上面这种实现方式在功能实现层面上来说完全没有任何问题,但,就是很麻烦,很容易漏改、很容易出错、甚至在过了几个月后,如果需要修改,大概率你自己也没有把握一下说出具体要改哪些地方!
为了解决这个问题,就要解耦。让各个层级相互独立,哪个层级出现了变化,就仅仅只改这个层级即可。
而回调函数,在我认为最大的优势就在于此!就是能够解耦,这一点,是普通函数形式无法实现的。
还是以上面的排序程序为例,我们想实现一个库,但目前普通函数形式会导致外部每次新增排序逻辑都需要我们修改库代码,在其中加入新的排序逻辑,而我们希望排序逻辑的新增或修改不要让我们改库函数。
这时候就要请出回调函数了。
既然我们不希望排序逻辑的更改或增加会影响到库代码的实现,那就将其中会变的部分抽出来,让回调函数去实现,本例中会变的部分就是两个数哪个在前哪个在后的判断逻辑。将这部分作为回调函数,此时由于回调函数是用户去传入的,因此其逻辑的实现也就脱离了库本身,由用户去根据实际应用场景实现,这样同样一个库,用户实现了什么样的判断逻辑,最终就能达成什么样的排序结果。我们还是以升序和降序逻辑的实现为例,看一下回调函数版本的实现:
#include <stdio.h>
// 比较函数类型定义
typedef int (*CompareFunc)(int, int);
// 排序库函数,使用回调函数进行比较
void sort(int* array, int size, CompareFunc compare) {
for (int i = 0; i < size - 1; ++i) {
for (int j = 0; j < size - 1 - i; ++j) {
if (compare(array[j], array[j + 1]) > 0) {
int temp = array[j];
array[j] = array[j + 1];
array[j + 1] = temp;
}
}
}
}
// 打印数组
void print_array(int* array, int size) {
for (int i = 0; i < size; ++i) {
printf("%d ", array[i]);
}
printf("\n");
}
// 升序比较函数,用户实现
int ascending(int a, int b) {
return a - b;
}
// 降序比较函数,用户实现
int descending(int a, int b) {
return b - a;
}
int main() {
int data[] = {3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5};
int size = sizeof(data) / sizeof(data[0]);
printf("Original array: ");
print_array(data, size);
// 使用升序排序
sort(data, size, ascending);
printf("Sorted in ascending order: ");
print_array(data, size);
// 使用降序排序
sort(data, size, descending);
printf("Sorted in descending order: ");
print_array(data, size);
return 0;
}
上面的代码中,sort 代表的就是排序库的核心函数,这部分是不会变的,而其接受的 compare 参数就代表用户需要实现的回调函数,库核心使用 compare 来进行数值的比较,根据 compare 的不同,其最终排序的结果也不同,并且不存在逻辑的上限,无论是正常的升降序,还是用户特定的排序逻辑,只要作为回调函数传入,库就能给你处理。正如案例中实现的 ascending 和 descending 一样,这两个函数就是在用户层实现,并传入库中,从而在无需修改 sort 中任意一行代码的情况下实现了数组的升序和降序排列。
实际上,根据上述代码,你也可以理解为回调函数将小部分会变的逻辑转嫁到其他层(此处为用户所代表的应用层)去实现,库自身实现大部分不会变的核心逻辑,通过回调函数将这两部分粘合起来。虽然应用层写的代码变多了,但由于同样一个库代码能在无需修改或增加代码的情况下来适应更多场景,实际整体代码量是精简了,并且应用层也仅仅需要实现与其逻辑相关的代码,而无需关注实现这个功能的完整逻辑,这样一来用户在使用这个库的时候也会变得非常灵活,其可以仅根据自己的需求来实现自己想要的功能,而不用了解与需求无关的内部实现逻辑,可维护性也大大增强。这种优势无论是对于库的实现还是应用实现来说都是普通函数无法替代的。
这也正是使用回调函数的最大意义!