**在C++17中使用 std::variant 实现类型安全的事件总线**

在现代 C++ 开发中,事件总线(Event Bus)是一种非常常见的设计模式,用来实现系统中不同组件之间的松耦合通信。传统实现往往依赖于 void* 或者 std::any,导致类型不安全且难以维护。C++17 引入的 std::variant 为我们提供了一种更安全、更高效的方式来存储多种事件类型。本文将演示如何利用 std::variantstd::visit、以及泛型编程,构建一个简单且类型安全的事件总线。


1. 需求分析

我们希望实现以下功能:

  1. 事件注册:不同的组件可以订阅自己感兴趣的事件类型。
  2. 事件发布:发布者能够广播事件,所有订阅该类型的监听器会被触发。
  3. 类型安全:事件总线内部不应出现 void* 或不安全的类型转换。
  4. 轻量级:不需要额外的第三方库,只使用标准库即可。

2. 设计思路

  • 事件类型:我们将所有可能的事件封装成一个 std::variant,例如 using Event = std::variant<MouseEvent, KeyboardEvent, CustomEvent>;

  • 监听器:每个事件类型都对应一个函数列表,使用 std::function<void(const EventType&)> 存储。

  • 内部存储:使用 std::unordered_map<std::type_index, std::vector<std::function<void(const void*)>>>,但通过 std::visit 的访问器将 void* 换成真正的类型安全实现。为了避免 void*,我们采用 std::any 并配合 std::visit

  • 发布过程:调用 std::visit,在访问器里调用对应类型的所有监听器。


3. 代码实现

#include <iostream>
#include <variant>
#include <vector>
#include <unordered_map>
#include <functional>
#include <typeindex>
#include <type_traits>

// 3.1 定义几种事件类型
struct MouseEvent {
    int x, y;
    std::string button;
};

struct KeyboardEvent {
    char key;
    bool ctrl;
};

struct CustomEvent {
    std::string msg;
};

// 3.2 事件总线类
class EventBus {
public:
    // 订阅函数
    template<typename EventType>
    void subscribe(std::function<void(const EventType&)> handler) {
        static_assert(std::is_constructible_v <EventType>, "EventType must be constructible");
        auto& vec = listeners_[std::type_index(typeid(EventType))];
        // 包装成 std::function<void(const void*)>
        vec.emplace_back(
            [handler = std::move(handler)](const void* ptr) {
                handler(*static_cast<const EventType*>(ptr));
            }
        );
    }

    // 发布函数
    template<typename EventType>
    void publish(const EventType& event) const {
        auto it = listeners_.find(std::type_index(typeid(EventType)));
        if (it != listeners_.end()) {
            for (const auto& fn : it->second) {
                fn(&event);
            }
        }
    }

private:
    // 对应事件类型的监听器列表
    std::unordered_map<std::type_index, std::vector<std::function<void(const void*)>>> listeners_;
};

// 3.3 使用示例
int main() {
    EventBus bus;

    // 订阅鼠标事件
    bus.subscribe <MouseEvent>([](const MouseEvent& e){
        std::cout << "Mouse at (" << e.x << "," << e.y << ") Button: " << e.button << "\n";
    });

    // 订阅键盘事件
    bus.subscribe <KeyboardEvent>([](const KeyboardEvent& e){
        std::cout << "Key: " << e.key << " Ctrl: " << (e.ctrl ? "Yes" : "No") << "\n";
    });

    // 订阅自定义事件
    bus.subscribe <CustomEvent>([](const CustomEvent& e){
        std::cout << "Custom event: " << e.msg << "\n";
    });

    // 发布事件
    bus.publish(MouseEvent{100, 200, "left"});
    bus.publish(KeyboardEvent{'A', true});
    bus.publish(CustomEvent{"Hello, EventBus!"});

    return 0;
}

4. 关键点剖析

  1. 类型安全
    subscribepublish 均使用模板参数,编译期就决定了事件类型。内部通过 std::type_index 来维护监听器,避免了运行时的类型擦除。

  2. *`std::function<void(const void)>** 为了把不同类型的监听器统一存储在同一个容器中,我们把每个监听器包装成接受const void*` 的函数。发布时直接传递事件地址,包装函数再把它强制转换为真正的类型并调用。

  3. 性能
    std::variant 本身并未直接使用,因为我们通过类型映射实现;若需要一次性广播多种类型,可以使用 std::variant 包装事件,然后在总线内部统一处理。

  4. 可扩展性
    若需要更复杂的订阅/发布模式(如优先级、一次性订阅、异步处理等),只需在 EventBus 内部添加相应字段与逻辑即可,而不会破坏现有的类型安全。


5. 进一步优化

  • 事件对象池:如果事件频繁产生且大小不一,使用对象池可以降低内存分配开销。
  • 多线程:为线程安全,可在 EventBus 内部加入互斥锁或使用 std::shared_mutex
  • 事件总线代理:通过代理模式让多个总线共享同一事件源,方便分层架构。

6. 结语

通过 std::variant(或 std::type_index)与 std::function 的巧妙配合,C++17 为我们提供了一套既简洁又类型安全的事件总线实现。无需第三方依赖,代码易读且可维护。希望本文能为你在项目中使用事件驱动架构提供实用参考。

发表评论