C++工厂模式详解:从简单工厂到抽象工厂的实战指南
从 new 到 工厂,这可能是你写 C++ 最值得花一小时搞明白的设计模式。做了这么多年 C++,但凡接手过一个超过两万行的业务系统,你一定见过这种代码:if 套 else if 再套 switch,创建对象的逻辑散落在各个模块里,改一个类型能牵连出一串编译错误。工厂模式就是冲着这类问题来的。
这篇不聊花架子,我把三种工厂——简单工厂、工厂方法、抽象工厂——从使用场景、代码实现、重构过程到踩坑经验完整捋一遍。不管你是刚接触设计模式的学生,还是在 VSCode 里配置 C++ 环境后想写点像样代码的初学者,这篇都能直接拿来用。
1. 工厂模式到底在解决什么问题
1.1 没有工厂的日子:需求从 1 个变成 8 个
想象一个很常见的场景:你要做一个日志系统,最开始只有文件日志。
直接 FileLogger logger;,轻松搞定。然后产品经理来了,说要加控制台日志、数据库日志、云日志、加密日志、压缩日志……你看着 LoggerManager 里 8 个 if-else 分支,每个分支里都是一个具体类对象,代码开始变成一团浆糊。
这时候工厂模式就派上用场了。它的核心思想一句话:把对象的创建和使用分离。调用方不再关心对象是怎么 new 出来的、具体是哪个类,只关心传入一个标识或参数,就能拿到一个可用的对象。
1.2 违反了什么原则,以及为什么重要
不用工厂的代码,问题出在违反了“依赖倒置原则”——高层模块依赖了低层具体类,还违反了“开闭原则”——加一个新日志类型就要改 LoggerManager 的代码。
我记得有次把一个项目里所有 new 关键词搜出来,发现创建对象的代码散布在几十个文件里。后来想要替换整个底层通讯库,光是找依赖点就花了整整两天。工厂模式就是把这些“创建点”收敛到同一个入口,改起来只动一处。
2. 简单工厂模式:最朴素也最容易用错的写法
2.1 核心实现:一个函数搞定所有创建
简单工厂(Simple Factory)严格来说不算 GoF 的 23 种设计模式,但它是最直观、最常用的一种。用一个统一的工厂函数,根据参数返回不同类型的对象。
这里有几个关键点值得细说。
2.2 为什么接口类必须声明虚析构函数
在上面的代码里,virtual ~ILogger() = default; 这行很多人会漏。如果你漏了,通过 std::unique_ptr<ILogger> 销毁对象时,只会调用基类析构函数,派生类 FileLogger、ConsoleLogger 中申请的资源就不会被释放。我排查过好几次内存泄漏,最后发现元凶都是这种没写虚析构的接口类。
2.3 用 std::unique_ptr 还是裸指针
现代 C++ 里强烈建议用智能指针。裸指针最大的问题是所有权不清晰——调用方拿到指针后要不要 delete?如果忘记 delete 就泄漏,如果 delete 两次就崩溃。用 std::unique_ptr 把所有权和生命周期管理交给 RAII 机制,异常安全,代码也简洁。C++ 14 及以上可以用 std::make_unique,如果你还在用 C++ 11,可以手动 std::unique_ptr<T>(new T(...)) 替代。
2.4 简单工厂的适用场景与天然局限
适用场景很明确:产品种类少、且不太可能频繁增加。比如一个工具类的字符串转换器,就两三种实现,用简单工厂完全足够。
局限也很明显:
- 加一个新类型,必须改工厂函数内部代码——违反了开闭原则
- 工厂函数会越来越膨胀,参数越来越多,负责的逻辑越来越杂
- 所有类型都耦合在一个函数里,不便于独立测试
我在实际项目里见过一个写了几百行的简单工厂,里面 switch 分支有 20 多个,还混进了配置读取和依赖初始化逻辑。这种时候,就该考虑下一步的工厂方法模式了。
3. 工厂方法模式:把选择权交给子类
3.1 从静态函数到虚函数
工厂方法模式解决的核心问题是:把“创建哪个对象”的决定权延迟到子类。基类定义了一个创建产品的接口,但是不实现具体逻辑,由子类去决定实例化哪个具体产品。
还是用日志系统的例子。现在需求变了:不同的业务模块需要不同的日志策略——A 模块写文件,B 模块写数据库,C 模块要加密。如果用简单工厂,得维护一个全局大函数;用工厂方法,就可以把创建逻辑分摊到各个模块自己的工厂里。
3.2 模板方法思想的穿插
注意我在 getLogger 里留了“公共初始化”的注释。这是一个很容易被忽视的好处:工厂基类可以在创建产品后统一做一些事情,比如设置日志级别、绑定上下文、埋点统计、资源审计等。子类只关心“提供什么样的产品”,而“产品出来之后怎么处理”可以在基类统一管理。这其实就是模板方法模式和工厂方法模式的结合,实际开发中非常实用。
3.3 什么时候该从简单工厂升级到工厂方法
我自己的判断标准有三个:
- 产品类型数量超过 5 个,且还在持续增加
- 创建产品的过程带有各自的定制逻辑,不只是一个
new - 需要在不同模块中使用不同的产品配置,希望创建逻辑随模块划分
之前我重构过一个消息推送系统,最初用简单工厂管理微信、短信、邮件三种推送。后来加了 App Push、钉钉、企业微信,每次都要改工厂函数,而且不同业务线想要不同的推送组合,简单工厂根本没法优雅处理。改成工厂方法后,每个业务线创建自己的工厂,互不干扰,产品扩展时只需要新增一个工厂类加一个产品类,老代码一行不动。
3.4 工厂方法的最大缺点
工厂方法也有它的代价:类的数量会翻倍。每增加一个产品,就要同时增加一个产品类和对应的工厂类。如果项目里只有两三种产品,这种“仪式感”反而显得繁琐。性能敏感场景也要注意,虚函数调用有极小的间接开销,但绝大多数业务系统根本感知不到,不用为这个过度优化。
4. 抽象工厂模式:产品族的批量生产方案
4.1 从单一产品到产品族
前两种工厂解决的是“某个类型怎么创建”的问题。但现实里还有一个场景:你需要的不是单个对象,而是一整个配套的产品组合。
举个例子,你要开发一个跨平台 UI 组件库。Windows 风格下需要 WindowsButton、WindowsTextBox;Linux 风格下需要 LinuxButton、LinuxTextBox。你不能让界面上出现 Windows 按钮配 Linux 文本框——这就是产品族的一致性要求。
4.2 抽象工厂的完整代码示例
看到区别了吗?抽象工厂中,客户端使用的是接口 IUIFactory,它能够保证:你用 WindowsUIFactory,拿到的就永远是 Windows 系产品,不会混进 Linux 系。这种一致性保障是抽象工厂最大的价值。
4.3 抽象工厂的“开闭”问题
抽象工厂有一个著名的矛盾:增加新产品族容易,增加新产品类型难。在上面例子里,如果你想新增一个 Mac 风格,只要加一个 MacUIFactory 和两个具体产品类,不用改动已有代码。但是如果 UI 组件库里想新增一个“下拉框”,你不得不去修改 IUIFactory 接口,添加一个 createComboBox() 方法,然后所有具体工厂类都要跟着改。这显然违反了开闭原则。
GoF 里也承认这一点。实际工程中,抽象工厂通常用在产品族结构比较稳定的领域,比如数据库访问层——你可能有 MySQL、PostgreSQL、SQLite 三种数据库,但“连接”“命令”“事务”这几个抽象操作是固定的;再比如主题换肤系统,主题风格可能越来越多,但组成主题的元素类型是固定的。
4.4 三种工厂里的“选择策略”如何配合
如果你现在面对的不仅是一族产品,而且产品类型还在持续扩张,可以考虑在抽象工厂内部配合工厂方法:每个具体工厂用工厂方法创建自己的具体产品,这样组合起来灵活度会高很多。C++ 的 std::pmr、插件系统、游戏引擎里的对象管理,很多就是这么实现的。
5. 三种工厂模式对比与选型建议
5.1 一张表看清区别
| 模式 | 层级 | 核心机制 | 解决什么问题 | 典型场景 | 扩展方式 |
|---|---|---|---|---|---|
| 简单工厂 | 无(不属于GoF 23) | 集中式静态函数 + 参数判断 | 根据参数创建不同类型的对象 | 类型少、创建逻辑简单 | 修改工厂函数 |
| 工厂方法 | 类级 | 虚函数延迟到子类 | 把创建逻辑下放给子类,各子类自己决定 | 不同类型的产品各自有定制创建过程 | 新增工厂类和产品类 |
| 抽象工厂 | 产品族级 | 一组工厂方法 | 保证一整套相关产品的一致性 | 跨平台 UI、数据库访问层、主题系统 | 新增具体工厂类 |
5.2 我的选型口诀
选型从来不是“越高级越好”,而是“复杂度匹配需求”。我一般这样判断:
- 产品就 2 到 3 个,以后大概率也不多 → 直接简单工厂,甚至干脆别用工厂,手动 new 就行
- 产品会持续增加,或者不同模块需要不同的创建逻辑 → 工厂方法
- 涉及一整套配套产品,必须保证互相兼容 → 抽象工厂
- 想兼顾产品族扩展和产品类型扩展,但暂时没把握 → 用工厂方法为主,别硬上抽象工厂
还有一个容易被忽略的角度:团队的维护成本。工厂方法 + 抽象工厂会产生大量类,对于一个小团队、一个小项目,代码结构越简单越好。我自己吃过亏,一上来就上最复杂的设计,结果代码量翻倍,新人接手一脸懵。
5.3 性能和资源管理考量
如果你在写对性能敏感的服务,比如每秒钟创建销毁几十万次对象,工厂模式里的虚函数调用和 make_unique 的堆分配开销是不能忽略的。这种情况下可以:
- 用
std::shared_ptr缓存实例,避免频繁创建销毁 - 用对象池复用对象
- 在工厂方法里返回
std::unique_ptr而不是shared_ptr,减少引用计数的原子操作开销
但记住:不要提前优化。先用清楚的结构把代码写对,再通过 profile 工具确认瓶颈。
6. 实战中的坑与排查技巧
6.1 坑一:忘记虚析构导致的内存泄漏
这是 C++ 工厂模式里最经典的坑。接口基类中没有 virtual ~ILogger() = default;,那么通过基类指针删除派生类对象时,派生类析构函数不会被调用。如果你在派生类里持有文件句柄、网络连接、std::thread 等资源,不释放就会泄漏。
排查方法:用 Valgrind 或者 AddressSanitizer 跑一轮用例,如果每次调用工厂创建再销毁都有泄漏报错,十有八九就是这个问题。
6.2 坑二:工厂方法内抛异常的处理
创建对象的过程中可能抛出异常,比如数据库连接失败、文件打不开、内存不足。如果工厂内部用裸指针 new,在初始化后续资源时抛出异常,会导致指针丢失、内存泄漏。用 std::unique_ptr 则没有这个问题——构造函数一旦完成,智能指针就拥有对象所有权,异常安全有保障。
还有一种情况:调用方尝试创建不存在的类型,比如配置写错了类型名。这时工厂应该抛出一个明确的异常或者返回 nullptr。我的建议是:能抛异常就不要返回 nullptr。返回空指针很容易被调用方忽视,运行到后面才空指针崩溃,排查起来很痛苦。抛出 std::invalid_argument 并带上可读的类型名,日志里能直接看到问题。
6.3 坑三:回归到不统一的“假工厂”
有一种反模式很常见:工厂函数里不只看类型,还掺杂了一堆业务逻辑,比如根据配置文件、环境变量、随机数来决定返回哪种类型。这种代码初看很灵活,但排查起来特别头疼——你不知道为什么这一次返回的是 A 而不是 B。
我的建议:工厂只负责“根据明确标识创建对应产品”。如果标识本身就来自配置或环境变量,那是上层调用方的事情,在调用工厂之前先解析好传进来,尽量不要让工厂再去做决策。
6.4 还没完:工厂模式的高级玩法——注册表工厂
如果你面临的是插件化架构,比如编辑器里支持各种文件格式解析器,每种格式一个类,未来还会不断增加。这种场景可以考虑“注册表工厂”思路:一个工厂类内部维护一个映射表,把类型名和创建函数绑定起来,新的产品类只需要注册自己的构造器,不需要修改工厂代码。
这种实现非常灵活:新增一种文件格式,你就写一个类,然后在源文件里调用 REGISTER_CODEC("png", PngCodec),工厂代码完全不用改。不过要注意静态初始化顺序的问题,注册代码不要依赖别的全局对象。
6.5 与单例模式、构建者模式如何搭配
工厂模式不会单独出现,几乎总是和其他模式搭配使用。我在实际开发中常用的组合:
- 工厂 + 单例:工厂本身做成单例,避免每次创建产品都构造工厂对象
- 工厂 + 原型模式:如果产品创建成本高,可以先存一个原型对象,工厂里克隆
- 工厂 + 构建者:工厂负责决定返回哪种复杂对象,构建者负责对象内部各个部件的装配
搭配要适度。我见过一个项目,一个工厂类里塞了单例、模板方法、观察者、状态机……代码完全没法读。设计模式是工具,不是目的,选两三个能解决问题的就够了。
7. 常见编译环境坑与跨平台注意事项
7.1 C++ 标准版本问题
std::make_unique 是 C++14 才有的,如果你还在用 VSCode 配置 C/C++ 环境后默认的 C++11,编译会报错。解决方案两种:把项目标准设置为 C++14 或更新版本,或者自己实现一个 make_unique。
在 CMake 里这样设置:
另外,VSCode 里如果你遇到 error: microsoft visual c++ 14.0 or greater is required 这种报错,通常是 Python 包编译或者其他依赖工具链的问题,和你写的 C++ 代码无关,但要注意它说明你的机器上没有安装完整的 MSVC 构建工具。这个报错挺坑的,我遇到过几次,解决方法一般是安装 Visual Studio Build Tools(勾选“使用 C++ 的桌面开发”工作负载),装完重启终端就能解决。
7.2 虚析构加 = default 还是 = 0
接口基类里通常会写:
或者
两种写法都行。前者是“默认虚析构”,更简洁;后者是“纯虚析构”,强制让接口类不可实例化。如果接口本身还有其他纯虚函数,那用 = default 就够了。如果这个接口只有一个纯虚析构,要注意给析构函数提供类外实现,否则链接会报错。
7.3 Windows 和 Linux 下工厂模式没有本质区别
工厂模式本身跨平台使用没有差异,但要注意不同平台下的动态库接口问题。如果你在 Windows 上用 DLL 导出工厂函数,记得导出的是接口类,不要在 DLL 边界上传 std::unique_ptr,因为两个模块可能链接了不同版本的 CRT,析构函数执行时会发生跨模块内存释放错误。稳妥的做法是:在 DLL 内部导出创建和销毁两个函数,销毁也在 DLL 内完成。
这个坑我印象很深,当时在 Windows 上做插件系统,插件返回 std::shared_ptr,DLL 卸载时直接崩溃,排查了一个下午。后来改成 DLL 内部持有对象,只导出一个 create() 和一个 destroy() 函数,问题彻底解决。
8. 一个完整实例:从零重构一个支付系统
前面讲了很多模式理论,最后用一个完整的例子串一遍。假设你要写一个支付网关,支持支付宝、微信、银联三种支付方式。用简简单单的 if-else 写,代码是能跑,但后续加支付方式非常痛苦。
8.1 第一版:简单工厂
8.2 第二版:升级工厂方法
后来各个支付通道对接的配置不一样,支付宝要设置 appId、私钥,微信要设置 mchId、证书路径,创建过程各不相同。为了让每个渠道自己管理配置,把工厂改成工厂方法模式,每个渠道有自己的工厂,配置从哪里读取由各自工厂决定。
这种演进的好处是:每个渠道的变化被隔离在各自类中,新增渠道不需要动已有支付代码,符合开闭原则。
8.3 第三版:抽象工厂处理多供应商
再后来你发现支付还有“国内版”和“国际版”,每个版本下都有这三种支付渠道。这时候用抽象工厂:一个 IPaymentFactory 定义 createAlipay()、createWeChat()、createUnionPay(),然后国内版和国际版各自实现一组工厂。这样整个支付体系在被替换时,只需换一个工厂实例,所有支付方式一致切换,不会出现国内版支付宝配国际版微信这种混乱。
如果你能跟着思路把这三版代码各写一遍,我对工厂模式的理解就算是真正过关了。
我在实际项目里踩过的坑很多,最有价值的一条经验是:设计模式的引入要趁早,但不要过度。等你看见 if-else 已经堆积到恶心再重构,成本很高;“啥也没发生”之前先抽象三层类,也只会让代码更复杂。最好的时机是在改动边界出现时顺手引入工厂,让代码结构始终比需求超前半步。工厂模式尤其如此——它的价值不在于多写几个类,而在于让你每一次新增功能,都能从容地“加一个类”而不是“改一堆代码”。