1. 从一次代码评审说起:对象创建为什么会失控
年前帮某团队做代码走查,一个不算大的业务模块里,new散落得到处都是——构造函数里 new、业务方法里 new、甚至析构函数里还在补 new 之后的释放逻辑。六个产品子类,切了七种创建方式:有的用静态方法封装,有的直接裸 new 再手动 set 参数,还有的在 switch 里同时做"创建 + 初始化 + 注册到全局表"三件事。最要命的是产品新增了一个维度之后,所有调用点都要跟着改,改漏一处就是线上事故。
这不是那个团队的问题,是几乎所有 C++ 项目在对象创建这件事上都会踩的坑。对象创建看起来是最简单的动作,可一旦产品种类变多、构造参数变复杂、创建时机和生命周期出现要求,散落的new就会变成修改的雷区。设计模式里的创建型模式,本质就是在回答一个问题:对象的创建逻辑应该放在哪里,才能让扩展不牵连调用方、让创建过程可复用、让约束可执行。
GoF 总结了五种创建型模式:单例、工厂方法、抽象工厂、建造者、原型。我在 C++ 项目里真正用下来,觉得它们不是五个并列的招法,而是解决"对象创建"这条主线上不同节点的工具:单例管"只能有一个",工厂管"该由谁决定具体类型",抽象工厂管"一套产品必须配套",建造者管"复杂对象怎么拼装",原型管"不再从零构造而是拷贝既有实例"。
这篇文章按实战经验来写,不堆概念定义,重点讲清楚每个模式在 C++ 里具体怎么写、为什么这么写、以及实际使用中总结出的边界与坑。适合刚工作一两年的 C++ 开发者读,也适合负责代码评审或者正在重构遗留系统的同学参考。文章里所有代码基于 C++11 及以后标准,编译器按 C++17 处理。如果你正准备系统梳理设计模式,或者正在被"改一个产品类型要动十处代码"折磨,这篇应该对你有用。
2. 单例模式:线程安全只是入场券,生命周期管理才是主线
2.1 从手动锁到 Meyers Singleton:线程安全的进化
单例应该是五种模式里被讨论最多、也被骂得最多的一种。骂它的理由集中在"全局状态导致测试困难""隐藏依赖",但说实话,日志系统、配置中心这类只允许存在一份状态的组件,单例依然是 C++ 里最务实的方案。关键在于实现要够硬。
老式教科书会教这么写:
class Logger { public: static Logger* instance() { if (instance_ == nullptr) { std::lock_guard<std::mutex> lock(mutex_); if (instance_ == nullptr) { instance_ = new Logger(); } } return instance_; } private: static Logger* instance_; static std::mutex mutex_; Logger() = default; ~Logger() = default; Logger(const Logger&) = delete; Logger& operator=(const Logger&) = delete; };这就是经典的 double-checked locking(双重检查锁)。第一次看到会觉得"外层检查 + 内层加锁检查,既避免锁开销又保证线程安全,妙啊"。但在 C++11 之前,这段代码的隐患是内存序问题:instance_ = new Logger()在 CPU 和编译器层面可能被重排成"先给 instance_ 赋值,再执行 Logger 构造函数",另一个线程在第一个 if 判断 instance_ 非空就直接返回了,拿到的却是构造尚未完成的对象。要修就得加 atomic 和 acquire/release 语义,很多老代码根本没做到这一步。
C++11 之后,标准把局部静态变量的初始化规定为线程安全,于是有了下面这个被广泛称为 Meyers Singleton 的写法:
class Logger { public: static Logger& instance() { static Logger logger; return logger; } void log(const std::string& message) { std::lock_guard<std::mutex> lock(mutex_); std::cout << message << std::endl; } private: Logger() = default; ~Logger() = default; Logger(const Logger&) = delete; Logger& operator=(const Logger&) = delete; private: std::mutex mutex_; };这段代码好在哪里?第一,初始化由编译器保证只执行一次且线程安全,不用再手写锁。第二,返回引用而不是指针,从语义上避免调用方误 delete。第三,构造函数、拷贝构造、赋值全部私有化或删除,从语言层面堵死"再创建一份"的路径。
注意:Meyers Singleton 在 C++11 起的标准里是线程安全的,但前提是编译器严格按标准实现。主流编译器在 C++11 之后都没问题,如果项目还在用非常古老的编译环境,那就另当别论。
2.2 我踩过的单例坑:跨编译单元初始化顺序
Meyers Singleton 解决了线程安全和并发初始化,但有一个坑很多人没注意:**如果单例的实例化依赖另一个全局对象,而两个全局对象的初始化顺序不同,就会出现未定义行为。**具体来说,静态局部变量在第一次使用时才初始化(这叫 lazy init),而文件作用域的全局变量在 main 之前就初始化。假设一个全局配置对象在 main 前被构建,它的构造函数里调用了某个单例,而那个单例内部又依赖另一个全局对象——初始化顺序一旦颠倒,访问到的就是未构造的内存,轻则拿到脏数据,重则直接崩溃。
我在实际项目里的处理办法是:**单例之间不要互相依赖。**如果确有必要,就把依赖关系显式放进初始化参数,或者用注册机制让被依赖方先注册。最省心的方案是让单例只依赖自己模块内部的静态数据,不跨模块引用全局变量。如果你在代码里看到 A 单例的构造函数里去调用 B 单例,那就要警惕了,这里已经埋了一颗定时炸弹。
另一个容易被忽略的是析构顺序。局部静态变量的析构顺序和构造顺序相反,但如果 A 单例析构时还在引用 B 单例(比如 A 的析构函数里写了B::instance().flush()),而 B 先一步析构了,就会访问到已销毁的对象。我的习惯是:单例的析构函数里不做任何依赖其他单例的操作,日志刷新这类收尾工作留到显式的 shutdown 方法里,由程序主流程控制顺序。
2.3 什么时候该放弃单例
单例的另一面是:它给你全局唯一的便利,同时给你全局状态的纠缠。我现在的判断标准很直接:**如果这个对象连测试 mock 都做不了,每次跑单元测试都要真实初始化整个依赖链,那它可能不该是单例。**可以用依赖注入代替——类自己持有实例的引用,构造时传入。注入让调用方可以传测试替身,代价是每个需要它地方都要显式把实例传过去,代码会多几行样板。
我一般建议:基础设施类(日志、配置、线程池)用单例没问题,它们是天然的全局限定资源;业务服务类尽量别用单例,除非你能证明"全生命周期只有一份"是硬需求而不是偷懒。见过太多把"用户会话""订单服务"写成单例的项目,最后全部卡在并发状态共享上,那时候再想拆,改动面已经收不住了。
3. 工厂方法:把 new 从业务代码里赶出去的起点
3.1 先看没有工厂的代码长什么样
假设一个图形编辑器要创建不同类型的图形对象。最初的实现可能是这样:
std::unique_ptr<Shape> createShape(const std::string& type) { if (type == "circle") { return std::make_unique<Circle>(Point(0, 0), 10); } else if (type == "rect") { return std::make_unique<Rectangle>(Point(0, 0), 20, 10); } else { throw std::invalid_argument("unknown shape type"); } }功能没有问题,但这段代码有典型的坏味道:**创建逻辑和类型判断耦合在一起。**每新增一种图形,都要改这个函数。而问题往往是:模块里有多个地方都需要"按类型创建"的逻辑,于是同样的 if-else 被复制了好几遍。更麻烦的是,如果创建动作需要附带"检查权限""记录日志""按配置调整默认参数"这些横切逻辑,复制粘贴会让它们逐渐不一致——改了一处权限检查,另一处忘了改,线上行为就出现差异。
这种代码的维护成本是隐性的:今天产品类型只有三种,你觉得很清爽;等半年后变成十二种,每个调用点的 if-else 都变成一堵墙,新增一种类型要动十几个地方,你就知道痛了。工厂方法模式就是为这个痛点存在的。
3.2 用工厂方法把选择权交给子类
工厂方法的思路是:**定义一个创建对象的接口,让子类决定实例化哪一个具体类。**换句话,把"创建什么"这个决策从调用方手里拿出去。
class ShapeFactory { public: virtual ~ShapeFactory() = default; virtual std::unique_ptr<Shape> create() const = 0; }; class CircleFactory : public ShapeFactory { public: std::unique_ptr<Shape> create() const override { return std::make_unique<Circle>(Point(0, 0), 10); } }; class RectangleFactory : public ShapeFactory { public: std::unique_ptr<Shape> create() const override { return std::make_unique<Rectangle>(Point(0, 0), 20, 10); } };调用方拿着一个ShapeFactory引用,调create(),完全不知道也不关心具体返回的是哪个子类。代码库里新增一种图形,就新增一个工厂类,原有代码一行不用改——这就是开闭原则的落地。
这里有个 C++ 特有的设计细节必须强调:**工厂方法的返回类型用std::unique_ptr<T>,而不是裸指针。**早期教材喜欢返回Shape*,然后让调用方负责 delete,这在现代 C++ 里几乎是自找麻烦——异常路径上漏 delete、所有权转移时语义不清,都是裸指针的锅。换成 unique_ptr 之后,所有权语义是"工厂转移给调用方",调用方把它存进容器或者直接使用,析构在生命周期结束时自动完成。需要共享所有权再改std::shared_ptr,但默认从 unique_ptr 起步。
我自己的扩展习惯是:让工厂持有配置对象,构造时传入,这样创建参数的变化被限制在工厂内部。比如 CircleFactory 可以接收一个默认半径的配置,创建出来的 Circle 大小不再是写死的,而调用方完全无感知。这种"参数变化封装在工厂里"的写法,比在调用方逐个 set 参数要干净得多。
3.3 简单工厂 vs 工厂方法:别把两种混为一谈
有个容易混淆的概念:简单工厂(Simple Factory)不是 GoF 的正式模式。3.1 里的createShape函数就是简单工厂——它把创建逻辑集中到一个函数里,但没有使用继承和多态。好处是代码量少、改动直接,坏处是"集中式 if-else"随着类型增加会膨胀,而且不同调用方如果只需要特定类型,也被迫经过这个集中入口。
我的经验是:**类型种类少且稳定时,简单工厂足够;种类会持续增长、或者创建逻辑本身要分成多个策略时,升级为工厂方法。**不要一上来就套工厂方法,那会造成类数量膨胀。一个只有两种产品的模块搞出四个类,维护成本远大于收益。我见过最夸张的例子,一个配置解析模块为了"仪式感"给两个子类型各配了一个工厂,然后所有调用方还要先判断该用哪个工厂——原本一个函数的活,变成五个文件。
4. 抽象工厂:产品族一致性比单个对象创建更重要
4.1 为什么要管"一套产品"
工厂方法关心的是"某个产品由谁创建",抽象工厂关心的是"一套互相匹配的产品能不能被整体创建"。举个我最近在做的界面库例子:程序需要支持两种视觉风格(我统称风格A和风格B),每种风格都包含按钮、输入框、对话框这三个组件。如果只用工厂方法,可以分别创建 Button、TextInput、Dialog,但没有任何约束保证"我拿到的是同一套风格"——用风格A的按钮搭配风格B的输入框,界面立刻花掉。
抽象工厂把这一层约束固化在接口里:
class UIFactory { public: virtual ~UIFactory() = default; virtual std::unique_ptr<Button> createButton() = 0; virtual std::unique_ptr<TextInput> createTextInput() = 0; virtual std::unique_ptr<Dialog> createDialog() = 0; }; class StyleAFactory : public UIFactory { public: std::unique_ptr<Button> createButton() override { return std::make_unique<StyleAButton>(); } std::unique_ptr<TextInput> createTextInput() override { return std::make_unique<StyleATextInput>(); } std::unique_ptr<Dialog> createDialog() override { return std::make_unique<StyleADialog>(); } };客户端代码只依赖UIFactory。手里有哪个工厂,生产出来的就是哪一个完整风格。新增一个风格C,加一个StyleCFactory即可,所有使用UIFactory的代码都不动。这就是抽象工厂比一组独立工厂方法多出来的价值:它把"成套"这个业务规则,变成了编译器能检查的接口约束。
4.2 抽象工厂和工厂方法的组合关系
有一种常见说法是"抽象工厂等于多个工厂方法",这个说法对,但不完整。抽象工厂的每个 createXxx 方法确实和工厂方法长得很像,但抽象工厂强调的不是单个方法的实现方式,而是这一组方法之间的产品族一致性。实现上,StyleAFactory里的 createButton 可以委托给专门的具体工厂对象,也可以直接 new——取决于产品构造复杂度。
如果你发现产品的"族"只有一个维度在变,那抽象工厂可以退化成一组工厂方法;如果两个维度都在变(比如风格 × 平台),抽象工厂也能扩展,但具体工厂类数量会迅速膨胀(m × n 个)。我建议这时候先停下来想想,是不是该用注册表、模板或者策略配置来替代继承结构。抽象工厂适合"维度少、族稳定"的场景,维度一多就要考虑别的组织了。
4.3 抽象工厂在 C++ 里的头文件组织
纯虚接口加多个具体工厂,还会带来一个实际的工程问题:**头文件依赖。**如果UIFactory.h里 include 了所有具体产品(StyleAButton、StyleBButton……),那任何一个产品类的改动都会触发一大片文件的重编译。随着项目变大,这种编译依赖会像滚雪球一样拖慢整个构建。我的做法是前置声明加实现文件里再 include:
// UIFactory.h class Button; class TextInput; class Dialog; class UIFactory { public: virtual ~UIFactory() = default; virtual std::unique_ptr<Button> createButton() = 0; virtual std::unique_ptr<TextInput> createTextInput() = 0; virtual std::unique_ptr<Dialog> createDialog() = 0; };注意,unique_ptr 的析构需要完整类型,所以返回std::unique_ptr<Button>的函数在头文件里只有声明没有问题,但调用方在最终使用Button的 .cpp 里必须 include 完整定义。这个约束在抽象工厂场景下尤其明显,因为你不需要在接口头文件里知道产品细节。编译时间在大项目里真的会从半小时降到几分钟,这个习惯值得养成。
5. 建造者模式与原型模式:对象构造的两条特殊路径
5.1 建造者:参数组合爆炸的救火队长
有一个场景几乎所有 C++ 开发者都会遇到:某个类的构造函数参数越来越多。我以前维护过一个 HTTP 请求类,最开始只有 URL 和超时,后来加了重试次数、代理、证书路径、连接池配置……构造函数签名变成七八个参数。调用方每次都写得跟天书一样:
HttpRequest req(url, 5000, 3, "", "", "", true, 1024);第七个参数是什么?新来的同学根本猜不出来。更危险的是,参数类型一旦都是 int 或 bool,顺序写反也不会报错,只是运行时行为完全不对。这就是经典的望远镜构造函数坏味道。
建造者模式的思路是:**把构造参数的收集过程和对象本身的构造解耦,用一个独立的 Builder 对象分步设置参数,最后统一 build。**C++ 里用 fluent 接口(链式调用)写起来非常自然:
class HttpRequest { public: class Builder { public: Builder& setUrl(const std::string& url) { url_ = url; return *this; } Builder& setTimeout(int ms) { timeout_ms_ = ms; return *this; } Builder& setRetryCount(int count) { retry_count_ = count; return *this; } Builder& setProxy(const std::string& proxy) { proxy_ = proxy; return *this; } HttpRequest build() const { return HttpRequest(*this); } private: friend class HttpRequest; std::string url_; int timeout_ms_ = 5000; int retry_count_ = 0; std::string proxy_; }; private: explicit HttpRequest(const Builder& b) : url_(b.url_), timeout_ms_(b.timeout_ms_), retry_count_(b.retry_count_), proxy_(b.proxy_) {} std::string url_; int timeout_ms_; int retry_count_; std::string proxy_; };调用端的观感提升立竿见影:
HttpRequest req = HttpRequest::Builder() .setUrl("https://example.com/api") .setTimeout(3000) .setRetryCount(3) .build();每个参数前面都有名字,读代码的人不用去猜第七个参数是什么;只设置需要的参数,其余走默认值。我实际使用中还会加两步:一是在build()里做参数校验(比如 URL 为空直接抛异常,比构造出一个坏对象再排查要快得多);二是 Builder 字段尽量给合理的默认值而不是零值,缺省配置能少写很多 set 调用。
注意:Builder 的 set 方法返回
Builder&是为了链式调用,但如果有人复用同一个 Builder 对象 build 两次,第二次 build 会带上第一次残留的参数。我建议把 Builder 语义限定成"就近构建、一次 build"——在调用前几行内完成所有 set,不要跨越半个函数再去 build。
Builder 还有一个容易被忽略的好处:**构建出来的目标对象可以做到不可变。**上面 HttpRequest 的所有成员变量只通过构造函数赋值,不提供 setter。对象一旦构建,内部状态就是固定的,多线程环境下不需要锁,这是裸构造函数加一堆 setter 很难做到的。在很多并发场景里,这个特性比链式调用的"好看"值钱得多。
5.2 原型:用克隆绕开构造的繁琐
原型模式解决的是另一个痛点:**有些对象构造成本高,或者构造参数来自运行时状态,与其重新走构造函数,不如把现有实例拷贝一份,再按需修改。**C++ 里实现原型模式的核心是 clone 函数——也就是所谓的虚构造。因为你手里拿着的可能是一个Shape*,只知道它是基类,不知道具体是 Circle 还是 Rectangle,不能直接调用拷贝构造函数,必须通过虚函数让实际类型自我复制:
class Shape { public: virtual ~Shape() = default; virtual std::unique_ptr<Shape> clone() const = 0; virtual double area() const = 0; }; class Circle : public Shape { public: Circle(Point center, double radius) : center_(center), radius_(radius) {} std::unique_ptr<Shape> clone() const override { return std::make_unique<Circle>(*this); } double area() const override { return 3.14159 * radius_ * radius_; } private: Point center_; double radius_; };注意 clone 里调用std::make_unique<Circle>(*this)——这要求 Circle 有可访问的拷贝构造函数。基类返回std::unique_ptr<Shape>,调用方拿到的是"无论原来是什么类型,拷贝出来的就是什么类型"的新对象。这个模式在编辑器、CAD、游戏关卡设计里非常实用:先做一个模板实例(比如默认的圆形),用户要新图形时 clone 一份出来,再微调坐标和属性,不用重新走一遍完整的构造流程。
原型模式的进阶玩法是原型注册表:用一个 map 把"原型名字 → 原型实例"存起来,新对象不去 new,而是从注册表里 clone。我见过一个配置驱动系统的实现——所有可创建的实体类型在启动时注册到注册表,新增类型只需要往注册表里塞一个原型实例,业务逻辑完全不用改。这种玩法把"创建"变成了"查表 + 拷贝",扩展路径非常平缓。
不过要提醒一句:**如果对象的拷贝深度不够,clone 出来的对象会共享底层资源,析构时出现双重释放或者资源重复释放。**C++ 里拷贝语义必须配合 RAII 才能安全。我踩过的坑是:一个图像处理 Demo 里用原型克隆带像素缓冲的对象,像素缓冲区是裸指针,clone 出来的两个对象共享同一块内存,其中一个修改像素数据,另一个"同步变化",排查了很久才发现是浅拷贝的锅。后来把缓冲区从裸指针换成std::vector<uint8_t>,深拷贝自动完成,问题一次性解决。这个教训说白了就是:用原型之前,先确认对象是值语义;如果不是,要么改成员类型,要么在 clone 里做手工深拷贝。
6. 选型逻辑与实战避坑:五个模式不是堆得越多越好
6.1 不同场景的选型对照
把五个模式放在一张表里对比,决策会清晰很多:
| 模式 | 核心问题 | 适用信号 | C++ 落地要点 | 主要风险 |
|---|---|---|---|---|
| 单例 | 全局唯一 | 日志、配置、线程池 | 私有构造 + 静态局部变量 | 生命周期顺序、测试困难 |
| 工厂方法 | 创建哪种产品由谁决定 | 产品类型持续增加 | 虚函数返回 unique_ptr | 类数量膨胀 |
| 抽象工厂 | 产品族必须成套 | 多组件风格/平台匹配 | 前置声明降低编译依赖 | 接口新增牵连全部实现 |
| 建造者 | 复杂对象参数太多 | 参数超5个且有默认值 | fluent + 不可变对象 | 链式调用残留状态 |
| 原型 | 构造贵/需拷贝既有状态 | 模板实体、编辑器场景 | clone 虚函数 | 深拷贝问题 |
选型时我通常按这个顺序问自己:
- 这个对象允许存在多份吗?不允许 → 考虑单例。
- 创建逻辑被多个调用方复用,且产品类型会持续变化?会 → 工厂方法。
- 有没有"成套产品"约束,比如风格A必须配风格A全家桶?有 → 抽象工厂。
- 对象参数多到调用方容易写错?是 → 建造者。
- 有"模板实例 + 派生变体"的诉求?有 → 原型。
不要反过来:先背一遍模式清单,然后给代码硬套。模式是解决问题的方案,不是装饰门面的标签。判断标准永远是"这个模式解决了当前代码里的哪个具体痛苦"——说不出来,就别用。
6.2 我在实战里踩过的坑
最后分享几个创建型模式在使用中容易翻车的细节,都是我真实踩过的。
第一个坑是单例返回引用后被意外拷贝。如果没有把拷贝构造删除,即使 instance() 返回引用,调用方写Logger logger = Logger::instance();也会试图拷贝,如果拷贝构造是默认的,编译直接通过,行为就完全错了。我后来养成了"构造函数私有 + 拷贝构造 delete"两条铁律,缺一不可。写单例的时候宁可多写两行,也别留隐患。
第二个坑是工厂方法子类返回类型推导不一致。C++14 允许用auto create() override { return std::make_unique<Circle>(...); }少写类型,但一旦基类接口是virtual std::unique_ptr<Shape> create() = 0;,子类推导出的类型必须与基类完全匹配,多继承或者存在中间基类时,偶尔会被推导成分支类型导致编译错误。我的建议是接口返回类型显式写清楚,子类也显式写,可读性优先,编译器报错时也更好定位。
第三个坑是建造者被当成可变对象使用。有的同事图方便,直接拿 Builder 当参数容器传来传去,最后 build 之前到处改 set,这让参数的来源变得跟裸构造函数一样混乱。Builder 的正确用法是"就近构建、一次 build",在调用点几行之内完成所有 set。如果你发现 Builder 实例的生命周期横跨了大半个函数,就该考虑把它拆成几个局部 Builder,或者重新思考参数来源。
第四个坑是原型模式的浅拷贝,前面已经详述。这里我只补充一句:使用原型模式之前,把所有成员都过一遍"深拷贝是否安全"这一关。裸指针、资源句柄、文件描述符这三样是重灾区,一个都不能放过。
还有一个容易被忽略的点:**创建型模式的类数量会显著增加。**五个模式全上一遍的小模块,会产生至少十来个类文件。我见过一个团队把一个只有三种产品的模块搞出十八个类,代码评审时看懵了一圈人。现在我坚持一个判断标准:**每个模式的引入都必须对应一个真实的修改痛点。**如果"新增产品不需要改调用方"这个收益当前用不上,那工厂方法可以先不抽象;如果产品两年没变过,简单工厂甚至直接 new 都行。等真痛了再重构,成本完全可控,模式永远不是越早用越好。
这套东西我实际用下来的体会是,创建型模式不是背五个名词就能会的,得真的在项目里碰到"改一处漏一处"的痛苦,才会理解为什么要把 new 收拢起来。我建议你从最痛的痛点开始:参数爆炸就上 Builder,产品类型总变就试工厂方法,全局唯一且稳定就写 Meyers Singleton。等这些招法都顺了,再回头看抽象工厂和原型,会发现它们解决的是另外两类更复杂的问题。下一篇计划写结构型模式在 C++ 里的实战应用,在那之前,你们项目里如果有被 new 和构造参数反复折磨的地方,不妨先挑一个模式练练手。