☰
C++中介者模式:从对象耦合到事件总线的解耦实践
2026/10/23 12:10:58 网站建设 项目流程

1. 需求来源:对象一多,耦合就变成了灾难

我第一次感受到“对象间通信”这个问题的分量,是在维护一个老旧的桌面应用时。那是一个窗体里有十几个控件互相联动的界面:下拉框变了,表格要刷新,按钮的可用状态要切换,状态栏要更新文字,某个滑块的值还会反过来影响另一个组合框的选项。最初的代码逻辑其实很简单,无非就是在一个控件的事件回调里直接调用另一个控件的公有方法。但随着业务迭代,控件越来越多,每个控件的事件处理函数里逐渐塞满了对其他控件的调用,代码的耦合程度高到让人不敢动任何一个细节。

当时我接手时的代码大概是这样的:

class MainWindow : public QMainWindow { public: void onComboChanged(int index) { m_table->updateRows(index); m_button->setEnabled(index > 0); m_statusBar->showMessage(QString("当前选中: %1").arg(index)); if (index == 2) { m_slider->setValue(50); } } private: QComboBox* m_combo; QTableWidget* m_table; QPushButton* m_button; QSlider* m_slider; QStatusBar* m_statusBar; };

这个版本看起来很“直接”,但真实项目中的情况远没有这么乐观。表格刷新可能要经过一层数据映射,按钮状态变化还要考虑权限系统,滑块设置值之前要计算有效范围。

后来我看到有人提到中介者模式,试着去理解它如何改变对象间的通信方式。它的核心思想听起来简单:不允许多个对象之间互相直接引用,而是让所有交互都通过一个中心化的对象来完成。听起来很像一个“红娘”,把原本需要互相认识的对象解耦,让它们只跟中介者打交道。

中介者模式在C++中的实现方式有很多种。最朴素的做法是用一个抽象基类定义通信接口,具体中介者继承实现;也有用std::function和回调函数注册的方式;更进阶的做法是用消息总线式的实现,让中介者持有一张信号到处理函数的映射表。这个模式的价值在面对大量业务对象时才会真正体现出来,对象越多、交互越复杂,它的优势越明显。

这篇文章就聚焦C++实现的中介者模式。我会从经典实现讲起,再讨论用现代C++特性可以怎么做得更轻量、更易维护,最后结合实际项目中的场景,讲一讲哪些地方适合用、哪些地方强行用只会增加复杂度。如果你也在维护一个对象间交互非常密集的模块,这篇文章应该能给你一些参考。

2. 中介者模式的核心机制:对象间通信的解耦核心

2.1 没有中介者的世界:网状耦合

在引入中介者之前,我们先明确一下它在解决什么问题。假设一个系统中有四个对象A、B、C、D,它们之间需要互相通信。如果没有任何约束,最直接的写法是让A持有B、C、D的引用,B持有A、C、D的引用,以此类推。这时对象之间的关系是网状的。

这种网状结构的问题在于:任何一个对象的修改,都可能引发连锁反应。比如A的构造函数签名变了,所有持有A引用的对象都要跟着改;D的某个公有方法改名了,调用过它的B和C都要同步修改。更隐蔽的问题是,这种直接调用往往产生隐式的时序依赖——A必须先设置某些状态,B才能正确响应,而这一点完全没有写在接口里。

有经验的开发者往往会在这时引入一个中间层。让A不再直接持有B、C、D,而是把所有想传达的消息发给一个“调度中心”。这个中心再根据消息类型决定调用谁。这样A只依赖中心,B也只依赖中心,网状结构被拆成了星形结构。这就是中介者模式最基础的形态。

2.2 中介者模式中的角色划分:从类图到密码本

中介者模式通常涉及三类角色:

  • Mediator(中介者接口):声明一个通用的通信接口,定义各个成员对象之间进行通信的方法。
  • ConcreteMediator(具体中介者):实现中介者接口,协调各个成员对象的行为,持有并维护各成员对象的引用。
  • Colleague(同事类):每个成员对象都持有一个中介者的引用。当它需要与其他成员通信时,就调用中介者的方法,而不是直接去调另一个成员的方法。

很多文章会用UML类图来解释这几者的关系,类图当然能说明结构,但真正让我理解这个模式的是另一件事。我实习时带我的前辈给的参考代码里,中介者类名叫ChatMediator,同事类叫User,我当时特别困惑——这和界面八竿子打不着啊。

后来前辈解释了个思路:中介者在某种意义上就像一个“密码本”。每个Colleague在通信时刻,不需要关心消息发给谁,只需要发出一个“代号”(比如EVENT_TYPE_A),具体中介者拿到代号后,根据自己的内部逻辑去调用对应的对象。这样一来,同事类之间完全解耦,唯一的约定就是事件类型枚举,而这个约定集中在一个地方维护,改动成本被大幅压缩。

2.3 一个最简单的C++实现:三位对象的人际关系重构

为了把机制讲清楚,我们用一个具体的例子。假设有一个对话框,需要三个控件协同工作:

  • 输入框(InputBox):输入内容后通知其他控件。
  • 列表(ListBox):显示选项。
  • 按钮(Button):需要一个选中项才能点击。

三个控件彼此关联:内容变化时列表要更新,列表有选中项时按钮要变得可用。如果不用中介者,InputBox需要持有ListBox和Button的引用,ListBox需要持有Button的引用,每个控件都要知道其他控件的存在,这就在代码层面形成了我们前面说的网状关系。

引入中介者后,结构变成了这样:

class DialogMediator; // 同事基类 class Widget { public: explicit Widget(DialogMediator* mediator) : m_mediator(mediator) {} virtual ~Widget() = default; protected: DialogMediator* m_mediator; }; class InputBox : public Widget { public: using Widget::Widget; void setText(const std::string& text) { m_text = text; if (m_mediator) { m_mediator->onInputChanged(); } } std::string text() const { return m_text; } private: std::string m_text; }; class ListBox : public Widget { public: using Widget::Widget; void setSelection(int index) { m_selection = index; if (m_mediator) { m_mediator->onSelectionChanged(); } } int selection() const { return m_selection; } private: int m_selection = -1; }; class Button : public Widget { public: using Widget::Widget; void setEnabled(bool enabled) { m_enabled = enabled; } void click() { if (m_enabled && m_mediator) { m_mediator->onButtonClicked(); } } private: bool m_enabled = false; }; // 具体中介者 class DialogMediator : public Widget { public: DialogMediator() : Widget(nullptr) {} void setInputBox(InputBox* pb) { m_inputBox = pb; } void setListBox(ListBox* pl) { m_listBox = pl; } void setButton(Button* pb) { m_button = pb; } void onInputChanged() { // 输入变化时,刷新列表内容 if (m_listBox) { m_listBox->setSelection(0); } } void onSelectionChanged() { // 有选中项时,按钮才可用 if (m_button) { m_button->setEnabled(m_listBox && m_listBox->selection() >= 0); } } void onButtonClicked() { // 点击按钮后,读取输入框内容,执行动作 if (m_inputBox) { std::string text = m_inputBox->text(); // 执行动作 } } private: InputBox* m_inputBox = nullptr; ListBox* m_listBox = nullptr; Button* m_button = nullptr; };

在这个实现中,InputBox、ListBox和Button三个类彼此互不相识,它们各自持有一个DialogMediator*。当某个控件的状态发生改变时,它只是通知中介者“我的状态变了”,而不是直接去操作其他控件。中介者作为整个交互流程的调度者,负责响应事件、协调数据流动。

这个版本对应的类图约定很简单:Widget作为同事基类,持有一个中介者指针;DialogMediator既继承了Widget(这是技巧,后面会讲)又担当了中介者角色。运行时只要把中介者和各个子控件通过setInputBox等方法连接起来,整个交互闭环就能正常工作。

这个模式的收益在添加新控件时尤其明显。比如某个版本要求增加一个“全选”复选框:选中时列表里所有项都被选中,取消时所有项取消选中。没有中介者时,可能需要修改InputBox、ListBox多个类的代码;有中介者在,只需要new一个CheckBox,然后在DialogMediator中增加对它的引用和事件处理逻辑,原有的控件类一行代码都不用动。

3. 现代C++视角下的中介者模式实现

3.1 经典实现的局限

上一节的经典实现有几个问题,在多线程或大规模项目中尤其突出:

问题一:中介者是个上帝对象。所有的交互逻辑集中在一个类里,类会迅速膨胀。我在一个项目里见过一个中介者类里面有二十多个事件方法、三十多个私有成员变量,每个方法里都是一长串的if-else或switch,改动一个功能要小心绕开其他功能,基本失去了维护性。

问题二:动态绑定带来的性能损耗不必需。如果中介者的方法只和具体的控件类型相关,用虚函数实现确实更灵活,但同时也意味着每次事件触发都有一次间接调用。这对UI来说无所谓,但如果在某些低延迟场景(比如消息路由)使用中介者,无谓的虚函数开销能省还是要省。

问题三:头文件依赖很重。经典实现里,同事类需要看到中介者类的完整定义(因为要调用它的成员方法),中介者又需要看到所有同事类的完整定义(因为要实现中转逻辑)。这导致头文件互相include,编译时间随类数量增长,而且几乎不可能用前置声明来缓解。

现代C++提供了一种更轻量、更灵活的实现思路:整个中介者演变成一个消息路由表——维护一张从“事件类型”到“处理函数”的映射。同事类之间不感知彼此,不需要共享中介者的完整定义,只需要约定一组事件类型和回调签名。

3.2 用std::function和std::map构建一个消息总线式中介者

这个思路的核心是用std::function<void()>把“某个事件发生时需要执行的动作”包装起来,用std::map<EventType, std::vector<std::function<void()>>>来维护事件到多个处理器的映射。同事类在构造时把自己的处理方法注册进去,事件触发时由中心对象统一调用所有订阅者。

#include <map> #include <vector> #include <functional> #include <memory> #include <string> enum class EventType { InputChanged, SelectionChanged, ButtonClicked }; class EventBus { public: using Handler = std::function<void()>; void subscribe(EventType event, Handler handler) { m_handlers[event].push_back(std::move(handler)); } void publish(EventType event) { auto it = m_handlers.find(event); if (it == m_handlers.end()) { return; } for (auto& handler : it->second) { handler(); } } private: std::map<EventType, std::vector<Handler>> m_handlers; }; class InputBox { public: explicit InputBox(EventBus& bus) : m_bus(bus) {} void setText(const std::string& text) { m_text = text; m_bus.publish(EventType::InputChanged); } std::string getText() const { return m_text; } private: EventBus& m_bus; std::string m_text; }; class ListBox { public: explicit ListBox(EventBus& bus) : m_bus(bus) {} void setSelection(int index) { m_selection = index; m_bus.publish(EventType::SelectionChanged); } int getSelection() const { return m_selection; } private: EventBus& m_bus; int m_selection = -1; }; class Button { public: explicit Button(EventBus& bus) : m_bus(bus) {} void setEnabled(bool enabled) { m_enabled = enabled; } void click() { if (m_enabled) { m_bus.publish(EventType::ButtonClicked); } } private: EventBus& m_bus; bool m_enabled = false; };

通知的流程从“控件A调B的方法”变成了“控件A向总线发事件,总线调用所有订阅者”。这个思路在概念上比经典实现更接近“消息”语义,也更容易扩展到一对多场景。按钮点击的订阅者可以同时有日志记录组件、UI刷新组件、业务处理组件,彼此互不感知。

3.3 回调注册与生命周期管理:用智能指针消灭悬挂引用

消息总线式的实现省去了中介者类的膨胀,但引入了新的问题:生命周期管理。经典实现中,中介者持有的是裸指针,只要保证在同事对象销毁之前销毁中介者,问题不大。但事件总线是全局性的——各个控件向总线注册回调时,回调内容往往捕获了this指针,如果控件先销毁了,总线里的回调就成了悬垂引用。

我见过一个比较典型的崩溃场景是这样的:窗体的生命周期绑定了总线,当窗体关闭时,控件们按顺序析构。但某个控件在析构函数里,因为用了std::async异步更新,回调晚于控件析构到达总线,总线内部调用的恰恰是那个对象的成员函数,程序直接把this给解引用了,当场崩溃。

规避这个问题的关键有两点:

第一点是明确所有权的边界。如果总线是某个对象(比如窗口)私有的,那总线必须确保在它管理下的所有注册者都销毁之后才能摧毁自己。推荐用shared_ptr管理同事对象、用weak_ptr在回调里捕捉,避免回调强持有对象阻止析构。

class DialogMediator { public: void setInputBox(std::shared_ptr<InputBox> box) { m_inputBox = std::move(box); } void setListBox(std::shared_ptr<ListBox> box) { m_listBox = std::move(box); } void init() { m_handlers[EventType::InputChanged].emplace_back([weakInput = std::weak_ptr<InputBox>(m_inputBox)]() { if (auto input = weakInput.lock()) { // 处理逻辑 } }); } private: std::shared_ptr<InputBox> m_inputBox; std::shared_ptr<ListBox> m_listBox; std::map<EventType, std::vector<std::function<void()>>> m_handlers; };

第二点是在析构时主动反注册。事件总线要提供一个unsubscribe接口,支持按订阅ID移除回调。C++里最简单的方式是让subscribe返回一个int类型的订阅ID,或者返回一个shared_ptr<SubscriptionGuard>,守卫对象析构时自动反注册。

class Subscription { public: Subscription(EventBus& bus, EventType event, uint64_t id) : m_bus(bus), m_event(event), m_id(id) {} ~Subscription() { m_bus.unsubscribe(m_event, m_id); } private: EventBus& m_bus; EventType m_event; uint64_t m_id; }; uint64_t EventBus::subscribe(EventType event, Handler handler); void EventBus::unsubscribe(EventType event, uint64_t id);

在使用端:

auto sub = std::make_shared<Subscription>(bus, EventType::InputChanged, bus.subscribe(EventType::InputChanged, [](){ // handler }));

当sub析构时,订阅自动取消,从根源上去除了悬垂调用的问题。这个模式在实际项目中值得优先考虑——只要用到事件总线,就应该配套做生命周期管理,不能靠“人肉保证存活顺序”。

3.4 事件携带参数:用泛型T扩展总线

上一节的EventBus里,publish没有参数。实际项目中,事件往往要携带数据,比如“列表数据更新”需要携带新的数据集合,“按钮点击”可能需要携带点击坐标。

最简单的方式是为不同事件定义不同的载荷类型,用一个类型擦除容器传递。C++中可以用std::any:

class EventBus { public: using Handler = std::function<void(const std::any& payload)>; void publish(EventType event, const std::any& payload = {}) { auto it = m_handlers.find(event); if (it == m_handlers.end()) { return; } for (auto& handler : it->second) { handler(payload); } } };

缺点是使用std::any_cast时需要显式类型匹配,调用方必须知道事件对应的载荷类型。

另一种做法是把整件事做成一个template <typename EventType, typename PayloadType>的泛型事件总线。模板在编译期就检查类型匹配,天然类型安全,但实现复杂度和使用门槛会升高。对普通UI场景,std::any方案够用了。如果追求类型安全,我建议用std::variant把单个模块内的事件载荷穷举出来,在编译期就杜绝类型错配。

using DialogPayload = std::variant< std::monostate, std::string, // InputChanged: 新文本 int, // SelectionChanged: 选中索引 struct ButtonClickInfo // 自定义结构 >; class DialogEventBus { public: using Handler = std::function<void(const DialogPayload&)>; void publish(EventType event, const DialogPayload& payload); };

4. C++中介者模式的实际应用:从UI到服务端

4.1 界面控件协作:经典中介者模式的主场

中介者模式最经典的应用场景是GUI界面控件之间的交互。在一个复杂的业务表单中,可能有几十个输入控件、校验控件、联动控件,它们之间的交互逻辑如果用直接引用实现,代码会非常难以维护。

举一个表单联动的实际例子:一个物流订单录入窗口,有“寄件地”、“收件地”、“物品类型”、“重量”、“报价方式”几个输入。“物品类型”改变时,“报价方式”的可用选项要跟着变;寄件地和收件地属于同一个城市时,“同城配送”选项要变得可用;按理说这些联动逻辑分散在各自的事件处理里即可,但问题在于,它们共享的状态(比如当前城市列表、当前物品类型)存储在多个地方,改起来容易漏。

如果用中介者:

  • 每个控件的事件处理函数只做一件事:把事件发给中介者。
  • 中介者统一管理状态机,根据当前状态决定后续动作。

在C++/Qt环境下,我一般会给每个控件发不同的信号,中介者槽函数里维护一个有限状态机。代码结构会非常清晰:

class OrderFormMediator : public QObject { Q_OBJECT public slots: void onItemTypeChanged(int type) { m_currentItemType = type; updateQuoteOptions(); checkSameCity(); } void onSenderCityChanged(const QString& city) { m_senderCity = city; checkSameCity(); } // ... private: void checkSameCity() { bool sameCity = (m_senderCity == m_receiverCity); ui.sameCityOption->setEnabled(sameCity); } int m_currentItemType = 0; QString m_senderCity; QString m_receiverCity; };

这里中介者把UI的交互逻辑从视图组件中提取出来,变成可测试、可维护的独立模块。同样一段逻辑,如果写在各个控件的事件回调里,测试要模拟用户点这、点那,非常痛苦;集中在中介者里,单测可以直接调用onItemTypeChanged(2)和onSenderCityChanged("北京")来验证状态。这也是我推荐在UI层使用中介者核心理由之一:可测试性大幅提升。

4.2 聊天室与群消息系统:服务端中介者的天然例子

中介者模式进入服务端之后,最典型的例子就是聊天室。

假设一个聊天室有N个用户,如果没有中介者,每条消息需要遍历所有其他用户,并调用各自的消息发送方法。用户会持有所有其他用户的引用,聊天室成员频繁进出时,每个进出的用户都要去更新所有现有用户身上的引用。这个成本在成员数几百上百时还能忍受,成员数千时就是灾难。

引入聊天室服务端作为中介者后:

class ChatRoomMediator; class User { public: explicit User(const std::string& name, ChatRoomMediator& room) : m_name(name), m_room(room) {} void sendMessage(const std::string& to, const std::string& message); void receiveMessage(const std::string& from, const std::string& message); std::string name() const { return m_name; } private: std::string m_name; ChatRoomMediator& m_room; }; class ChatRoomMediator { public: void addUser(std::shared_ptr<User> user); void removeUser(const std::string& name); void sendMessage(const std::string& from, const std::string& to, const std::string& message); private: std::map<std::string, std::weak_ptr<User>> m_users; }; void ChatRoomMediator::sendMessage(const std::string& from, const std::string& to, const std::string& message) { auto it = m_users.find(to); if (it != m_users.end()) { if (auto user = it->second.lock()) { user->receiveMessage(from, message); } } } void User::sendMessage(const std::string& to, const std::string& message) { m_room.sendMessage(m_name, to, message); }

用户类只依赖中介者和自己的属相,当需要支持新的消息类型(比如广播、系统消息、@提人),只需在ChatRoomMediator中增加对应方法,用户类不需要改动。注意上面用weak_ptr存放用户引用——用户可能因为网络断开或主动退出而消失,weak_ptr加lock()保证不会处理一个已析构的会话,是服务端中介者实现中比较推荐的写法。

除了聊天室外,服务端场景里消息路由、命令分发、球员匹配等模块都可以用中介者来设计。凡是“多对多消息通信+成员动态进出+交互逻辑集中管理”的场景,中介者模式基本都适合。

4.3 深浅程度评估:什么时候不该用中介者

下面这个判断标准是我在实际项目中反复验证过的:

适合用中介者模式的情况:

  • 对象数量较多(大约3个以上),且彼此之间存在复杂的交互。
  • 交互逻辑常常变更,集中管理能降低修改成本。
  • 成员对象需要复用,不希望被特定的交互逻辑污染。
  • 团队需要单元测试交互规则,集中管理便于测试。

不适合用中介者模式的情况:

  • 对象数量少(2个或3个),交互单一,中介者的引入反而增加间接层。
  • 是纯粹的流水线式数据处理,每一步的输出就是下一步的输入,用管道或策略模式更直接。
  • 交互逻辑本身变化不大,不值得为此增加一个间接层。

中介者模式的本质是“用间接换解耦”。如果引入它并没有降低耦合,而是多了一层无谓的转发,那就不如不用。判断方法很朴素:如果直接把类A的指针传给类B就能解决所有问题,别折腾中介者。

5. 一个完整案例:用中介者模式重构组织结构树

纸上谈兵不如实际动一次手。这一节我会完整演示一个场景:组织结构树。这个场景比较贴近浏览器端/桌面端的实际业务,便于理解模式落地。

5.1 场景描述

系统里有一个组织结构树,左侧显示员工列表,中间显示部门信息,右侧显示公司公告。它们之间有关系:点击某个部门,右侧公告栏要刷新为该部门的公告,中间要显示该部门的基本信息;点击某个员工,右侧要显示该员工相关的公告(如人事变动),中间要显示该员工的职位信息;同时,左下角有个“全局筛选框”,输入关键词时所有区域都要联动刷新。

窗口里的“控件”有三个:

  • OrgTreeWidget(组织树)
  • DetailPanel(详情面板)
  • AnnouncementPanel(公告面板)
  • 外加一个全局筛选输入框SearchBox

如果用事件总线+中介者模式来实现,结构大概是这样:

5.2 代码实现

定义事件类型:

enum class OrgEvent { NodeSelected, // 选中节点(部门或员工) SearchKeyword, // 搜索关键词 RefreshRequest // 手动刷新请求 };

定义事件载荷:

struct NodeSelectPayload { enum class NodeType { Department, Employee } type; std::string id; std::string name; }; struct SearchPayload { std::string keyword; }; using OrgPayload = std::variant<std::monostate, NodeSelectPayload, SearchPayload>;

定义一个轻量事件总线:

#include <map> #include <vector> #include <functional> #include <variant> #include <mutex> class OrgEventBus { public: using Handler = std::function<void(const OrgPayload&)>; void subscribe(OrgEvent event, Handler handler) { m_handlers[event].push_back(std::move(handler)); } void publish(OrgEvent event, const OrgPayload& payload) { auto it = m_handlers.find(event); if (it == m_handlers.end()) { return; } for (auto& handler : it->second) { handler(payload); } } private: std::map<OrgEvent, std::vector<Handler>> m_handlers; };

各控件作为模块类,只持有总线引用:

class OrgTreeWidget { public: explicit OrgTreeWidget(OrgEventBus& bus) : m_bus(bus) {} void onUserClickNode(const NodeSelectPayload& node) { m_bus.publish(OrgEvent::NodeSelected, node); // 界面本地高亮等操作到此为止,其余交给总线 } private: OrgEventBus& m_bus; }; class DetailPanel { public: explicit DetailPanel(OrgEventBus& bus) : m_bus(bus) { m_bus.subscribe(OrgEvent::NodeSelected, [this](const OrgPayload& payload) { auto node = std::get_if<NodeSelectPayload>(&payload); if (node) { showDetail(*node); } }); m_bus.subscribe(OrgEvent::SearchKeyword, [this](const OrgPayload& payload) { auto search = std::get_if<SearchPayload>(&payload); if (search) { filterDetails(search->keyword); } }); } private: void showDetail(const NodeSelectPayload& node) { /* 展示详情 */ } void filterDetails(const std::string& keyword) { /* 按关键词过滤 */ } OrgEventBus& m_bus; };

实际项目里可以使用依赖注入把总线传给所有模块,更灵活的方式是提供全局的getEventBus()接口。不过从可测性角度,我偏向「构造函数注入」,关键是让测试能构造独立的OrgEventBus实例,单独验证交互逻辑。

5.3 重构收益与注意点

用事件总线+中介者思路重构之后:

  • OrgTreeWidget只负责“用户点击了哪个节点”,不感知DetailPanel和AnnouncementPanel的存在。
  • DetailPanel和AnnouncementPanel各自在构造时订阅感兴趣的事件,彼此完全独立。
  • 新增一个RecentVisitorPanel(最近访客面板)时,只需要让它订阅NodeSelected事件,在构造时把自己的处理逻辑挂到总线上即可。其他面板一行代码都不用改。

这种扩展性是经典实现难以企及的。经典中介者需要修改中介者类本身,新增一个成员变量、增加一个事件处理方法;而事件总线式的结构,新增模块的成本基本等同于“新增一个订阅者”。

需要注意的地方是:事件总线式的结构里,DetailPanel和AnnouncementPanel不再相互可见,但也丧失了面向对象里“友元类”带来的便利。如果两个模块之间有高频、紧耦合的调用(比如AnnouncementPanel需要实时获取DetailPanel内部的临时状态),用事件总线显然绕了远路,此时直接持有对方引用反而是更合适的设计。

6. 使用上的实际问题与解决思路

6.1 队列与重入:事件处理中的时序陷阱

事件总线式中介者有一个经典隐患:重入。比如控件A在事件回调里调用publish(newEvent),而这又触发了控件B的某个方法,B反过来又publish了另一个事件,整个过程变成了一串难以预料的递归调用。尤其是在多线程环境下,两个线程同时向总线发布消息,还会导致数据竞争。

处理方式主要有几种:

同步发布+重入保护。在总线内部加一个bool m_publishing标志。如果publish时发现已经在发布过程中,就把事件加入一个pending队列,等当前处理的这轮结束后再统一处理。这能有效避免回调内的意外重入。

void EventBus::publish(EventType event, const std::any& payload) { if (m_publishing) { m_pending.emplace_back(event, payload); return; } m_publishing = true; CallHandlers(event, payload); while (!m_pending.empty()) { auto [e, p] = std::move(m_pending.front()); m_pending.pop_front(); CallHandlers(e, p); } m_publishing = false; }

异步发布+线程安全。如果事件触发频率不高,可以把事件放入一个线程安全的队列,由独立的事件循环线程统一分发。这样事件处理完全脱离开触发方的线程上下文,不会出现长时间在UI线程执行的问题。代价是排队的延迟和一点点复杂性。

实际项目里我一般按场景选用:UI层用同步发布+重入保护,因为UI操作本身要立刻反馈;后台消息处理用异步队列,避免阻塞业务逻辑。

6.2 广播风暴:订阅了用户根本不需要的事件

事件总线用起来很爽,一不小心就会陷入“订阅一切”的冲动。但事件广播本身是有代价的——事件多、订阅者多时,每个事件都要遍历所有订阅者,即便它们根本不关心数据。我用过的一个项目里,某个事件广播一次要触发十几个订阅回调,其中真正对该事件感兴趣的只有两个。

优化思路:

  • 精准订阅。EventBus支持按事件类型订阅,天然只通知关注该事件的订阅者。
  • 事件细分。把大而全的DataUpdated事件拆成UserListUpdated、OrderStatusUpdated等多个细粒度的事件。
  • 考虑线程语义。后台任务完成时,如果用队列异步抛出事件,尽量在抛出前做一次合并,比如同一秒内的多次“进度更新”只发一条“最终状态”。

6.3 消息总线传参用“结构体打包”还是“多参数直传”

有人觉得事件总线的handler签名是std::function<void(int a, int b, const std::string& c)>更直接,也有人推荐用一个大结构体把所有参数塞进去。两种方式在真实项目中都有,但如果要兼容未来扩展,我更推荐用结构体打包。

理由很简单:如果未来某个事件需要新增一个参数,用多参数直传的版本,所有订阅者的回调签名都要跟着改;用结构体打包,只需要在结构体里增加一个字段,已订阅的回调签名不用动,内部逻辑按需处理。代价是手写结构体时稍显繁琐,换来的是更稳的演进能力。

现代C++里可以用std::tuple暂时顶一下,但可读性不如具名结构体。在跨团队协作的项目里,具名的struct NodeSelectPayload比std::tuple<std::string, std::string, int>好理解得多。

7. 结合其他模式延伸:中介者模式与相关模式的取舍

7.1 中介者模式 vs. 观察者模式

观察者模式是"被观察者维护一个观察者列表,状态变化时通知所有观察者"。中介者模式是"所有对象都把通信请求发给中介者,由中介者决定怎么处理"。两者都分离了对象,但侧重点不同:

  • 观察者模式更偏向一对多通知:发布者发出事件,谁关心谁订阅。
  • 中介者模式更偏向多对多的交互仲裁:A和B交互、B和C交互、C和D交互,这些交互规则全集中在中介者中。

如果一个系统里,“事件发布者”和“事件接收者”是一一对应的关系,用观察者就足够。当事件是一张网(多个发布源、多个接收源,互相交织),中介者模式更合适。在实际项目中,我经常看到事件总线同时实现这两种语义——订阅机制是观察者,路由逻辑是中介者。

7.2 中介者模式 vs. 门面模式(外观模式)

门面模式为复杂子系统提供统一的简单接口,屏蔽内部实现。它和中介者一样都做“集中化”,但目的不同:门面模式核心是“让调用方更简单”,调用方依然可以绕过门面直接调用子系统的接口;中介者模式核心是“让成员之间不直接交互”,成员通信必须经过中枢。

一句话总结:门面是给客户用的入口,中介者是不允许员工私聊的管家。

7.3 中介者模式 vs. 命令模式与回调模式

命令模式把请求封装成对象,便于记录、排队、撤销。中介者模式管理请求的路由,不关心请求是什么。两者经常配合:在中介者内部,可以用一个命令队列存储待处理的任务,按优先级或顺序调度。命令模式是“动作的表示”,中介者模式是“通信的协调者”,互补关系多于替代关系。

8. 一个反直觉的结论:中介者模式不只是UI控件的工具

很多资料把中介者模式放在“行为型设计模式”这一章,主要举的例子也都是聊天室或UI控件协作,时间一长容易形成一种刻板印象:中介者就是用来管理界面控件的。这个印象其实低估了中介者的适用范围。

在现代C++程序里,中介者的核心能力是把“谁在”和“谁做什么”解耦。这意味着凡是存在“成员动态注册、按类型分发、多对多通信”的场景,都用得上它的设计思想:

  • 插件系统:插件之间不直接调用,而是向宿主注册事件,宿主作为中介者统一调度。插件与插件之间的解耦程度,直接决定系统的可插拔性。
  • 游戏对象间的碰撞与事件处理:多个游戏对象需要知道其他对象的位置和状态(攻击、移动、拾取),直接引用会让每个对象都捆绑着一堆其它对象的生命周期。一个WorldMediator统一管理对象注册、碰撞检测结果分发,这个思路在实时对战类游戏中尤其常见。
  • 消息鉴权与路由:在微服务架构中,网关某种意义上就是一个中介者——各服务不直接互连,统一经过网关做鉴权、限流、路由。这个架构思想和中介者模式的核心是同一回事。

C++生态里最有名的实例之一,是Qt的QObject父子关系机制和信号槽机制。信号槽本质上是一种“事件发布—订阅”的中介者:一个QObject发出信号,不需要知道谁在监听;另一个QObject通过槽函数接收,不需要知道信号的源头是谁。中间的那一层连接管理,就是中介者的概念。理解了中介者,也就理解了Qt庞大组件体系能够自由组合的根基。

9. C++特定注意事项与踩坑实录

9.1 头文件循环依赖与前置声明

经典中介者实现中,DialogMediator需要调用InputBox、ListBox、Button的方法,这意味着中介者头文件必须include这三个控件的头文件;反过来,控件又通过基类Widget持有DialogMediator*,Widget只需要声明class DialogMediator;前置声明即可。

要小心的地方在于:避免让中介者直接持有InputBox的具体头文件导致循环依赖。要么用接口(抽象基类)隔离,要么用std::shared_ptr<InputBox>的完整定义延迟到实现文件(.cpp)中构造。后者能显著减少头文件之间的耦合。

9.2 继承 vs. 组合

设计模式的传统C++实现喜欢用抽象基类+虚函数。但C++中虚函数有运行时开销,且继承体系一旦形成,很难后期改。现代C++风格更偏向组合:

  • 用std::function表示“处理动作”,存进容器。
  • 用类型擦除(std::any/std::variant)表示“消息载荷”。
  • 用std::map把“事件类型”映射到“处理动作集合”。

这种组合式的写法没有虚函数、没有继承链,代码更贴近数据流和路由语义,也更容易测试。唯一的代价是类型擦除引入的std::any_cast调用在极端性能场景下会有一点开销,但在绝大多数业务场景里可以忽略不计。

9.3 线程安全与性能权衡

事件总线不可避免要涉及多线程。如果总线的publish需要加锁,锁的粒度直接影响性能:

  • 订阅表只会在初始化阶段修改,运行时基本是只读的。可以用读写锁(std::shared_mutex)来优化。
  • 如果确认事件处理器都在同一线程执行,可以不加锁,把线程安全性交给调用方保证。
  • 如果多个线程同时发布,建议用无锁队列或带锁队列集中收集事件,由一个专用工作线程统一处理,避免回调里再触发新的复杂锁竞争。

9.2和9.3两点组合起来,带来的性能提升在真实场景中常常比预想的大。曾经我在一个实时交互系统里用了加锁版的广播总线,性能测试始终不达标;排查下来发现热点就是在回调里频繁加锁。改成事件队列+单线程分发后,延迟直接降到原来的1/3。

9.4 调试思维:从“它调了我”变为“事件是什么”

用中介者模式之后,调试思路也要调整。传统直接引用的代码,打日志时是“A调了B的方法”,一眼能看到调用链。事件总线式的中介者,事件流变了,用户点了一下,事件发布出去,N个订阅者被触发,光靠断点很难看出全貌。

我的习惯是给EventBus加一个宏开关或条件编译,在发布事件时打印event和payload摘要,方便在日志里追踪一整个交互链路。比如:

#ifndef NDEBUG std::cerr << "[EventBus] publish event=" << (int)event << " payload-type=" << payload.index() << std::endl; #endif

有了这么一行日志,排查时就知道某个事件有没有被正确发布、哪一步没触发订阅者,省去了大量无谓的断点苦战。

10. 最后的一点个人体会

中介者模式在一堆设计模式里不算炫技,但它在实际工程中的价值非常实在。我在维护老旧UI代码时被网状耦合折磨得想重写系统,后来用了中介者模式,代码瞬间变得清爽,那天下午我就在想:所谓设计模式,本质上是对“无序依赖”的约束——用一种大家都认可的规矩,去限制本能膨胀的类关系。

回顾这一整套实践,我最想强调的其实是两个容易被忽略的点。

第一,中介者模式的核心价值不在“减少代码量”,而在于“把变的东西和不变的东西分开”。交互规则一直在变,控件本身相对稳定,用中介者把这些规则集中管理,正是为了应对变化。

第二,C++里实现中介者模式并不一定需要类继承,现代C++的事件总线方案(std::function + map + variant)在很多场景下比传统的虚函数中介者更好用。但工具终究是工具,真正要决策的是:什么样的耦合才是你需要消除的。如果对象间交互简单直白,强行加中介者反而是多余的一层。

如果你在维护的项目里刚好有多个对象互相纠缠的代码块,不妨试着把它们的交互抽到一个独立的管理者里。不用整套重构,先从一个交互闭环开始。动手之后你会发现,原来那些令人头秃的隐性依赖,一旦被集中到明面上,其实并没有那么难拆。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询