回调函数(Callback)
一、一句话定义
回调函数是"你写好、但不由你调用,而是交给别人在合适时机调用"的函数。
它的关键不在"函数"三个字,而在调用权的转移:你把函数的地址(或可调用对象)作为参数交给另一个模块,双方约定"当某件事发生时,请调用这个函数"。
二、和普通调用的区别
| 普通调用 | 回调 | |
|---|---|---|
| 谁发起调用 | 你的代码 | 被调用的那个模块(框架/库/系统) |
| 调用时机 | 你写在哪一行就何时执行 | 由外部事件决定,你无法预知 |
| 数据流方向 | 你的代码 → 库 | 库 → 你的代码 |
| 控制权 | 始终在你手里 | 交给框架,发生"控制反转" |
普通调用是"我调库":
c
int n = strlen("hello");
回调是"库调我":
c
int cmp(const void *a, const void *b) { ... }
qsort(arr, 10, sizeof(int), cmp); // cmp 由 qsort 在排序过程中反复调用
qsort 完全不需要知道你要比较什么类型、按什么规则排序,它只负责排序算法本身,比较规则由你通过回调注入。这就是回调最原始、最典型的价值。
三、生活中的类比
- 订闹钟:你设好"7 点叫我",然后去睡觉。叫醒动作不是你执行的,是闹钟在条件满足时替你执行。你设置的就是一个回调。
- 点外卖:你留下地址和电话,餐做好了商家/骑手会主动联系你,而不是你一直打电话问"好了吗"。留联系方式 = 注册回调,被联系 = 回调被触发。
- 医院挂号后等叫号:你不需要每隔几分钟去问护士,系统到号会喊你。
共同点:你留下"到时候要做的事",然后就不管了,由对方在正确时机执行。
四、C 语言里的回调:函数指针
C 没有闭包和 lambda,回调就是函数指针。
c
/* 定义回调类型:接收 int,返回 void */
typedef void (*event_handler_t)(int event_id);
/* 库函数:只负责"何时调用",不关心"调用后干什么" */
void run_event_loop(event_handler_t handler, int loops)
{
for (int i = 0; i < loops; ++i) {
/* 模拟事件发生 */
handler(i); /* 反向调用你注册的函数 */
}
}
/* 你写的回调 */
void on_event(int event_id)
{
printf("event %d happened\n", event_id);
}
int main(void)
{
run_event_loop(on_event, 3); /* 注册,把函数地址交出去 */
return 0;
}
系统 API 里回调无处不在:
pthread_create(&tid, NULL, start_routine, arg):start_routine是新线程的回调,pthread 库负责起线程,然后调用它;signal(SIGINT, handler):信号到达时由内核回调你的 handler;atexit(cleanup):进程退出前由运行时回调;qsort/bsearch的比较函数。
五、需要携带上下文时:C 的 void* 惯用法
C 的函数指针无法携带额外数据,标准做法是给回调加一个 void *userdata 参数,把上下文"塞"进去:
c
typedef void (*handler_t)(int event_id, void *userdata);
void run_loop(handler_t handler, void *userdata)
{
for (int i = 0; i < 3; ++i)
handler(i, userdata);
}
struct Context {
const char *name;
int count;
};
void on_event(int id, void *userdata)
{
struct Context *ctx = (struct Context *)userdata;
ctx->count++;
printf("[%s] event %d, total %d\n", ctx->name, id, ctx->count);
}
int main(void)
{
struct Context ctx = { "worker", 0 };
run_loop(on_event, &ctx);
return 0;
}
这个 void *userdata 模式在 C 库(如 libevent、SQLite、各种驱动框架)里极其常见。C++ 的 std::bind / lambda 捕获,本质上就是在语言层面替你做这件事。
六、C++ 里的回调:四种写法
1. 函数指针(最简单,无状态)
cpp
void onTick(int n) { std::cout << "tick " << n << "\n"; }
void run(void (*cb)(int)) { cb(1); }
run(onTick);
2. 成员函数指针 + 对象(回到 C 风格,需要手动带 this)
cpp
class Timer {
public:
void onTick(int n) { std::cout << "Timer::onTick " << n << "\n"; }
};
成员函数不能像普通函数那样直接取地址调用,必须同时知道"哪个对象",所以实际工程里会配合 std::bind 或 lambda。
3. std::function + std::bind
cpp
#include <functional>
class Timer {
public:
void onTick(int n) { std::cout << "tick " << n << "\n"; }
};
void run(const std::function<void(int)> &cb) { cb(42); }
Timer t;
run(std::bind(&Timer::onTick, &t, std::placeholders::_1)); // 绑定对象 + 占位符
std::bind(&Timer::onTick, &t, _1) 的含义是:把成员函数 Timer::onTick 和对象 t 打包成一个普通的 void(int) 可调用对象。这样库只认 std::function,不需要认识你的类。
4. Lambda(现代 C++ 首选)
cpp
int counter = 0;
run([&counter](int n) {
counter += n; // 捕获外部变量,等价于携带 userdata
std::cout << "counter=" << counter << "\n";
});
Lambda 的捕获列表([&]、[=]、[this])就是"上下文携带"的语法糖,比 void* 和 bind 都清晰。
注意:
[&]按引用捕获,若 lambda 的生命周期长于被捕获变量(比如被存起来稍后执行),会产生悬垂引用。这是回调最常见的 bug 来源之一。
七、回调的作用
-
解耦
框架/库只定义"插槽"(函数签名),不依赖具体业务;你不需要修改库源码就能定制行为。库的作者和业务作者可以互不知晓地工作。
-
事件驱动 / 异步
事件何时发生不可预知。你不可能"死等"客户端连接或用户点击,只能先注册处理函数,事件到来时被调用。所有 GUI 框架、网络库、Node.js 风格异步 API 都建立在这个模式上。
-
控制反转(IoC)
程序主体交给框架,你只填"空位"。这也是很多框架让人初期不适应的原因:main 很快就"结束"了,真正的逻辑都在等待被调用。
-
策略注入 / 可扩展
排序规则、比较函数、日志输出目标、超时处理策略......同一套流程用不同回调就能产生不同行为,避免写一堆
if/else分支或继承子类。
八、代价与常见坑
| 问题 | 说明 |
|---|---|
| 控制流不直观 | 代码不再从上到下一眼读完,调试时需要顺着"谁注册的、谁触发的"追 |
| 生命周期问题 | 回调里引用了已销毁的对象(悬垂 this、悬垂引用)会崩溃或数据错乱 |
| 线程安全 | 回调可能在别的线程执行,共享数据必须加锁或通过消息投递串行化 |
| 重入 / 递归 | 回调中再次触发同一事件,可能造成无限递归或状态不一致 |
| 异常传播 | C 风格回调里抛出异常会跨过 C 栈帧,属于未定义行为 |
| 回调地狱 | 层层嵌套的异步回调难以阅读,现代方案是 Promise / Future / 协程 |
避免悬垂的关键原则 :谁注册回调,谁就要负责保证"回调被触发时,它引用的对象还活着"。常见做法是注册时使用弱引用(std::weak_ptr),或在对象析构时主动注销回调。
九、同步回调与异步回调
- 同步回调 :注册后立即(或在本函数返回前)被调用,例如
qsort的比较函数、std::for_each的仿函数。调用栈连续,行为容易预测。 - 异步回调 :注册后先返回,未来某个时刻在别处(可能是别的线程)被调用,例如
pthread_create的入口函数、网络库的消息回调、setTimeout。
判断方式:回调执行时,注册它的那个函数是否已经返回。异步回调才需要考虑生命周期和线程安全。
十、总结
- 回调 = 把函数当参数传出去,把调用权交给对方。
- 本质是"延迟执行 + 解耦":现在不知道什么时候做、由谁触发,所以先把"要做什么"存下来。
- C 用函数指针 +
void*,C++ 用std::function/std::bind/ lambda,思想完全一致。 - 用它的收益是解耦和事件驱动能力,代价是控制流变隐晦,以及必须谨慎处理生命周期与线程安全。