如何使用C++17的std::variant实现类型安全的事件系统?

在现代C++中,事件驱动编程已经成为游戏引擎、GUI框架甚至网络协议栈中的核心模式。传统上,事件系统常依赖于基类指针、虚函数或字符串标识符来实现多态,往往伴随运行时类型检查、手动类型转换以及潜在的类型错误。随着C++17的到来,std::variant 这一类型安全的联合体为事件系统的实现提供了更简洁、更可靠的手段。

下面通过一个完整的示例,演示如何利用 std::variant 搭建一个轻量级、类型安全的事件系统,并进一步扩展到事件总线(EventBus)和事件监听器(Listener)的实现。

1. 定义事件类型

首先,我们为每种事件定义一个结构体。每个结构体仅包含与该事件相关的数据成员。

struct MouseMoveEvent {
    int x;
    int y;
};

struct MouseClickEvent {
    int button; // 1: left, 2: right, 3: middle
    bool pressed;
};

struct KeyboardEvent {
    int keyCode;
    bool pressed;
};

using Event = std::variant<MouseMoveEvent, MouseClickEvent, KeyboardEvent>;

Event 是一个 std::variant,可以容纳上述三种事件之一。借助 std::visit,我们可以在不需要显式 ifswitch 的情况下访问事件的内容。

2. 事件监听器接口

为了实现松耦合的事件处理,我们定义一个基类 IEventListener,它声明了一个模板成员函数 onEvent。每个监听器只关心自己感兴趣的事件类型。

class IEventListener {
public:
    virtual ~IEventListener() = default;

    template <typename T>
    void onEvent(const T& event) {
        if constexpr (std::is_base_of_v<IEventListener, T>) {
            // 这行不会被编译,保留以满足模板约束
        }
        else {
            handle(event);
        }
    }

protected:
    virtual void handle(const MouseMoveEvent&) {}
    virtual void handle(const MouseClickEvent&) {}
    virtual void handle(const KeyboardEvent&) {}
};

这里使用 if constexpr 进行编译期检查,确保 handle 只在子类中被实现。每个子类只需要重写它关心的事件类型的 handle 函数。

3. 事件总线(EventBus)

事件总线负责维护监听器列表并分发事件。它的实现保持极简。

class EventBus {
public:
    void subscribe(std::shared_ptr <IEventListener> listener) {
        listeners_.push_back(listener);
    }

    void emit(const Event& ev) {
        for (auto& listener : listeners_) {
            std::visit([&](auto&& e){ listener->onEvent(e); }, ev);
        }
    }

private:
    std::vector<std::shared_ptr<IEventListener>> listeners_;
};

emit 方法遍历所有已注册的监听器,并对每个事件调用 std::visit,把具体事件类型传递给 listener->onEvent。由于 onEvent 是模板函数,编译器会根据事件类型自动调用对应的 handle

4. 示例监听器

下面给出两个具体的监听器示例:一个绘图系统处理鼠标事件,另一个控制系统处理键盘事件。

class RenderSystem : public IEventListener {
protected:
    void handle(const MouseMoveEvent& e) override {
        std::cout << "RenderSystem: Mouse moved to (" << e.x << "," << e.y << ")\n";
    }

    void handle(const MouseClickEvent& e) override {
        std::string action = e.pressed ? "pressed" : "released";
        std::cout << "RenderSystem: Button " << e.button << " " << action << "\n";
    }
};

class InputSystem : public IEventListener {
protected:
    void handle(const KeyboardEvent& e) override {
        std::string state = e.pressed ? "pressed" : "released";
        std::cout << "InputSystem: Key " << e.keyCode << " " << state << "\n";
    }
};

5. 主程序演示

int main() {
    EventBus bus;

    auto render = std::make_shared <RenderSystem>();
    auto input  = std::make_shared <InputSystem>();

    bus.subscribe(render);
    bus.subscribe(input);

    // 发射一些事件
    bus.emit(MouseMoveEvent{100, 200});
    bus.emit(MouseClickEvent{1, true});
    bus.emit(KeyboardEvent{65, true});   // 'A' key

    return 0;
}

运行结果:

RenderSystem: Mouse moved to (100,200)
RenderSystem: Button 1 pressed
InputSystem: Key 65 pressed

6. 高级扩展

6.1 事件过滤

如果某个监听器只对特定事件源感兴趣,可以在 handle 函数中添加额外的过滤条件,例如检查事件坐标是否在某个区域。

6.2 异步事件

在多线程环境中,可以把 EventBus::emit 的实现改为将事件推入线程安全的队列,由后台线程消费并分发。

6.3 事件池

为减少频繁的动态分配,可以使用对象池技术缓存 Event 对象,或直接使用 `std::vector

` 在事件循环中复用。 ## 7. 结语 通过 `std::variant` 与 `std::visit` 的组合,我们实现了一个类型安全、易于扩展且无运行时类型检查开销的事件系统。相比传统的基类指针方式,`std::variant` 更能发挥 C++ 的静态类型优势,减少错误并提高代码可读性。随着 C++20 的 `std::variant::visit` 进一步完善,事件系统的实现将更加简洁与高效。

发表评论