1. 项目概述与设计模式全景
翻遍各大招聘网站、技术博客和校招面经,C++设计模式永远是绕不开的那一座山。有人把“23种设计模式”背得滚瓜烂熟,面试对答如流,一写代码就懵;也有人根本记不住这么多模式,但代码写得干净利落,模块扩展起来顺手得不行——区别就在于真懂和假懂的差距。
我最早啃《设计模式:可复用面向对象软件的基础》的时候,也踩过“背诵式学习”的坑。后来在真实项目里把C++重构了好几轮,才慢慢明白设计模式不是一个个孤立的花架子,它本质上是一批被反复验证过的“类与类之间协作的套路”。你用的语言是C++,那模式和语言特性之间就有非常强的化学反应:RAII、模板、智能指针、虚函数、const语义、移动语义,这些东西会让同一个模式在C++里写出来和在Java里完全不一样。
这篇文章会从头拆解23种GoF设计模式,按创建型、结构型、行为型三大类展开。每个模式我都会给出“解决什么问题”“C++实现要点”“常见坑”这三个维度的说明,重点放在C++11/14/17/20下最实用的写法上。适合正在准备C++面试的开发者、想优化现有代码结构的中级工程师,以及那些想系统过一遍设计模式但没耐心啃原书的朋友。我已经把代码写法和语言特性绑在一起讲,照着敲一遍,会比你死记硬背一个月都有用。
2. 内容整体设计与思路拆解
2.1 为什么C++里的设计模式要单独讲
很多培训资料用Java教设计模式,逻辑上没错,但直接照搬到C++会出问题。Java有GC,有interface,实在不行new一个匿名内部类就完事;C++没有这些,却多了析构、拷贝控制、值语义、模板元编程这几把烈火。
举个例子,Java里的观察者模式,Subject里存放Observer的List,事件来了遍历一遍通知。换到C++,你得考虑这些Observer的生命周期归谁管。裸指针存进去,Observer销毁了Subject不知道,回调就变野指针。用shared_ptr存进去,Observer又被Subject无意间延长生命周期,搞出内存不释放的问题。这个矛盾在Java里完全不存在,但在C++里这就是设计模式落地时真正的“含金量”所在。
所以日常交流里我说“C++设计模式”时,指的不是把UML图照抄一遍,而是要为每种模式找到适合C++资源管理、值语义、模板特性的表达方式。学习顺序上也应该反过来,先理解C++的语言特性能给模式带来什么,再去看模式的意图与结构,这样理解深度完全不同。
2.2 从六大原则推导23种模式
23种模式不是东一榔头西一棒子堆出来的,它们背后有六个设计原则做纲:单一职责、开闭原则、里氏替换、依赖倒置、接口隔离、迪米特法则。其中对C++影响最大的就是开闭原则和依赖倒置。
开闭原则说“对扩展开放,对修改关闭”,这在C++里有非常硬核的落地手段。多态给你运行期扩展的能力,模板给你编译期扩展的能力,这两种能力各有代价:多态有虚函数调用的间接开销,模板爆头文件和编译时间。很多模式就是在这两者之间做权衡。命令模式可以用虚函数实现,也能用std::function实现;策略模式可以做虚基类多态,也可以用模板策略做编译期绑定,两者在性能和行为上的差异需要结合场景选择。
依赖倒置原则强调“依赖抽象,不依赖具体”。在C++代码里表现为:模块之间的依赖要指向接口或抽象基类,而不是某个具体的实现类。这对大型项目极其重要,因为它决定了代码的模块能不能被单独测试、替换、维护。
真正理解这些原则之后,你会发现“23种模式”其实是这些原则在不同场景下的具象化产物,不需要靠背诵来学习,而是像工具箱里的扳手和螺丝刀一样,看到对应的“螺母”就知道该拿哪一把。
3. 创建型模式详解与C++实现要点
3.1 从简单工厂到抽象工厂:封装对象创建的层层递进
创建型模式的核心意图就是一句话:把“怎么创建对象”这件事从业务代码里抽出来,让Client不需要看着构造函数写业务逻辑。
简单工厂模式严格来说不算GoF 23种之一,但它是最接地气的起点。我接手过一个图像处理库,最初所有的图片解码器都是直接new出来的,加一种新格式就要改动所有上层调用代码。后来我把解码器创建逻辑全部收敛到一个CreateDecoder函数里,根据图片头字节判断格式,返回不同的解码器对象,上层彻底不用关心具体类型了。这就是一个典型的简单工厂,胜在简单直接,适合对象种类不频繁变化的场景。
再往上是工厂方法模式,它会定义一个抽象的创建接口,让子类去决定实例化哪个具体类。简单工厂是把“选择”集中在一个上帝函数里,工厂方法则是把“选择”下放到各自的子类工厂中去。C++里实现工厂方法时,返回类型通常是一个抽象基类的智能指针,比如std::unique_ptr。这里有个很容易被新手踩的坑:基类必须有虚析构函数,否则delete派生类对象时行为未定义。用unique_ptr不是万能保险,析构不虚照样崩。
抽象工厂模式解决的是“一族相关产品”的创建问题。假如你在做跨平台UI库,Windows风格的按钮和菜单得配套,Linux风格的按钮和菜单也得配套,你不能让系统A的按钮配上系统B的菜单。抽象工厂把“整套UI组件”当成产品族,平台工厂实现整个族的创建逻辑。C++实现里,通常用一组纯虚函数返回不同类型的抽象产品,而产品本身又各自是多态接口。抽象工厂的缺点是新增一种产品会牵连所有工厂子类,这也逼着你提前把产品族边界想清楚。
3.2 单例模式的线程安全与生命周期之争
单例模式在任何面试里出现频率都很高,在真实代码里争议也很大。它保证全局只有一个实例并提供全局访问点。C++实现单例,结论上用Meyers Singleton就对了:用函数内局部static变量,C++11标准开始保证局部static的初始化是线程安全的。
class Logger { public: static Logger& instance() { static Logger logger; // 线程安全的懒加载 return logger; } Logger(const Logger&) = delete; Logger& operator=(const Logger&) = delete; private: Logger() = default; ~Logger() = default; };这段代码看着简单,细节其实不少。第一个细节是删除了拷贝构造和赋值,确保单例不可能被复制;第二个细节是析构函数的访问权限,private析构在Meyers Singleton里是可行的,因为实例是static对象,程序结束时自己销毁。
但我得说句公道话,全局单例本质上就是全局变量,它破坏函数式纯度,让单元测试变得拧巴。后来我在做插件系统时,把“Logger单例”改成了“Logger实例注入”,通过构造函数或者一个Context结构传进各个模块。代码反而更好测了。真正适合单例模式的场景,应该满足两个条件:实例确实是全局唯一的资源,且访问它的频率和位置非常多,比如日志、配置中心、线程池。
如果用单例,还有一个容易忽略的坑:跨编译单元初始化顺序。非局部static对象的初始化顺序在C++里是不确定的,如果单例A的构造函数里读了单例B,而两个单例分处不同编译单元,运行初期就可能炸。Meyers Singleton把static搬进函数内部之后,这个问题就自然消失了,这也是它比“类内部static成员变量”方案更优的主要原因。
3.3 建造者模式与原型模式的C++特质
建造者模式解决的是“复杂对象逐步构建”的问题。特征非常明显:构造函数参数太多、可选参数成堆,而且构建过程有固定顺序。C++工程里最常见的就是各种配置类,我曾经写过的一个网络库的Config类就有二十多个字段,一路构造函数传参传得想骂人。后来改成Builder模式,每一步返回*this,链式调用就舒服多了。
class HttpClient { public: class Builder { public: Builder& setUrl(std::string url) { url_ = std::move(url); return *this; } Builder& setTimeout(int sec) { timeout_ = sec; return *this; } Builder& setRetry(int times) { retry_ = times; return *this; } HttpClient build() { return HttpClient(url_, timeout_, retry_); } private: std::string url_; int timeout_ = 3; int retry_ = 0; }; private: HttpClient(std::string url, int timeout, int retry) : url_(std::move(url)), timeout_(timeout), retry_(retry) {} std::string url_; int timeout_; int retry_; };构造函数的私有权限把“唯一合理构建路径”锁死在了Builder身上,Client代码变成一行链式调用,清晰得不能再清晰。
原型模式在C++里的存在感比Java弱很多。Java原型靠clone(),C++里常见做法是写一个clone()虚函数,内部调用拷贝构造来复制自身。为什么这个模式用得少?因为C++本来就有值语义,对象传参返回默认是拷贝,很多时候直接把对象存成值、用拷贝构造就完事了。但有一个场景原型模式仍然重要:在不知道具体类型的情况下复制对象。比如一个容器里存着Animal的unique_ptr数组,其中可能是Dog可能是Cat,你想复制其中一只,光靠Animal抽象类没有办法直接拷贝,就只能靠Dog和Cat各自实现的clone()来干活。在现代C++里,我会顺手用“多态克隆+unique_ptr”的组合,既安全又好用。
4. 结构型模式详解与C++实现要点
4.1 适配器模式:让不匹配的接口重新接上头
适配器模式是我重构遗产代码时最常动的刀子。它的意图很朴素:把一个类的接口转换成客户希望的另一个接口,让原本不兼容的类能一起工作。这套路放在C++工程里,最典型的场景是“封装第三方SDK”和“兼容旧版系统接口”。
我参与过一个支付平台的重构,原来对接一家支付渠道的接口叫PayByBankCard,后来上游公司被合并,新接口叫CreateTransaction,参数格式也变了。业务层不能大改,于是写了适配器类,实现老的接口,内部转发到新接口,顺带把字段映射和签名逻辑都收进去了。改完以后业务层代码纹丝不动,只在初始化时把适配器注入进去,风险面小得多。
C++里实现适配器尽量用组合而不是继承:适配器持有被适配对象的指针或引用,通过委托调用其方法。这样做的原因是,C++多继承本来就是一坨烂泥,牵一发动全身,能用组合就别去招惹那个复杂度。适配器模式看名字很浅显,但它背后的哲学是:不要在疯狂改接口的地方硬扛,加一层薄薄的壳,让新旧世界各自安好。
4.2 装饰器模式:动态组合职责,避开继承爆炸
装饰器模式解决的是“给对象动态添加职责”的问题。最经典的教学案例是给咖啡加牛奶加糖,每个附加品都是一个装饰器,可以层层包裹。C++里实现装饰器有个特别有趣的变体,就是构造函数接收同类型对象的引用或智能指针,自己包裹在上层。
实际开发中,我用装饰器做过一个日志增强器。核心类只负责写日志,后来需要加密,我包了一个EncryptDecorator;再后来需要压缩,又在外面包了一层CompressDecorator。每一层都实现相同的接口,但每层可以在调用前后做自己的事。这个设计比我改原来那一个类要稳妥得多,所有职责拆得清清楚楚,关闭了原类的修改通道,扩展全在装饰器上进行。
C++写装饰器时需要特别留意“值语义”的干扰。如果装饰器按值持有被包装对象,拷贝时就会把整个祖先链条复制一遍,语义上容易出问题。工程上建议统一使用shared_ptr或unique_ptr来串联装饰器链,明确所有权再传递。另外一个坑是析构时如果基类析构不是虚的,一整串装饰器会析构不完整,程序直接给你看内存泄漏报表。
说句题外话,装饰器模式和C++20的std::jthread以及各种功能组合器思路同源,都是“层层包装,逐层增强”这一套哲学,只不过装饰器是用面向对象的方式表达,模板元编程里的mixin和policy-based design则是编译期的表达。两者各有各的适用场景,装饰器胜在运行期灵活,模板方案胜在零开销抽象。
4.3 外观模式与代理模式:门面和守门员
外观模式特别简单,它给子系统提供一个统一的高层接口,把系统内部的东拉西扯全部藏起来。很多人觉得这模式没技术含量,但它的价值在C++大型项目中非常明显。我维护过一个视频渲染引擎,底层涉及几十个模块:解码、滤镜、混流、编码、回调系统,业务方直接用底层接口根本无从下手。外观类把“拉流渲染”这个高频路径封装成三五个方法,其他人全去调外观,内部随便折腾,外部API稳定得像山一样。
代理模式则是给对象提供一个替身,控制对这个对象的访问。C++里常见的形态包括:懒加载代理、访问控制代理、日志代理、智能指针本质上也算一种代理。有一个典型场景是远程资源访问,代理类在本地,真正重量级的资源在远端,代理负责转发请求并隐藏通信细节。我在分布式存储的SDK里见过大量类似设计,客户端调一个GetObject(),背后是完整的网络请求序列,代理把这摊脏活全扛下来了。
写代理模式要注意和装饰器、适配器的区分:适配器改接口,装饰器加责任,代理控制系统访问。这三个结构相似,意图完全不同,面试时能把它们讲出区别,才算真正理解了结构型模式这一组。
4.4 组合模式与桥接模式:树与多维度
组合模式处理“部分-整体”的层次结构,让客户端可以用一致的方式对待单个对象和组合对象。画图程序就是典型场景,一个Shape可以单独画,也可以放进Group,Group里还能嵌套Group,最终整个画布就是一个对象树。C++实现组合模式时,叶子节点和组合节点共同继承一个抽象组件接口,组合节点内部存储子节点的容器。
C++这里有一个老话题:这个容器到底存什么?存值会切割派生类型,存裸指针生命周期难管,稳妥方案是存unique_ptr。如果你需要共享儿童节点,再换成shared_ptr。删除组合里的子节点时,直接从容器里erase对应的unique_ptr即可,内存自动释放。这套做法代码写起来很顺,但有一个语义陷阱:组合模式适合“树状结构相对稳定”的业务,如果对象树频繁增删而且并发访问非常重,那加锁和拷树的开销会让人重新思考方案。
桥接模式解决的是“抽象和实现可以各自独立演化”的问题。最经典的例子是图形库:Shape是抽象,Circle、Rectangle是具体形状;Drawing是颜色/画笔的实现维度,不同平台的Drawing各不相同。如果把Shape和Drawing做成两套独立的继承体系,再用桥把它们连起来,新加一个形状不需要动Drawing体系,新加一种绘制方式也不需要动形状体系。C++实现时,Shape里持有Drawing的引用或指针,形状只管自己的几何逻辑,绘制细节委托给Drawing。这段关系用“组合优于继承”来解释最直观,也是开闭原则在结构设计上最清晰的一次体现。
4.5 享元模式的现代C++姿势
享元模式的目标是共享细粒度对象,减少内存占用。它适合大量相似对象的场景,典型例子是字符渲染:一篇文档里有成千上万个字符,每个字符如果都带着字体、字号、颜色信息,内存会炸;把“字符编码+字体样式”做成共享的享元对象,每个具体出现位置只保存位置信息,就省太多了。
C++实现享元时,关键决策是享元对象怎么存储。一般用工厂或对象池来统一管理,保证相同key返回同一个实例。我见过有人用unordered_map来当享元池,key是属性组合,value是shared_ptr,需要时先查池子,没有就构造存入。这套逻辑本身不复杂,真正的复杂度在“key怎么设计”。key设计得太粗,会把不同的对象误当成同一个;太细,池子里的对象数量又上去了,享元失去意义。另一个容易犯的错是忘了考虑线程安全,池子被并发访问时map本身不加锁就是数据竞争,用C++11以后的std::mutex包一层是底线。
5. 行为型模式详解与C++实现要点
5.1 观察者模式:回调那点事,先把生命周期理清楚
观察者模式(也叫发布-订阅)解决的是“一对多通知依赖”的问题。发布者状态变化时,自动通知所有订阅者,订阅者不需要轮询。C++里实现观察者最折腾的就是生命周期管理。
根据我的经验,一个可用的通知中心至少要处理三类问题:通知时回调函数怎么存、订阅者销毁时怎么自动取消订阅、通知过程中能不能安全地修改订阅列表。在不依赖第三方库的前提下,我推荐一套“弱引用+代理解绑”的思路:TopicManager内部存weak_ptr或者Observer的标记ID,发布者持有shared_ptr,消费者注销时通过ID移除,这样既能防止悬挂引用,又能防止Observer被延长生命周期。在C++工程里,这是进退取舍最平衡的写法。
如果你觉得手写观察者太麻烦,用std::function直接作为回调也是现代C++的常见姿势。不过std::function藏了很多开销,拷贝一般也偏重,性能敏感路径上能不用就不妨缓存一下。观察者模式还有一个容易踩坑的地方是“通知风暴”:一个事件触发导致一连串回调,回调里又改共享状态,造成难以排查的逻辑混乱。我的习惯是所有通知走统一的事件队列,某个回调里如果需要再发事件,就入队而不是同步递归,程序会安稳非常多。
5.2 策略模式与模板方法:跑赢继承的两种姿态
策略模式是把一组可互换的算法封装起来,让客户端在运行期决定用哪一套。C++里最朴素的实现是抽象策略基类加不同策略子类,客户端持有一个策略的指针,需要时切换。但现代C++在大多数场景里,用std::function就足够轻量地替代多态策略了:
using SortStrategy = std::function<void(std::vector<int>&)>; void sortWithStrategy(std::vector<int>& data, SortStrategy strategy) { strategy(data); } sortWithStrategy(data, [](std::vector<int>& d) { std::sort(d.begin(), d.end()); });这里我们不再需要为每一个算法定义一个类,一个lambda就能搞定。除非算法本身足够复杂,有内部状态或需要继承复用,否则虚基类策略就显得笨重。策略模式的另一个关键是“语境只有委托,没有实现”,即调用方不关心策略内部细节,算法替换对它透明。性能敏感的开发里,你还得考虑函数指针/virtual的调用开销与std::function的间接调用开销哪个更大,具体场景要profile之后再说,别想当然。
模板方法模式和策略模式容易混。策略是把整块算法委托出去,模板方法是把算法的骨架钉死在基类里,只把少数可变步骤开放给子类重写。这个模式在C++里有大量应用,比如很多框架的基类Init()里固定走“加载配置 → 校验 → 启动 → 注册回调”四步,其中校验环节是纯虚函数,子类各自实现。模板方法用“protected虚函数+public非虚函数”的组合非常多见,非虚的公开函数负责兜住流程,避免子类重写整个算法顺序,比较安全。
5.3 状态模式与命令模式:状态机与请求中转
状态模式解决的问题是“同一个行为在不同状态下表现不同”。如果一个类里全是if (state == ...) 这种判断,而且状态越来越多,代码就会越来越臭。状态模式把每个状态封装成独立类,上下文只保存当前状态对象,所有行为委托给状态对象处理。
我写过一个手游中的角色AI控制模块,最初就是else if堆了战斗状态、巡逻状态、逃跑状态,新加一个“眩晕状态”时,要在三四个方法里各插一段if,改一次就想吐一次。重构后按状态模式来,每个状态是一个类,实现统一的OnUpdate/OnEvent,切换状态就是替换状态对象指针,代码结构干净多了。C++里可以用unique_ptr持有当前状态,状态切换时reset到新状态即可,注意状态对象里需要保存上下文的引用或指针,让状态能触发切换。
命令模式把请求封装成一个对象,这样可以用队列管理请求、记录日志、支持撤销重做。C++里最典型的是编辑器的Undo/Redo系统:每个操作实现execute()和undo()两个方法,编辑器维护命令栈和撤销栈,操作来了压栈,撤销时从撤销栈弹出并调用undo(),再压入重做栈。现代C++常用std::function来简化命令实现,尤其当命令只是一次性任务时,完全不需要为每个命令写一个类:
std::vector<std::function<void()>> undoStack; undoStack.push_back([this] { /* 反操作 */ });但如果你需要命令具有复杂的序列化、配对、状态回滚能力,还是老老实实建具名命令类,层次更清晰。
5.4 责任链模式与迭代器模式:把链路和遍历抽象出来
责任链模式构造一系列处理者,请求沿着链传递,直到某个处理者接住它。典型的例子是日志级别处理和请求拦截器。C++实现责任链时,每个处理器持有后继者的指针(或shared_ptr),handle方法里决定是自己处理还是传给下一个。这里有个常见的自坑点:如果后继是裸指针而且被提前释放,链在路上突然断掉,程序就崩给你看。工程上最好整条链的所有权由外部管理器统一持有,内部只用原始指针或引用串联。
还有个容易忽略的问题是“链是否可能没人处理”。处理不了就算了还是抛异常,必须在设计时定好。很多框架采用“兜底处理器”接在链尾,专门处理谁都不接的请求。
迭代器模式在C++里的地位非常特殊,语言和标准库已经把它内化成了“随时可用的语法糖”,也就是范围for循环配合begin()/end()。但迭代器模式背后的思想依然有学习价值:你将“遍历方式”从“容器内部实现”中抽离,客户端只看到迭代器抽象,不同容器的内部结构差异被藏了起来。C++20的ranges库更是把这种抽象推向新高峰,view、filter、transform链式处理容器,代码简洁得不像在写C++。想真正理解迭代器模式,建议自己手写一个简单的链表迭代器,体会operator++、operator*和end比较这些细碎操作。
5.5 剩下的几个模式:中介者、备忘录、访问者、解释器
中介者模式常用于多对象交互的复杂网状关系解耦。聊天室是最经典的教学案例:用户对象不需要互相持有引用,而是都指向一个聊天室中介,消息通过中介转发。C++实现时,中介者内部往往持有大量成员对象的智能指针,会有一定“上帝类”的倾向,所以要时刻克制,只把事件路由逻辑放进去,业务规则尽量留在成员对象自己身上。
备忘录模式用于保存和恢复对象内部状态。实现时要注意“什么该存、什么不该存”:通常不应该把大数据量内部缓存全部复制,而是保存“最小恢复所需状态”。C++里可以利用移动语义让保存状态非常廉价。我之前的项目里就用它做了配置快照的回滚,状态保存为一份ConfigurationSnapshot结构,恢复时直接替换当前配置,操作简单可靠。
访问者模式是23种模式中最难懂也最被误解的一个。它的特点是“把数据结构与操作分离”,使得在不修改元素类的前提下,为元素集合新增操作。但在C++里,访问者模式有更好的替代方案:直接用std::variant加std::visit,在编译期完成类似的分发。如果你代码里的对象集合是异构的且未来会频繁加操作,可以认真考虑std::variant的访问者方案,代码比传统访问者写法简洁,性能还更高。但也要注意,std::visit要求类型集合完全确定,如果想随意扩展类型,传统访问者反而更合适。
解释器模式在商业项目中用得极少,它主要是为某种语言定义语法和解释器。C++工程里如果真需要表达式求值,优先选择现成的解析库,比如exprtk或PEG解析库,很少有人从零手搓一个解释器。理解这个模式时,抓住“每个语法规则对应一个表达式类”这一核心就够了。
6. 23种设计模式速查与选型指南
光看完每种模式还不够,日常写代码时最难的往往是“知道该用哪种”。下面是我按实际工程经验整理的速查表,可以收藏备用。遇到设计问题时先按“需求特征”对号入座,再决定具体实现方案。
| 分类 | 模式 | 一句话识别特征 | C++落地注意点 | 使用频率 |
|---|---|---|---|---|
| 创建型 | 简单工厂 | 一个函数按参数返回不同对象 | 返回unique_ptr,不要裸指针 | 极高 |
| 创建型 | 工厂方法 | 子类工厂决定创建哪个对象 | 基类析构必须虚;返回智能指针 | 高 |
| 创建型 | 抽象工厂 | 创建一整族相关对象 | 产品族扩展成本高,提前规划好 | 中 |
| 创建型 | 单例 | 全局唯一实例 | Meyers Singleton最省心;慎用全局访问 | 高 |
| 创建型 | 建造者 | 复杂对象分步构建 | 链式调用;构造函数私有 | 高 |
| 创建型 | 原型 | 未知类型下复制自身 | clone虚函数+拷贝构造 | 低 |
| 结构型 | 适配器 | 让不兼容的接口接上头 | 组合优先于继承 | 极高 |
| 结构型 | 装饰器 | 动态叠加职责 | shared_ptr串联;虚析构必写 | 高 |
| 结构型 | 外观 | 为子系统提供统一门面 | 不要把底层实现细节泄漏到接口 | 极高 |
| 结构型 | 代理 | 控制对目标对象的访问 | 区分代理、适配器、装饰器 | 中 |
| 结构型 | 组合 | 处理整体-部分树形结构 | 容器存unique_ptr | 中 |
| 结构型 | 桥接 | 抽象与实现独立演化 | 抽象持有实现引用 | 中 |
| 结构型 | 享元 | 大量相似对象共享内部状态 | key设计要精准;线程安全 | 中 |
| 行为型 | 观察者 | 一对多事件通知 | 弱引用管理生命期;防通知风暴 | 极高 |
| 行为型 | 策略 | 算法运行期可互换 | std::function可替代多态 | 极高 |
| 行为型 | 模板方法 | 骨架固定,步骤可扩展 | 公开非虚函数保护流程 | 高 |
| 行为型 | 状态 | 行为随状态变化 | 状态对象持上下文引用 | 中 |
| 行为型 | 命令 | 请求封装成对象 | undo栈配合std::function | 高 |
| 行为型 | 责任链 | 请求沿链传递 | 所有权统一管理,防悬挂 | 中 |
| 行为型 | 迭代器 | 遍历方式与容器结构解耦 | 标准库已内化 | 极高 |
| 行为型 | 中介者 | 网状交互改为星状交互 | 避免上帝类 | 低 |
| 行为型 | 备忘录 | 保存/恢复状态快照 | 只保存最小恢复状态 | 中 |
| 行为型 | 访问者 | 数据结构与操作分离 | std::variant+std::visit是替代 | 低 |
| 行为型 | 解释器 | 定义语言语法并求值 | 优先使用成熟解析库 | 极低 |
这张表里“使用频率”是我结合多年源码阅读和开发经历给出的个人判断,不代表所有人观点。可以看出,并不是23种模式干活时都用得上,像解释器、访问者、中介者这种一年到头也用不了几次的模式,先把原理看懂、会简单实现就够了;反而是几个高频模式,值得花大量时间打磨。
7. 新手常踩的坑与实战排查技巧
7.1 模式和代码结构的“面积”问题
很多刚从设计模式入门的人容易犯一个毛病:为了用模式而用模式。一个只有三个函数的小模块,也要硬套抽象工厂加门面加观察者,最后代码量翻了一倍,维护难度直线上升。我见过一个配置读取类,就两三百行,被包装了四层设计模式,调试时跳了七八层栈才找到真正读文件的那一行。这属于严重过度设计。
经验法则是:先让代码以最朴素的方式跑通,当出现“重复代码多”“扩展一处要改N处”“if else满天飞”这些信号时,再考虑用模式去重构。设计模式是重构的方向,不是编码的起点。团队协作时,更要注意模式的落地是否给同事带来理解负担,这种沟通成本常常被忽略。
7.2 虚析构、拷贝控制、移动语义三件套
C++实现任何涉及继承的模式,第一步检查的就是有没有虚析构。虚析构缺失时,通过基类指针删除派生类对象是未定义行为,症状可能是内存泄露或崩溃,还很难排查。这条规则对几乎所有多态场景都适用,不只是设计模式。
拷贝控制也是重灾区。当你定义了一个含有unique_ptr成员的类,编译器会默默删除拷贝构造,“你永远不能把Factory对象按值传进函数”这个错,真到编译期才一脸懵。所以在写模式相关类时,要提前想清楚类是值语义还是引用语义:如果它是多态体系中的抽象接口,通常应该禁止拷贝、鼓励移动;如果是简单的值对象,可以考虑拷贝,但要深拷贝到位。
移动语义让很多模式实现变得更优美。建造者模式的Builder在返回最终对象时用std::move即可,不需要做昂贵拷贝;工厂方法里return std::make_unique ()本身就是移动友好的。现代C++写设计模式,脑子里一定要时刻带着“这个对象会不会被复制”“能不能移动”两个问题。
7.3 智能指针选型:别让所有权成为糊涂账
智能指针是现代C++设计模式的最佳拍档,但选错了照样出问题。我的建议是:默认使用unique_ptr表达独占所有权,最常用;出现多个对象需要共享同一个资源时,才用shared_ptr;需要打破shared_ptr循环引用时,用weak_ptr。裸指针不背锅,它仍然适合做“观察者”和“非拥有引用”,但绝不应该被用来语义不明地保存一个它不负责销毁的对象。
制品代码里最常见的设计模式翻车现场,就是“shared_ptr满天飞但没人说得清谁拥有资源”。一个好的类设计要能明确回答三个问题:谁创建、谁持有、谁销毁。观察者模式里Subject和Observer的关系,命令模式里命令栈和历史记录的关系,责任链里处理器之间的关系,全都能用这套问题理清。
7.4 集成环境相关的“C++学不动”杂症
写C++代码时,环境问题有时候比语言本身还烦人。热搜词里反复出现的microsoft visual C++ 14.0 is required这类报错,本质是Python生态里的扩展模块需要MSVC编译器来编译C++接口,不是你的项目本身有问题。遇到时去装对应版本的Visual Studio Build Tools,并确保在安装勾选“使用C++的桌面开发”,一般就能解决。
VS Code配C++环境也有不少同学卡住。我的建议是先装C/C++扩展插件,接着写好c_cpp_properties.json指定编译器路径和智能提示用的标准,再用tasks.json配置构建任务。如果智能提示的路径优先级不对,优先检查includePath和compileCommands配置,看是否存在互相覆盖。这些细节虽说不属于设计模式的核心,但对刚入门的C++学习者来说,环境卡关会让学习热情迅速熄灭,我这里提一嘴省得大家走冤枉路。
7.5 排查套路:定位设计模式相关Bug的高效顺序
一旦你的项目用了设计模式,出Bug后追查的范围往往被拉宽了,因为代码不再是一层调用,而是多层委托和回调。我的排查顺序是:
先看对象的此生生命周期是不是符合预期。模式代码里大量对象是动态分配的,生命周期一旦不对,功能异常和崩溃就会层出不穷。这个阶段可以用日志或调试器给关键对象的构造析构打点,观察顺序。再走一遍调用链,理清每次调用到底经过哪几层包装,装饰器链里是不是包错了顺序,观察者通知里是不是有线程同步问题。然后检查所有权,确认shared_ptr没有造成循环引用,unique_ptr没有被无谓拷走,裸指针没有指向已释放内存。最后看看多线程相关:单例初始化是否线程安全,通知回调是否触发数据竞争,责任链上是否有共享状态需要加锁。
这套顺序处理了八成以上的疑难杂症。我自己的经验是,设计模式引入的Bug基本不会“报错报得很明确”,要么是悬空指针导致的偶发崩溃,要么是资源未释放导致的内存增长,要么是生命周期延长导致的逻辑错位。快速理清生命周期和所有权关系,比盲目看监控日志高效得多。
8. 一个组合实战:用责任链+策略+建造者重构支付风控
理论知识讲再多,不如一个能跑通的小项目有说服力。分享一个我印象挺深的改造案例。当时一个支付系统的风控模块,规则散落在各个下单流程里,从判断用户行为、查询黑名单、限制金额到最终是否放行,每一个环节都写在服务入口函数里,谁看了都头疼。我趁机把设计模式用到了一次真实重构上。
第一步,用策略模式定义每一类风控算法的接口,比如“风险评分”“黑名单校验”“频率限制”各自做成策略类,内部算法互不干扰。第二步,用责任链把数个策略串接起来:所有策略实现同一接口,每个Handler里持有下一个Handler,做完自己的校验后,要么拦截请求并返回原因,要么把请求交给下一个Handler。这样“拦截”和“放行”的语义非常清晰。第三步,用建造者模式组装整条链路,把链路构造参数(超时、并发上限、开关选项)全部收拢到Builder里,业务层只需要一行代码拿到组装好的链路并执行。
改造完以后,新增一条风控规则时,我只需要新增一个Handler类,然后在Builder里决定插到链路的哪个位置即可,主逻辑一行不改。这就是开闭原则最直观的兑现:对扩展开放,对修改关闭。
具体实现时还有个优化:风控链路里同一类校验可能会反复执行相同的计算,因此我在黑名单Handler里加了一个结果缓存,用享元模式的思想管理重复查询结果,让相同特征的黑名单访问直接命中缓存,减少了大量数据库压力。最后测试时还发现,责任链里有个Handler的析构顺序不对,导致下一个Handler引用了已释放的节点,这个问题最容易在程序退出阶段爆发。最终统一用unique_ptr把整条链的所有权交给管理器持有,问题立刻消失。
9. 最后再分享一点真话
从面试角度看,设计模式经常被包装成“八股文”,但这不代表它没有价值。真正的价值不在全局背诵的“23种”这个数字,而在你对每个模式背后的取舍理解有多深。C++里的设计模式更是如此,它和语言的内存模型、对象模型、类型系统绑得太紧了,单纯把UML背熟没有意义,只会在面试官随口问一句“unique_ptr和shared_ptr在这个模式里怎么选”时露怯。
我自己的学习路线是:先精读高频的六七个模式,把每一种都用C++从零手写一个能跑的最小例子;然后开始读开源项目,在别人的代码里动态识别设计模式痕迹;最后尝试在真实项目里通过重构自然而然“遇见”某种模式,而不是硬塞进去。遇到不懂的就去查cppreference、Stack Overflow和编译器警告,这种主动发现问题并解决的流程,比刷一百篇文章都管用。
设计模式的学习没有终点,但每真正吃透一个模式,你写出的代码就会多一点秩序,少一点纠结。如果你按上面那些C++示例自己动手敲了一遍,相信你会明显感觉到,设计模式终于不再是“看不懂的PPT”,而是一套能帮你把复杂问题拆干净、让同事读代码时不再皱眉的硬功夫。