`if/else` 创建逻辑越写越长怎么办?用工厂分开“创建”与“使用”
时间:2026/04/09
一个图形程序最初只支持圆和正方形时,创建逻辑可能只是两行分支:
1 | if (type == "circle") { |
随着类型增加,同样的分支开始出现在配置解析、测试、命令行入口和业务服务中。每个调用方都知道具体类名、构造参数和失败方式;新增一种类型时,需要到处修改代码。
问题不在于 if/else 本身,而在于“选择并构造具体对象”的决策已经散落到“使用对象”的代码里。工厂模式的实际价值,就是把这两种变化分开:调用方只依赖稳定接口,创建逻辑集中处理类型选择、参数校验与所有权转移。
本文从最简单的工厂函数开始,再说明什么时候注册表工厂值得使用,以及虚析构、对象切片和错误返回为什么比模式名称更重要。
1. 我们究竟想把什么隔离出去?
假设所有图形都能计算面积。业务代码真正需要的是“给我一个可以计算面积的对象”,而不是“我必须亲自构造 Circle”。
1 | 配置中的类型名称 |
这里包含两个不同机制:
- 运行时多态让不同具体类型通过同一个接口使用;
- 工厂根据输入选择具体类型并完成构造。
只有多态没有工厂,调用方仍然要知道怎样创建每个派生类;只有工厂没有稳定接口,调用方仍要根据返回的具体类型分支。两者经常配合,但解决的是不同问题。
2. 一个可用于多态的接口需要满足什么?
最小接口可以写成:
1 | struct Shape { |
area() = 0 表示纯虚函数,Shape 因而是抽象类,不能直接实例化。派生类通过 override 明确重写接口:
1 | struct Circle final : Shape { |
这里有三个不能只当作语法细节的设计点:
- 基类析构函数是虚函数,因此通过
Shape*删除派生对象时会执行完整析构链; override让编译器检查函数签名是否真的覆盖基类接口;area()是const,表示计算面积不会修改对象的可观察状态。
如果一个基类允许通过基类指针拥有并销毁派生对象,析构函数通常必须是虚函数。另一种合法设计是让析构函数受保护且非虚,从接口层禁止多态删除;但它不适合本文返回 unique_ptr<Shape> 的工厂。
3. 为什么工厂通常返回 unique_ptr?
下面的返回值只给出了地址,没有说明谁负责释放:
1 | Shape* make_shape(std::string_view type); |
返回 std::unique_ptr<Shape> 则明确表达:工厂创建对象,返回后调用方取得独占所有权,对象会在智能指针离开作用域时自动销毁。
1 | std::unique_ptr<Shape> make_shape(std::string_view type, double argument); |
如果对象能够按值返回且不需要运行时多态,值语义通常更简单;如果确实需要共享生命周期,才考虑 shared_ptr。工厂模式本身并不要求堆分配,返回哪种类型应由对象语义决定。
还要避免对象切片:
1 | Shape make_shape(); // 对抽象基类不可行;普通基类也会切掉派生部分 |
经典继承多态通常通过引用或指针工作。把派生对象复制成基类值,会丢失派生类状态和动态类型信息。
4. 小规模类型选择:一个简单工厂就够了
如果类型集合很小且由同一模块维护,集中式工厂函数最直接:
1 | std::unique_ptr<Shape> make_shape(std::string_view type, double argument) { |
新增类型需要修改工厂,但修改点只有一个。对于封闭、稳定的产品集合,这通常比注册机制更容易阅读、调试和测试。
不要为了消灭几个分支就建立复杂抽象。工厂值得引入的信号是创建决策正在重复、构造过程复杂,或者调用方不应该依赖具体实现,而不是代码中出现了 new 或 if。
5. 类型由不同模块扩展时怎么办?
当可用类型需要在运行期组合,或不同模块需要向同一创建入口贡献实现时,可以把“名称到创建函数”的映射放进注册表。
下面是一个完整的 C++17 示例。它仍然保持较小范围:所有图形都接受一个 double 构造参数,重复注册返回失败,未知类型抛出带名称的异常。
1 |
|
在 macOS、Linux 或其他支持 C++17 的环境中编译:
1 | clang++ -std=c++17 -O2 -Wall -Wextra -pedantic \ |
预期输出:
1 | circle area = 12.57 |
main 只通过 Shape 接口调用 area(),不参与具体对象的生命周期管理。注册阶段知道具体类型,创建阶段只认识名称与统一参数,使用阶段只认识抽象接口。
6. 注册表真的实现了“新增类型不用改代码”吗?
它只把修改位置从中央 if/else 移到了注册阶段。新类型仍然需要实现、编译、链接并在某处注册。如果使用静态初始化自动注册,还会引入初始化顺序、链接器丢弃未引用目标文件和重复注册等新问题。
因此,小项目优先显式注册:
1 | ShapeFactory make_default_factory() { |
显式代码更容易搜索和测试。只有确实需要独立模块或动态库在运行期加入类型时,才进一步设计插件发现、ABI、版本兼容和卸载生命周期。注册表本身不等于完整插件系统。
7. 接口应该设计得多宽?
抽象接口越宽,派生类越容易被迫实现与自己无关的操作。例如把绘制、序列化、碰撞检测和数据库保存全部放进 Shape,会让简单类型承担不必要依赖。
更稳妥的原则是:接口围绕调用方真正需要的稳定能力设计,而不是围绕现有类的所有成员取并集。若不同调用方需要不同能力,可以拆分小接口或直接使用组合。
继承也不是唯一的运行时替换方式:
| 需求 | 可优先考虑 |
|---|---|
| 封闭且类型很少 | std::variant + std::visit |
| 编译期选择策略 | 模板或普通组合 |
| 只需要一个可调用行为 | std::function |
| 开放类型集合、统一运行时接口 | 虚函数多态 |
虚函数工厂适合开放的类型集合,但它带来间接调用、指针生命周期和堆分配等设计成本。选择应由变化方式决定,而不是把继承当成默认抽象工具。
8. 创建失败应该返回空指针还是抛异常?
失败策略取决于“未知类型”是否属于正常分支:
- 用户输入可能经常不合法时,可以返回
optional/expected风格结果并携带错误; - 配置已通过校验,未知类型代表程序配置错误时,抛异常通常更直接;
- 返回
nullptr虽然简单,却容易丢失失败原因,调用方还可能忘记检查。
示例使用异常,同时让具体类在构造时验证尺寸。这保证工厂成功返回的对象已经满足类不变量,不会产生“先构造一个无效对象,再要求调用方记得初始化”的中间状态。
9. 注册表在并发环境中安全吗?
示例没有内部锁。初始化完成后,如果所有线程只调用 create 且不再修改注册表,并且各个 Creator 自身允许并发调用,就可以并发读取。
如果运行时还允许注册或注销,就必须同步 unordered_map 的读写,并进一步定义:正在创建对象时能否卸载代码、已有对象是否依赖被卸载模块。动态插件的难点往往是代码和对象生命周期,而不只是给 map 加一把锁。
10. 工程中最容易踩哪些坑?
误区一:基类没有虚析构,却通过基类指针删除
这会产生未定义行为。只要接口支持多态拥有,首先检查析构策略,而不是只关注业务虚函数。
误区二:工厂返回裸 owning pointer
调用方无法从类型得知是否负责 delete。优先返回值、unique_ptr 或其他能明确表达所有权的类型。
误区三:为了开放扩展,提前建立全局注册表
全局可变状态、静态初始化和隐式注册会增加调试成本。类型稳定时,一个局部工厂函数通常更清楚。
误区四:工厂同时承担业务流程
工厂应负责创建有效对象,不应顺便执行对象的主要业务、缓存所有实例或管理无关全局状态。否则“创建边界”又会变成新的巨型模块。
误区五:认为多态必须使用 shared_ptr
多态只要求通过基类引用或指针调用虚函数,与共享所有权无关。一个明确所有者的多态对象优先使用 unique_ptr<Base>。
11. 什么时候应该使用工厂?
适合引入工厂的信号包括:
- 创建逻辑在多个调用方重复;
- 具体实现需要根据配置或运行时输入选择;
- 构造涉及参数校验、依赖组装或多个步骤;
- 调用方应该只依赖稳定抽象。
如果只是一个局部位置构造一个明确类型,直接调用构造函数更清楚。若类型集合封闭且操作比类型更常变化,variant 可能比继承工厂更合适。不要把工厂包装在工厂里,也不要为尚不存在的插件需求提前设计注册协议。
12. 总结
开头不断复制的 if/else 暴露的是创建决策与业务使用耦合,而不是分支语句本身有问题。
- 多态统一“怎样使用”,工厂集中“创建哪一种”;
unique_ptr<Base>清楚表达多态对象的独占所有权,基类必须支持安全析构;- 类型集合较小时,简单工厂通常比注册表更直接;
- 注册表适合运行期组合扩展,但不自动解决插件、并发和生命周期问题;
- 接口应围绕稳定需求保持精简,错误策略也应成为工厂契约的一部分。
实际重构时,先把散落的创建分支收拢到一个普通函数,并让调用方只依赖最小接口。只有当“谁可以新增类型”真的跨越模块边界时,再把这个函数升级为注册表。