设计模式这东西,我在 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::function<void()> callback) = 0; }; class WindowsButton : public Button { public: void render() override { // Windows 原生绘制逻辑 } void onClick(std::function<void()> callback) override { // 注册 Windows 消息回调 } }; class MacButton : public Button { public: void render() override { // macOS 原生绘制逻辑 } void onClick(std::function<void()> 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_ptr<Button> createButton() = 0; private: void onConfirm() { // 确认逻辑 } }; class WindowsDialog : public Dialog { protected: std::unique_ptr<Button> createButton() override { return std::make_unique<WindowsButton>(); } };这段代码的关键在于show()函数只依赖Button接口和createButton(),至于按钮到底是哪个平台的,Dialog 完全不知道。新增一个 LinuxDialog 只需要继承 Dialog 并实现 createButton,原有的 WindowsDialog 一行不用改。
2.2 工厂方法在 C++ 里的实现细节
写工厂方法时有一个常见的认知误区:以为工厂方法只是“把 new 包一层”。其实工厂方法的精髓在于业务流程的复用,如果基类只有一个工厂接口、没有业务流程,那它和直接调用的区别不大。我习惯先在基类里写好完整的业务模板方法,再让子类通过工厂方法注入变化。
C++ 特有的几个实现要点:
- 返回类型用
std::unique_ptr而不是裸指针,避免资源泄漏问题。C++11 之后这是标准做法,裸指针只适合做非拥有型观察者。 - 工厂方法本身可以是 protected,因为它是给基类业务流程调用的,没必要暴露给外部使用者。
- 如果创建过程需要参数,可以给 createButton 加上配置结构体参数,但不要破坏基类接口的稳定性。
另外一个很实用的技巧:工厂方法可以和模板结合起来。如果需要创建的控件类型是编译期就能确定的,可以减少虚函数调用:
template <typename ButtonType> class TypedDialog : public Dialog { protected: std::unique_ptr<Button> createButton() override { return std::make_unique<ButtonType>(); } };不过这种写法要求按钮类型无参构造,如果有参数就得用转发构造。模板加虚函数混合使用的时候,要小心代码膨胀,一个模板实例化一份代码,控制模板参数数量就很重要。
2.3 实战中工厂方法的边界:什么时候别用
工厂方法不是万能的,我在项目里吃过一次亏:某个配置文件解析器,一开始为了“以后可能支持多种格式”,给每个格式都建了子类工厂。结果写了半年,实际只用了 JSON 一种格式,那些抽象层级变成了纯粹的预测性设计。
后来我养成了一个判断标准:至少出现两种已知的具体实现需要切换时,再考虑工厂方法。如果只有一种实现,直接创建对象,预留抽象没有意义,因为 C++ 里没有用的抽象不会帮你省任何事,只会增加阅读障碍。如果变化点不是“创建什么对象”,而是“怎么初始化这个对象”,那该考虑的应该是建造者模式而不是工厂方法。
3. 抽象工厂与建造者:一个管产品族,一个管复杂装配
3.1 抽象工厂:应对成套对象的约束
抽象工厂解决的是“一系列相关对象必须配套使用”的问题。前面工厂方法只关心一个按钮,但一个跨平台 UI 不是只有按钮,还有文本框、复选框、滚动条,而且 Windows 风格的按钮必须搭配 Windows 风格的其他控件,不能混搭。
抽象工厂模式就是定义一个创建整套产品的工厂接口:
class UIFactory { public: virtual ~UIFactory() = default; virtual std::unique_ptr<Button> createButton() = 0; virtual std::unique_ptr<TextBox> createTextBox() = 0; virtual std::unique_ptr<CheckBox> createCheckBox() = 0; }; class WindowsUIFactory : public UIFactory { public: std::unique_ptr<Button> createButton() override { return std::make_unique<WindowsButton>(); } std::unique_ptr<TextBox> createTextBox() override { return std::make_unique<WindowsTextBox>(); } std::unique_ptr<CheckBox> createCheckBox() override { return std::make_unique<WindowsCheckBox>(); } };这里有一个微妙的设计点:抽象工厂的“抽象”体现在产品族层面,而不是单个产品层面。工厂接口的每个方法都是工厂方法,但抽象工厂强调的是一组产品的一致性。如果项目里只有一个产品需要创建,用抽象工厂就是杀鸡用牛刀。
我还遇到过一种变体需求:不是按平台区分产品族,而是按主题区分。比如“暗色主题的按钮、文本框”和“浅色主题的按钮、文本框”,模式结构完全一样,只是换了个维度。这说明抽象工厂的维度可以是任何业务概念,不一定是平台。
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++ 的标准写法,这是 C++11 之后线程安全且简洁的:
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,利用函数内静态变量的初始化时机来保证线程安全。C++11 标准明确规定了局部静态变量的初始化是线程安全的,所以不用自己加锁。这段代码我用了很多年,没有出过问题。
早期很多教材教的是带锁的双检锁写法:
static ConfigManager* instance() { if (instance_ == nullptr) { std::lock_guard<std::mutex> lock(mutex_); if (instance_ == nullptr) { instance_ = new ConfigManager(); } } return instance_; }这种写法在 C++11 之前是有问题的,因为指令重排可能导致对象指针被非空赋值但构造函数还没完成,另一个线程拿到半成品对象。虽然 C++11 之后由于内存模型完善了,双检锁配合原子变量也可以写对,但完全没有必要,Meyers Singleton 又短又稳,别折腾。
4.2 单例模式最常见的坑
我在项目里踩过的单例坑主要有三类。
第一类是生命周期问题。单例的析构顺序在多个单例相互依赖时不可控。比如日志单例依赖于配置单例,程序退出时日志单例先销毁了,后续析构函数再写日志就会踩空指针。解决思路是给单例注入依赖时要小心,不要让单例之间的依赖关系形成环,必要时可以加一个 Shutdown 函数来控制析构顺序。
第二类是单例被塞了全局可变状态。配置管理器这种只读为主的场景适合单例,但如果单例内部是海量的可变缓存、又有很多线程同时读写,那就会变成一个隐性的并发瓶颈。我遇到过某个项目把内存缓存做成单例,高并发下性能上不去,后来改成依赖注入、按请求作用域创建实例才解决。
第三类是用单例来包装工具类。纯函数式的工具类根本不需要单例,直接静态方法就好,因为静态方法不持有状态,单例的意义就消失了。判断标准很简单:如果一个类没有成员变量,那它不该是单例,它应该是一组静态方法。
单例在创建型模式里比较特殊,我个人建议把它当作最后的手段。能注入就注入,能传递就传递,除非这个对象确实是进程级唯一、且从启动到退出一直存在,才考虑单例。比如 Logger、全局配置、进程级事件总线,这些是合理的场景。
5. 原型模式:以对象为模板制造新对象
5.1 深拷贝与浅拷贝的坑
原型模式的核心是“复制现有对象来创建新对象”,在 C++ 里通常通过 Clone 接口实现:
class Shape { public: virtual ~Shape() = default; virtual std::unique_ptr<Shape> 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_ptr<Shape> clone() const override { return std::make_unique<Circle>(*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_ptr<Shape> clone() const override { return std::make_unique<Circle>(*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_ptr<Circle>接收时会自动访问派生类的重写版本:
class Circle : public Shape { public: std::unique_ptr<Circle> clone() const override; // 返回类型协变 };不过我很少用协变返回值,因为实际场景中调用方通常还是想通过基类指针统一管理,统一返回unique_ptr<Shape>更简单。
6. 创建型模式的综合选型与排查经验
6.1 创建型模式决策思路
很多人学完五种模式,到真实项目里还是不知道怎么选。我整理了一个简单的思考路径,基本上每次遇到“怎么创建对象”的问题,都会走一遍这个流程:
第一步,看对象创建是否有“切换实现”的需求。如果有两种以上的具体实现需要在运行时选择,优先考虑工厂方法。如果是一整套产品都需要同步切换,升级为抽象工厂。这里的“切换”可以是平台切换,也可以是业务策略切换。
第二步,看对象的构造是否复杂。构造参数超过四个、有可选参数、有默认值依赖、或者构造过程有先后顺序要求,优先考虑建造者。如果对象还有不可变性的要求,建造者几乎是唯一合理的选择。
第三步,看对象是否需要保持全局唯一且长期存活。比如日志、配置、事件总线这类,可以考虑单例。但记住:能用依赖注入解决的,就别用单例。
第四步,看创建对象的成本是否很高、是否需要批量复制。如果从模板复制比从头创建更高效,或者需要保留历史快照,考虑原型模式。
这四步不是严格的优先级,更像是一棵决策树的路径。大多数业务对象只需要简单 new,模式是工具,不是装饰品。
6.2 调试创建逻辑时的经验技巧
创建型模式的代码表面上看很规整,但调试起来有隐藏的困难——因为多了一层间接。我在排查问题时会重点关注几个地方。
一个是工厂返回的空指针问题。工厂方法内部如果有条件分支,某个分支走了默认创建逻辑,结果配置不匹配返回了 nullptr,调用方没检查就解引用,崩溃点离问题根源很远。排查思路是:在工厂的默认分支里直接抛异常,而不是返回空对象,这样崩溃点就在现场。
另一个是单例和全局状态的排查。单例对象内部如果状态被某个线程改坏了,另一个线程读取到脏数据,这种问题用常规断点几乎没法定位。我的习惯是给单例对象加上线程安全的访问日志,或者在高风险函数的入口退出打印关键状态变化,把复现路径抓出来再优化。
还有一个是建造者模式中链式调用的默认值问题。相当多隐蔽的 bug 不是因为显式设置的参数错了,而是某个字段忘记设置、用了默认值,而默认值并不符合预期。排查时我会先在 build 函数里对关键字段做校验,如果某个必填字段还是默认值就抛异常。这在业务上等价于“编译期错误转运行期暴露”,能省下大量调试时间。
原型模式的调试难点在拷贝语义。复制后的对象和原对象偶尔会互相影响,多半是浅拷贝残留。排查技巧是,在 clone 出来的对象里修改一个可疑字段,观察原对象是否变化。如果有变化,就去检查复制构造函数和拷贝赋值的实现。
6.3 我实际维护老项目的心得
最后说说我个人在维护遗留 C++ 项目时的体会。很多老项目的对象创建逻辑是在构造函数里直接 new 依赖项,代码之间耦合成一坨。我接手之后没有大范围重写,而是从调用频率最高的类开始,逐步把依赖项的创建方式改成工厂方法或者参数注入,每改一个类就跑一遍全量测试。这种渐进式重构比一次性推倒重来稳妥得多,风险可控,也让团队慢慢适应新的创建逻辑。
另外,创建型模式有一个附加收益:长期维护时,测试代码更好写了。因为依赖项可以通过工厂换掉,测试时就能注入一个 mock 版本,不用真的去连接数据库或者拉起 UI。我参与的某个项目,就是因为把数据源对象的创建收敛到了工厂里,单元测试覆盖率才能从百分之二十提到百分之八十。这个收益虽然不在模式的定义里,但在真实项目中往往比模式本身的灵活性更值钱。
创建型模式只是设计模式的第一块拼图。下一篇我会沿着结构型模式继续往下写,把解耦的思路从对象创建扩展到对象组合。如果你正在整理自己的 C++ 代码库,不妨先从这五个模式里挑一个最贴近实际痛点的,小范围试点对比效果,再决定要不要铺开。