上周我在帮朋友调试一个消息网关,业务方给的需求特别简单:再多支持一种自定义协议。我顺着调用链一路点开,看到的却是连续四段几乎同一个模子里刻出来的 if-else,每段都是 new JsonMessage、new XmlMessage、new ProtoMessage,然后调用 handle。加一个分支当然不难,难的是消息类型一变,所有牵扯到消息创建的地方都要一起改;想换一个假消息做测试,压根插不进去。最后让我从这堆 if-else 里爬出来的,就是今天要写的这个主题:C++ 中的工厂方法模式。
工厂方法模式是经典创建型设计模式里最好理解、也最容易用出价值的一个。它的核心不是“消灭 new”,而是把“创建哪个具体类”的决策从调用方手里接管过来,交给独立的创建者。对 C++ 还不太熟的读者,可以通过它理解多态、虚函数和智能指针怎么配合;已经在写业务代码的人,则可以用它把散落在各处的 new 分支收口,让新增类型变成一件局部的小事。这篇文章会从最原始的 if-else 代码讲起,逐步演变成符合工厂方法模式的 C++ 实现,再补上现代 C++ 里常用的注册表、依赖注入和测试技巧,最后告诉你哪些场景硬上工厂方法反而更难受。
1. 先搞清楚:工厂方法到底解决什么问题
1.1 从一段“直接 new”的代码说起
不少项目里,消息解析模块的入口都长这样:
std::unique_ptr<Message> createMessage(const std::string& type) { if (type == "json") { return std::make_unique<JsonMessage>(); } if (type == "xml") { return std::make_unique<XmlMessage>(); } return nullptr; }这段代码本身非常直白,但它的毛病会在需求逐渐变多时被放大。每加一种协议,就要回到这个函数里加一个分支,而且其他业务代码里很可能还有一模一样的 switch 或 if 链,分散在十几个调用现场。改消息类型、加构造参数、调整初始化顺序,你都得把这些地方全部找出来,漏一个就是运行时问题。用设计模式的术语说,这违反了开闭原则:对扩展没有封闭,对修改相当开放。
这种写法的另一个问题是调用方知道了太多不该知道的东西。消息中心本来只关心拿到一个能process()的对象,现在却要显式地写出 JsonMessage、XmlMessage 这些具体类名。一旦产品类和调用方绑死,后面想切到另一种实现,或者想依赖接口写测试,都变得非常别扭。真正的问题不是 new 本身,而是“创建对象的责任放错了位置”。
1.2 定义与由来:让子类决定实例化哪个类
工厂方法模式的官方定义很拗口:定义一个用于创建对象的接口,让子类决定实例化哪一个类。直白一点说,就是把“生产什么东西”变成接口上的一个方法,每个创建者子类自己决定返回哪个具体产品。
在 C++ 里,常见的形态是在基类里声明一个虚的 create 方法,然后各个子类覆盖它。调用方手里拿的是创建者基类的引用或指针,它调用 create() 时只看到 Product 接口,至于实际返回的是 JsonMessage 还是 XmlMessage,由创建者的具体子类在运行时决定。这种设计也被称为“虚构造函数”,意思是在对象创建层面也应用多态。
为什么需要这么绕?因为对象的创建过程往往比简单的 new 复杂得多。有的产品需要从配置里读取参数,有的需要连接缓存池,有的需要在构造之后再做几轮校验。这些逻辑如果散落在调用方代码里,每个调用点都要重复写一遍初始化流程。把它收进创建者子类的 create() 方法,调用方就可以完全不了解具体产品,也不用关心产品是怎么造出来的。你可以把工厂方法理解成一个“配送中心”:用户只管下单,具体是哪个供应商、走什么冷链、用什么包装,都是配送中心内部的事。
1.3 工厂方法 vs 简单工厂 vs 抽象工厂
这里先帮大家把三个容易混的名字区分开。简单工厂其实不算 GoF 的正式模式,它通常是一个普通函数或静态方法,里面用 if-else 或 switch 决定返回什么产品,就像上面那段 createMessage。简单工厂的优点是写起来快,缺点是所有产品选择逻辑还是集中在一个函数里,本质上还是用分支硬编码。
工厂方法模式则把“选择产品”的责任分散到创建者的子类里。使用者的代码依赖的是 Creator 抽象类,而不是某个具体的工厂函数。真正切换产品时,你通常只需要换掉传入的 Creator 实例,产品创建逻辑不会堆积在一个中央函数里。
抽象工厂的粒度更大,它用来创建一组相关的产品族。比如一套跨平台图形库,需要同时创建 Button、Menu、Slider,那么 WindowsUIFactory 和 LinuxUIFactory 分别产出整套不同平台的控件。工厂方法创建单种对象,抽象工厂创建对象族。下面这个对比表适合快速查阅:
| 模式 | 核心思路 | 典型实现 | 扩展方式 |
|---|---|---|---|
| 简单工厂 | 用分支在同一个函数里选产品 | 静态函数 + if-else | 改分支 |
| 工厂方法 | 让创建者子类决定具体产品 | Creator 接口 + 子类覆盖 create | 新增 Creator 子类 |
| 抽象工厂 | 创建一组有关联的产品 | 一族产品的工厂接口 | 新增整套工厂子类 |
实际项目中,抽象工厂内部常常也由多个工厂方法组织起来,三个概念并不是互斥的。理解它们的分界,主要为了在选择方案时想清楚当前变化的轴在哪里:你要扩展的是一种产品,还是一条产品族?变化的轴不同,模式也要跟着换。
2. C++ 中的经典实现:结构、代码与逐步改造
2.1 产品与创建者的接口设计要点
用 C++ 实现工厂方法模式,第一件事是设计两个抽象接口:产品接口和创建者接口。产品接口定义使用者关心的行为;创建者接口定义创建动作。最基础的代码差不多长这样:
class Message { public: virtual ~Message() = default; virtual void process() = 0; }; class MessageFactory { public: virtual ~MessageFactory() = default; virtual std::unique_ptr<Message> create() = 0; };这里有几个容易被新手忽略的点。第一,产品基类必须声明虚析构函数,否则通过基类指针删除派生对象属于未定义行为,轻则内存泄漏,重则崩溃。写成virtual ~Message() = default;是目前最干净的做法。第二,工厂方法 create() 返回什么类型需要仔细考虑,后面会专门讨论,默认建议返回std::unique_ptr<Message>,由它表达所有权的转移。
第三,MessageFactory 的析构函数也要写成虚函数。因为调用方经常通过基类指针持有具体的工厂对象,如果没有虚析构,删除时同样会有问题。这些接口设计细节平时不显山不露水,但工厂方法模式的所有安全性都建立在它们之上。
产品接口也不宜设计得太胖。很多工厂被设计出来后发现 create() 返回的对象还要调用者做大量类型强转,这就变味了。正确姿势是让产品接口涵盖所有使用方真正关心的行为,把类型差异隐藏在虚函数实现里。比如 JsonMessage 和 XmlMessage 可以各自实现dump()、parse(),调用方不需要知道底层是什么格式。
2.2 从 switch 分支到多态工厂的迁移实录
假设现在有一个业务模块,启动时需要根据配置创建不同类型的调度器,原来的代码可能是:
std::unique_ptr<Scheduler> makeScheduler(const Config& cfg) { if (cfg.mode == "debug") { return std::make_unique<DebugScheduler>(); } if (cfg.mode == "remote") { return std::make_unique<RemoteScheduler>(); } return std::make_unique<LocalScheduler>(); }重构的第一步,把 create 动作抽象成工厂接口:
class SchedulerFactory { public: virtual ~SchedulerFactory() = default; virtual std::unique_ptr<Scheduler> create() = 0; };第二步,为每种调度器写一个工厂子类:
class DebugSchedulerFactory final : public SchedulerFactory { public: std::unique_ptr<Scheduler> create() override { return std::make_unique<DebugScheduler>(); } }; class RemoteSchedulerFactory final : public SchedulerFactory { public: std::unique_ptr<Scheduler> create() override { return std::make_unique<RemoteScheduler>(); } };第三步,把原来调用 makeScheduler 的地方改成注入 SchedulerFactory:
void startModule(std::unique_ptr<SchedulerFactory> factory) { auto scheduler = factory->create(); scheduler->run(); }改造之后,调用方不再关心 Config 里是什么 mode,它只知道手里有一个工厂,调用 create() 就能拿到一个符合 Scheduler 接口的对象。而工厂子类把原来散落的分支收敛到了各自的类里。如果你依然需要一个函数根据配置选择工厂,那也简单,配置解析的地方只剩一行分支:return make_unique<RemoteSchedulerFactory>()。对比之前的写法,至少创建调度器的逻辑不会再在业务代码里到处重复。
这个迁移过程也揭示了一个点:工厂方法模式并不是让你彻底消灭 if-else,而是把“选具体类”的决策边界划得清清楚楚。分支仍然存在,但只会出现在系统边界上,不会污染核心业务逻辑。
2.3 模板化与 CRTP:把重复创建器压缩到最少
工厂子类如果太多,代码里会充满大量“换了个名字但内容一模一样”的类。比如 DebugSchedulerFactory、RemoteSchedulerFactory,它们都只是返回对应产品。这种情况下,可以用模板和 CRTP 来消除重复:
template <typename Concrete> class StandardSchedulerFactory : public SchedulerFactory { public: std::unique_ptr<Scheduler> create() final { return std::make_unique<Concrete>(); } }; class DebugSchedulerFactory final : public StandardSchedulerFactory<DebugScheduler> { }; class RemoteSchedulerFactory final : public StandardSchedulerFactory<RemoteScheduler> { };每个具体工厂类只剩一个空壳,创建逻辑统一由模板基类提供。模板在这里天然适合,因为它把“产品类型”作为编译期参数,等于是让编译器帮我们生成工厂实现。需要注意,如果你某个具体产品需要特殊的构造参数或初始化步骤,那就不能套用这个模板,老老实实在子类里覆盖 create()。模板只解决最常见的情况,遇到不回绝的复杂初始化,还是应该用自定义代码。
用 CRTP 还有一个小好处:create() 可以标记成 final,防止子类再被错误重写。因为 Concrete 类型已经在模板参数里定死了,再重写 create() 通常意味着代码逻辑混乱。把这个方法 final 掉,反而能让意图更清晰。
3. 现代 C++ 实践:内存管理、注册表与依赖注入
3.1 看过那么多代码,工厂方法返回什么类型才安全
关于返回类型,最常见的选择是std::unique_ptr<Product>。工厂生产出来的对象,所有权是从工厂转移给调用方的,调用方持有这个对象的唯一所有权,是最符合“创建后交出去”这个语义的表示。unique_ptr 在 C++11 之后成为移动语义的典型代表,工厂返回它不会产生多余的拷贝,编译器还能做返回值优化,几乎零额外负担。
有的人习惯返回裸指针Product*,这会让调用方陷入“到底该不该 delete、谁来 delete”的迷茫里。也有可能返回std::shared_ptr<Product>,但如果调用方没有共享需求,那 shared_ptr 会引入额外的引用计数开销,没有必要。我的建议是默认 unique_ptr,只在确实需要多方持有同一个产品对象时才转成 shared_ptr。
还有一个容易忽略的问题:工厂返回 nullptr 怎么办。创建失败时,unique_ptr 可以是一个空指针,调用方在使用前必须判断。这个检查和异常方案各有取舍。返回值可空时,用空 unique_ptr 比较直接;创建失败需要携带错误原因时,抛异常也是合理选择。自己的项目里,我会根据工厂被调用的频率和失败概率来决定:高频率、失败可预期,优先返回空指针;低频率、失败需要详细上下文,则抛异常。
另外强调一下,不要用值类型返回产品。Message一旦作为值返回,派生部分就会被“切片”掉,对象退化成基类对象,多态信息彻底丢失。这属于 C++ 特有的坑,老手也容易在重构时踩上,后面问题排查那里会再展开。
3.2 用 std::function 注册表实现运行时扩展
当产品种类非常多,或者希望让模块之间彻底解耦时,经典工厂子类方案还是有点笨重。另一种现代 C++ 流行的做法是“工厂注册表”:把创建动作打包成函数对象,放到一个字典里,按名字或类型标识查表创建。
class ProtocolRegistry { public: using Builder = std::function<std::unique_ptr<Message>()>; void add(std::string name, Builder builder) { builders_[std::move(name)] = std::move(builder); } std::unique_ptr<Message> create(const std::string& name) const { auto it = builders_.find(name); if (it == builders_.end()) { return nullptr; } return it->second(); } private: std::unordered_map<std::string, Builder> builders_; };使用的时候,可以在模块注册阶段往里塞 lambda:
registry.add("json", [] { return std::make_unique<JsonMessage>(); }); registry.add("xml", [] { return std::make_unique<XmlMessage>(); });这样的设计下,新增一种协议,只需要在那个协议的实现文件里写一行注册,其他地方完全不用动。如果不想在 main 函数开始前手动注册,还可以利用静态对象构造时自动注册,不过要留意静态初始化顺序问题,最好的做法是用函数内局部静态的 “Meyers 单例”来保存注册表,注册动作也在函数调用里完成:
class ProtocolRegistry { public: static ProtocolRegistry& instance() { static ProtocolRegistry registry; return registry; } // ... };注册表方案和工厂方法并不冲突。你可以把它看作“工厂的工厂”:注册表里的每个 Builder 本身就是一个匿名工厂。对于经常需要按配置字符串创建对象的序列化、协议解析、插件系统来说,这种组合非常顺手。
3.3 工厂注入配合测试替身,代码立刻变成可测状态
工厂方法模式最大的长期价值之一,是把测试变得简单。假设有一个消息处理中心,它在内部直接 new 具体协议对象,那么单测非常难写,因为真协议对象可能要连接网络、访问文件或者依赖时间。把工厂注入进去,测试时就可以换成假工厂。
class MessageCenter { public: explicit MessageCenter(std::unique_ptr<MessageFactory> factory) : factory_(std::move(factory)) {} std::string handle(const std::string& raw) { auto msg = factory_->create(); if (!msg) return {}; return msg->dump(); } private: std::unique_ptr<MessageFactory> factory_; };测试代码里只需要一个轻量替身:
class FakeMessage final : public Message { public: void process() override { processed_ = true; } bool processed_ = false; }; class FakeFactory final : public MessageFactory { public: std::unique_ptr<Message> create() override { return std::make_unique<FakeMessage>(); } };然后构造 MessageCenter 时传入 FakeFactory,就能在完全不依赖具体协议的情况下验证核心逻辑。这种能力在大型 CI 系统里特别重要:业务代码不需要等待外部环境,提交一次代码就能跑出可靠结果。
工厂注入本身也提醒我们一点:对象之间的关系应该尽量通过构造函数传递,而不是在成员函数里用 new 偷偷拉出一个依赖来。只要依赖关系变得透明,替换、测试、维护都会顺很多。
4. 实战案例:一个跨平台消息处理网关
4.1 需求建模:协议上线为什么让人头疼
把前面的理论放在一个具体的场景里看,会很直观。假设你要做一个跨平台的消息处理网关,配置里说明了当前消息是 json、xml 还是 protobuf。每种消息的解析方式不同,序列化输出也不同。更让人头疼的是,市场部随时可能说“下周我们要支持 msgpack”。
如果没有工厂方法,正常做法是给网关主类加一个分支,判断消息格式后 new 相应对象。第一次加协议没感觉,第二次也还能忍,等到第三次、第四次,主类会塞满创建逻辑、配置解析和平台相关代码,谁都很难快速定位一个新增格式到底影响哪些地方。把创建动作抽象出来,就是要把“新增格式影响范围”控制在最小。
4.2 核心实现:产品、创建者与调用方三方解耦
完整的实现可以分成三段看。首先是产品层次:
class Message { public: virtual ~Message() = default; virtual std::string dump() const = 0; }; class JsonMessage final : public Message { public: explicit JsonMessage(std::string source) : source_(std::move(source)) {} std::string dump() const override { return "json:" + source_; } private: std::string source_; }; class XmlMessage final : public Message { public: explicit XmlMessage(std::string source) : source_(std::move(source)) {} std::string dump() const override { return "xml:" + source_; } private: std::string source_; };然后是创建者层次,每个具体工厂只负责一种产品的创建:
class MessageFactory { public: virtual ~MessageFactory() = default; virtual std::unique_ptr<Message> create(const std::string& source) = 0; }; class JsonMessageFactory final : public MessageFactory { public: std::unique_ptr<Message> create(const std::string& source) override { return std::make_unique<JsonMessage>(source); } }; class XmlMessageFactory final : public MessageFactory { public: std::unique_ptr<Message> create(const std::string& source) override { return std::make_unique<XmlMessage>(source); } };最后是网关本体,它不再关心具体 Message 类型,只依赖工厂接口:
class Gateway { public: explicit Gateway(std::unique_ptr<MessageFactory> factory) : factory_(std::move(factory)) {} std::string handle(const std::string& input) { auto message = factory_->create(input); if (!message) return {}; return message->dump(); } private: std::unique_ptr<MessageFactory> factory_; };这里要注意 create() 传入 source 参数,说明工厂方法不一定是无参的。产品可能需要构造参数、环境信息、配置对象,都可以在创建接口里显式传递。这也体现了一个设计经验:把“怎么建”和“建什么”分清楚。建什么由具体工厂决定,怎么建需要什么原料,则在接口签名里约定好。
4.3 扩展与权衡:加一种协议需要动多少代码
现在再回到开头那个需求:新增 msgpack。有了上面的结构,要做的事情是新增一个 MsgPackMessage 产品类,再新增一个 MsgPackMessageFactory,然后在系统装配点把具体工厂传给 Gateway。核心 Gateway 的代码一字不改,现有 json、xml 的测试也完全不用动。
这不是说工厂方法没有代价。每多一种产品,就要多两个类,类数量会翻倍。所以使用前最好想清楚:这个系统的“产品族”会不会频繁扩展?如果产品数量固定不变,而且模块规模很小,用工厂方法反而有点大炮打蚊子。另外,工厂方法带来的是一次虚函数调用和一次堆内存分配,在绝大多数业务系统里可以忽略不计,但如果在极端热路径里每秒创建几百万个对象,堆分配本身的成本就不是模式能解决的,需要考虑对象池或者更扁平的数据结构。
我自己的经验是,架构设计不是为了炫技,而是为了减少未来需求的改动面积。工厂方法真正值钱的地方,不在于它多么优雅,而在于它把“新增一种产品”从“改一堆文件”变成“新增两个类”。这个收益随着项目生命周期越往后越明显。
5. 容易踩的坑与实战经验速查
5.1 构造函数里调用工厂方法:经典得不能再经典的陷阱
很多刚接触 C++ 多态的人会想:既然工厂方法是虚函数,那我在基类构造函数里调用 create(),派生类重写的方法总该生效吧?答案是,不会。
C++ 的规则是:在基类构造阶段,对象的动态类型还是基类,虚函数调用会被解析到基类版本,而不是派生类版本。也就是说,你在 Creator 基类的构造函数里调用一个指向 create() 的虚函数,最后执行的是基类的 create(),派生类的覆盖根本没机会运行。等到派生类构造完成,对象类型才变成真正的派生类型。
这也是工厂方法模式不建议把 create() 放进构造函数的原因。正确做法通常有两种:一种是在代码里显式调用 factory->create(),创建过程由外部驱动;另一种是采用懒初始化,在第一次使用产品时才真正创建。不要在构造函数里期待虚函数分派,这条规则在任何涉及构造的 C++ 设计里都适用。
5.2 切片、悬垂引用与异常安全:细节决定 bug 大小
切片问题在工厂方法的代码 review 里非常常见。如果有人把返回类型写成了Message create(),编译器一般不会立刻报错,反而可能通过拷贝构造把派生对象截断成基类对象。结果就是工厂明明创建了 JsonMessage,但拿到的对象却表现得像 Message 本身。因为抽象类无法直接实例化,这种错误往往以“无法实例化抽象类”的编译错误出现,容易被忽视。
悬垂引用则多出在返回裸指针的地方。假如工厂返回了一个堆对象的裸指针,调用方持有这个指针后又被别人 delete 了,后面任何解引用都是定时炸弹。这个问题在多人协作项目里很难靠自觉避免,使用 unique_ptr 可以直接从类型系统层面拦住它。
异常安全方面,要注意工厂中间步骤抛出异常时资源不能泄漏。假如你手动 new 了两个临时对象,第二个 new 抛异常,第一个对象就没人管了。用 make_unique 或 make_shared 能避免这种问题,因为临时对象在异常路径上会被智能指针正确释放。除非有特殊的内存分配器需求,否则强烈建议用智能指针配合 make 系列函数。
5.3 什么时候最好别用工厂方法
不是所有地方都需要工厂方法。如果整个系统只有一种具体产品,而且你找不到第二个变体的影子,那就直接用 new。这时候抽象出工厂接口只是凭空多了一层间接,没有换来任何灵活性。
如果产品本身是轻量、值语义的小对象,比如一个坐标点、一个配置项,那就更没必要。工厂方法天然和“堆上、可多态、有生命周期”的对象配合得好,对小值类型只会拖慢性能并且让代码变得绕。
更常见的误区是:把工厂做成一个巨大的 switch 分发中心。这样表面上用了工厂,实际上所有选择逻辑还是集中在一个函数里,扩展时还是要改这个函数。真正的工厂方法应该把创建责任分散到多个类。如果发现工厂函数里有一长串 if-else 表示不同类型,说明只是在“简单工厂”层面打转,还没真正用上工厂方法的多态能力。
5.4 常见问题排查速查表与我的几条心得
把实践中遇到的典型问题整理成一张速查表,以后遇到可以直接翻:
| 现象 | 可能原因 | 建议 |
|---|---|---|
| 删除基类指针时崩溃或资源没释放 | 产品基类缺少虚析构函数 | 声明virtual ~Message() = default; |
| 工厂返回的对象总表现出基类行为 | 返回类型写成了值类型,发生切片 | 返回std::unique_ptr<Product> |
| 注册表里找不到刚注册的类型 | 静态对象初始化顺序不稳定 | 用函数内 static 局部对象保存注册表 |
| 工厂里堆满了 if-else 分支 | 只是披着工厂外衣的简单工厂 | 把分支细化到各 Creator 子类里 |
| 测试时总是 new 真对象,很难替代 | 依赖没有通过构造函数注入 | 把工厂作为参数传入,测试时换成替身 |
踩过几次坑之后,我自己总结出一条判断标准:如果“创建谁”这件事正在散落到各个调用方,早点用工厂方法把它收口;如果系统只有一种产品,并且看不到未来的变体,直接 new 反而是更诚实的做法。工厂方法不会消灭设计复杂度,它只是把复杂度放在一个更容易演进的位置上。希望这些经验能帮你在下一次重构里少走一点弯路。