`if/else` 创建逻辑越写越长怎么办?用工厂分开“创建”与“使用”

时间:2026/04/09

一个图形程序最初只支持圆和正方形时,创建逻辑可能只是两行分支:

1
2
3
4
5
if (type == "circle") {
// 创建圆
} else if (type == "square") {
// 创建正方形
}

随着类型增加,同样的分支开始出现在配置解析、测试、命令行入口和业务服务中。每个调用方都知道具体类名、构造参数和失败方式;新增一种类型时,需要到处修改代码。

问题不在于 if/else 本身,而在于“选择并构造具体对象”的决策已经散落到“使用对象”的代码里。工厂模式的实际价值,就是把这两种变化分开:调用方只依赖稳定接口,创建逻辑集中处理类型选择、参数校验与所有权转移。

本文从最简单的工厂函数开始,再说明什么时候注册表工厂值得使用,以及虚析构、对象切片和错误返回为什么比模式名称更重要。


1. 我们究竟想把什么隔离出去?

假设所有图形都能计算面积。业务代码真正需要的是“给我一个可以计算面积的对象”,而不是“我必须亲自构造 Circle”。

1
2
3
4
5
6
7
配置中的类型名称

创建决策与校验 ← 容易随产品类型变化

统一的 Shape 接口

面积计算等业务 ← 不需要知道具体类型

这里包含两个不同机制:

  • 运行时多态让不同具体类型通过同一个接口使用;
  • 工厂根据输入选择具体类型并完成构造。

只有多态没有工厂,调用方仍然要知道怎样创建每个派生类;只有工厂没有稳定接口,调用方仍要根据返回的具体类型分支。两者经常配合,但解决的是不同问题。

2. 一个可用于多态的接口需要满足什么?

最小接口可以写成:

1
2
3
4
struct Shape {
virtual ~Shape() = default;
virtual double area() const = 0;
};

area() = 0 表示纯虚函数,Shape 因而是抽象类,不能直接实例化。派生类通过 override 明确重写接口:

1
2
3
4
5
6
7
struct Circle final : Shape {
explicit Circle(double radius) : radius_(radius) {}
double area() const override { return 3.141592653589793 * radius_ * radius_; }

private:
double radius_;
};

这里有三个不能只当作语法细节的设计点:

  1. 基类析构函数是虚函数,因此通过 Shape* 删除派生对象时会执行完整析构链;
  2. override 让编译器检查函数签名是否真的覆盖基类接口;
  3. 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
2
3
4
5
6
7
8
9
std::unique_ptr<Shape> make_shape(std::string_view type, double argument) {
if (type == "circle") {
return std::make_unique<Circle>(argument);
}
if (type == "square") {
return std::make_unique<Square>(argument);
}
throw std::invalid_argument("unknown shape type");
}

新增类型需要修改工厂,但修改点只有一个。对于封闭、稳定的产品集合,这通常比注册机制更容易阅读、调试和测试。

不要为了消灭几个分支就建立复杂抽象。工厂值得引入的信号是创建决策正在重复、构造过程复杂,或者调用方不应该依赖具体实现,而不是代码中出现了 newif

5. 类型由不同模块扩展时怎么办?

当可用类型需要在运行期组合,或不同模块需要向同一创建入口贡献实现时,可以把“名称到创建函数”的映射放进注册表。

下面是一个完整的 C++17 示例。它仍然保持较小范围:所有图形都接受一个 double 构造参数,重复注册返回失败,未知类型抛出带名称的异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
#include <cmath>
#include <functional>
#include <iomanip>
#include <iostream>
#include <memory>
#include <stdexcept>
#include <string>
#include <string_view>
#include <unordered_map>
#include <utility>

struct Shape {
virtual ~Shape() = default;
virtual double area() const = 0;
};

class Circle final : public Shape {
public:
explicit Circle(double radius) : radius_(radius) {
if (radius_ <= 0.0) {
throw std::invalid_argument("radius must be positive");
}
}

double area() const override {
constexpr double pi = 3.141592653589793;
return pi * radius_ * radius_;
}

private:
double radius_;
};

class Square final : public Shape {
public:
explicit Square(double side) : side_(side) {
if (side_ <= 0.0) {
throw std::invalid_argument("side must be positive");
}
}

double area() const override {
return side_ * side_;
}

private:
double side_;
};

class ShapeFactory {
public:
using Creator = std::function<std::unique_ptr<Shape>(double)>;

bool register_type(std::string name, Creator creator) {
if (name.empty() || !creator) {
return false;
}
return creators_.emplace(std::move(name), std::move(creator)).second;
}

std::unique_ptr<Shape> create(std::string_view name, double argument) const {
const auto iterator = creators_.find(std::string(name));
if (iterator == creators_.end()) {
throw std::invalid_argument("unknown shape type: " + std::string(name));
}
return iterator->second(argument);
}

private:
std::unordered_map<std::string, Creator> creators_;
};

int main() {
ShapeFactory factory;

factory.register_type("circle", [](double radius) {
return std::make_unique<Circle>(radius);
});
factory.register_type("square", [](double side) {
return std::make_unique<Square>(side);
});

auto circle = factory.create("circle", 2.0);
auto square = factory.create("square", 3.0);

std::cout << std::fixed << std::setprecision(2)
<< "circle area = " << circle->area() << '\n'
<< "square area = " << square->area() << '\n';
}

在 macOS、Linux 或其他支持 C++17 的环境中编译:

1
2
3
clang++ -std=c++17 -O2 -Wall -Wextra -pedantic \
shape_factory.cpp -o shape_factory
./shape_factory

预期输出:

1
2
circle area = 12.57
square area = 9.00

main 只通过 Shape 接口调用 area(),不参与具体对象的生命周期管理。注册阶段知道具体类型,创建阶段只认识名称与统一参数,使用阶段只认识抽象接口。

6. 注册表真的实现了“新增类型不用改代码”吗?

它只把修改位置从中央 if/else 移到了注册阶段。新类型仍然需要实现、编译、链接并在某处注册。如果使用静态初始化自动注册,还会引入初始化顺序、链接器丢弃未引用目标文件和重复注册等新问题。

因此,小项目优先显式注册:

1
2
3
4
5
ShapeFactory make_default_factory() {
ShapeFactory factory;
// 在这里集中注册内置类型
return 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 暴露的是创建决策与业务使用耦合,而不是分支语句本身有问题。

  1. 多态统一“怎样使用”,工厂集中“创建哪一种”;
  2. unique_ptr<Base> 清楚表达多态对象的独占所有权,基类必须支持安全析构;
  3. 类型集合较小时,简单工厂通常比注册表更直接;
  4. 注册表适合运行期组合扩展,但不自动解决插件、并发和生命周期问题;
  5. 接口应围绕稳定需求保持精简,错误策略也应成为工厂契约的一部分。

实际重构时,先把散落的创建分支收拢到一个普通函数,并让调用方只依赖最小接口。只有当“谁可以新增类型”真的跨越模块边界时,再把这个函数升级为注册表。