C++创建型模式实战:五种对象创建模式详解与选型指南
设计模式这东西我在 C 项目里用了十几年说实话早期写代码根本不在乎什么模式不模式能用就行。直到有一次接手一个老项目里面密密麻麻全是new出来又散落各处的对象改一个底层接口要牵连十几个文件我才意识到问题不在于代码量而在于对象创建这件事被完全写死了。创建型模式解决的核心问题就是把“怎么创建对象”和“在哪里使用对象”拆开让代码有办法在运行时换实现、在编译期解依赖。这篇是创建型模式实战的第一篇我会结合真实项目场景把工厂方法、抽象工厂、建造者、单例、原型这五种模式在 C 里的写法、选型思路和踩过的坑一次讲透适合刚看完理论却不知道怎么落地的人也适合想重新梳理 C 对象创建策略的开发者。1. 为什么创建型模式在 C 项目里这么重要1.1 从实际问题出发new 一个对象并没有那么简单很多人在学校写作业的时候new一个对象就是一行代码的事但到了真实项目里对象创建往往伴随着一堆隐藏逻辑参数要从配置文件读取、要经过缓存判断是否已有实例、要根据运行平台选择不同的实现、创建完还要注册到管理器里。这些逻辑如果全部写在调用方代码就会变成“面条代码”——调用方既要关心业务又要关心对象是怎么拼出来的。我接手过一个跨平台项目同一套业务逻辑在 Windows、Linux、macOS 上跑界面控件、文件路径、系统托盘的行为全都不一样。最初的实现方式是到处#ifdef一个文件里塞了四五套分支后来加了新的平台每个分支点都要改一遍。改用工厂模式之后平台差异被收敛到工厂内部调用方永远只跟抽象接口打交道加平台的时候只动工厂一个地方。这个经历让我彻底明白了创建型模式省的不是那几行代码而是把变化隔离在一个可控的范围内。1.2 创建型模式的共性问题把“创建”和“使用”解耦创建型模式一共有五种工厂方法、抽象工厂、建造者、单例、原型虽然形态各不相同但底层诉求是一致的让调用方不直接依赖具体类。C 里这种解耦有一个独特的难度——不像 Java 或 C# 有统一的反射机制C 的运行时类型信息很有限对象创建往往要依赖模板、静态工厂或者注册表这类手写机制。正因为如此C 里的创建型模式通常有两种实现风格一种是经典的多态风格通过虚函数和抽象基类让派生类决定创建什么另一种是模板风格利用编译期多态把工厂行为固化。两种风格没有绝对的好坏多态风格灵活、适合运行时切换模板风格零虚函数开销、适合性能敏感的场景。实战中我经常两者混用比如工厂内部用模板做默认实现暴露出去的接口还是抽象类。后面我会在每个模式里具体演示这两种风格怎么写。2. 工厂方法让基类定规则子类决定创建谁2.1 经典场景跨平台 UI 控件的创建工厂方法的典型结构是定义一个创建对象的接口让子类决定实例化哪个类。听起来有点绕换个说法基类把业务流程定好但流程里要创建的具体对象由子类来提供。我之前做一个跨平台渲染引擎需要统一的按钮接口class Button { public: virtual ~Button() default; virtual void render() 0; virtual void onClick(std::functionvoid() callback) 0; }; class WindowsButton : public Button { public: void render() override { // Windows 原生绘制逻辑 } void onClick(std::functionvoid() callback) override { // 注册 Windows 消息回调 } }; class MacButton : public Button { public: void render() override { // macOS 原生绘制逻辑 } void onClick(std::functionvoid() callback) override { // 注册 macOS 事件回调 } };如果直接在窗口代码里new WindowsButton()那窗口类就绑死了 Windows。工厂方法的做法是把创建动作放到一个虚函数里子类自己去覆盖class Dialog { public: virtual ~Dialog() default; void show() { auto btn createButton(); // 调用工厂方法 btn-render(); btn-onClick([this] { onConfirm(); }); // 其他业务流程... } protected: virtual std::unique_ptrButton createButton() 0; private: void onConfirm() { // 确认逻辑 } }; class WindowsDialog : public Dialog { protected: std::unique_ptrButton createButton() override { return std::make_uniqueWindowsButton(); } };这段代码的关键在于show()函数只依赖Button接口和createButton()至于按钮到底是哪个平台的Dialog 完全不知道。新增一个 LinuxDialog 只需要继承 Dialog 并实现 createButton原有的 WindowsDialog 一行不用改。2.2 工厂方法在 C 里的实现细节写工厂方法时有一个常见的认知误区以为工厂方法只是“把 new 包一层”。其实工厂方法的精髓在于业务流程的复用如果基类只有一个工厂接口、没有业务流程那它和直接调用的区别不大。我习惯先在基类里写好完整的业务模板方法再让子类通过工厂方法注入变化。C 特有的几个实现要点返回类型用std::unique_ptr而不是裸指针避免资源泄漏问题。C11 之后这是标准做法裸指针只适合做非拥有型观察者。工厂方法本身可以是 protected因为它是给基类业务流程调用的没必要暴露给外部使用者。如果创建过程需要参数可以给 createButton 加上配置结构体参数但不要破坏基类接口的稳定性。另外一个很实用的技巧工厂方法可以和模板结合起来。如果需要创建的控件类型是编译期就能确定的可以减少虚函数调用template typename ButtonType class TypedDialog : public Dialog { protected: std::unique_ptrButton createButton() override { return std::make_uniqueButtonType(); } };不过这种写法要求按钮类型无参构造如果有参数就得用转发构造。模板加虚函数混合使用的时候要小心代码膨胀一个模板实例化一份代码控制模板参数数量就很重要。2.3 实战中工厂方法的边界什么时候别用工厂方法不是万能的我在项目里吃过一次亏某个配置文件解析器一开始为了“以后可能支持多种格式”给每个格式都建了子类工厂。结果写了半年实际只用了 JSON 一种格式那些抽象层级变成了纯粹的预测性设计。后来我养成了一个判断标准至少出现两种已知的具体实现需要切换时再考虑工厂方法。如果只有一种实现直接创建对象预留抽象没有意义因为 C 里没有用的抽象不会帮你省任何事只会增加阅读障碍。如果变化点不是“创建什么对象”而是“怎么初始化这个对象”那该考虑的应该是建造者模式而不是工厂方法。3. 抽象工厂与建造者一个管产品族一个管复杂装配3.1 抽象工厂应对成套对象的约束抽象工厂解决的是“一系列相关对象必须配套使用”的问题。前面工厂方法只关心一个按钮但一个跨平台 UI 不是只有按钮还有文本框、复选框、滚动条而且 Windows 风格的按钮必须搭配 Windows 风格的其他控件不能混搭。抽象工厂模式就是定义一个创建整套产品的工厂接口class UIFactory { public: virtual ~UIFactory() default; virtual std::unique_ptrButton createButton() 0; virtual std::unique_ptrTextBox createTextBox() 0; virtual std::unique_ptrCheckBox createCheckBox() 0; }; class WindowsUIFactory : public UIFactory { public: std::unique_ptrButton createButton() override { return std::make_uniqueWindowsButton(); } std::unique_ptrTextBox createTextBox() override { return std::make_uniqueWindowsTextBox(); } std::unique_ptrCheckBox createCheckBox() override { return std::make_uniqueWindowsCheckBox(); } };这里有一个微妙的设计点抽象工厂的“抽象”体现在产品族层面而不是单个产品层面。工厂接口的每个方法都是工厂方法但抽象工厂强调的是一组产品的一致性。如果项目里只有一个产品需要创建用抽象工厂就是杀鸡用牛刀。我还遇到过一种变体需求不是按平台区分产品族而是按主题区分。比如“暗色主题的按钮、文本框”和“浅色主题的按钮、文本框”模式结构完全一样只是换了个维度。这说明抽象工厂的维度可以是任何业务概念不一定是平台。3.2 建造者分步装配复杂对象建造者和抽象工厂看着有点像实际上解决的问题完全不同。抽象工厂关心“同一套产品怎么配套”建造者关心“一个复杂对象怎么分步骤组装”。举个最直观的例子构建一个查询请求对象可能需要设置分页、排序、过滤条件、超时时间、重试策略每个步骤可以单独配置最后生成不可变的对象。如果直接在构造函数里写上七八个参数调用方根本记不住顺序。我做一个数据访问层的时候用过这样的建造者class QueryBuilder { public: QueryBuilder withPage(int page, int size) { page_ page; size_ size; return *this; } QueryBuilder withSort(const std::string field, bool ascending) { sortField_ field; ascending_ ascending; return *this; } QueryBuilder withFilter(const Filter filter) { filter_ filter; return *this; } QueryBuilder withTimeout(std::chrono::milliseconds timeout) { timeout_ timeout; return *this; } Query build() const { return Query(page_, size_, sortField_, ascending_, filter_, timeout_); } private: int page_ 1; int size_ 20; std::string sortField_; bool ascending_ true; Filter filter_; std::chrono::milliseconds timeout_ std::chrono::seconds(5); };调用方就非常清晰auto query QueryBuilder() .withPage(2, 50) .withSort(created_at, false) .withFilter(Filter::eq(status, active)) .build();建造者模式在 C 里有几个优势。第一可以保留默认值调用方只设置自己关心的字段其他字段用默认配置降低使用门槛。第二可以保证构建出来的对象是不可变的因为 build 返回的 Query 所有字段都是 const 或私有成员。第三链式调用的可读性比构造函数传参高很多尤其是参数超过三个的时候。3.3 抽象工厂和建造者怎么选我见过不少团队在这两个模式上纠结其实选型逻辑很直接如果变化维度是“一批不同产品的组合方案”选抽象工厂。如果变化维度是“同一个对象的装配过程”选建造者。两者可以嵌套使用建造者的某个步骤内部可以用抽象工厂来生产部件。比如渲染引擎里场景对象的构建可能分“加载模型、创建材质、设置灯光”几步每步用什么具体类型的资源又由平台工厂来决定。这种组合很自然不需要刻意区隔。有一点要提醒建造者如果只是把 setter 换个写法、没有真正的校验和装配规则那它只是个语法糖。我见过有人把简单的两个字段也套上建造者这就没必要了。合理的标准是构建过程有默认值、有可选参数、有校验逻辑或者构建结果需要不可变性。4. 单例模式最熟悉也最容易写错的一个4.1 线程安全的懒汉单例怎么写单例模式可能是 C 里被讨论最多的创建型模式也是被滥用得最严重的。先给一份现代 C 的标准写法这是 C11 之后线程安全且简洁的class ConfigManager { public: static ConfigManager instance() { static ConfigManager config; return config; } ConfigManager(const ConfigManager) delete; ConfigManager operator(const ConfigManager) delete; std::string get(const std::string key) const; private: ConfigManager() default; ~ConfigManager() default; };这被称为 Meyers Singleton利用函数内静态变量的初始化时机来保证线程安全。C11 标准明确规定了局部静态变量的初始化是线程安全的所以不用自己加锁。这段代码我用了很多年没有出过问题。早期很多教材教的是带锁的双检锁写法static ConfigManager* instance() { if (instance_ nullptr) { std::lock_guardstd::mutex lock(mutex_); if (instance_ nullptr) { instance_ new ConfigManager(); } } return instance_; }这种写法在 C11 之前是有问题的因为指令重排可能导致对象指针被非空赋值但构造函数还没完成另一个线程拿到半成品对象。虽然 C11 之后由于内存模型完善了双检锁配合原子变量也可以写对但完全没有必要Meyers Singleton 又短又稳别折腾。4.2 单例模式最常见的坑我在项目里踩过的单例坑主要有三类。第一类是生命周期问题。单例的析构顺序在多个单例相互依赖时不可控。比如日志单例依赖于配置单例程序退出时日志单例先销毁了后续析构函数再写日志就会踩空指针。解决思路是给单例注入依赖时要小心不要让单例之间的依赖关系形成环必要时可以加一个 Shutdown 函数来控制析构顺序。第二类是单例被塞了全局可变状态。配置管理器这种只读为主的场景适合单例但如果单例内部是海量的可变缓存、又有很多线程同时读写那就会变成一个隐性的并发瓶颈。我遇到过某个项目把内存缓存做成单例高并发下性能上不去后来改成依赖注入、按请求作用域创建实例才解决。第三类是用单例来包装工具类。纯函数式的工具类根本不需要单例直接静态方法就好因为静态方法不持有状态单例的意义就消失了。判断标准很简单如果一个类没有成员变量那它不该是单例它应该是一组静态方法。单例在创建型模式里比较特殊我个人建议把它当作最后的手段。能注入就注入能传递就传递除非这个对象确实是进程级唯一、且从启动到退出一直存在才考虑单例。比如 Logger、全局配置、进程级事件总线这些是合理的场景。5. 原型模式以对象为模板制造新对象5.1 深拷贝与浅拷贝的坑原型模式的核心是“复制现有对象来创建新对象”在 C 里通常通过 Clone 接口实现class Shape { public: virtual ~Shape() default; virtual std::unique_ptrShape clone() const 0; virtual void draw() const 0; }; class Circle : public Shape { public: explicit Circle(double radius, std::string color) : radius_(radius), color_(std::move(color)) {} std::unique_ptrShape clone() const override { return std::make_uniqueCircle(*this); } void draw() const override { // 绘制圆形 } private: double radius_; std::string color_; };看起来很简单深坑在于默认的拷贝构造是浅拷贝。如果 Circle 里有一个指针成员指向一块动态分配的数据那么 clone 出来的对象会共享这块内存。两个对象任何一个析构另一个就悬空任何一个修改内容另一个也会跟着变。正确的做法是提供深拷贝逻辑class Circle : public Shape { public: Circle(double radius, std::string color) : radius_(radius), color_(std::move(color)), metadata_(new Metadata()) {} Circle(const Circle other) : radius_(other.radius_), color_(other.color_), metadata_(new Metadata(*other.metadata_)) {} std::unique_ptrShape clone() const override { return std::make_uniqueCircle(*this); } ~Circle() override { delete metadata_; // 或改用 unique_ptr 成员 } private: double radius_; std::string color_; Metadata* metadata_; };这里的关键是复制构造函数要对所有指针成员做深度复制。实战中我更推荐直接用std::shared_ptr或std::unique_ptr管理资源成员这样默认复制构造的语义就清晰了如果资源该共享就共享_ptr如果该独立就每次都 make。5.2 原型模式的实际应用场景很多人觉得原型模式在 C 里没什么用因为可以直接 new这种看法低估了它的价值。我实际用过的场景有三个第一个场景是撤销恢复系统。编辑器里的图形对象列表每一步操作前要把整个列表快照下来如果将来要恢复就重新 clone 一份。用原型模式的好处是撤销栈里存的是 prototype 的拷贝修改原型不会污染历史状态。第二个场景是对象池。数据库连接池、线程池里的对象需要反复复用与其每次都重新构建连接、重新握手不如保存一个模板对象需要的时候 clone 一份用完销毁。第三个场景是配置模板。游戏开发里常见一个敌人类型定义好基础属性生成具体敌人时 clone 一份再微调。比从零构造省去大量默认参数。原型模式在 C 里的一个常见疑问clone 作为虚函数返回类型能不能用协变类型答案是可以的派生类可以返回自己的类型指针调用方用std::unique_ptrCircle接收时会自动访问派生类的重写版本class Circle : public Shape { public: std::unique_ptrCircle clone() const override; // 返回类型协变 };不过我很少用协变返回值因为实际场景中调用方通常还是想通过基类指针统一管理统一返回unique_ptrShape更简单。6. 创建型模式的综合选型与排查经验6.1 创建型模式决策思路很多人学完五种模式到真实项目里还是不知道怎么选。我整理了一个简单的思考路径基本上每次遇到“怎么创建对象”的问题都会走一遍这个流程第一步看对象创建是否有“切换实现”的需求。如果有两种以上的具体实现需要在运行时选择优先考虑工厂方法。如果是一整套产品都需要同步切换升级为抽象工厂。这里的“切换”可以是平台切换也可以是业务策略切换。第二步看对象的构造是否复杂。构造参数超过四个、有可选参数、有默认值依赖、或者构造过程有先后顺序要求优先考虑建造者。如果对象还有不可变性的要求建造者几乎是唯一合理的选择。第三步看对象是否需要保持全局唯一且长期存活。比如日志、配置、事件总线这类可以考虑单例。但记住能用依赖注入解决的就别用单例。第四步看创建对象的成本是否很高、是否需要批量复制。如果从模板复制比从头创建更高效或者需要保留历史快照考虑原型模式。这四步不是严格的优先级更像是一棵决策树的路径。大多数业务对象只需要简单 new模式是工具不是装饰品。6.2 调试创建逻辑时的经验技巧创建型模式的代码表面上看很规整但调试起来有隐藏的困难——因为多了一层间接。我在排查问题时会重点关注几个地方。一个是工厂返回的空指针问题。工厂方法内部如果有条件分支某个分支走了默认创建逻辑结果配置不匹配返回了 nullptr调用方没检查就解引用崩溃点离问题根源很远。排查思路是在工厂的默认分支里直接抛异常而不是返回空对象这样崩溃点就在现场。另一个是单例和全局状态的排查。单例对象内部如果状态被某个线程改坏了另一个线程读取到脏数据这种问题用常规断点几乎没法定位。我的习惯是给单例对象加上线程安全的访问日志或者在高风险函数的入口退出打印关键状态变化把复现路径抓出来再优化。还有一个是建造者模式中链式调用的默认值问题。相当多隐蔽的 bug 不是因为显式设置的参数错了而是某个字段忘记设置、用了默认值而默认值并不符合预期。排查时我会先在 build 函数里对关键字段做校验如果某个必填字段还是默认值就抛异常。这在业务上等价于“编译期错误转运行期暴露”能省下大量调试时间。原型模式的调试难点在拷贝语义。复制后的对象和原对象偶尔会互相影响多半是浅拷贝残留。排查技巧是在 clone 出来的对象里修改一个可疑字段观察原对象是否变化。如果有变化就去检查复制构造函数和拷贝赋值的实现。6.3 我实际维护老项目的心得最后说说我个人在维护遗留 C 项目时的体会。很多老项目的对象创建逻辑是在构造函数里直接 new 依赖项代码之间耦合成一坨。我接手之后没有大范围重写而是从调用频率最高的类开始逐步把依赖项的创建方式改成工厂方法或者参数注入每改一个类就跑一遍全量测试。这种渐进式重构比一次性推倒重来稳妥得多风险可控也让团队慢慢适应新的创建逻辑。另外创建型模式有一个附加收益长期维护时测试代码更好写了。因为依赖项可以通过工厂换掉测试时就能注入一个 mock 版本不用真的去连接数据库或者拉起 UI。我参与的某个项目就是因为把数据源对象的创建收敛到了工厂里单元测试覆盖率才能从百分之二十提到百分之八十。这个收益虽然不在模式的定义里但在真实项目中往往比模式本身的灵活性更值钱。创建型模式只是设计模式的第一块拼图。下一篇我会沿着结构型模式继续往下写把解耦的思路从对象创建扩展到对象组合。如果你正在整理自己的 C 代码库不妨先从这五个模式里挑一个最贴近实际痛点的小范围试点对比效果再决定要不要铺开。