30. 综合实战:用 Lambda 和函数对象实现一个简单的事件系统
终于到了最后一章。说实话,每次讲到这里,我都会想起自己刚入行时写的一个烂摊子——一个游戏引擎里的事件分发,全是硬编码的 if-else 链,每次加一个新事件都要改核心代码。后来我重构了一遍,用的就是 Lambda 和函数对象。今天咱们就把这套思路拆开看看。
事件系统的核心需求
一个事件系统,说白了就是「你发生了什么事,我来处理」。比如用户点击按钮、网络数据到达、定时器超时。我们需要一个机制:
- 事件的产生方(发布者)不需要知道谁会处理
- 事件的消费方(订阅者)可以随时注册或取消
- 同一个事件可以有多个处理者
嗯,这就是观察者模式。但用 C++ 实现时,关键问题在于:怎么存储和调用这些处理函数?
用 std::function 做万能容器
Lambda 表达式和函数对象,最终都可以包装进 std::function。我个人习惯用 std::function<void(EventType, const std::string&)> 作为事件处理器的签名。这样不管是 Lambda、仿函数还是普通函数指针,都能往里塞。
核心数据结构:
// 事件类型用枚举,清晰且可扩展
enum class EventType {
Click,
KeyPress,
DataArrived,
TimerTick
};
// 每个事件类型对应一组处理器
std::unordered_map<EventType, std::vector<std::function<void(const std::string&)>>> handlers;
这里有个坑:std::function 的拷贝开销不小。我在项目中遇到过,当事件频繁触发时,每次遍历都会拷贝整个 vector。后来改成了 std::shared_ptr<std::vector<...>>,或者用 std::list 来避免插入时的拷贝。
注册与触发:Lambda 的用武之地
注册事件处理器时,Lambda 是最自然的写法。你想想看,用户只需要写一段逻辑,不需要定义一个类,不需要继承什么接口。
EventSystem system;
// 注册点击事件处理器
system.on(EventType::Click, [](const std::string& data) {
std::cout << "点击事件: " << data << std::endl;
});
// 注册按键事件处理器
system.on(EventType::KeyPress, [](const std::string& key) {
std::cout << "按键: " << key << std::endl;
});
触发事件时,我们只需要遍历对应的处理器列表,逐个调用。这里要注意:如果处理器中抛出了异常,整个事件循环就断了。我曾经因为一个处理器里写了 throw,导致后面所有处理器都没执行,排查了半天。所以建议在触发循环里加 try-catch。
我的经验:在触发事件时,用 try-catch 包裹每个处理器的调用,并记录异常信息。这样即使某个处理器挂了,也不会影响其他处理器。
函数对象 vs Lambda:什么时候用哪个?
Lambda 适合简单、一次性的逻辑。但如果处理器需要维护状态(比如计数器、连接池),函数对象更合适。举个例子:
// 函数对象:带状态的处理器
class ClickCounter {
int count = 0;
public:
void operator()(const std::string& data) {
++count;
std::cout << "点击次数: " << count << " 数据: " << data << std::endl;
}
};
system.on(EventType::Click, ClickCounter{});
Lambda 也可以捕获状态,但捕获的变量生命周期需要小心管理。函数对象则更明确——它就是一个对象,生命周期由你控制。
完整的事件系统骨架
下面是一个精简但可用的实现。我故意去掉了线程安全、取消注册等细节,只保留核心逻辑,方便你理解。
#include <iostream>
#include <functional>
#include <unordered_map>
#include <vector>
#include <string>
enum class EventType {
Click,
KeyPress,
DataArrived,
TimerTick
};
class EventSystem {
public:
using Handler = std::function<void(const std::string&)>;
void on(EventType type, Handler handler) {
handlers[type].push_back(std::move(handler));
}
void emit(EventType type, const std::string& data) {
auto it = handlers.find(type);
if (it == handlers.end()) return;
for (auto& handler : it->second) {
try {
handler(data);
} catch (const std::exception& e) {
std::cerr << "事件处理器异常: " << e.what() << std::endl;
}
}
}
private:
std::unordered_map<EventType, std::vector<Handler>> handlers;
};
int main() {
EventSystem sys;
sys.on(EventType::Click, [](const std::string& d) {
std::cout << "点击: " << d << "\n";
});
sys.on(EventType::KeyPress, [](const std::string& k) {
std::cout << "按键: " << k << "\n";
});
sys.emit(EventType::Click, "button1");
sys.emit(EventType::KeyPress, "Enter");
return 0;
}
事件系统的知识结构
下面这张图梳理了事件系统的核心组成和流程,方便你对照理解。
避坑指南:我踩过的几个坑
- 生命周期问题:我曾经在 Lambda 里捕获了一个局部对象的引用,事件触发时对象已经销毁了。解决方案:要么按值捕获,要么用
std::shared_ptr。 - 性能陷阱:如果事件触发非常频繁(比如每帧触发),
std::function的虚函数调用开销会累积。我后来在性能关键路径上改用了裸函数指针 + 类型擦除。 - 取消注册:上面的代码没有实现
off()方法。实际项目中,你需要给每个处理器一个唯一 ID,或者用std::weak_ptr来管理。
注意:不要在事件处理器中做耗时操作(比如文件读写、网络请求)。否则会阻塞整个事件循环。建议把耗时操作丢到线程池里,或者用异步队列。
扩展思路:让事件系统更灵活
上面的实现是最基础的版本。实际项目中,你可能需要:
- 优先级:某些处理器需要先执行。可以给每个处理器加一个优先级字段,排序后调用。
- 事件过滤:只处理特定数据内容的事件。比如只处理
data以 "error" 开头的事件。 - 异步事件:把事件投递到队列中,由另一个线程处理。这需要线程安全,可以用
std::mutex保护处理器列表。
我个人觉得,事件系统的设计没有银弹。关键是根据你的场景做取舍。比如游戏引擎里,事件频率高、种类多,我会用对象池 + 无锁队列。而在业务系统里,稳定性和可读性更重要,用 std::function 就足够了。
好了,这一章就到这里。希望你能动手写一个自己的事件系统,把 Lambda 和函数对象用起来。遇到问题随时翻翻前面的章节,或者自己调试一下——相信我,踩坑才是最好的学习方式。