ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++中介者模式实战:用智能家居系统彻底解决对象耦合

C++中介者模式实战:用智能家居系统彻底解决对象耦合 写C有一段时间的朋友应该都体会过“对象之间互相耦合”的难受劲儿。A要通知BB要同步给CC又要回调A几轮下来代码里全是互相持有的指针改一个地方炸一片。这时候就该中介者模式登场了。它做的事情很朴素在一堆互相认识、互相调用的对象中间硬塞一个“话事人”让对象之间不再直接打交道所有通信统一走中介者转发。这篇博文我会用C完整实现一个智能家居联动系统从接口设计、生命周期管理到实际踩坑把中介者模式的落地细节讲透适合正在学习设计模式的C开发者、准备面试的朋友以及写业务代码时觉得对象间依赖太乱的读者。1. 中介者模式到底在解决什么问题1.1 一个能让你瞬间共鸣的耦合场景先想象一个最简单的聊天室4个人在群里聊天。如果不用中介者A要给B、C、D分别发消息就得持有另外三个人的对象引用B也得持有A、C、D的引用。4个人需要12条连接10个人需要90条连接人越多连接数越爆炸。更麻烦的是每来一个新成员所有老成员都要改代码去适配维护成本随着规模增长呈指数级上升。写图形界面程序的时候也有类似的痛点。一个对话框里通常有输入框、确认按钮、取消按钮、列表控件它们之间的交互规则往往很复杂输入框内容变化时确认按钮要置灰列表选中项变化时输入框要自动填充点击确认按钮时要校验输入框内容并关闭窗口。如果这些控件两两互相持有指针每加一个控件都要去改所有相关控件代码很快就变成一团乱麻。这个问题的本质是“多对多的隐式交互”。当参与交互的对象超过两三个、且交互规则还在不断演进时直接让对象之间互相通信就会导致强耦合、难扩展、难测试。你可能已经意识到我们真正需要的不是删除交互而是把交互的管理权集中到一个点上。1.2 网状依赖变成星状依赖之后中介者模式做的事情就是把上面的网状结构改造成星状结构。所有同事对象Colleague不再直接引用彼此而是各自持有中介者Mediator的引用发送消息时发给中介者接收消息时也由中介者转发。这样原来的多对多连接被拆成了多个“一对多”连接每个同事只认识中介者中介者认识所有同事。拿我做过的一个消息推送系统来举例。当时有十几个模块需要互相订阅事件比如用户登录后要通知钱包模块刷新余额、通知消息模块拉取未读、通知推荐模块更新首页。一开始大家直接互相订阅模块间引用关系乱得一塌糊涂。后来我用中介者模式改造事件全部发到一个中央调度器由它根据事件名分发给对应模块模块之间再也不互相认识新模块接入只需要在中介者里登记一条路由规则就行。这么做的好处很直接交互逻辑集中化了。以前“用户登录后各模块要干什么”这个业务规则分散在十几个模块里现在集中写在中介者里一眼就能看全模块之间互相隔离测试时可以单独拿出一个模块配一个模拟中介者来验证行为不用再费劲初始化一整个对象网络。1.3 和观察者、门面模式的边界很多初学者会把中介者模式和观察者模式搞混我在这里把边界划清楚。观察者模式解决的是“一对多”的订阅关系一个发布者状态变化通知所有订阅者订阅者之间没有联系。中介者模式解决的是“多对多”的协调关系多个对象平等交互消息需要在它们之间流转而中介者就是这个流转的控制点。门面模式则更像一个“对外窗口”。它给复杂子系统提供一个统一入口外部模块只跟门面打交道不需要了解子系统内部。门面是单向的外部调用门面门面调用子系统但子系统不会反向调用外部。中介者是双向的同事对象之间可以通过中介者互发消息参与交互的每个对象都在这个体系内部。这三者的使用场景完全不同如果只是“一个发布者通知多个订阅者”用观察者如果是“给复杂系统提供简化接口”用门面如果是“多个对象之间需要灵活的定向或广播通信”才轮到中介者。理解了这个边界在实际项目里就不容易选错。2. C实现先想清楚这三件事2.1 角色划分谁负责什么别混在一起中介者模式有四个核心角色我建议先记清楚再动手写代码。角色类型职责Mediator抽象类或接口定义同事之间通信的接口比如广播、定向发送ConcreteMediator实现类维护所有同事的注册表实现具体的消息转发逻辑Colleague抽象类或接口定义同事对象的公共接口持有中介者的引用ConcreteColleague实现类实现自己的业务逻辑需要跟其他同事通信时调用中介者关键点是同事对象不直接持有其他同事的引用只持有中介者的引用。而中介者需要持有所有同事的引用才能把消息转发到正确的对象上。这意味着中介者要比同事对象知道得更多这一点在设计时就要接受。我在实际工程里习惯把注册逻辑也放进中介者里。同事对象创建后调用mediator-registerDevice(colleague)完成注册注册时中介者会反向设置同事对象指向自己的引用。这样做的好处是同事对象不需要在构造函数里强制要求中介者可以先创建再挂载灵活度更高也方便写单元测试时注入模拟中介者。2.2 消息接口的粒度怎么定抽象中介者的接口设计是C实现里最需要动脑子的一步。很多人第一版会写成这样class Mediator { public: virtual void notify(const std::string from, const std::string event, void* data) 0; };这个接口很通用但void*是类型安全的灾难。调用方塞一个int*进去接收方以为是std::string*解引用就崩溃。工程实践里我推荐的方案是用std::any或者std::variant来承载数据配合一个事件名做分发。C17用std::variant更安全事件类型枚举出来后编译器可以帮你检查穷尽性C17之前或者事件类型经常增加的时候用std::any加any_cast更灵活。另一种思路是给每种事件定义单独的虚函数比如onLightChanged()、onTemperatureChanged()。类型安全是保证了但每新增一种事件所有继承者都要跟着改这个成本在中介者模式里会格外高因为中介者接口一旦变化所有同事类全部要重新编译。我见过一个项目就是因为接口收得太紧每加一个事件同事类全要改一遍团队苦不堪言。综合来看我推荐消息接口采用“事件名结构化数据”的组合struct BaseEvent { virtual ~BaseEvent() default; }; struct AlarmEvent : BaseEvent { int level 0; std::string description; }; class Mediator { public: virtual ~Mediator() default; virtual void broadcast(const std::string from, const std::string event, const std::shared_ptrBaseEvent data) 0; virtual void sendTo(const std::string to, const std::string from, const std::string event, const std::shared_ptrBaseEvent data) 0; };接收方根据事件名对BaseEvent做dynamic_pointer_cast到具体事件类型。这样接口保持稳定事件类型可以自由扩展又能拿到一定的类型安全性。2.3 shared_ptr和weak_ptr的搭配用法C实现中介者模式时生命周期管理是个大坑。同事对象要持有中介者的引用中介者又要持有所有同事对象的引用如果两边都用shared_ptr就会形成循环引用谁都无法释放内存泄漏悄无声息就出现了。我的破法原则其实就一句话把“双向持有”变成“单向持有”。具体来说中介者作为整个交互体系的核心用shared_ptr持有所有同事对象保证同事对象生命周期的确定性同事对象反过来只持有中介者的weak_ptr需要发消息时先lock()一下成功说明中介者还活着失败就安全退出。代码上长这样class Device { public: void attach(const std::shared_ptrMediator mediator) { mediator_ mediator; } protected: void send(const std::string event, const std::shared_ptrBaseEvent data) { if (auto mediator mediator_.lock()) { mediator-broadcast(name_, event, data); } } private: std::string name_; std::weak_ptrMediator mediator_; // 关键弱引用打破循环 };用weak_ptr还有一个额外的好处同事对象发消息前能感知中介者是否已经销毁避免野指针。如果你用裸指针中介者销毁后同事对象还在一发消息就是未定义行为。这里多说一句中介者本身有时候需要拿到自己的shared_ptr来传给后注册的同事所以实际实现里会让具体中介者继承std::enable_shared_from_this。3. 案例实操一个智能家居联动系统3.1 需求与协议定义我选智能家居系统来做完整演示因为它特别能体现中介者模式的价值。先定义需求有一盏客厅灯、一台空调、一扇窗帘、一个安防系统外加一个用户控制面板。它们之间的联动规则是这样的用户触发“回家模式”灯点亮空调设到26度窗帘打开安防撤防。用户触发“睡觉模式”灯关闭空调设到24度窗帘关闭安防布防。安防系统检测到烟雾发出告警灯开始闪烁空调关闭。如果不用中介者模式这四个设备要两两互相引用每个设备都要知道别人的接口。现在用中介者模式所有设备只跟一个智能中枢具体中介者打交道联动规则全部收在中枢里设备本身只关心自己收到某个事件后要做什么反应。消息协议我先定义一下事件名用字符串比如arrive_home、go_to_bed、smoke_detected消息载荷这里不需要复杂数据用空的BaseEvent子类就行但为了演示结构化事件烟雾报警会带一个SmokeEvent包含烟雾浓度值。3.2 抽象层代码实现先写抽象中介者和抽象同事类。这层代码是整个系统的地基之后所有设备都从这里继承。#include iostream #include memory #include string #include vector #include unordered_map #include mutex // 事件基类 struct BaseEvent { virtual ~BaseEvent() default; }; // 烟雾事件携带浓度信息 struct SmokeEvent : BaseEvent { int level 0; }; // 中介者抽象 class Mediator { public: virtual ~Mediator() default; virtual void registerDevice(const std::shared_ptrclass Device device) 0; virtual void broadcast(const std::string from, const std::string event, const std::shared_ptrBaseEvent data nullptr) 0; virtual void sendTo(const std::string to, const std::string from, const std::string event, const std::shared_ptrBaseEvent data nullptr) 0; }; // 设备抽象同事基类 class Device : public std::enable_shared_from_thisDevice { public: explicit Device(std::string name) : name_(std::move(name)) {} virtual ~Device() default; void attach(const std::shared_ptrMediator mediator) { mediator_ mediator; onAttached(); } const std::string getName() const { return name_; } // 收到来自其他设备的消息由子类实现 virtual void onMessage(const std::string from, const std::string event, const std::shared_ptrBaseEvent data) 0; protected: virtual void onAttached() {} void send(const std::string event, const std::shared_ptrBaseEvent data nullptr) { if (auto mediator mediator_.lock()) { mediator-broadcast(name_, event, data); } else { std::cout [ name_ ] mediator已销毁消息丢弃 std::endl; } } void sendTo(const std::string target, const std::string event, const std::shared_ptrBaseEvent data nullptr) { if (auto mediator mediator_.lock()) { mediator-sendTo(target, name_, event, data); } else { std::cout [ name_ ] mediator已销毁消息丢弃 std::endl; } } private: std::string name_; std::weak_ptrMediator mediator_; };这里有个细节值得注意Device继承了enable_shared_from_this虽然它在代码里没有直接用shared_from_this但保留它是为了在onAttached里如果需要把自己暴露给外部时可以直接使用。另外attach方法在设置完中介者后调用了onAttached钩子这个钩子可以被子类重写用来在注册完成后主动做一些初始化操作。3.3 具体设备与中枢实现接下来写四个具体设备类。每个设备只关心自己收到某类事件后的反应不关心是谁发的。比如灯的逻辑很简单收到arrive_home就打开收到go_to_bed就关闭收到smoke_detected就闪烁。// 客厅灯 class Light : public Device { public: Light() : Device(客厅灯) {} void onMessage(const std::string from, const std::string event, const std::shared_ptrBaseEvent data) override { if (event arrive_home) { std::cout [客厅灯] 点亮 std::endl; } else if (event go_to_bed) { std::cout [客厅灯] 关闭 std::endl; } else if (event smoke_detected) { std::cout [客厅灯] 闪烁告警 std::endl; } } }; // 空调 class AirConditioner : public Device { public: AirConditioner() : Device(空调) {} void onMessage(const std::string from, const std::string event, const std::shared_ptrBaseEvent data) override { if (event arrive_home) { temp_ 26; std::cout [空调] 设置温度 temp_ 度 std::endl; } else if (event go_to_bed) { temp_ 24; std::cout [空调] 设置温度 temp_ 度 std::endl; } else if (event smoke_detected) { std::cout [空调] 检测到烟雾关闭 std::endl; } } private: int temp_ 25; }; // 窗帘 class Curtain : public Device { public: Curtain() : Device(窗帘) {} void onMessage(const std::string from, const std::string event, const std::shared_ptrBaseEvent data) override { if (event arrive_home) { std::cout [窗帘] 打开 std::endl; } else if (event go_to_bed) { std::cout [窗帘] 关闭 std::endl; } } };安防系统特殊一点它既是一个普通同事能接收其他中介者转发来的消息也是一个事件源能主动发出烟雾告警。所以我在它上面加了一个detectSmoke方法由外部触发模拟烟雾检测。// 安防系统 class SecuritySystem : public Device { public: SecuritySystem() : Device(安防系统) {} void onMessage(const std::string from, const std::string event, const std::shared_ptrBaseEvent data) override { if (event arrive_home) { armed_ false; std::cout [安防系统] 撤防 std::endl; } else if (event go_to_bed) { armed_ true; std::cout [安防系统] 布防 std::endl; } } // 模拟检测到烟雾发出告警事件 void detectSmoke(int level) { std::cout [安防系统] 检测到烟雾浓度 level std::endl; auto event std::make_sharedSmokeEvent(); event-level level; send(smoke_detected, event); } private: bool armed_ true; };最后是核心的智能中枢也就是具体中介者实现。它维护设备的注册表提供registerDevice方法、broadcast广播方法和sendTo定向发送方法还额外暴露了triggerScene接口用来触发用户场景。// 智能中枢具体中介者 class SmartHomeHub : public Mediator, public std::enable_shared_from_thisSmartHomeHub { public: void registerDevice(const std::shared_ptrDevice device) override { devices_.push_back(device); deviceMap_[device-getName()] device; device-attach(shared_from_this()); } // 触发场景由用户面板调用 void triggerScene(const std::string scene) { if (scene arrive_home) { broadcast(用户面板, arrive_home); } else if (scene go_to_bed) { broadcast(用户面板, go_to_bed); } } void broadcast(const std::string from, const std::string event, const std::shared_ptrBaseEvent data nullptr) override { std::lock_guardstd::mutex lock(mutex_); // 把事件转发给除发送者外的所有设备 for (auto device : devices_) { if (device-getName() ! from) { device-onMessage(from, event, data); } } } void sendTo(const std::string to, const std::string from, const std::string event, const std::shared_ptrBaseEvent data nullptr) override { std::lock_guardstd::mutex lock(mutex_); auto it deviceMap_.find(to); if (it ! deviceMap_.end()) { it-second-onMessage(from, event, data); } else { std::cout [智能中枢] 找不到设备 to std::endl; } } private: std::vectorstd::shared_ptrDevice devices_; std::unordered_mapstd::string, std::shared_ptrDevice deviceMap_; std::mutex mutex_; };SmartHomeHub继承了enable_shared_from_this这样在registerDevice里才能安全地构造shared_ptrMediator传给设备。你可能会问设备类型是shared_ptrDeviceshared_from_this()返回的是shared_ptrSmartHomeHub类型不完全匹配怎么办这里C的智能指针支持向上转型shared_ptrSmartHomeHub可以隐式转为shared_ptrMediator所以赋值没有问题。3.4 运行效果与代码走读主函数里把设备和中枢组装起来模拟用户触发和烟雾告警两条链路。int main() { auto hub std::make_sharedSmartHomeHub(); auto light std::make_sharedLight(); auto ac std::make_sharedAirConditioner(); auto curtain std::make_sharedCurtain(); auto security std::make_sharedSecuritySystem(); hub-registerDevice(light); hub-registerDevice(ac); hub-registerDevice(curtain); hub-registerDevice(security); std::cout 用户触发回家模式 std::endl; hub-triggerScene(arrive_home); std::cout \n 安防系统检测到烟雾 std::endl; security-detectSmoke(3); std::cout \n 用户触发睡觉模式 std::endl; hub-triggerScene(go_to_bed); return 0; }运行结果如下 用户触发回家模式 [客厅灯] 点亮 [空调] 设置温度 26 度 [窗帘] 打开 [安防系统] 撤防 安防系统检测到烟雾 [客厅灯] 闪烁告警 [空调] 检测到烟雾关闭 [窗帘] 无操作忽略消息 用户触发睡觉模式 [客厅灯] 关闭 [空调] 设置温度 24 度 [窗帘] 关闭 [安防系统] 布防注意窗帘那行输出它收到了烟雾事件但onMessage里没有对应处理所以什么都不做。这正是中介者模式里常见的现象同事对象可以只关心自己感兴趣的事件其余消息直接忽略完全不影响系统运转。这个例子跑通之后你再往系统里加一个设备就非常方便。新增一个Speaker音箱让它收到smoke_detected时播放警报声只需要写一个继承Device的新类在onMessage里加一条判断然后在main里注册进去其他设备一行都不用改。这就是“减少对象之间的耦合”这句设计原则在实际代码里的直观体现。4. 实战避坑5个高频问题实录4.1 循环引用导致的中介者无法销毁这是C实现中介者模式最容易踩的坑。如果你偷懒让同事类持有shared_ptrMediator中介者持有shared_ptrDevice双方互相持有强引用那么即使外部已经没有任何引用指向这个对象网络它们的引用计数也不会归零析构函数永远不会被调用。我在一个项目里就吃过这个亏。当时用shared_ptr管理所有对象跑了一晚上内存涨了几百兆用valgrind一查全是循环引用泄漏。解决方式就是前面讲过的同事通过weak_ptr持有中介者。你可以在自己的代码里做一个实验把Device里的mediator_改成std::shared_ptrMediator然后在main结束前打印中枢和设备对象的析构日志就会发现析构函数根本不会执行。排查这类问题有个小技巧在现代C工程里建议开发阶段就开启ASanAddress Sanitizer和-fsanitizeleak一旦出现循环引用泄漏会直接报告LeakSanitizer信息。CI流水线里也建议跑一遍别等到线上OOM了才回来查。4.2 中介者逐步膨胀成上帝对象中介者模式用久了会有一个典型的反模式随着业务规则增加具体中介者越写越胖里面全是if (event xxx)的转发逻辑最后膨胀成一个几千行的上帝对象比原来的网状依赖还难维护。我的经验是中介者只应该做“路由”不应该做“业务”。联动规则里“回家要干什么”这种业务决策最好放在一个独立的场景配置层或状态机里中介者只负责根据事件名和配置找到下游设备并转发。比如上面例子里的场景触发完全可以抽出SceneManager它维护一个“事件名 - 设备列表”的映射表智能中枢只是执行它。如果业务确实复杂可以考虑拆分成多个中介者每个中介者负责一类交互。比如一个照明协调器管灯和窗帘的联动一个安防协调器管安防和报警设备UI层需要跨域联动时再引入更上层的协调者。拆分的标准是同一个中介者内部的交互频次高、业务内聚强跨中介者的交互尽量少。4.3 广播风暴和重复通知在使用broadcast广播时如果不做任何防护一个事件可能引起连锁反应。比如安防系统发出烟雾报警灯收到后开始闪烁并发出“已闪烁”事件中枢又把“已闪烁”广播给所有人再次触发灯的闪烁逻辑形成一个死循环。解决这类问题有几个手段。第一广播时排除发送者本人这个我在代码里已经做了避免对象自己收到自己发出的消息。第二在事件里加一个sequenceId或processed标记设备收到已经处理过的事件就不再重复响应。第三设计一个“抑制窗口”比如灯的闪烁逻辑在收到事件后短暂屏蔽同类事件防止振荡。我在实际项目里更推荐“定向发送优先于广播”的原则。如果事件只有一个接收者有反应就用sendTo而不是broadcast。这样既能减少无效调用也降低了消息风暴的概率。上面的例子中烟雾告警其实并不需要发给窗帘用sendTo分别发给灯和空调反而更清晰。4.4 多线程场景下的安全问题在分布式系统或IoT场景里设备事件往往来自不同线程。如果中介者的broadcast和sendTo不加锁多个线程同时遍历devices_容器轻则数据竞争重则直接崩溃。我给智能中枢加了一把std::mutex加锁范围是整个消息入口也就是broadcast和sendTo函数的开头和结尾。有个更容易被忽略的点你不仅要保护中介者的内部容器还要考虑同事对象的onMessage是否线程安全。如果灯的开关状态被后台线程和UI线程同时读写那还需要在Light内部也做同步。这里我的建议是同步放在边界上设备内部尽量做到无共享或者用原子变量不要把锁撒得到处都是。如果事件量很大同步调用会导致消息处理阻塞。进阶方案是给中介者引入一个事件队列收到消息后先入队由一个专门的worker线程按顺序分发这样设备和中介者之间的调用就解耦了。代价是消息变成了异步调试时要注意日志的先后顺序不代表实际处理顺序。4.5 为了模式而模式什么时候别用设计模式也有适用范围中介者模式不是银弹。如果你只有两个对象需要交互强行引入中介者就是多此一举凭空增加一层间接调用代码可读性反而下降。我的判断标准很朴素只有当交互关系达到“多对多”并且规则经常变化时才引入中介者。两三个对象之间关系稳定、交互简单直接持有引用就行。另外如果对象之间的交互是强依赖树形结构比如只有父节点调子节点子节点从不反向调用那也不需要用中介者用组合模式或责任链模式更贴切。还有一点中介者模式增加了消息的“绕行”。每个事件都要先到中介者再转发出去原本一次直接调用现在至少两次间接调用。虽然现代CPU对这类调用的开销很小但在低延迟场景里比如高频交易系统或游戏物理引擎的每帧更新中这种额外开销和设计复杂度可能并不划算。先评估自己的场景和瓶颈再动手永远比上来就套模式强。5. 面试与代码评审中的考点盘点5.1 面试官常问的4个问题中介者模式在C面试里出现频率很高我整理了几个常问问题和参考回答方向。“谈谈你对中介者模式的理解”这是开放题建议按照“问题背景 - 解决思路 - 优缺点 - 使用场景”的顺序来答。先说对象两两交互导致的耦合爆炸再说中介者把所有交互集中到中间层最后提下面要讲到的缺点。“中介者模式和观察者模式的区别”这是最高频的对比题。核心区别是通信方向和数据流向观察者是单向的一对多一个发布者通知多个订阅者中介者是双向的多对多协调所有同事之间都可以通过中介者互相通信。另外观察者的订阅关系是松耦合的发布者不关心订阅者是谁中介者往往需要明确知道同事的注册信息才能准确转发。“中介者模式有什么缺点”这个问题考察你是否真的理解模式的取舍。缺点主要有中介者本身可能成为单点瓶颈和上帝对象消息间接转发带来额外开销当同事数量不太多时使用中介者反而增加复杂度如果中介者挂了整个交互体系就瘫痪了。“C里如何管理中介者和同事对象的生命周期”这就是考察C专属的智能指针知识了。回答要点包括中介者用shared_ptr持有同事对象同事用weak_ptr持有中介者以打破循环引用中介者需要注册给同事时可以通过enable_shared_from_this获取自己的强引用。5.2 代码评审时我优先看的几个点如果代码评审中出现了中介者模式的实现我一般会按下面的顺序快速扫一遍。先看对象之间是否还有直接引用。如果团队里有人把“中介者模式”写在文档里代码里却依然能看到同事对象之间互相调用的痕迹那这个实现就是不干净的需要指出来。再看中介者的转发逻辑是否太厚。如果一个具体中介者里塞满了对具体事件的业务处理而不是纯粹的转发那就要提醒团队考虑把业务决策拆出去。第三条是看消息传递是否类型安全如果接口里能看到void*建议尽快改成std::variant或结构化事件对象。最后看一下注册机制是否灵活理想状态下同事对象应该能动态注册和注销而不是硬编码在中介者的构造函数里。还有一个评审中经常被忽略的点中介者和同事对象的双向引用到底克不克制。如果只是引用了但没有实际调用那这个引用就是多余的会白白增加生命周期管理的复杂度。我一般会要求每个方向的引用都要有对应的调用场景没有调用就要删掉。用个人经验收个尾我做过的几个项目里中介者模式帮我解决过不少实际问题但真正让我对它保持警觉的恰恰是一次失败的经历。当时我接手一个老系统里面十几个对象互相耦合我一看“这是典型的中介者模式场景啊”然后大刀阔斧地引入中介者。结果因为中介者承载了太多业务规则改了几轮之后它自己也变成了一个无人敢动的巨石。后来我学到的教训是中介者解决的是通信路由问题业务复杂度还是得靠领域模型和配置化去承载设计模式它只是工具不是终点。如果你正在为对象间纠缠不清的依赖而头疼希望这篇能帮你少走一些弯路也欢迎在实践中多验证、多调整。
返回列表