先把丑话说在前头23种设计模式不是靠背就能会的更不是靠“看完一篇全解”就能上手的。但你如果把这篇当成一张地图搞清楚每个模式到底在解决什么、彼此之间是什么关系、在C里实现时有哪些语言特有的坑那它就是你从“知道名字”走向“敢在项目里用”的那座桥。这篇的东西适合刚学完C语法、开始读项目源码时被各种模式名词砸晕的人也适合写过一阵子业务代码、想系统整理一遍套路的人。我会把23种模式全部过一遍但重心放在真正高频、真正容易写错的那些上面并且全程用C的视角去讲不跟你扯纯理论。1. 23种模式的全景图先搞清这三大家族到底在解决什么设计模式这个概念来自1994年那本GoFGang 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; // C11起局部静态变量初始化是线程安全的 return logger; } void log(const std::string msg) { /* ... */ } private: Logger() default; Logger(const Logger) delete; Logger operator(const Logger) delete; };这段代码在C11之后是线程安全的不需要加锁不需要双重检查也不会有内存泄漏问题程序结束时静态对象自动析构。很多人喜欢写“饿汉式”也就是在类里定义一个 static Logger instance 成员这种写法在C里最大的坑是初始化顺序不可控不同编译单元里的静态对象初始化顺序是未定义的万一你的单例在另一个静态对象的构造函数里被调用很可能拿到一个还未构造的实例。所以全局单例优先用函数内局部静态变量这个坑能直接绕开。但比“怎么写单例”更重要的是“要不要用单例”。我见过太多人把单例当成“方便的全局变量”来用日志单例、配置单例、数据库连接单例、工具类也搞单例。滥用之后整个代码库里到处是XXX::instance()的隐形依赖单元测试根本没法做。我的建议是真正的单例只留给那些“进程内确实只需要一份、且没有状态替换需求”的东西比如日志器、全局配置。如果你有多个实例的可能比如多配置文件、多个日志输出目标就别用单例直接用普通对象加依赖注入以后会省太多事。2.2 工厂模式的三种形态简单工厂、工厂方法、抽象工厂工厂模式是创建型里最家族化的一个初学者最容易被三种形态搞晕。先明确一点GoF书里只定义了工厂方法和抽象工厂所谓“简单工厂”其实是一个非正式的变体。简单工厂就是一个普通函数根据参数返回不同类型的对象比如// 简单工厂本质是集中式的对象创建函数不属于GoF 23种 std::unique_ptrTransport createTransport(const std::string type) { if (type truck) return std::make_uniqueTruck(); if (type ship) return std::make_uniqueShip(); return nullptr; }简单工厂的缺点是违反开闭原则每加一种Transport都要改这个函数。当类型很多、变化频繁时就得升级为工厂方法让每个子类决定创建哪种具体对象。这里先说明工厂方法模式适用的场景是少数几种类型、创建逻辑简单如果产品类型经常增加那需要的是把工厂做成虚函数也就是定义virtual std::unique_ptrTransport createTransport() 0;抽象工厂则更进一步解决的是“一系列产品要配套使用”的问题。比如你需要一套“现代风格UI”和一套“复古风格UI”每套都包含Button和TextBox两个产品你不能让现代Button配复古TextBox这时候就定义一个抽象工厂class UIFactory { public: virtual std::unique_ptrButton createButton() 0; virtual std::unique_ptrTextBox createTextBox() 0; }; class ModernUIFactory : public UIFactory { public: std::unique_ptrButton createButton() override { return std::make_uniqueModernButton(); } std::unique_ptrTextBox createTextBox() override { return std::make_uniqueModernTextBox(); } };三种形态的选择逻辑很朴素只偶尔创建一两种类型用简单工厂或工厂方法需要保证“一套产品配套”的时候用抽象工厂。别一上来就上抽象工厂抽象工厂本身的抽象层次高代码量也不小业务没有配套约束时属于过度设计。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_ptrBase 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_ptrStream next) : next_(std::move(next)) {} void write(const std::string data) override { auto encrypted encrypt(data); next_-write(encrypted); } private: std::unique_ptrStream next_; };这里的EncryptedStream可以继续被CompressedStream包装形成一个运行时灵活组合的“能力栈”。装饰器最关键的约束是所有装饰类和具体类必须继承同一个抽象接口。这样任何一层装饰对象都能当作原始对象用。在C里用std::unique_ptr持有下一层把所有权链串起来生命周期很清晰。组合模式则用于表达“部分-整体”的树形结构让调用方对单个对象和组合对象一视同仁。典型场景是文件系统、组织架构、XML节点。设计组合模式时有一个经典问题叶子节点和中间节点要不要共享很多接口方法比如addChild()在叶子节点上没意义。处理方式有两种一种是在基类抛异常或空实现另一种是把节点安全方法分开定义调用方用dynamic_cast判断。C里我倾向于把“叶子特有的操作”在叶子类里实现组合类多维护一个子节点容器基类里可以给addChild一个默认空实现或断言避免接口过宽。装饰器和组合如果结合起来就非常强大组合负责把对象组成树装饰器负责给每个节点加能力。很多框架里UI组件树就是组合模式而给组件加边框、阴影、滚动条就是装饰器模式。3.3 桥接与享元解耦两个变化维度 vs 共享细粒度对象桥接模式在“设计模式最被低估”的榜单上常年排名靠前因为它的经典例子图形和颜色太抽象导致很多人看不懂。其实桥接的精髓就是当一个类有两个独立变化的维度时别用继承去组合它们而是把其中一个维度作为成员组合进来。在C里最典型的桥接实现就是PimplPointer to Implementation指向实现的指针// 头文件 class Widget { public: Widget(); ~Widget(); void draw(); private: struct Impl; std::unique_ptrImpl pImpl_; }; // 源文件 struct Widget::Impl { std::string style_; int width_; // 真正的实现细节 }; void Widget::draw() { /* 通过 pImpl_ 实现 */ }这样写的好处不仅在于“抽象和实现分离”还在编译上特别划算头文件里没有任何具体的成员变量任何成员变量的改动都只需要重新编译源文件而不是把所有包含这个头文件的编译单元全部重编一遍。大项目里这个收益非常明显。桥接模式和适配器别混淆适配器是让“已有的接口”能配合起来桥接是提前设计“让接口和实现能各自独立演进”。享元模式的目标是减少内存占用适合有大量相似对象的场景。在C里享元通常配合共享指针和工厂使用把所有对象共有的内部状态集中到一个std::shared_ptrconst 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::vectorint v) const 0; }; class QuickSortStrategy : public SortStrategy { /* ... */ }; class HeapSortStrategy : public SortStrategy { /* ... */ }; // 调用方根据场景选一种 std::unique_ptrSortStrategy strategy std::make_uniqueQuickSortStrategy();而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_uniqueClosedState()); } };区分它们就看“切换的主动权在谁手里”策略模式是外部设置是一次性的、无状态迁移的状态模式是内部一步一步迁移的而且每个状态可能对同一个操作有不同的反应。实际写代码时如果发现自己用状态模式时一个状态要频繁查询另一个状态的信息那大概率是设计错了应该把共享信息提升到上下文对象里就像上面代码里的TcpConnection。4.2 观察者也被称为发布-订阅从回调函数到消息总线的演变观察者模式可能是现代软件里最无处不在的模式。它的核心是当一个对象主题的状态变化时自动通知所有注册的观察者。在C里实现观察者我强烈建议直接用std::function替代“观察者接口类”这样注册的可以是普通函数、lambda、成员函数绑定灵活度极高class EventBus { public: using Handler std::functionvoid(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_mapint, Handler handlers_; int nextId_ 0; };这个实现有几个工程上非常实际的细节要提醒。第一观察者回调里很可能会有新的订阅动作如果你用的是std::vector而不是std::unordered_map遍历时插入元素会导致迭代器失效所以注册接口要返回一个可退订的ID而不是只往容器里塞。第二发布事件时如果某个回调抛异常后续的回调就不会执行了建议在发布循环里捕获异常或者约定回调内部自己处理所有异常。第三回调里不应当做耗时操作否则发布者会被拖死一个被大量使用的观察者事件应该设计成异步投递。观察者模式的最大风险是“隐式依赖”主题不知道观察者到底干了什么一旦回调里又去调主题的方法容易形成循环触发。现代框架里的消息总线和信号槽机制本质上都是观察者模式的工程化变体只是加了线程模型、生命周期绑定、自动退订这些更具体的约束。4.3 模板方法继承体系里最优雅的流程复用模板方法在C里有个特别顺手的实现空间因为C的虚函数和访问控制组合非常灵活。经典写法是“public非虚接口 protected虚函数”NVINon-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_ptrHandler next_handle里判断自己能否处理不能就调next_-handle()。最经典的例子是日志的严重等级过滤、异常处理中的多层catch。这个模式在工程上最大的坑是“链长不知道请求被谁处理”调试时候需要打日志追踪我会在建链时给每个处理器加一个名字方便排查。命令模式把请求封装成对象支持撤销、重做、事务。在C里早期版本用命令模式是因为没法把函数当参数传现在有了std::function和lambda命令模式的大部分场景可以直接用函数对象代替只有当你需要“撤销栈”、把命令序列化、或者统一记录日志时才有必要定义一个真正的Command类。迭代器模式C标准库已经把迭代器做得非常到位了正常情况下你不需要自己实现迭代器。但如果你写了一个自定义容器那么为该容器实现STL兼容迭代器就是迭代器模式的实践。重点是要实现begin()/end()、operator、operator*、operator!并且注意容器和迭代器失效的语义这些很难一次写对强烈建议先用std::vector作为内部存储迭代器直接包装内置迭代器。中介者模式多个对象之间的交互过于复杂时引入中介对象统一调度。C里的典型实现就是“事件总线”或“消息中心”和观察者模式很接近差别是中介者模式强调的是“集中化”观察者模式强调的是“通知”。UI系统中的对话框经常用中介者模式多个控件不直接互相引用都只和中介者通信避免网状依赖变成蜘蛛网。备忘录模式保存对象的状态快照支持回滚。C实现时通常让备忘录类持有对象私有成员状态的副本要访问私有字段就得在备忘录类里声明原对象类为友元。注意快照如果是深拷贝可能很昂贵所以备忘录模式应该配合“状态增量”或“COPCopy-On-Write写时复制”来用否则大对象的回滚功能会变成内存杀手。解释器模式为一种简单语言定义语法和解释器。这个模式在工程里其实用得不多因为做真正的编程语言解释器有更好的工具如ANTLR、Flex/Bison但如果你要给一个配置文件写个非常小巧的表达式解析器比如支持age 18 score 60这种规则引擎解释器模式是很好的参考。实现时建议用递归下降解析每个语法规则对应一个递归函数比抽象语法树类的复杂设计更容易调试。状态模式的一大落地细节是状态切换时如何传递上下文。我的经验是每次状态切换都让新状态持有上下文对象的引用这样状态方法不需要同时接收上下文参数和数据参数签名清爽很多。另外状态对象本身的创建频率不宜太频繁如果状态切换很频繁可以用预先创建好的“不可变状态单例”同时把会变化的数据放到上下文对象里这就是“状态模式 享元模式”的组合用法。访问者模式这是23种里最“重”的一个它解决的核心问题是“在类结构不变的情况下增加新操作”。C里需要双重分派因为普通虚函数只会根据实际对象动态分派一次访问者想要同时根据元素类型和访问者类型动态决定调用哪个重载。访问者模式适合那些结构稳定的对象树比如AST抽象语法树节点类很少变但操作类型检查、代码生成、打印经常变。只要记住一句话类很少变但你经常想加操作用访问者操作很少变但类经常变绝不要用访问者。5. C实现设计模式时最大的坑其实在语言本身很多人学设计模式时用的是Java的例子然后原封不动地往C里搬结果跑起来到处是问题。不是说设计模式在C里失效而是C有自己的语言特性直接照搬会踩到这些坑没有垃圾回收、值语义与引用语义并存、析构函数和拷贝控制绕不开、模板元编程可以将很多模式“编译期化”。这一章值得你反复看很多“为什么这么写”的答案都在这里。5.1 RAII和析构风格如何影响模式的落地RAIIResource Acquisition Is Initialization资源获取即初始化是C区别于几乎所有高级语言的核心习惯。在Java里对象有没有被释放由GC决定在C里一个对象的生命周期由栈上的作用域和智能指针决定。这个差异直接决定了观察者模式、命令模式、备忘录模式里“对象什么时候死”这件事。就拿观察者来说Java里你可以在观察者被GC回收时依赖终结器去退订但C里没有可靠的终结时机如果你让一个shared_ptrObserver被主题持有着那么观察者永远不会析构因为它被主题引用着——这就是一个循环引用问题。正确做法是主题持用weak_ptrObserver观察者在析构前主动退订或者在发布时通过lock()检查观察者是否还活着。如果观察者的生命周期由外部完全管理主题甚至可以存裸指针前提是保证退订接口一定会被调用。另一个RAII相关的点是单例和全局状态。如果一个单例对象内部打开了一个文件句柄或网络连接那么它的析构顺序就是一件要紧的事。C里局部静态对象在程序退出时按构造逆序析构如果多个单例存在依赖关系比如日志器依赖配置器那么构造顺序就决定了析构顺序倒过来会出问题。最稳的办法是让单例之间不互相依赖如果一个单例必须依赖另一个单例就要考虑在main函数里手动构建依赖顺序而不是全部依赖静态初始化。5.2 智能指针把“所有权”写进了类型签名现代CC11及以后里写设计模式智能指针是绕不开的。unique_ptr表达独占所有权shared_ptr表达共享所有权weak_ptr表达“只观察、不拥有”。这套所有权语义直接影响模式的设计模式经典Java写法C现代写法抽象工厂返回基类引用GC管理返回std::unique_ptrProduct观察者主题持有ObserverList主题持有std::vectorstd::weak_ptrObserver组合子节点ListGC管理子节点用std::unique_ptrNode或shared_ptr命令命令对象入队std::functionvoid()或unique_ptrCommand享元共享对象用引用std::shared_ptrconst SharedState举一个具体例子组合模式的树形结构内存管理一旦没想清楚不是泄漏就是悬垂。我的经验是父节点独占持有子节点也就是子节点集合用std::vectorstd::unique_ptrNode这样删除父节点时整个子树会被自动释放。但如果同一个子节点会被多个父节点引用比如共享某个“文件夹快捷方式”那就得用shared_ptr。表达“共享所有权”别省事类型签名本身就是文档。还有一点很重要不要把shared_ptr当成万能钥匙。shared_ptr虽然省心但控制块的原子递增递减是有开销的而且在多线程环境里互相持有shared_ptr的对象图会让“谁都不能被释放”形成内存泄漏。能用unique_ptr就不要升级到shared_ptr这是我在代码评审里几乎每次都会说的一句话。5.3 模板让设计模式有了静态版本C里有一批模式天然可以通过模板实现出编译期版本这是其他面向对象语言很难做到的。比如策略模式传统写法是运行时的基类指针但其实std::function已经是最轻量的策略容器再比如工厂方法模板化之后连“工厂基类”都不用写了template typename ConcreteProduct, typename... Args std::unique_ptrProduct makeProduct(Args... args) { return std::make_uniqueConcreteProduct(std::forwardArgs(args)...); }写代码时经常被误解的一点是“设计模式都是运行时多态”其实GoF在书里就提到过“有时候可以避免引入继承”。C的模板提供了编译期的鸭子类型只要能满足接口要求不需要继承同一个基类就能参与统一的算法流程。这就是所谓的“静态多态”或者“基于模板的策略模式”。模板版本的好处是性能更好无虚函数开销、可内联坏处也很明显二进制体积变大、编译期类型约束错误信息很难读、运行时也不能在种类列表之外动态替换。因此我的建议是运行时需要动态扩展比如插件系统、类型集合不能完全预知的场景用经典的虚函数模式类型集合在编译期就确定、追求性能的场景优先用模板。两者不冲突一个系统里经常是“外层用虚函数做插件化内层用模板做高性能算法”。5.4 拷贝语义、虚析构与接口设计三个最容易翻车的细节三个C特有的细节不管写哪种模式都必须绷紧弦。第一个是虚析构函数。只要一个类设计成基类、并且会被多态地删除例如std::unique_ptrBase指向new Derived那么Base必须有虚析构函数。继承一个没有虚析构的基类然后用基类指针删除派生类对象是未定义行为。这个坑在设计模式里尤其容易踩因为模式里的接口类到处都是。检查清单上第一条凡是拿来做多态基类的类析构函数写成virtual ~Base() default;。第二个是禁止拷贝和移动的基类。很多模式里的基类并不希望被拷贝比如工厂返回的对象、观察者、命令。C11之后可以用Base(const Base) delete; Base operator(const Base) delete;显式禁掉拷贝避免切片。但注意如果你禁用了拷贝移动构造和移动赋值也需要考虑禁用或重新声明不然编译器可能生成不正确的默认移动操作。一个典型错误是基类禁拷贝、但子类还在用std::vectorBase存对象这会直接编译不过正确做法是存std::vectorstd::unique_ptrBase。第三个是接口设计不要过度抽象。设计模式带来的一个副作用是“为了用模式而用模式”把接口拆得太细。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这套语言本身的语法变化非常快C11/14/17/20每一版都在简化旧模式的实现方式。等你真正把设计模式装进脑子里之后再看那些“现代C已经不需要设计模式了”的说法就会明白它们争论的其实是实现手段而不是问题本身。该解决的问题不会因为语言变新了就不存在只是解法更优雅了而已。