先把丑话说在前头:23种设计模式,不是靠背就能会的,更不是靠“看完一篇全解”就能上手的。但你如果把这篇当成一张地图,搞清楚每个模式到底在解决什么、彼此之间是什么关系、在C++里实现时有哪些语言特有的坑,那它就是你从“知道名字”走向“敢在项目里用”的那座桥。这篇的东西,适合刚学完C++语法、开始读项目源码时被各种模式名词砸晕的人,也适合写过一阵子业务代码、想系统整理一遍套路的人。我会把23种模式全部过一遍,但重心放在真正高频、真正容易写错的那些上面,并且全程用C++的视角去讲,不跟你扯纯理论。
1. 23种模式的全景图:先搞清这三大家族到底在解决什么
设计模式这个概念,来自1994年那本GoF(Gang of Four,四人组)写的《Design Patterns》。书里总结了23种面向对象设计中反复出现的经典解决方案,后来几乎所有编程语言社区都在讨论这套东西。23这个数字之所以被反复提起,不是因为只有23种套路,而是因为这23种是从大量真实项目里筛出来的、被验证过的最通用的一批。你以后还会看到很多别的“模式”,但底层思路基本都能在这23种里找到影子。
那这23种为什么分成三大类?不是随便分的,是按“在解决什么问题”分的:
- 创建型模式(Creational):管的是对象怎么创建。核心思路是“别直接new”,把创建过程封装起来,让调用方不依赖具体类。
- 结构型模式(Structural):管的是类和对象怎么组合成更大的结构。核心思路是“在尽量不动现有代码的前提下,扩展出新的能力”。
- 行为型模式(Behavioral):管的是对象之间怎么通信、怎么分配职责。核心思路是“把变化的算法、状态、请求方式从稳定结构里抽出来”。
可以这么理解:创建型解决“对象怎么来”,结构型解决“对象怎么拼”,行为型解决“对象之间怎么配合”。三者的边界偶尔会有重叠,但大方向是清晰的。比如工厂方法既在创建对象,又依赖继承来改变创建结果,所以它被归为创建型;而模板方法也依赖继承,但它改变的是整体算法流程,所以归为行为型。分类看的是“主要意图”,不是实现细节。
下面是23种的完整清单,建议你先把这张表存下来,后面每一节都会对照这张表展开:
| 分类 | 模式名称 | 一句话本质 | C++中的常见实现手段 |
|---|---|---|---|
| 创建型 | 工厂方法 | 让子类决定创建哪种对象 | 虚函数 + 模板 |
| 创建型 | 抽象工厂 | 创建一组相关对象的家族 | 多个工厂方法组合 |
| 创建型 | 单例 | 全局只存在一个实例 | 局部静态变量(Meyers Singleton) |
| 创建型 | 建造者 | 分步骤构造复杂对象 | 链式调用 |
| 创建型 | 原型 | 通过拷贝已有对象来创建新对象 | Clone() 虚函数 |
| 结构型 | 适配器 | 让接口不兼容的对象能合作 | 包装类 / 多重继承 |
| 结构型 | 桥接 | 把抽象与实现分离,两者独立变化 | Pimpl 惯用法最典型 |
| 结构型 | 组合 | 树形结构中统一处理叶子与容器 | 虚函数 + 子节点容器 |
| 结构型 | 装饰器 | 动态给对象增加责任 | 持有基类引用的包装类 |
| 结构型 | 外观 | 为复杂子系统提供统一入口 | 封装一个高层接口 |
| 结构型 | 享元 | 大量细粒度对象共享内部状态 | 共享指针 + 工厂缓存 |
| 结构型 | 代理 | 替另一个对象控制访问 | 同接口包装类 |
| 行为型 | 职责链 | 请求沿链传递,直到有人处理 | 链式持有下一个处理器 |
| 行为型 | 命令 | 把请求封装成对象 | 函数对象 / std::function |
| 行为型 | 解释器 | 定义语言语法树并解释执行 | 递归下降 + 多态 |
| 行为型 | 迭代器 | 顺序访问聚合对象内部元素 | 标准库迭代器 |
| 行为型 | 中介者 | 用中介对象协调多个对象交互 | 中央状态/事件总线 |
| 行为型 | 备忘录 | 保存并恢复对象内部状态 | 快照类 + 友元 |
| 行为型 | 观察者 | 状态变化时自动通知依赖方 | 回调注册 + std::function |
| 行为型 | 状态 | 对象根据内部状态改变行为 | 状态基类 + 状态切换 |
| 行为型 | 策略 | 把算法的不同实现封装成可替换对象 | std::function / 策略基类 |
| 行为型 | 模板方法 | 父类定义流程骨架,子类实现步骤 | 非虚接口 + 受保护虚函数(NVI) |
| 行为型 | 访问者 | 在不改类结构的前提下增加新操作 | 双重分派 + 一组重载 |
这张表的价值在于,先建立“每个模式在解决什么”的整体认知,再深入细节,就不会一上来就被“桥接和适配器长得有点像”这种问题搞晕。真正开始用的时候你会发现,大部分项目里高频出现的也就是单例、工厂、观察者、策略、模板方法、装饰器、适配器这几种,剩下的属于“工具箱里得有,但不天天用”的类型。
2. 创建型模式:绕开new引发的一连串麻烦
创建型模式是很多人接触设计模式的第一站,因为new是C++里最容易被滥用、也最容易写出死代码的操作。你把 new 写在业务代码里,意味着你硬编码了一个具体类;明天这个类要换实现,你得在调用方里到处找。创建型模式的本质就是把这层依赖关系倒过来,让“创建什么”这件事变成一个可以替换的决策点。
2.1 单例模式:最常被写错、也最常被滥用
单例的实现方式网上有无数版本,但C++里我只推荐一种,就是Meyers Singleton,利用局部静态变量的初始化机制来实现:
class Logger { public: static Logger& instance() { static Logger logger; // C++11起,局部静态变量初始化是线程安全的 return logger; } void log(const std::string& msg) { /* ... */ } private: Logger() = default; Logger(const Logger&) = delete; Logger& operator=(const Logger&) = delete; };这段代码在C++11之后是线程安全的,不需要加锁,不需要双重检查,也不会有内存泄漏问题(程序结束时静态对象自动析构)。很多人喜欢写“饿汉式”,也就是在类里定义一个 static Logger instance 成员,这种写法在C++里最大的坑是初始化顺序不可控:不同编译单元里的静态对象初始化顺序是未定义的,万一你的单例在另一个静态对象的构造函数里被调用,很可能拿到一个还未构造的实例。所以全局单例优先用函数内局部静态变量,这个坑能直接绕开。
但比“怎么写单例”更重要的是“要不要用单例”。我见过太多人把单例当成“方便的全局变量”来用,日志单例、配置单例、数据库连接单例、工具类也搞单例。滥用之后,整个代码库里到处是XXX::instance()的隐形依赖,单元测试根本没法做。我的建议是,真正的单例只留给那些“进程内确实只需要一份、且没有状态替换需求”的东西,比如日志器、全局配置。如果你有多个实例的可能(比如多配置文件、多个日志输出目标),就别用单例,直接用普通对象加依赖注入,以后会省太多事。
2.2 工厂模式的三种形态:简单工厂、工厂方法、抽象工厂
工厂模式是创建型里最家族化的一个,初学者最容易被三种形态搞晕。先明确一点:GoF书里只定义了工厂方法和抽象工厂,所谓“简单工厂”其实是一个非正式的变体。简单工厂就是一个普通函数,根据参数返回不同类型的对象,比如:
// 简单工厂:本质是集中式的对象创建函数,不属于GoF 23种 std::unique_ptr<Transport> createTransport(const std::string& type) { if (type == "truck") return std::make_unique<Truck>(); if (type == "ship") return std::make_unique<Ship>(); return nullptr; }简单工厂的缺点是违反开闭原则:每加一种Transport,都要改这个函数。当类型很多、变化频繁时,就得升级为工厂方法,让每个子类决定创建哪种具体对象。这里先说明,工厂方法模式适用的场景是少数几种类型、创建逻辑简单;如果产品类型经常增加,那需要的是把工厂做成虚函数,也就是定义virtual std::unique_ptr<Transport> createTransport() = 0;
抽象工厂则更进一步,解决的是“一系列产品要配套使用”的问题。比如你需要一套“现代风格UI”和一套“复古风格UI”,每套都包含Button和TextBox两个产品,你不能让现代Button配复古TextBox,这时候就定义一个抽象工厂:
class UIFactory { public: virtual std::unique_ptr<Button> createButton() = 0; virtual std::unique_ptr<TextBox> createTextBox() = 0; }; class ModernUIFactory : public UIFactory { public: std::unique_ptr<Button> createButton() override { return std::make_unique<ModernButton>(); } std::unique_ptr<TextBox> createTextBox() override { return std::make_unique<ModernTextBox>(); } };三种形态的选择逻辑很朴素:只偶尔创建一两种类型,用简单工厂或工厂方法;需要保证“一套产品配套”的时候,用抽象工厂。别一上来就上抽象工厂,抽象工厂本身的抽象层次高,代码量也不小,业务没有配套约束时属于过度设计。
2.3 建造者模式与原型模式:用得少,但关键时刻很管用
建造者模式适合“创建过程特别复杂、参数特别多”的对象。典型场景是配置对象、网络请求参数、或者一个有很多可选字段的复杂实体。C++里最常见的实现方式就是链式调用:
class QueryBuilder { public: QueryBuilder& from(const std::string& t) { table_ = t; return *this; } QueryBuilder& where(const std::string& cond) { cond_ = cond; return *this; } QueryBuilder& limit(int n) { limit_ = n; return *this; } Query build() const { return Query(table_, cond_, limit_); } private: std::string table_, cond_; int limit_ = -1; // -1表示不限 };这样写的好处是调用点清晰、可读性强,特别适合那种“构造函数没法表达默认值和可选项”的场景。C++里还有一种替代方案是命名参数模拟,但链式建造者是最直观的。
原型模式我单独提一句,因为它和C++的拷贝语义有天然的关系。原型模式的核心是通过Clone()虚函数来复制自身,而不是用构造函数手动指定每个字段。在C++里,这个模式的意义主要体现在:你想复制一个对象,但它的具体类型在编译期不可见,只能拿到基类引用。这时你就不能在基类引用上直接拷贝(会切掉子类部分),必须在基类里定义虚函数virtual std::unique_ptr<Base> clone() const = 0;。这其实是C++里“多态拷贝”的标准解法,和GoF原意很接近,而且现代C++用智能指针返回后基本不用担心内存管理。
2.4 创建型模式在C++实现时的通用要点
创建型模式最容易出问题的地方不是模式本身,而是C++的内存管理语义。你在Java里new一个对象不用管释放,在C++里如果不用智能指针,就会陷入“这个对象到底谁拥有、谁释放”的泥潭。我的统一建议是:工厂返回std::unique_ptr表示独占所有权,需要共享时再用std::shared_ptr转出来。这么做的好处是,调用点没法“忘了释放”,同时也不会到处拷贝。
另外一点,C++的构造函数调用虚函数时不会走虚派发,所以“工厂方法在基类构造函数里被调用”这种代码是危险的。不要在构造函数里调用虚函数去创建对象,如果确实需要在初始化时做多态创建,可以引入一个独立的init()方法,等对象完全构造好之后再调用。
3. 结构型模式:怎样把类和对象拼成更大的结构
结构型模式的核心思路是“组合优于继承”。这一族的模式大多数不会改变对象本身的功能,而是通过包装、组合、共享的方式来重新组织对象之间的关系。C++比很多语言多了一个强大的工具——模板,这让结构型模式可以有两种实现路线:基于继承的多态实现和基于模板的静态实现。下面按“哪个模式最常用、最容易混淆”的顺序来拆。
3.1 适配器、外观、代理:三者都包装,但目的完全不同
三个模式都会写一个包装类,但包装的意图差别很大。
适配器的目标是接口转换。你手上有个老接口OldLogger::log_to_file(string),新代码需要NewLogger::info(string),在不改老代码的前提下,写一个Adapter把新接口调用转成老接口。C++里实现适配器有两种方式:对象适配器(持有被适配对象的引用)和类适配器(通过多重继承同时继承接口和实现)。实际项目中我绝大多数时候用对象适配器,因为类适配器会把老实现的所有内部细节都暴露到新接口里,耦合更大。
外观(Facade)的目标是简化。它不改变接口,而是给子系统提供一个统一入口。比如你的播放器有解封装模块、解码模块、渲染模块,调用方如果直接去调这三个模块,不仅要对底层API熟,还要知道调用顺序,耦合特别重。这时做一个PlayerFacade,把open() / play() / stop()这几个高层接口提供给外部,内部去编排那些底层模块。外观模式和适配器最核心的差别是:适配器让你在某个具体接口下“能调用”现有实现,外观让你“不用知道复杂细节”就能完成一件事。
代理(Proxy)的目标是控制访问。它和适配器的区别在于代理保持接口不变,只是加了一层间接层。典型场景是远程代理(本地对象代理远程服务)、虚拟代理(延迟加载大对象)、保护代理(控制访问权限)。在C++里,一个很常用的代理是“写时复制”代理:多个对象共享同一个大资源,只有某个对象要修改它的时候才深拷贝一份出来。写代理类的时候要注意,代理必须完整保存原接口的语义,如果加了一些“方便方法”,它就从代理变成门面或适配器了。
这三个模式的实战判断口诀是:只是想换个接口,用适配器;想隐藏子系统复杂度,用外观;想在访问前后加逻辑,用代理。
3.2 装饰器与组合:树形结构上的两种扩展思路
装饰器和组合在结构型里经常被一起提,因为它们都涉及到“一组同类对象的递归组织”。
装饰器的核心是动态增加责任,它通过持有与被装饰对象相同基类的引用,一层层包裹基础对象。C++里最常见的例子是给数据流加缓冲、加密、压缩:
class Stream { public: virtual void write(const std::string& data) = 0; virtual ~Stream() = default; }; class FileStream : public Stream { public: void write(const std::string& data) override { /* 写入文件 */ } }; class EncryptedStream : public Stream { public: EncryptedStream(std::unique_ptr<Stream> next) : next_(std::move(next)) {} void write(const std::string& data) override { auto encrypted = encrypt(data); next_->write(encrypted); } private: std::unique_ptr<Stream> next_; };这里的EncryptedStream可以继续被CompressedStream包装,形成一个运行时灵活组合的“能力栈”。装饰器最关键的约束是:所有装饰类和具体类必须继承同一个抽象接口。这样任何一层装饰对象都能当作原始对象用。在C++里用std::unique_ptr持有下一层,把所有权链串起来,生命周期很清晰。
组合模式则用于表达“部分-整体”的树形结构,让调用方对单个对象和组合对象一视同仁。典型场景是文件系统、组织架构、XML节点。设计组合模式时有一个经典问题:叶子节点和中间节点要不要共享很多接口方法?比如addChild()在叶子节点上没意义。处理方式有两种:一种是在基类抛异常或空实现,另一种是把节点安全方法分开定义,调用方用dynamic_cast判断。C++里我倾向于把“叶子特有的操作”在叶子类里实现,组合类多维护一个子节点容器,基类里可以给addChild一个默认空实现或断言,避免接口过宽。
装饰器和组合如果结合起来,就非常强大:组合负责把对象组成树,装饰器负责给每个节点加能力。很多框架里,UI组件树就是组合模式,而给组件加边框、阴影、滚动条就是装饰器模式。
3.3 桥接与享元:解耦两个变化维度 vs 共享细粒度对象
桥接模式在“设计模式最被低估”的榜单上常年排名靠前,因为它的经典例子(图形和颜色)太抽象,导致很多人看不懂。其实桥接的精髓就是:当一个类有两个独立变化的维度时,别用继承去组合它们,而是把其中一个维度作为成员组合进来。
在C++里,最典型的桥接实现就是Pimpl(Pointer to Implementation,指向实现的指针):
// 头文件 class Widget { public: Widget(); ~Widget(); void draw(); private: struct Impl; std::unique_ptr<Impl> pImpl_; }; // 源文件 struct Widget::Impl { std::string style_; int width_; // 真正的实现细节 }; void Widget::draw() { /* 通过 pImpl_ 实现 */ }这样写的好处不仅在于“抽象和实现分离”,还在编译上特别划算:头文件里没有任何具体的成员变量,任何成员变量的改动都只需要重新编译源文件,而不是把所有包含这个头文件的编译单元全部重编一遍。大项目里这个收益非常明显。桥接模式和适配器别混淆:适配器是让“已有的接口”能配合起来;桥接是提前设计“让接口和实现能各自独立演进”。
享元模式的目标是减少内存占用,适合有大量相似对象的场景。在C++里,享元通常配合共享指针和工厂使用:把所有对象共有的内部状态集中到一个std::shared_ptr<const SharedState>里,工厂维护一个状态到共享资源的缓存字典,类似对象直接复用同一个共享资源。要注意的是,C++的std::string在比较老的标准里就有小字符串优化和写时复制(COW)的历史,不同标准库实现不一样,所以享元模式里如果涉及字符串内部状态,要检查标准库的实现策略,避免猜错。
3.4 结构型模式和继承体系的设计策略
环顾结构型这一族,会发现一个规律:适配器、装饰器、代理、外观、桥接,本质上都是“组合一个已有对象,保持或改变它的接口”。C++里这意味着你经常需要写一个“虚函数转发”的包装层。这种代码重复度很高,很容易抄错,我建议对纯转发接口做一些约定:转发函数必须保持签名一致,并且把const、noexcept、override这些修饰符也一并转发。如果转发层和原接口不一致,调试时问题极难排查。
对于“要不要用虚拟接口来实现结构型模式”,我建议分两步走:小规模、固定类型的场景直接用模板;需要运行时多态、插件化扩展的场景再用继承体系。比如适配器如果是给两个固定类型做适配,写个模板适配器比抽象基类加两个子类清爽得多:
template <typename Adapted> class NewLoggerAdapter { public: explicit NewLoggerAdapter(Adapted& a) : adapted_(a) {} void info(const std::string& msg) { adapted_.log_to_file(msg); // 假设 Adapted 有 log_to_file 方法 } private: Adapted& adapted_; };现代C++里,“组合+模板+智能指针”已经能覆盖结构型模式的大部分需求,纯虚接口只保留给真正需要运行时多态的地方。这一点在做架构评审时非常有用,可以帮你砍掉一半不必要的继承层级。
4. 行为型模式:对象之间怎么说话、怎么分工
行为型模式是23种里数量最多的,一共11种。这一族解决的核心问题已经不再是“对象怎么创建、怎么组合”,而是“运行时的行为怎么组织”——算法如何封装、请求如何传递、状态变化如何通知。写业务系统时,行为型模式是最常被踩的坑,因为稍不注意,对象之间就会约定太多,改一个地方牵一发动全身。
4.1 策略与状态:长得很像,但换的“东西”不一样
策略模式和状态模式结构上几乎一模一样,都是“持有一个基类指针,运行时能切换行为”,但意图完全不同。策略模式换的是算法,而且算法切换通常由调用方决定;状态模式换的是“状态”,状态切换一般由对象内部根据当前状态和执行结果自动决定。
举一个最直白的例子。给排序器设置排序算法,这就是策略:
class SortStrategy { public: virtual ~SortStrategy() = default; virtual void sort(std::vector<int>& v) const = 0; }; class QuickSortStrategy : public SortStrategy { /* ... */ }; class HeapSortStrategy : public SortStrategy { /* ... */ }; // 调用方根据场景选一种 std::unique_ptr<SortStrategy> strategy = std::make_unique<QuickSortStrategy>();而TCP连接对象里有Listening、Established、Closed三种状态,收发数据时状态会自己切换,这就是状态模式:
class TcpState { public: virtual ~TcpState() = default; virtual void send(TcpConnection& ctx, const std::string& data) {} virtual void close(TcpConnection& ctx) {} }; class EstablishedState : public TcpState { public: void close(TcpConnection& ctx) override { ctx.setState(std::make_unique<ClosedState>()); } };区分它们,就看“切换的主动权在谁手里”:策略模式是外部设置,是一次性的、无状态迁移的;状态模式是内部一步一步迁移的,而且每个状态可能对同一个操作有不同的反应。实际写代码时,如果发现自己用状态模式时一个状态要频繁查询另一个状态的信息,那大概率是设计错了,应该把共享信息提升到上下文对象里(就像上面代码里的TcpConnection)。
4.2 观察者(也被称为发布-订阅):从回调函数到消息总线的演变
观察者模式可能是现代软件里最无处不在的模式。它的核心是:当一个对象(主题)的状态变化时,自动通知所有注册的观察者。在C++里实现观察者,我强烈建议直接用std::function替代“观察者接口类”,这样注册的可以是普通函数、lambda、成员函数绑定,灵活度极高:
class EventBus { public: using Handler = std::function<void(const std::string&)>; int subscribe(Handler h) { handlers_[nextId_++] = std::move(h); return nextId_ - 1; } void unsubscribe(int id) { handlers_.erase(id); } void publish(const std::string& event) { for (const auto& [id, h] : handlers_) h(event); } private: std::unordered_map<int, Handler> handlers_; int nextId_ = 0; };这个实现有几个工程上非常实际的细节要提醒。第一,观察者回调里很可能会有新的订阅动作,如果你用的是std::vector而不是std::unordered_map,遍历时插入元素会导致迭代器失效,所以注册接口要返回一个可退订的ID,而不是只往容器里塞。第二,发布事件时如果某个回调抛异常,后续的回调就不会执行了,建议在发布循环里捕获异常,或者约定回调内部自己处理所有异常。第三,回调里不应当做耗时操作,否则发布者会被拖死,一个被大量使用的观察者事件应该设计成异步投递。
观察者模式的最大风险是“隐式依赖”:主题不知道观察者到底干了什么,一旦回调里又去调主题的方法,容易形成循环触发。现代框架里的消息总线和信号槽机制,本质上都是观察者模式的工程化变体,只是加了线程模型、生命周期绑定、自动退订这些更具体的约束。
4.3 模板方法:继承体系里最优雅的流程复用
模板方法在C++里有个特别顺手的实现空间,因为C++的虚函数和访问控制组合非常灵活。经典写法是“public非虚接口 + protected虚函数”(NVI,Non-Virtual Interface,非虚接口惯用法),让外部只能调用固定流程,子类去实现步骤:
class DataParser { public: // 非虚接口:定义不可被覆盖的流程骨架 void parse(const std::string& path) { auto data = readFile(path); // 步骤1,子类可重写 auto cleaned = clean(data); // 步骤2,子类可重写 auto result = doParse(cleaned); // 步骤3,纯虚,必须实现 onParsed(result); // 步骤4,默认空实现,子类可选重写 } protected: virtual std::string readFile(const std::string& path) { /* 默认从文件读 */ } virtual std::string clean(const std::string& data) { return data; } // 默认不过滤 virtual int doParse(const std::string& data) = 0; virtual void onParsed(int result) {} virtual ~DataParser() = default; };模板方法模式最适合那种“流程相对固定、但某些步骤需要变化”的场景。它和策略模式的区别在于:模板方法靠继承来复用骨架、覆盖步骤;策略靠组合来替换算法整体。继承的缺点是“子类和父类深耦合”,所以使用模板方法时,父类要对子类开放哪些步骤、隐藏哪些步骤,想得非常清楚。上面的例子中readFile和clean是钩子(Hook),子类可以自行决定要不要覆盖;doParse是抽象步骤,必须实现;parse本身是模板方法,不许重写。
4.4 剩下八种行为型模式,逐个给你划重点
现在把剩下的行为型模式一次性讲完,重点放在“什么时候用、C++实现的最大注意点是什么”。
职责链模式:把多个处理器串成链条,请求沿着链传递。C++实现时每个处理器持有
std::unique_ptr<Handler> next_,handle里判断自己能否处理,不能就调next_->handle()。最经典的例子是日志的严重等级过滤、异常处理中的多层catch。这个模式在工程上最大的坑是“链长不知道请求被谁处理”,调试时候需要打日志追踪,我会在建链时给每个处理器加一个名字,方便排查。命令模式:把请求封装成对象,支持撤销、重做、事务。在C++里,早期版本用命令模式是因为没法把函数当参数传,现在有了
std::function和lambda,命令模式的大部分场景可以直接用函数对象代替,只有当你需要“撤销栈”、把命令序列化、或者统一记录日志时,才有必要定义一个真正的Command类。迭代器模式:C++标准库已经把迭代器做得非常到位了,正常情况下你不需要自己实现迭代器。但如果你写了一个自定义容器,那么为该容器实现STL兼容迭代器,就是迭代器模式的实践。重点是要实现
begin()/end()、operator++、operator*、operator!=,并且注意容器和迭代器失效的语义,这些很难一次写对,强烈建议先用std::vector作为内部存储,迭代器直接包装内置迭代器。中介者模式:多个对象之间的交互过于复杂时,引入中介对象统一调度。C++里的典型实现就是“事件总线”或“消息中心”,和观察者模式很接近,差别是中介者模式强调的是“集中化”,观察者模式强调的是“通知”。UI系统中的对话框经常用中介者模式:多个控件不直接互相引用,都只和中介者通信,避免网状依赖变成蜘蛛网。
备忘录模式:保存对象的状态快照,支持回滚。C++实现时通常让备忘录类持有对象私有成员状态的副本,要访问私有字段就得在备忘录类里声明原对象类为友元。注意,快照如果是深拷贝,可能很昂贵,所以备忘录模式应该配合“状态增量”或“COP(Copy-On-Write,写时复制)”来用,否则大对象的回滚功能会变成内存杀手。
解释器模式:为一种简单语言定义语法和解释器。这个模式在工程里其实用得不多,因为做真正的编程语言解释器有更好的工具(如ANTLR、Flex/Bison),但如果你要给一个配置文件写个非常小巧的表达式解析器(比如支持
age > 18 && score >= 60这种规则引擎),解释器模式是很好的参考。实现时建议用递归下降解析,每个语法规则对应一个递归函数,比抽象语法树类的复杂设计更容易调试。状态模式的一大落地细节是状态切换时如何传递上下文。我的经验是:每次状态切换都让新状态持有上下文对象的引用,这样状态方法不需要同时接收上下文参数和数据参数,签名清爽很多。另外,状态对象本身的创建频率不宜太频繁,如果状态切换很频繁,可以用预先创建好的“不可变状态单例”,同时把会变化的数据放到上下文对象里,这就是“状态模式 + 享元模式”的组合用法。
访问者模式:这是23种里最“重”的一个,它解决的核心问题是“在类结构不变的情况下增加新操作”。C++里需要双重分派,因为普通虚函数只会根据实际对象动态分派一次,访问者想要同时根据元素类型和访问者类型动态决定调用哪个重载。访问者模式适合那些结构稳定的对象树,比如AST(抽象语法树)节点类很少变,但操作(类型检查、代码生成、打印)经常变。只要记住一句话:类很少变但你经常想加操作,用访问者;操作很少变但类经常变,绝不要用访问者。
5. C++实现设计模式时,最大的坑其实在语言本身
很多人学设计模式时用的是Java的例子,然后原封不动地往C++里搬,结果跑起来到处是问题。不是说设计模式在C++里失效,而是C++有自己的语言特性,直接照搬会踩到这些坑:没有垃圾回收、值语义与引用语义并存、析构函数和拷贝控制绕不开、模板元编程可以将很多模式“编译期化”。这一章值得你反复看,很多“为什么这么写”的答案都在这里。
5.1 RAII和析构风格如何影响模式的落地
RAII(Resource Acquisition Is Initialization,资源获取即初始化)是C++区别于几乎所有高级语言的核心习惯。在Java里,对象有没有被释放由GC决定;在C++里,一个对象的生命周期由栈上的作用域和智能指针决定。这个差异直接决定了观察者模式、命令模式、备忘录模式里“对象什么时候死”这件事。
就拿观察者来说,Java里你可以在观察者被GC回收时依赖终结器去退订,但C++里没有可靠的终结时机,如果你让一个shared_ptr<Observer>被主题持有着,那么观察者永远不会析构,因为它被主题引用着——这就是一个循环引用问题。正确做法是:主题持用weak_ptr<Observer>,观察者在析构前主动退订,或者在发布时通过lock()检查观察者是否还活着。如果观察者的生命周期由外部完全管理,主题甚至可以存裸指针,前提是保证退订接口一定会被调用。
另一个RAII相关的点是单例和全局状态。如果一个单例对象内部打开了一个文件句柄或网络连接,那么它的析构顺序就是一件要紧的事。C++里局部静态对象在程序退出时按构造逆序析构,如果多个单例存在依赖关系(比如日志器依赖配置器),那么构造顺序就决定了析构顺序,倒过来会出问题。最稳的办法是让单例之间不互相依赖,如果一个单例必须依赖另一个单例,就要考虑在main函数里手动构建依赖顺序,而不是全部依赖静态初始化。
5.2 智能指针把“所有权”写进了类型签名
现代C++(C++11及以后)里写设计模式,智能指针是绕不开的。unique_ptr表达独占所有权,shared_ptr表达共享所有权,weak_ptr表达“只观察、不拥有”。这套所有权语义,直接影响模式的设计:
| 模式 | 经典Java写法 | C++现代写法 |
|---|---|---|
| 抽象工厂 | 返回基类引用,GC管理 | 返回std::unique_ptr<Product> |
| 观察者 | 主题持有ObserverList | 主题持有std::vector<std::weak_ptr<Observer>> |
| 组合 | 子节点List,GC管理 | 子节点用std::unique_ptr<Node>或shared_ptr |
| 命令 | 命令对象入队 | std::function<void()>或unique_ptr<Command> |
| 享元 | 共享对象用引用 | std::shared_ptr<const SharedState> |
举一个具体例子:组合模式的树形结构,内存管理一旦没想清楚,不是泄漏就是悬垂。我的经验是:父节点独占持有子节点,也就是子节点集合用std::vector<std::unique_ptr<Node>>,这样删除父节点时整个子树会被自动释放。但如果同一个子节点会被多个父节点引用(比如共享某个“文件夹快捷方式”),那就得用shared_ptr。表达“共享所有权”别省事,类型签名本身就是文档。
还有一点很重要:不要把shared_ptr当成万能钥匙。shared_ptr虽然省心,但控制块的原子递增递减是有开销的,而且在多线程环境里,互相持有shared_ptr的对象图会让“谁都不能被释放”,形成内存泄漏。能用unique_ptr就不要升级到shared_ptr,这是我在代码评审里几乎每次都会说的一句话。
5.3 模板让设计模式有了静态版本
C++里有一批模式天然可以通过模板实现出编译期版本,这是其他面向对象语言很难做到的。比如策略模式,传统写法是运行时的基类指针,但其实std::function已经是最轻量的策略容器;再比如工厂方法,模板化之后连“工厂基类”都不用写了:
template <typename ConcreteProduct, typename... Args> std::unique_ptr<Product> makeProduct(Args&&... args) { return std::make_unique<ConcreteProduct>(std::forward<Args>(args)...); }写代码时经常被误解的一点是“设计模式都是运行时多态”,其实GoF在书里就提到过“有时候可以避免引入继承”。C++的模板提供了编译期的鸭子类型,只要能满足接口要求,不需要继承同一个基类,就能参与统一的算法流程。这就是所谓的“静态多态”或者“基于模板的策略模式”。模板版本的好处是性能更好(无虚函数开销、可内联),坏处也很明显:二进制体积变大、编译期类型约束错误信息很难读、运行时也不能在种类列表之外动态替换。
因此我的建议是:运行时需要动态扩展(比如插件系统)、类型集合不能完全预知的场景,用经典的虚函数模式;类型集合在编译期就确定、追求性能的场景,优先用模板。两者不冲突,一个系统里经常是“外层用虚函数做插件化,内层用模板做高性能算法”。
5.4 拷贝语义、虚析构与接口设计:三个最容易翻车的细节
三个C++特有的细节,不管写哪种模式都必须绷紧弦。
第一个是虚析构函数。只要一个类设计成基类、并且会被多态地删除(例如std::unique_ptr<Base>指向new Derived),那么Base必须有虚析构函数。继承一个没有虚析构的基类,然后用基类指针删除派生类对象,是未定义行为。这个坑在设计模式里尤其容易踩,因为模式里的接口类到处都是。检查清单上第一条:凡是拿来做多态基类的类,析构函数写成virtual ~Base() = default;。
第二个是禁止拷贝和移动的基类。很多模式里的基类并不希望被拷贝,比如工厂返回的对象、观察者、命令。C++11之后,可以用Base(const Base&) = delete; Base& operator=(const Base&) = delete;显式禁掉拷贝,避免切片。但注意,如果你禁用了拷贝,移动构造和移动赋值也需要考虑禁用或重新声明,不然编译器可能生成不正确的默认移动操作。一个典型错误是基类禁拷贝、但子类还在用std::vector<Base>存对象,这会直接编译不过,正确做法是存std::vector<std::unique_ptr<Base>>。
第三个是接口设计不要过度抽象。设计模式带来的一个副作用是“为了用模式而用模式”,把接口拆得太细。C++的接口类如果虚拟方法太多,所有子类都要跟着改,所以遵循“最小接口”:接口类只要包含业务上的关键操作,别为了将来的扩展把每个方法都虚拟化。每增加一个虚函数,以后重构的成本都在成倍增加。我见过很多所谓“抽象工厂 + 策略 + 观察者”三层套娃的项目,到最后没人说得清对象之间的调用链,维护成本高得吓人。模式的本质是让代码可读、可改,而不是让对象关系绕圈子。
6. 怎么学才不吃灰:一条针对C++开发者的实战路径
最后这部分讲学习路径。23种设计模式,如果只是按目录一个个“看懂”,很快就忘了。真正有效的学习方法,是把模式当成“问题清单”,带着问题去读代码、写代码。下面这条路径是按照我个人经验总结的,适合绝大多数C++开发者。
6.1 别贪多,先吃透核心8种
23种模式不是等权重的。日常业务开发里,单例、工厂方法(含简单工厂)、策略、观察者、模板方法、装饰器、适配器、外观,这8种占到了实际使用频率的八成以上。我建议除了这8种,剩下15种可以按“知道是什么、知道什么时候该翻书”的标准来要求自己,不必强求立刻会写。特别是下面几个重量级模式,它们的学习收益并不与代码量成正比——访问者模式的实现复杂且应用场景狭窄,解释器模式在绝大多数项目里毫无用武之地,中介者模式用事件总线代替更香。
先把8种核心模式的C++实现各写50到100行,能背出来说明真正理解了。写的过程中用“问题驱动”来替代“目录驱动”:不要问“策略模式怎么写”,而要问“排序算法怎么才能随时切换”,然后自己推演,推演不出来再翻开书看策略模式的答案。这样学一次,基本就忘不掉了。
6.2 用“场景清单”代替概念背诵
每种模式都需要记三个层次的答案:它解决什么问题?不解决什么问题?在C++里用什么语法特性最贴合?我把核心常用的模式做成下面这张自查表,方便你随时对照:
| 模式 | 典型场景 | 反例(什么时候别用) | C++实现提示 |
|---|---|---|---|
| 单例 | 全局配置、日志器 | 所有可以被注入的对象 | 局部静态变量,注意单例依赖 |
| 工厂方法 | 对象类型需要被延迟决定 | 只有一种具体类型时 | 子类实现创建虚函数 |
| 策略 | 算法可替换 | 算法固定不变时 | std::function优先于基类 |
| 观察者 | 事件通知、UI刷新 | 一对一无状态回调 | std::function+ 订阅ID |
| 模板方法 | 流程固定、步骤多变 | 流程本身也会变 | NVI惯用法:public非虚+protected虚 |
| 装饰器 | 动态叠加能力 | 组合关系固定不变 | unique_ptr持有下一层包装 |
| 适配器 | 兼容老接口 | 老接口可以用别名解决时 | 对象适配器优先 |
| 外观 | 子系统太复杂 | 调用方只用一个模块时 | 避免让门面变成上帝对象 |
这张表用起来的方式是:拿到一个需求,先尝试用上面的场景去匹配,而不是去翻模式目录。如果两个模式的场景描述都贴得很近,就看“反例”那一栏。比如“要不要用单例”,先问自己“这个对象真的能且只能有一个吗?如果有人想临时再创建一个来做测试怎么办?”
6.3 如何通过重构成长为模式思维
模式不是写出来的,是重构出来的。我的习惯是:第一版代码怎么直接简单怎么来;当发现代码开始变乱时,再根据“变乱的类型”去对照模式。下面是一些很实用的触发信号:
- 代码里到处都是
if (type == "xxx")的创建逻辑,每加一个类型就要改一个switch分支,这时候引入工厂方法。 - 一个类的构造函数参数超过7个,调用点完全看不出哪个参数是干什么的,这时候用建造者模式。
- 对象A要通知B、C、D三个对象,而且D自己还有一层通知,这时候用观察者模式。
- 一个算法有排序、搜索两种大类型,每种类型又有细分实现,而且客户端不关心它拿到的到底是哪个实现,这时候用策略模式。
- 想在已有的类上增加记录日志、性能计时、权限校验,又不想把这些横切逻辑写进原类,这时候用装饰器,或者更现代的AOP思路。
- 两个老系统要对接,接口对不上,但老代码动不了,这时候用适配器。
这里有个小技巧:每次重构完,在git的commit message里写清楚“为什么选择这个模式、它替代了哪段坏代码”。一个月后根据commit记录复盘,你的模式敏感度会提升得很快。
6.4 关于模式争议:别神化,也别妖魔化
写到这里一定会有人问:现在C++社区不是都在说“设计模式是过时的东西吗”?我不认同这个说法。真正被批判的其实是“过度使用设计模式”和“模式原教旨主义”。GoF自己也从没说过每个项目都要用满23种模式,他们只是把已验证的方案记录下来。一个判断标准:如果你的团队里新来的人读代码时需要翻一堆类图才能理解业务流程,那就说明抽象过度了;如果改一个需求要同时改七个文件,但每个文件的改动都很机械、方向都一样,那往往是抽象不足或者抽象方向不对。
用模式最健康的心态是:模式是代码的词汇表,不是为了把代码写得“高级”,而是为了把代码里反复出现的结构性问题用大家熟悉的词命名出来,降低沟通成本。当你说“这个模块用观察者模式”时,协作者脑中立刻会浮现出“事件注册、通知、回调”这些语义,这比拿着20行代码从头解释高效得多。
最后,C++这套语言本身的语法变化非常快,C++11/14/17/20每一版都在简化旧模式的实现方式。等你真正把设计模式装进脑子里之后,再看那些“现代C++已经不需要设计模式了”的说法,就会明白它们争论的其实是实现手段,而不是问题本身。该解决的问题,不会因为语言变新了就不存在,只是解法更优雅了而已。