资讯动态

C++中介者模式实战:从对象网状耦合到星型架构的工程重构指南

发布时间:2026/10/10 18:11:43 来源:尧图企业网站定制
以前我在做一个跨模块的消息分发组件时各个模块之间的调用关系已经乱到改一个接口要牵连五六个地方的程度。A模块要通知B和CB又要回传给AC还要往D那边塞数据。排查问题的时候沿着调用链走一半自己都忘了是从哪里进来的。后来我重构的时候引入了中介者模式Mediator Pattern把互相纠缠的网状调用改成了大家都只跟调度中心说话的星型结构整个系统的可维护性直接上了一个台阶。这篇文章我会从痛点场景开始讲拆解中介者模式在C里该涉及的几个核心角色然后直接给一份能复用的实现代码最后再用一个聊天室消息分发器的实战例子串一遍整个流程同时把我在实际工程里踩过的几个坑也一并写出来。适合对设计模式有基础了解、想把它落地到C项目中的开发者参考。1. 从一团乱麻到中介者对象网状耦合的痛点1.1 一个让人头疼的真实场景GUI控件互相联动我们先回到最经典的场景——对话框控件联动。假设你维护一个设置界面里面有复选框A启用高级选项、下拉框B日志级别、文本输入框C日志文件路径和按钮D保存配置。业务规则是这样的A被勾选后B和C才允许操作B选择了不同级别C里的提示文字要跟着变C的内容合法时D才会亮起A取消勾选B和C恢复禁用D变灰。如果你直接在控件类里互相持有对方的指针去调用代码会长成什么样子大概是下面这样void Checkbox::onToggled(bool checked) { dropdown_-setEnabled(checked); textbox_-setEnabled(checked); if (!checked) { saveButton_-setEnabled(false); } } void Dropdown::onSelectionChanged(int index) { if (index 0) { textbox_-setPlaceholder(默认路径); } else { textbox_-setPlaceholder(自定义路径); } saveButton_-setEnabled(!textbox_-getText().empty()); } void Textbox::onTextChanged(const std::string text) { saveButton_-setEnabled(!text.empty()); }这段代码看起来还行问题出在它只是冰山一角。真实项目里一个对话框可能有十几个控件联动规则比这复杂得多。A可能依赖B的某个状态B又依赖C和DD还会反向影响A。每个控件类里头都要塞三四个外部伙伴的指针初始化的时候还得逐个set进去。1.2 网状耦合的本质为什么直接引用会失控我们把控件看成节点把直接调用另一个控件方法看成一条连线。N个控件两两交互最坏情况下会产生 N×(N-1)/2 条连线。四个控件是6条十个控件就是45条。这种结构在代码层面的直接后果有三点第一编译依赖被打散又被迫集中。本来一个控件只需要关心自己现在为了调用别人头文件里要么include别人的完整定义要么频繁用前置声明指针。改了某个控件的接口所有跟它相连的类重新编译。第二可读性急剧下降。控件和控件之间的业务规则散落在各自的实现里你根本说不清A勾选后B和C要禁用这条完整规则到底写在哪一个类里。新人接手代码只能沿着引用一个一个点进去看。第三复用基本没戏。你想把这套控件搬去另一个对话框发现它们每一个都耦合了其他控件的具体类型只能一起搬走连带着搬走一堆用不上的依赖。1.3 中介者的核心思路对象间不再私聊统一群聊中介者模式的思路其实特别朴素不让他们两两打电话了先打到总机由话务员转接。每个控件只认识中介者对象Mediator自己状态改变了就发一条事件通知给中介者中介者内部维护了一条完整的业务规则表知道这个事件来了接下来要触发谁、调用什么。这样N×(N-1)/2条连线直接降为N条。上面那个对话框例子重构之后就变成Checkbox只告诉Mediator我改变了当前状态是已勾选Mediator收到后根据规则去调Dropdown::setEnabled(true)、Textbox::setEnabled(true)Dropdown也只发事件后续改哪里的文字由Mediator决定所有控件仍然彼此不直接引用。你删掉任何一个控件类型其他控件类连编译错误都不报只要Mediator里少写一行调用就行。这种解耦带来的改动局部化正是中介者模式最核心的价值。2. 中介者模式的核心角色拆解谁在当调度中心2.1 四个参与者的职责边界在C里落中介者模式你至少要有四个角色我直接用前面的对话框例子说明它们的对应关系角色抽象/接口具体类职责中介者接口Mediator声明所有同事对象共用的事件入口通常叫notify或者onEvent定义同事与调度中心通信的契约具体中介者ConcreteMediator对话框本身保存所有同事的引用承载业务联动规则决定事件如何分发同事基类Colleague所有控件的公共基类持有中介者的指针提供sentEvent这类受保护方法给子类使用具体同事类Checkbox、Dropdown等具体的控件类在自身状态变化时发事件不关心其他同事是否存在这里最容易被忽略的是Colleague基类。很多人图省事直接在控件类里各自写一个mediator_-notify(...)的调用结果每个控件都要手动保存中介者指针代码重复。提供一个基类把这些公共逻辑收拢后续新增控件类型就能少写很多样板代码。2.2 为什么C里中介者接口更适合抽象基类而非std::function有朋友问过我一个问题中介者的通知入口就一个notify搞这么多接口是不是小题大做直接用std::functionvoid(Colleague*, const std::string)传给每个控件不行吗在回调数量很少的场景下std::function确实可以。但中介者模式里事件分发往往是多路分支的——不同事件类型对应不同处理逻辑后续大概率还要加查询状态获取某个同事之类的同步方法。std::function只解决了调用入口这一个问题后面的分支还是得落在某个地方。抽象基类的好处是事件入口和其他交互方法可以一起放进接口统一约束编译器帮你兜底子类漏实现某个方法会直接报错。我的建议是如果只是几个回调std::function可以一旦发现需要在多个地方分发多种事件就规规矩矩用抽象基类。别混着来混到最后接口边界极其模糊。2.3 同事对象如何认识中介者注册与通知流程同事对象要联系上中介者常见做法有两种。一种是构造函数注入class Checkbox : public Colleague { public: explicit Checkbox(Mediator* mediator) : Colleague(mediator) {} };另一种是setter注入class Checkbox : public Colleague { public: void setMediator(Mediator* mediator) override { mediator_ mediator; } };工程上我更偏好构造函数注入理由很实际它强制对象在创建时就必须把中介者绑定好不可能出现忘了调setMediator导致通知为空指针的运行时问题。唯一需要setter的场景是中介者本身创建得比较晚或者同事对象要用工厂统一构建没法在构造时传入这种情况才退而求其次。整个通知流程是单向的状态变化 → 调mediator_-notify(this, event)→ 中介者根据this和event查规则表 → 调用目标同事的公开方法。注意事件里要把this带上否则中介者无法区分事件是哪个同事发出来的。3. 一份可直接复用的C中介者实现从接口设计到生命周期管理3.1 完整代码实现接口与基类下面这份代码是我从一个实际项目里提炼出来的标准形态C11以上都可编译。先把接口和基类亮出来。// mediator.h #pragma once #include string #include memory // 前置声明避免头文件互相include class Colleague; class Mediator { public: virtual ~Mediator() default; // 同事发事件统一走这个口 virtual void notify(Colleague* sender, const std::string event) 0; }; class Colleague { public: explicit Colleague(Mediator* mediator) : mediator_(mediator) {} virtual ~Colleague() default; // 禁止拷贝避免出现两份同事对象指向同一个中介者导致的混乱 Colleague(const Colleague) delete; Colleague operator(const Colleague) delete; protected: void sendEvent(const std::string event) { if (mediator_) { mediator_-notify(this, event); } } Mediator* mediator_; };要点有两处。Colleague的拷贝构造我直接delete掉了这是用实际教训换来的——同事对象如果被拷贝一份拷贝体里的mediator_还是那个指针但它自己跑去注册进中介者的事件表就会出现一个事件被处理两次的怪异现象。某些同事类是值语义的真要拷贝就要显式重新绑定中介者别让编译器帮你悄悄生成默认拷贝。sendEvent是受保护方法好处是只有子类自身能发起通知外部代码无法绕过中介者直接触发某个同事的事件流分发的统一性就保住了。3.2 具体中介者把业务规则集中到一个地方接着来看具体中介者和同事类的实现。这里我用对话框的简化版示范注意中介者里直接持有同事的裸指针。// dialog_mediator.h #pragma once #include mediator.h class Checkbox; class Dropdown; class Textbox; class SaveButton; class DialogMediator : public Mediator { public: void setCheckbox(Checkbox* cb) { checkbox_ cb; } void setDropdown(Dropdown* dd) { dropdown_ dd; } void setTextbox(Textbox* tb) { textbox_ tb; } void setSaveButton(SaveButton* sb) { save_button_ sb; } void notify(Colleague* sender, const std::string event) override; private: Checkbox* checkbox_ nullptr; Dropdown* dropdown_ nullptr; Textbox* textbox_ nullptr; SaveButton* save_button_ nullptr; };实现文件里就是具体规则// dialog_mediator.cpp #include dialog_mediator.h #include controls.h void DialogMediator::notify(Colleague* sender, const std::string event) { if (sender checkbox_ event toggled) { bool checked checkbox_-isChecked(); dropdown_-setEnabled(checked); textbox_-setEnabled(checked); if (!checked) { save_button_-setEnabled(false); } return; } if (sender dropdown_ event selection_changed) { textbox_-setPlaceholder(dropdown_-currentItem()); save_button_-setEnabled(!textbox_-getText().empty()); return; } if (sender textbox_ event text_changed) { save_button_-setEnabled(!textbox_-getText().empty()); return; } }这个中介者的notify看起来就是一堆if但它把所有联动规则集中到了一个文件里头。以后要改规则只需要打开dialog_mediator.cpp对照着改不用在四个控件类之间来回跳。某个控件的事件不需要分发时直接不写那个分支就行其他控件完全感知不到改动。代码风格上我刻意保持朴素没在notify里写多个tab页的巨大switch也没上反射。因为中介者这种集中式规则表本质上面积不会太大非要提取得过分抽象反而增加阅读负担。3.3 为什么我不在实现里用shared_ptr持有同事对象这里必须解释一个关键选择DialogMediator里我用的是裸指针不是shared_ptr或unique_ptr。原因有三个。第一中介者并不拥有同事对象的生命周期。在对话框的场景中控件通常由界面布局对象持有或者直接创建在栈上。中介者只是借用这些指针来调用方法一旦生命周期所有权交给中介者析构顺序就很难控制容易造成二次释放。第二如果中介者持有shared_ptr而同事对象又持有中介者的shared_ptr就形成了循环引用两个对象都析构不掉内存泄漏。想打破循环就得引入weak_ptr系统复杂度直接翻倍。第三裸指针配合先创建同事、再set给中介者的顺序配合断言可以尽早发现中介者里引用了一个已析构对象的问题。我习惯在notify入口处加一行断言检查所有指针非空调试期就能抓到多半问题void DialogMediator::notify(Colleague* sender, const std::string event) { assert(checkbox_ dropdown_ textbox_ save_button_ sender); // 后续分发逻辑 }3.4 事件标识的选择枚举、字符串还是 constexpr 常量notify的第二参数event我用的是std::string。有经验的C开发者会立刻问为什么不用enum class字符串拼错了怎么办说实话enum class的类型安全确实更好。但中介者的事件标识不一定限定在一个封闭集合里——你可能要对接外部模块传来的一堆字符串消息事件名会动态出现。用枚举就得提前把所有事件都列出来灵活性大打折扣。工程上比较稳的折中方案是定义一个命名空间常量把所有事件名集中管理// events.h #pragma once namespace events { inline constexpr const char* kToggled toggled; inline constexpr const char* kSelectionChanged selection_changed; inline constexpr const char* kTextChanged text_changed; } // namespace events使用方写sendEvent(events::kToggled)其实是把字符串的拼写错误风险降低到了和枚举差不多的程度又保留了字符串的灵活性。我推荐这个方案既能拿字符串的好处又不会让事件名散落在代码各处。4. 实战演练用一个聊天室消息分发器打通全流程4.1 从控件联动到消息分发场景设定对话框控件的例子是中规中矩的中介者。接下来去掉GUI背景换个更贴近服务端开发的场景一个简单的聊天室消息分发器。我们有三种角色User代表一个聊天参与者可以发消息、收消息SystemBot自动回复某些关键词消息RoomLog把每一条消息记录到日志直接在User之间互相持有对方指针去发送消息会变成O(N^2)的全连接结构——每来一个新用户所有人都得知道他的地址。而聊天室天然就应该有一个中心来统一管理成员、接收消息、决定消息是广播给所有人还是定向发给某个人。这个中心就是中介者ChatRoom。4.2 核心代码注册、广播与定向投递先定义同事基类和事件类型。这里我把User、SystemBot和RoomLog都当作同事来处理。// chat_room.h #pragma once #include unordered_map #include string #include functional #include mediator.h enum class MessageType { Broadcast, // 所有用户都能看到 Direct, // 定向投递比如私聊 System // 系统级别的通知 }; struct ChatMessage { std::string from; std::string content; MessageType type; }; class ChatRoom : public Mediator { public: void notify(Colleague* sender, const std::string event) override; void registerUser(Colleague* user, const std::string name); void unregisterUser(const std::string name); void deliverMessage(const ChatMessage msg); private: std::unordered_mapstd::string, Colleague* users_; };同事类User的声明// user.h #pragma once #include string #include mediator.h class User : public Colleague { public: User(Mediator* mediator, std::string name) : Colleague(mediator), name_(std::move(name)) {} void sendMessage(const std::string content); void sendDirectMessage(const std::string to, const std::string content); void receiveMessage(const ChatMessage msg); const std::string name() const { return name_; } private: std::string name_; };实现关键在User::sendMessage用户对象不直接遍历其他用户它只是组装好一个ChatMessage通过sendEvent发给ChatRoom。// user.cpp #include user.h #include chat_room.h void User::sendMessage(const std::string content) { ChatMessage msg{name_, content, MessageType::Broadcast}; sendEvent(user_message); // 注意ChatRoom后续如何拿到msg // 这里有个设计点下面会解释。 }这里卡住了一个常见问题sendEvent只传了事件名没传消息体。要解决这个问题我在Colleague基类里扩展一个方法让子类能携带自定义数据class Colleague { public: // 原有的 sendEvent 保留 protected: template typename... Args void sendEventWithArgs(const std::string event, Args... args) { if (mediator_) { mediator_-notifyWithArgs(this, event, std::forwardArgs(args)...); } } };对应的Mediator接口也要跟上class Mediator { public: virtual ~Mediator() default; virtual void notify(Colleague* sender, const std::string event) 0; // 带参数的版本默认实现转发到 notify template typename... Args void notifyWithArgs(Colleague* sender, const std::string event, Args... args) { notify(sender, event); } };ChatRoom里可以对带参数的版本做特化。用模板特化还是std::any这里我不引入过重的机制实际项目里我用的是让notify接受一个std::shared_ptrvoid类型的负载分发时再强转回具体类型。void ChatRoom::notify(Colleague* sender, const std::string event) override { // 这里只做事件路由详细消息体从事件里携带 if (event user_message) { auto msg pending_message_; if (!msg) return; if (msg-type MessageType::Broadcast) { for (auto [name, user] : users_) { if (user ! sender) { static_castUser*(user)-receiveMessage(*msg); } } } else if (msg-type MessageType::Direct) { Colleague* target users_[msg-to]; if (target) { static_castUser*(target)-receiveMessage(*msg); } } } }这只是一个演示用的简化版本实际工程里负载用std::any、std::variant或者直接增加不同的notify重载都可以。关键是思路事件名决定要不要处理消息体决定怎么处理两者分离。4.3 测试驱动怎么验证中介者逻辑是对的写了中介者之后最怕一个问题事件满天飞测试特别难写。好消息是中介者模式天然对测试友好原因在于你可以往中介者里注入Fake同事对象然后验证调用。比如我要测试User A发广播消息User B应该收到User C没登录就不该收到// test_chat_room.cpp #include cassert #include chat_room.h #include user.h class MockUser : public User { public: using User::User; int receive_count 0; std::string last_content; void receiveMessage(const ChatMessage msg) override { receive_count; last_content msg.content; } }; void test_broadcast_message() { ChatRoom room; MockUser alice(room, alice); MockUser bob(room, bob); room.registerUser(alice, alice); room.registerUser(bob, bob); alice.sendMessage(hello, world); assert(bob.receive_count 1); assert(bob.last_content hello, world); }测试的关键在于有了中介者你可以直接控制registerUser的顺序构造各种边界情况比如发送者还没注册就发消息同一个名字注册两次之类的场景。这些要是没有中介者你得先构造一堆用户对象的关系测试代码写得比业务代码还累。5. 该用和不该用的边界中介者与观察者/命令模式的取舍5.1 三种模式各管什么事中介者模式经常和观察者模式Observer、命令模式Command混在一起因为它们都涉及到事件回调解耦。实际工程里我见过有人把三者揉成一团最后谁也说不清哪个是哪个。模式核心用途典型场景关键区别中介者把多个对象之间的交互逻辑集中到一处对话框控件联动、聊天室消息分发同事对象之间完全隔离交互由中介者调度观察者一个对象状态变化多个订阅者收到通知数据模型变更驱动UI刷新发布者不关心谁订阅订阅者自主注册命令模式把一次操作封装成一个对象支持延迟/撤销/排队编辑器里的Undo/Redo、任务队列重点是操作的封装与可撤销不是对象间的互相通信中介者和观察者最大的区别是观察者里发布者不知道订阅者具体是什么类型也不需要知道中介者则是明确知道每个同事是谁并且有一套完整的业务规则来决定怎么调度它们。中介者比观察者更偏中心化观察者更偏去中心化。5.2 中介者的上帝对象危机如何破解中介者模式被吐槽最多的点是具体中介者容易膨胀成一个啥都干的上帝对象。在一个复杂系统里所有业务规则都堆在ChatRoom::notify里方法体越写越长最终变成一个没人敢动的巨无霸。我在实践中用的做法是把大型中介者按业务域拆成多个小中介者每个小中介者只负责一组高内聚的同事对象。比如上面的聊天室可以拆成MembershipMediator负责用户注册、注销、在线状态管理MessageMediator负责消息广播、定向投递ModerationMediator负责敏感词过滤、禁言操作同事发事件时先发给上一层的聚合门面再由门面决定路由到哪个子中介者。这样每个子中介者的notify体积都被限制在可控范围内。还有一种更轻的缓解做法不在notify里写具体的业务逻辑而是把每个事件的处理函数拆成独立的方法notify只做路由void ChatRoom::notify(Colleague* sender, const std::string event) { if (event user_message) { handleUserMessage(sender); } else if (event user_joined) { handleUserJoined(sender); } else if (event user_left) { handleUserLeft(sender); } }这样的好处是每个handle方法都能单独测试不至于为了测一条消息分发逻辑得先把五十行分支全部mock掉。5.3 什么时候该上中介者我的三条判断准则我给自己定了三条简单的判断标准满足其一时就值得考虑中介者第一两个以上的对象需要互相感知并联动。如果只是单向通知观察者就够用了上中介者属于杀鸡用牛刀。第二对象之间存在改一个就要牵动一串的规则。你发现每次修改业务规则都要同时改三四个类说明交互逻辑分散在这些类里了应该集中到一个中介者里。第三你需要在运行时动态调整交互关系。中介者里维护同事列表可以随时增删这一点在聊天室这种成员动态进出的场景下非常关键。直接用对象互相引用的话动态增删一个成员要同时改好多处。反过来如果两个类的交互非常固定、很少变化中介者反而是多余的。硬套中介者只会多出一层间接调用读代码要多跳一次维护成本没有降低反而增加。6. 实现中的常见坑与我的调试心得6.1 坑一通知链路里的循环调用中介者把交互集中之后一个事件可能触发新的状态变化新的状态变化又发事件。一旦规则设计不清就会出现A发事件 → 中介者调B → B内部又发事件 → 中介者调A → A再发事件……递归无限加深最后栈溢出。我在一个消息推送系统里真的遇到过用户上线触发通知通知模块给中介者发已通知事件中介者又回头查询用户状态并再次推送。规避方法有两个。第一个是在中介者里加一个处理中标志位class ChatRoom : public Mediator { private: bool handling_ false; }; void ChatRoom::notify(Colleague* sender, const std::string event) { if (handling_) return; // 防重入 handling_ true; // 处理分发逻辑 handling_ false; }但这个方法过于粗暴——它会丢弃重入事件而不是排队处理。第二个更精细的做法是事件去重给每个事件按发送者事件名序号做一个短期记录重复的事件直接忽略。不过我的经验是循环调用本质上反映了规则设计有问题好端端的通知链路不应该出现反馈回路。优先考虑的是让事件流变成单向的DAG而不是在运行期打补丁。6.2 坑二中介者持有裸指针同事析构后悬垂中介者持有同事的裸指针最大的风险是同事先于中介者析构。比如某个用户退出聊天室时先销毁了User对象但ChatRoom的users_里还留着一个悬垂指针。下一次广播消息遍历到那个用户调用receiveMessage就直接未定义行为。这类问题排查起来非常痛苦因为它只在特定时序下出现。我现在的经验是两条腿走路一是在同事析构时主动告诉中介者我要走了。在聊天室场景里就是统一的leaveRoom接口在GUI场景里对应控件从父窗口移除时调用unregister。这个注销动作要贯穿所有脱离中介者管理的路径包括异常分支。二是在调试期用智能指针辅助检测。同事对象实际生命周期由外部管理外部持有它的shared_ptr中介者里存weak_ptr。分发消息前lock()一下优秀的编译器在debug模式还能触发一些引用计数检查能帮我们提前暴露某处持有了一个即将失效的引用。class ChatRoom : public Mediator { private: std::unordered_mapstd::string, std::weak_ptrColleague users_; }; void ChatRoom::deliverMessage(const ChatMessage msg) { if (msg.type MessageType::Broadcast) { for (auto it users_.begin(); it ! users_.end();) { auto user it-second.lock(); if (!user) { it users_.erase(it); // 顺手清理失效项 continue; } static_castUser*(user.get())-receiveMessage(msg); it; } } }这个写法比裸指针安全得多代价是同事对象创建时必须接受shared_ptr管理。如果同事对象必须栈上分配那就要严格保证先创建同事、后释放同事。我的体会是别在这种地方省内存操作悬垂指针的调试成本远高于多写几个weak_ptr。6.3 坑三事件标识的拼写错误与难调试用字符串当事件名拼写错误是跑不掉的编译期漏网之鱼。sendEvent(message_broadcast)和notify里的mesage_broadcast只差一个字母运行期静默失效查半天。我解决这个问题的办法前面提过集中在命名空间常量里定义。但还有一个更实用的调试技巧——在Mediator::notify入口打印日志把事件名和发送者关键信息打出来。void ChatRoom::notify(Colleague* sender, const std::string event) { log(ChatRoom::notify sender sender-name() event event); // 路由逻辑 }不要小看这一行日志。线上排查消息没送达的问题时如果能看到notify被调用了但后续分发没生效问题就能定位到ChatRoom内部的规则分支如果连notify都没被调那问题在同事对象发事件的那一步排查范围直接缩小一半。6.4 关于性能的补充中介者会不会成为瓶颈中介者把通信集中到一点自然会有人担心性能是不是所有消息都要经过中介者转发中介者会成为吞吐瓶颈通信量不大时这种担心完全多余一次中介者调用只是多了一层间接调用现代C编译器还会内联额外开销微乎其微。真正需要担心的是中介者内部处理逻辑太重比如广播时挨个调用同事方法如果同事数量很大且每次都有重I/O那确实该考虑加中间队列或者改异步。我的经验是先把中介者做对做清楚再考虑性能优化。为了一点可能存在的开销放弃模式带来的结构清晰是典型的本末倒置。若真到了需要优化的时候有个很自然的路径——让中介者在队列通知和同步分发之间做切换同事接口不用改影响面可控。最后再分享一点实际体会中介者模式在C里实现起来门槛不高难的是掌握什么时候用、什么时候千万别用的分寸。我自己吃过亏也见过别人把本来简单的两个类硬套中介者结果代码变得更绕。说穿了中介者是给多对多复杂交互准备的不是给一对多简单事件准备的。如果要在项目里引入它我建议从聊天室或者对话框这种有明显的中心节点形态的业务入手先让整个链路跑通再逐步把规则收拢进去。重构过程中把界面、消息模块里互相渗透的控制逻辑一点点剥离出来你会发现代码的可读性和可测试性慢慢就上来了。中介者不会帮你写出更精妙的算法但它能让你的项目在长大之后不至于变成一个牵一发动全身的泥潭。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑