C++中介者模式实战:拆解对象蜘蛛网的完整指南
如果你在C项目里见过几十个对象互相持有指针、回调里面套回调的场面那你大概率体验过什么叫“对象蜘蛛网”。改一个东西要顺着引用链摸半天加一个模块要动七八个类测试的时候根本分不清是谁调了谁。中介者模式就是专门用来拆这张网的它把一组对象之间的交互从每个对象彼此直接通信收敛到由一个中介者对象统一转发和协调。C设计模式里它属于行为型模式同时也是面试里频率不低的八股题但真正在工程里能把它的边界划清楚的人其实不多。我写这篇东西的初衷是把自己在实际项目中用中介者模式的经验摊开来讲一遍包括原理拆解、C实现的关键细节、完整可运行的示例代码以及哪些场景真正适合用它、哪些场景用了反而添乱。无论你是刚开始学C设计模式还是已经写过几年业务代码想找一套更清晰的对象协作方案这篇内容应该都能给你一些可以直接落地的参考。1. 中介者模式解决的核心问题——对象之间的“蜘蛛网”1.1 没有中介者时对象交互为什么会失控先想一个很常见的业务场景界面上有一个输入框、一个确定按钮、一个表格列表、一个状态栏。用户往输入框里敲字按钮要跟着变色点下按钮表格要刷新表格刷新完状态栏要显示当前记录数如果输入了非法字符按钮又得置灰。你写代码的时候自然会让每个控件之间互相通信输入框要把内容发给按钮按钮要通知表格表格要通知状态栏。页面小的时候还可以接受可一旦控件数量多起来对象之间的关系就开始失控了。你拿笔在纸上画一下引用关系就会发现每两个需要联动的对象之间都有一根线最后画出来的是一张网。这种网状结构带来的直接后果是新增一个对象时你必须找到所有需要和它交互的老对象逐一加引用、加调用逻辑。某个对象的内部实现变化时所有直接依赖它的对象都可能要跟着改。排查问题时你根本不知道一次用户操作到底沿哪条路径触发了几个对象调试器里堆栈深深的一层。单元测试极其痛苦为了测一个按钮你得把输入框、表格、状态栏全部构造出来甚至还得模拟它们的各种状态。这不是某个特定项目独有的毛病而是所有“多对多直接交互”的结构都逃不掉的复杂度。早年我维护过一套遗留的MFC界面代码对话框里二十几个控件互相new来new去有个窗口销毁时析构顺序不对直接崩溃查了两天才发现是某个控件在另一个控件已经释放之后还去调用它的指针。这种问题一旦出现你本能地会想能不能中间加一层统一调度的东西让控件们不要再直接认识对方了1.2 中介者模式的中心化思路把网状改成星型中介者模式的回答非常直接把多对多的网状交互压缩成一对一多个同事对象对单个中介者的星型交互。所有对象不再直接互相引用而是各自持有一个中介者的引用当某个对象发生变化时它只通知中介者具体要转发给谁、怎么处理全部由中介者去协调。用一个生活化的类比来理解很容易群聊。没有群聊之前你想把一条消息告诉五个人得分别给五个人发五遍而且每个人回复你之后你还得决定要不要把回复再转发给别人。有了群聊群中介者所有人都只往群里发一条消息谁该看、谁不用看由群聊服务器决定。同事们依旧互相不认识但信息却准确到达了该去的地方。引入中介者之后原来的对象网就变成了一个以中介者为中心的星型结构。这里涉及两个核心角色Mediator中介者定义统一的通信接口负责协调各个同事对象之间的交互。Colleague同事所有需要协作的对象都是同事每个同事只和中介者通信不直接依赖其他同事。这套结构的价值在于把“交互的控制权”从同事对象手里收走统一放到中介者手里。同事对象只关心自己的业务我产生了变化我把变化告诉中介者剩下的与我无关。而中介者集中管理所有交互规则哪些对象要响应、按什么顺序响应、是否需要阻止某些响应这些逻辑都写在中介者里。2. C实现中介者模式的核心元件与设计要点2.1 抽象接口与依赖倒置为什么两个接口缺一不可在C里落地中介者模式最基本的做法是先定义两个抽象基类一个给中介者一个给同事。注意这里说的是“基类”不是说直接把具体的ChatRoom和User抛出来用。为什么要多这一层抽象因为想要同事对象不依赖具体的中介者实现就得把它编程到抽象接口上——这是依赖倒置原则在模式里的应用。具体来说同事对象里持有一个IMediator*指针它只知道自己能和中介者通信完全不关心背后到底是个聊天室、一个窗口管理器还是一个消息调度中心。这样带来的直接好处是以后想换一种中介者实现或者给中介者加一层装饰逻辑同事对象一行都不用改。先看这两个接口的样子#include string // 抽象中介者 class IMediator { public: virtual ~IMediator() default; // 由具体中介者实现用于协调同事之间的通信 virtual void sendMessage(const std::string sender, const std::string message) 0; }; // 抽象同事 class IColleague { public: explicit IColleague(IMediator* mediator) : mediator_(mediator) {} virtual ~IColleague() default; virtual void send(const std::string message) 0; virtual void receive(const std::string from, const std::string message) 0; protected: IMediator* mediator_; // 只依赖抽象中介者 };IColleague里那个mediator_是整个模式的核心纽带但它只是一个抽象接口指针。这里多说一句IColleague的构造函数直接要求传入中介者指针意思是“一个同事对象入局时就必须绑定某个中介者”这对生命周期管理来说很重要避免出现一个没有中介者的同事对象放在那儿无法通信的情况。接口设计上有一个值得注意的细节中介者的sendMessage接收一个sender参数加一个message参数而同事的receive接收from加message。发送方和接收方的身份信息被显式地传递是为了让中介者有能力实现“某些消息不让某个特定的人收到”之类的过滤逻辑。如果你的场景不需要身份区分也可以把sender去掉接口可以更干净但通用性会弱一些。我个人的经验是先保留身份参数哪怕现在没用上后面扩展筛选、路由、日志时你会感谢当初的自己。2.2 C特有麻烦智能指针、循环引用与生命周期聊C就不能只画UML图内存管理是绕不过去的坎。中介者模式在C里最容易翻车的地方之一就是生命周期管理。先理清楚对象之间的关系中介者需要知道有哪些同事在它这儿注册所以它通常要持有同事的指针同事又需要向中介者发消息所以同事也要持有中介者的指针。如果两边都是std::shared_ptr那就会出现循环引用中介者引用同事同事引用中介者引用计数永远归不了零两个对象谁都析构不了内存就泄漏了。实际项目里常见的解法有这么几种中介者持有同事的std::shared_ptr同事持有中介者的裸指针或std::weak_ptr。这样同事销毁不会影响中介者中介者析构时才会真正释放所有同事。两边都用裸指针但由外部统一管理生命周期。适合对象创建和销毁都有明确时序的小型场景但风险在于一旦时序乱了就悬垂指针崩溃。中介者只保存同事的std::weak_ptr同事持有中介者的std::shared_ptr。这和第一种方向相反适合中介者比较轻、随时可能被替换的场景。从实际工程经验看我比较推荐第一种核心业务对象同事用shared_ptr正常管理中介者只登记它们的weak_ptr这样既能避免循环引用又能让中介者在转发消息时通过lock()判断同事是否还健在。后面写示例代码时我会把这个方案落实下来。还要提醒一个很多人第一次实现时会忽略的事同事对象注册进中介者和取消注册的时机。如果在构造时才注册、析构时不取消注册那么中介者里就可能残留指向已析构对象的指针。用weak_ptr能在一定程度上兜底但最理想的做法还是在中介者里提供一个remove接口在同事析构时主动调一下。注意顺序要先移除再进入基类析构否则虚表可能已经被切掉了。3. 从零实现一个可运行的C聊天室示例3.1 工程结构确定谁是谁的什么人聊完设计我们来做一个能真正编译运行的中介者模式示例。场景选聊天室因为它的逻辑直观多个用户是同事聊天室是中介者用户不发消息给另一个用户而是发给聊天室聊天室负责广播给其他人。类关系是这样的ChatRoom继承IMediator内部维护一个用户名到用户对象的映射收到某个用户的消息后遍历所有用户把消息转发给除发送者以外的其他人。ChatUser继承IColleague核心逻辑很简单——send时调用中介者的sendMessagereceive时把收到的消息打印出来。为了让示例兼顾正确性和内存安全性我采用中介者用weak_ptr登记用户的方式。这样即便某个用户在聊天室中间被销毁聊天室广播时也不会通过悬垂指针去访问它。3.2 完整代码实现与逐段说明下面给出完整的代码你复制到一个cpp文件里就可以直接编译运行。我用的是C17主要用到std::map、std::weak_ptr、std::shared_ptr和结构化绑定。#include iostream #include map #include memory #include optional #include string #include vector // 抽象中介者 class IMediator { public: virtual ~IMediator() default; // 把一个用户发出的消息转发给所有相关的同事 virtual void sendMessage(const std::string sender, const std::string message) 0; }; // 抽象同事 class IColleague { public: explicit IColleague(IMediator* mediator) : mediator_(mediator) {} virtual ~IColleague() default; virtual void send(const std::string message) 0; virtual void receive(const std::string from, const std::string message) 0; protected: IMediator* mediator_; }; // 具体同事聊天用户 class ChatUser : public IColleague { public: ChatUser(IMediator* mediator, std::string name) : IColleague(mediator), name_(std::move(name)) {} const std::string getName() const { return name_; } void send(const std::string message) override { std::cout [我] name_ 发送消息: message std::endl; mediator_-sendMessage(name_, message); } void receive(const std::string from, const std::string message) override { std::cout [ name_ ] 收到来自 from 的消息: message std::endl; } private: std::string name_; }; // 具体中介者聊天室 class ChatRoom : public IMediator, public std::enable_shared_from_thisChatRoom { public: void join(const std::shared_ptrChatUser user) { users_[user-getName()] user; std::cout user-getName() 加入了聊天室 std::endl; } void leave(const std::string name) { users_.erase(name); std::cout name 离开了聊天室 std::endl; } void sendMessage(const std::string sender, const std::string message) override { // 遍历当前所有用户除了发送者之外都转发 for (auto it users_.begin(); it ! users_.end();) { if (it-first sender) { it; continue; } if (auto user it-second.lock()) { user-receive(sender, message); } else { // 用户已销毁顺手清理掉 it users_.erase(it); continue; } it; } } private: std::mapstd::string, std::weak_ptrChatUser users_; }; // 为了演示生命周期安全写一个小工具函数 std::shared_ptrChatUser createUser(ChatRoom room, const std::string name) { auto user std::make_sharedChatUser(room, name); room.join(user); return user; } int main() { // 中介者必须比同事活得更久这里用栈对象管理中介者 ChatRoom room; auto alice createUser(room, Alice); auto bob createUser(room, Bob); auto charlie createUser(room, Charlie); alice-send(Hello, everyone!); bob-send(Hi, Alice!); // 演示一个用户中途离开 charlie-send(Im leaving now.); room.leave(Charlie); alice-send(Goodbye, Charlie!); return 0; }这段代码有几点值得说明。ChatRoom单独继承了std::enable_shared_from_this但实际上在这个示例里我们用栈对象管理ChatRoom同事用的是裸指针所以这个继承并不是必需的我特意加上的目的是提示你如果哪天安排ChatRoom实体也用shared_ptr管理那么同事注册进去的shared_from_this()就能派上用场。实际项目中我通常把中介者也丢进一个shared_ptr然后创建一个全局的weak_ptr给同事用。再看join和leave。join负责把一个已构造好的用户登记进聊天室leave则从映射里删掉同时打印日志。你可能注意到了createUser先创建用户再调用room.join这里顺序不能反过来。因为ChatUser的构造函数必须得传一个IMediator指针而用户还不存在时又没法把它加入聊天室所以只能先造出来再注册。这个“构造时传中介者、构造后注册”的方式是中介者模式在C里的标准落地手法。3.3 运行过程拆解消息到底是怎么流动的我们假设程序跑起来用户依次是Alice、Bob、Charlie。当Alice调用send(Hello, everyone!)时完整的调用路径如下ChatUser::send被调用首先打印自己的发送日志然后调用mediator_-sendMessage(Alice, Hello, everyone!)。这里的mediator_是指向ChatRoom的裸指针。ChatRoom::sendMessage遍历自己的users_映射发现Alice是发送者跳过Bob在线把Alice和Hello, everyone!交给Bob::receive。Bob::receive打印出收到的消息。然后继续遍历Charlie也收到同样的消息。当Bob后续调用send(Hi, Alice!)时聊天室会把这个消息转发给Alice和Charlie但不会发回给Bob自己。这个流程的核心点在于同事对象之间没有任何直接引用Alice根本不知道Bob和Charlie的实体存在更不持有它们的指针。所有转发决策都收敛在ChatRoom::sendMessage这一个函数里。如果你想以后加一条“某某用户被禁言”的规则只需要在sendMessage里做一次过滤不需要每个用户都改一遍。动作上还有一点容易被忽略如果你想让单个用户能收到自己发的消息比如界面上显示自己发送的记录可以在sendMessage里去掉if (it-first sender)的判断让发送者也收到一份。这种细颗粒度的转发策略调整不改同事对象只改中介者就是中介者模式优化协作逻辑的主要操作位置。4. 典型应用场景与模式选型4.1 哪些场景真正适合中介者模式聊天室只是一个教学示例真实项目中中介者模式的应用要广泛得多。我梳理了自己在实际项目和一些高并发后台系统里见过、写过的几类典型场景。第一个是GUI组件协调。不管是MFC、Qt还是自研界面框架一个表单里往往有多个控件需要联动比如下拉框选中值会影响表格列显隐复选框会影响按钮可用状态。把这些联动逻辑塞进各个控件里会非常痛苦但用一个DialogController作为中介者就清爽得多每个控件只通知控制器“我变了”控制器统一决定其他控件怎么变。第二个是游戏对象协作。游戏里一群NPC、玩家、怪物之间往往有复杂的互相感知和技能效果交互。A技能命中BB掉血后通知C……如果每个实体都持有其他实体列表那新增一个实体类型时你的耦合度会爆炸。一个战斗管理器作为中介者所有实体上报自己的状态变化管理器统一结算伤害、仇恨、Buff这个结构在MMO里非常常见。第三个是消息调度中心。后台服务里客户端连接管理器、登录服务、房间管理器、日志系统这些模块之间无需互相知道对方的存在。它们都把要处理的事项抛给一个中央调度器调度器根据消息类型路由到对应的处理器。这种风格其实已经接近消息队列的雏形了只是中介者通常是进程内的同步调用而消息队列通常是跨进程或异步的。第四个是机场塔台或者交通管制这类模拟系统。飞机同事不需要认识彼此它们只需要向塔台中介者报告位置和意图塔台决定谁可以起飞、谁需要等待。这个例子几乎是教科书级别的“中介者模式解决复杂协作”的写照。你可能会问这些场景是不是都能用别的方案替代答案是可以。比如GUI可以通过信号槽解耦游戏可以用事件系统后台可以用消息队列。但这些替代方案本质上都在追求同一件事让对象之间不直接互相引用。中介者模式是其中最直接、最轻量的一种实现思路不需要引入额外的消息框架用C本身的虚函数多态就能完成。4.2 中介者模式和观察者模式到底差在哪选择设计模式时人们最容易混淆的就是中介者模式和观察者模式因为它们都能实现对象间解耦代码结构还有点像。我自己也踩过这个坑写了一个类似聊天室的逻辑用了观察者模式重构最后发现本质上写的还是一个中介者。两者的差异可以从关系结构和控制流两个角度看对比维度中介者模式观察者模式关系结构多对多关系收敛为中心化的星型结构一对多关系Subject持有多个Observer列表通信方向同事对象之间可以双向通信中介者负责双向转发和协调通常是单向的Subject状态变化通知ObserverObserver更新自己决策位置交互规则集中在中介者里哪个对象该响应、按什么顺序响应都由中介者决定Observer自行决定是否响应Subject不关心典型场景多个模块需要复杂协作、联动比如表单控件、战斗系统一个数据源需要通知多个展示层比如Model更新通知多个View扩展重点增加新的同事时通常要同时修改中介者增加新的Observer时只需向Subject注册不需要改Subject用一个具体的例子感受更清楚一个UI里有两块区域A区域的数据变化需要B、C、D三块区域各自刷新。如果用观察者模式A就是一个SubjectB、C、D作为Observer注册进去A变了就通知所有Observer。如果B的变化反过来也要刷新A和C那观察者模式就有点别扭了你得给每个对象都当Subject注册关系变成好几组“一对多”本质上还是会退化成一张网。这个时候把A、B、C、D都当成同事放进一个Mediator里统一协调结构反而更清楚。这并不是说观察者模式不好而是说它们解决的问题其实有细微差别。观察者模式的目标是“数据变化及时通知订阅方”中介者模式的目标是“复杂协作逻辑集中管理”。判定该用哪个就看你的交互是不是双向的、是不是多对多的、是不是需要集中控制转发规则。只要答案是肯定的优先考虑中介者模式。4.3 一个模式选型的“该不该用”判断清单依据我在真实项目里的体感给出几个非常实战的信号命中越多越适合用中介者模式有超过三个对象需要相互通信或联动。通信关系是双向的A变要通知BB变也要通知A。转发规则复杂比如某些消息需要过滤、重定向、合并或延时处理。新增一个协作对象时你预计要改动很多其他对象的代码。你希望在多个对象之间共享一套状态语义但不想让它们直接互相引用。反过来以下情况就不要硬套中介者模式只有两个对象交互直接有一个引用就够了。通信路径是明确的单向传递比如定时器触发一个任务的处理器中间插一个中介者纯属多余。你明确知道同事之间数量很少且不会频繁扩展。项目里已经有事件总线、信号槽机制再用中介者等于两套方案叠在一起维护成本反而翻倍。中介者模式不是银弹它把对象之间的网状耦合翻译成了对象对中介者的依赖代价是中介者本身会越来越膨胀。所以做判断时一定要权衡“集中管理的收益”和“中介者膨胀的代价”哪个更大。5. 实操中容易踩的坑与改进经验5.1 三个典型坑上帝对象、循环通知、悬垂引用第一个坑是中介者慢慢变成“上帝对象”。你能把所有协作逻辑都塞进中介者写到最后它可能几千行什么东西都要经过它最后它自己变成了新的瓶颈改一个功能要动中介者的一个大函数测试它要构造一大票Mock对象。避坑的思路是别让中介者管到“具体业务实现”层面它只负责把消息路由到正确的同事至于同事收到消息后怎么执行是同事自己的事。如果中介者已经把同事的内部逻辑也接管了那就该考虑把中介者按业务模块拆分成多个小中介者每个只协调一组关系。第二个坑是循环通知。A通知中介者中介者转发给BB收到后觉得状态有变又通知中介者中介者再转发给AA又……这就是消息风暴。中介者模式的“中心化协调”很容易演变成互相触发的循环调用链而且因为逻辑都集中在中介者里一旦出现循环排查起来特别痛苦。我的做法是从状态机角度控制规定只有“真正的状态变化”才发通知或者在转发时判断一下对方是否真的需要响应比如比较新旧状态没变化就不发通知。在这个例子里如果Alice重复发送相同内容聊天室完全没有必要再转发一遍就是一种对消息的去重控制。第三个坑是悬垂引用这已经在上文生命周期部分强调过。这里再补充一个更隐蔽的情况同事对象在某个线程被析构而中介者在另一个线程还拿着它的weak_ptr调用lock()。weak_ptr能保证你的指针不会悬垂但它不能保证线程安全——lock()之后另一个线程可能正好把这个对象reset()掉你拿到的shared_ptr马上变成最后一个持有者并在当前线程析构对象。所以一旦涉及多线程中介者的内部状态本身也要加锁或者确保同事对象的销毁都在同一个线程内发生。5.2 让模式更好用的落地方案纯中介者模式的代码在复杂场景中确实会单调膨胀所以我习惯把它和事件机制小范围结合。具体来说中介者可以不用一个大函数处理所有消息而是内部挂一张“事件类型-处理器集合”的映射表。同事向中介者发送消息时带上事件类型中介者根据类型路由到对应的处理器。这样既保留了中介者模式“集中协调”的优点又避免了所有分支都堆在一个函数里的问题。再就是和状态模式配合。如果中介者的转发规则和某个状态有关比如“会议室只有主持人可以全员禁言”可以把转发逻辑抽到状态对象里中介者只负责切换状态并调用当前状态的策略。这个组合能让中介者的if/else减少很多代码也更容易测试。还有个实用小技巧给中介者提供统一的日志入口。因为所有交互都会经过中介者所以这是记录交互日志、统计流量、排查线上问题的天然据点。我当年调试一个疑似被用户刷接口的问题就是在中介者里打了一条包含sender、messageType、目标数量的日志一下子就定位到了某个同事对象在一个循环里批量发消息导致了后面的风暴。最后分享一个我在实际项目里使用后的体会如果你的对象协作关系已经在“第3到第5个对象之间”的临界点上别犹豫尽早引入中介者。等对象网已经织成之后再去拆改动的代价比一开始就集中管理要大得多。我自己第一次重构一套GUI联动逻辑时就是这样前期忍着没抽象等控件一多我就后悔了。后来老老实实引入中介者两周内把原来互相引用的网状结构全部清零后续每加一个功能都只在中介者里加一小段清爽太多了。设计模式这种东西不在项目里踩一次坑很难建立起真正的体感我希望这篇内容能帮你把那个坑的位置提前标出来。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →