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;
}

事件系统的知识结构

下面这张图梳理了事件系统的核心组成和流程,方便你对照理解。

事件系统核心结构 事件源 (EventSource) 事件系统 (EventSystem) 处理器列表 emit() 遍历调用 Handler 1 (Lambda) Handler 2 (函数对象) Handler 3 (函数指针) on() 注册 核心:std::function 统一包装,Lambda/函数对象/函数指针均可注册

避坑指南:我踩过的几个坑

  • 生命周期问题:我曾经在 Lambda 里捕获了一个局部对象的引用,事件触发时对象已经销毁了。解决方案:要么按值捕获,要么用 std::shared_ptr
  • 性能陷阱:如果事件触发非常频繁(比如每帧触发),std::function 的虚函数调用开销会累积。我后来在性能关键路径上改用了裸函数指针 + 类型擦除。
  • 取消注册:上面的代码没有实现 off() 方法。实际项目中,你需要给每个处理器一个唯一 ID,或者用 std::weak_ptr 来管理。

注意:不要在事件处理器中做耗时操作(比如文件读写、网络请求)。否则会阻塞整个事件循环。建议把耗时操作丢到线程池里,或者用异步队列。

扩展思路:让事件系统更灵活

上面的实现是最基础的版本。实际项目中,你可能需要:

  • 优先级:某些处理器需要先执行。可以给每个处理器加一个优先级字段,排序后调用。
  • 事件过滤:只处理特定数据内容的事件。比如只处理 data 以 "error" 开头的事件。
  • 异步事件:把事件投递到队列中,由另一个线程处理。这需要线程安全,可以用 std::mutex 保护处理器列表。

我个人觉得,事件系统的设计没有银弹。关键是根据你的场景做取舍。比如游戏引擎里,事件频率高、种类多,我会用对象池 + 无锁队列。而在业务系统里,稳定性和可读性更重要,用 std::function 就足够了。

好了,这一章就到这里。希望你能动手写一个自己的事件系统,把 Lambda 和函数对象用起来。遇到问题随时翻翻前面的章节,或者自己调试一下——相信我,踩坑才是最好的学习方式。


公众号:蓝海资料掘金营,微信deep3321