在 C++ 工程里聊工厂模式,很多人的第一反应是:这不就是个 switch 套 new 吗?我早期也这么写过,直到一个项目的对象类型从 5 个涨到 30 个,工厂文件翻了七八倍,每次加类型都得小心翼翼去补分支,补漏一个分支编译期根本发现不了,运行到线上才炸。那以后我才认真去研究 C++ 里的工厂模式变体。
所谓工厂模式变体,本质上都是一个思路:把“创建哪个对象”和“怎么创建对象”这件事,从调用方手里剥离出来。C++ 因为模板、智能指针、类型擦除这些工具的存在,变体的花样比很多静态语言多得多。这篇文章写给正被 switch 型工厂折磨的 C++ 开发者,也写给想在设计模式落地时少踩坑的人。我会把实际项目里真正用得上的几种变体拆开讲,顺便说说它们背后的取舍和坑。
1. 先搞清楚工厂模式解决什么问题
1.1 从“看到 new 就头痛”说起
只要代码里出现new ConcreteType(),这一行就把你死死绑在具体类上。调用方一旦直接 new,后面想换实现、加代理、做缓存、注入依赖,全都得回头改调用点。一个大型项目里,这种调用点可能散落在几十个文件里,改一次就是一场灾难。
工厂模式解决的核心问题是依赖方向。正常情况下,高层模块依赖底层具体类;引入工厂后,高层只依赖抽象接口,底层具体类由工厂在运行时决定。这就像你去餐厅吃饭,不用关心厨师今天用哪个灶台、哪口锅,你只需要和服务员说“来一份套餐”,后厨怎么组织是后厨的事。工厂就是那个服务员,把“你要什么”和“后厨怎么做”隔离开。
但很多人在没有理解这个本质之前,就开始套用简单工厂,结果只是把散落的new集中到一个switch里。代码确实不再到处 new 了,但每次加新类型还是要改工厂,开闭原则一点没沾上。这也是工厂模式后来演化出各种变体的根本原因:简单工厂做不到的事情,大家想用更好的方式去补。
1.2 经典三分法:简单工厂、工厂方法、抽象工厂
教科书上通常把工厂模式分成三类。简单工厂就是把创建逻辑集中到一个函数里,用一个枚举或者字符串去判断该创建谁。代码短,但分支会随类型数量膨胀。适合类型非常稳定、几乎不会扩张的场景,比如一个工具库内部的两个内部实现。
工厂方法则是把“创建方法”本身定义成虚函数,让子类去决定返回什么样的具体对象。每个子类自己管自己的产品,新增一种产品就新增一个子类,符合开闭原则,代价是类的数量变多。如果你只有一个维度在变化,工厂方法很舒服。
抽象工厂更重一些,它要解决的是“一组相关对象”的创建问题。比如一套 UI 风格需要皮肤、字体、控件的配套对象,你不能随便混搭。抽象工厂定义一族产品的创建接口,每个具体工厂给出一整套实现。这三个形态不是互相替代的关系,而是不同复杂度下的选择。
经典三分法真正想表达的是:创建逻辑和业务逻辑分离。变体万变不离其宗,都是在这条线上加各种实现手段。
1.3 为什么 C++ 里变体特别多
Java 或 C# 的工厂实现相对固定,基本都是虚函数加反射。C++ 不一样,它同时有继承多态、模板、类型擦除、lambda、智能指针,这些东西排列组合一下,工厂的写法就非常多了。
比如模板可以做到“一个工厂函数通吃所有子类”,注册表可以把运行期字符串映射到创建函数,std::function可以捕获创建上下文,if constexpr可以直接在编译期选分支。这些能力让工厂模式在 C++ 里变成了一座工具箱,而不是一道固定料理。变体多不代表每一种都值得用,关键在于你的变化点在哪里:是运行期按名字找类型,还是编译期按模板参数找类型,这两者的解法完全不同。
2. 实战中最常用的几种工厂变体
2.1 注册表工厂:用映射替代 switch
注册表工厂是干掉 switch 最直接的手段。思路很简单:用一个std::unordered_map把“类型标识符”映射到“创建函数”。创建函数可以是std::function<std::unique_ptr<Base>()>,每次新增类型时不需要改工厂类本身,只需要在类型自己的代码里往注册表丢一个创建函数。
我先给你看一个基础骨架:
class Node { public: virtual ~Node() = default; virtual void DoSomething() = 0; }; using NodeFactoryFunc = std::function<std::unique_ptr<Node>()>; class NodeFactory { public: static NodeFactory& Instance() { static NodeFactory factory; return factory; } void Register(std::string_view key, NodeFactoryFunc func) { registry_[std::string(key)] = std::move(func); } std::unique_ptr<Node> Create(std::string_view key) const { auto it = registry_.find(std::string(key)); if (it == registry_.end()) { return nullptr; } return it->second(); } private: std::unordered_map<std::string, NodeFactoryFunc> registry_; };然后在每个具体子类对应的源文件里做注册:
class TextNode : public Node { public: void DoSomething() override; }; namespace { bool registerTextNode = [] { NodeFactory::Instance().Register("text", [] { return std::make_unique<TextNode>(); }); return true; }(); }这个写法最大的优点是“新增类型时零改动旧文件”。你新写一个ImageNode,自己负责注册自己,工厂不用动。缺点是没有编译期的类型检查,如果你注册名拼错了,运行期会拿到nullptr。所以最好在Create失败时给出明确日志,或者在调试配置里直接assert。
注册表工厂特别适合插件化架构、动态配置驱动的对象创建,以及类型清单会持续扩张的项目。我在实际项目里统计过,把 30 多个分支的 switch 改造成注册表之后,新增一个类型的改动量从“改 4 个地方”缩减到“只写一个新文件”。
2.2 模板工厂:把重复代码交给编译器
注册表虽好,每个具体类型还是要手写一遍注册 lambda。如果项目的类很多,这种样板代码也很烦。模板工厂的目的就是把重复交给编译器。
最简单的模板工厂就是完美转发加std::make_unique:
template<typename Base, typename Concrete, typename... Args> std::unique_ptr<Base> Create(Args&&... args) { static_assert(std::is_base_of_v<Base, Concrete>, "Concrete must derive from Base"); return std::make_unique<Concrete>(std::forward<Args>(args)...); }这个模板函数能自动帮你做类型检查,如果Concrete根本不是Base的子类,编译期直接报错。配合注册表使用时,注册代码可以变成一个模板:
template<typename Concrete> bool RegisterNode(std::string_view key) { static_assert(std::is_base_of_v<Node, Concrete>, "Concrete must derive from Node"); NodeFactory::Instance().Register(key, [] { return std::make_unique<Concrete>(); }); return true; }调用时:
bool reg = RegisterNode<TextNode>("text"); bool reg2 = RegisterNode<ImageNode>("image");这比手写 lambda 清晰多了,而且类型检查在编译期就能拦住低级错误。模板工厂在 C++17 之后还能和折叠表达式结合,一次性注册一堆类型:
template<typename... NodeTypes> void RegisterAll() { (RegisterNode<NodeTypes>(NodeTypes::Key()), ...); }只要每个类型提供一个静态的Key(),注册动作一行搞定。模板工厂的代价是代码的可读性对新手不太友好,而且一旦用错模板参数,编译报错会一大片。这时候static_assert就是你最好的朋友,一定要加,否则读者会被模板报错淹没。
2.3 类型擦除与 std::function 工厂
传统工厂返回的都是基类指针,但对于创建函数本身,我们也可以用类型擦除。std::function就是最常见的类型擦除容器,它能把普通函数、lambda、函数对象统一收进来。
工厂函数不一定只能是无参的。实际项目里,创建对象经常需要一些上下文,比如当前用户权限、配置对象、会话信息。如果你用裸函数指针,就得把上下文塞到单独的参数里;但用std::function可以提前捕获:
class Config; class Service { public: virtual ~Service() = default; virtual void Start() = 0; }; class HttpService : public Service { public: explicit HttpService(const Config& config) : config_(config) {} void Start() override; private: Config config_; }; // 在初始化阶段捕获 config auto httpFactory = [config = loadedConfig]() { return std::make_unique<HttpService>(config); }; std::function<std::unique_ptr<Service>()> factoryFunc = httpFactory;这种工厂的语义和普通函数指针完全不同。函数指针只能携带显式参数,而std::function可以捕获任意上下文。它还让工厂具备了“策略化”能力:你可以在运行时替换factoryFunc指向的实现,从而切换不同的创建策略。
不过要提醒一点:类型擦除不是免费的。std::function的调用通常比裸函数指针慢一点,还可能有堆分配。如果创建操作是低频的,完全不用在意;但如果工厂被放在每秒几十万次的热路径上,最好先做性能测量再决定。
2.4 编译期工厂:if constexpr 与类型分派
注册表是运行期方案,但有时候类型集合在编译期就固定了,我们可以直接用if constexpr做一个编译期工厂模板。它没有任何查找开销,分支在编译期就被消掉。
比如你在模板里根据是否某个标签类型创建不同对象:
template<typename T> std::unique_ptr<Base> CreateByTag() { if constexpr (std::is_same_v<T, TextTag>) { return std::make_unique<TextNode>(); } else if constexpr (std::is_same_v<T, ImageTag>) { return std::make_unique<ImageNode>(); } else { static_assert(!sizeof(T), "unsupported tag type"); } }如果传入不支持的T,static_assert会在编译期直接报错。这比运行期返回空指针更安全,因为所有错误都被提前到编译阶段。
编译期工厂适合类型数量固定、完全可以在编译期推导的场景。比如一个系统根据网络协议版本号编译期选择不同解析器,就很舒服。缺点是没法动态扩展,你不可能让用户在运行时往编译期工厂里塞一个新类型。所以编译期工厂和运行期注册表不是竞争关系,是互补关系。
3. 落地一个工厂变体需要迈过的坎
3.1 接口设计与所有权语义
工厂返回什么类型,直接决定整个使用体验。我强烈建议工厂返回std::unique_ptr<Base>而不是裸指针。裸指针表达不了所有权归属,调用方到底要不要 delete?谁负责释放?全靠约定。只要项目里出现一个忘了 delete 的窗口,内存泄漏就埋下了。
返回unique_ptr的缺点是你拿到的是独占所有权。如果多个模块要共享同一个对象,就需要转移成shared_ptr。也可以让工厂返回shared_ptr<Base>,但每次创建都多了引用计数的开销。我的做法是默认返回unique_ptr,只有在明确需要共享生命周期时才提供一个返回shared_ptr的变体。另外,基类一定要声明虚析构:
class Base { public: virtual ~Base() = default; ... };如果基类没有虚析构,通过基类指针删除派生类对象是未定义行为。这个问题特别隐蔽,很多人写工厂时不注意,等到对象内部有资源泄漏或用完析构崩溃才反应过来。还有一点,基类接口要尽量最小化。工厂返回的是抽象能力集合,不要把每个派生类的特有方法都塞进基类,否则工厂越灵活,基类越臃肿。
3.2 线程安全的注册与读取
工厂最常见的使用方式是:程序启动阶段注册所有类型,运行阶段只调用Create。这种场景下,注册发生在单线程,读取发生在多线程,那unordered_map可以放心用。只要没有并发修改,多个线程同时调用const find是安全的。
但如果你要在程序运行过程中动态注册,比如从外部资源加载插件,那注册阶段会有并发写入,这时候必须加锁。简单做法是给Register和Create都加同一个std::mutex,缺点是连续创建几千个对象时锁竞争明显。稍微优化一点,用std::shared_mutex:注册用独占锁,创建用共享锁。这样高并发创建时读锁之间不互斥,性能会好很多。
更要小心的是静态注册的初始化顺序。如果你用一个全局对象去注册,而这个全局对象在另一个编译单元里依赖了工厂实例,那初始化顺序就是未定义的。解决办法是用函数内静态局部变量,也就是 Meyers Singleton:
NodeFactory& NodeFactory::Instance() { static NodeFactory factory; return factory; }C++11 之后,函数内static局部变量的初始化是线程安全的,首次进入时只会初始化一次。这样无论全局注册对象在哪个时候触发,只要它调用Instance(),工厂一定已经初始化。
3.3 创建失败的错误处理
工厂创建对象有很多失败可能:类型名找不到、构造函数抛异常、依赖服务未初始化、内存分配失败。最省事的方式是让异常直接抛出去,但有些项目禁用异常,或者团队不想在工厂接口里冒异常。
如果返回nullptr,你只知道了“创建失败”,不知道失败原因。这会让排查问题变得很痛苦。我见过一个项目,工厂返回nullptr,所有调用方都只打一行 “create failed”,最后没人知道是哪种类型不存在。
更好的做法是给工厂增加错误信息记录,或者自定义一个简单的结果类型:
template<typename T> struct Result { std::unique_ptr<T> object; std::string error; bool ok() const { return object != nullptr; } };Create返回Result<Base>,失败时把“未注册的 key”写进error。这样调用方既能选择不抛异常,又能在排查时拿到根因。如果在 C++23 环境下,可以用std::expected代替自定义Result,语义更标准。我建议工厂接口不要在内部吞掉异常,构造函数抛异常时应该如实往外传,否则你把“没有该类型”和“对象构造失败”混在一起,调用方很难区分。
3.4 最小侵入:逐步替换旧工厂
工厂重构最怕大面积推倒重来。我见过一个团队在两周内把所有 switch 工厂一次性改成注册表,结果线上出了好多问题,最后被迫回滚。稳妥的方式是过渡式重构。
旧的CreateFromType先保留,内部改成这样:
std::unique_ptr<Node> CreateFromType(const std::string& key) { if (auto obj = NodeFactory::Instance().Create(key)) { return obj; } // legacy path if (key == "old_a") { return std::make_unique<LegacyA>(); } ... return nullptr; }先让注册表接管新类型,旧类型继续走旧分支。每次迁移几个类型,等所有类型都迁过去了,再删掉旧分支。这个“兼容垫片”看起来有点丑,但它能保证重构过程随时可上线、可回滚。工厂变体是长期演化的产物,没必要追求一次一步到位。
4. 实操:搭建一个可扩展的注册表工厂
4.1 基础骨架与注册工具
我们以一个具体例子走一遍。假设要做一个消息处理系统,消息类型有很多种:文本、图片、语音、视频。每个消息处理器都继承同一个Handler接口,然后我们用一个工厂来创建处理器。
接口长这样:
class Handler { public: virtual ~Handler() = default; virtual void Handle(const Message& msg) = 0; };工厂骨架和前面类似,但我会把注册工具再封装一层,让调用方更舒服。
class HandlerFactory { public: using FactoryFunc = std::function<std::unique_ptr<Handler>()>; static HandlerFactory& Instance() { static HandlerFactory factory; return factory; } void Register(std::string_view key, FactoryFunc func) { std::unique_lock lock(mutex_); factories_[std::string(key)] = std::move(func); } std::unique_ptr<Handler> Create(std::string_view key) { std::shared_lock lock(mutex_); auto it = factories_.find(std::string(key)); if (it == factories_.end()) { return nullptr; } return it->second(); } private: mutable std::shared_mutex mutex_; std::unordered_map<std::string, FactoryFunc> factories_; };注册工具可以直接用模板函数,省去手写 lambda 的琐碎:
template<typename Concrete> bool RegisterHandler(std::string_view key) { static_assert(std::is_base_of_v<Handler, Concrete>, "Concrete must derive from Handler"); HandlerFactory::Instance().Register(key, [] { return std::make_unique<Concrete>(); }); return true; }然后在每个消息处理器的实现文件里写:
// text_handler.cpp class TextHandler : public Handler { public: void Handle(const Message& msg) override; }; static const bool textHandlerRegistered = RegisterHandler<TextHandler>("text");这里static const bool变量的初始化发生在程序启动阶段。它只是为了触发注册函数,所以即使旁边有[[maybe_unused]]也无妨。你甚至可以把它写成static bool registered = RegisterHandler<...>();,只要初始化函数执行即可。
4.2 参数化创建:让工厂接收上下文
很多处理器创建时需要上下文,比如配置、线程池、数据库连接池。无参注册函数就有些不够用了。
一种方案是给每个工厂函数传入一个统一的Context对象:
struct CreateContext { const Config* config = nullptr; ThreadPool* threadPool = nullptr; Database* database = nullptr; }; using FactoryFunc = std::function<std::unique_ptr<Handler>(const CreateContext& ctx)>;注册时,lambda 接收ctx并传给具体对象:
RegisterHandler<TextHandler>("text", [](const CreateContext& ctx) { return std::make_unique<TextHandler>(*ctx.config, ctx.threadPool); });这样既保持工厂接口统一,又让不同处理器可以按需取用上下文。代价是CreateContext本身变成了一个新的耦合点。如果项目里对象创建依赖差异太大,也可以让每个处理器自己定义“工厂函数类型”,但那样注册表就退化成std::unordered_map<std::string, std::any>了,类型安全大打折扣。我个人更倾向CreateContext,因为它简单直接,且团队容易统一规则。
4.3 批量创建与类型清单
注册表除了创建单个对象,还能顺便提供一个“列出所有已注册类型”的能力。
std::vector<std::string> RegisteredKeys() const { std::shared_lock lock(mutex_); std::vector<std::string> keys; keys.reserve(factories_.size()); for (const auto& [key, func] : factories_) { (void)func; keys.push_back(key); } return keys; }这个功能在做配置校验时非常有用。比如配置里写了type = "video",启动时可以先检查RegisteredKeys()是否包含该名字,不包含就提前报错,而不是等到处理消息时才失败。另一个用法是生成帮助信息,比如消息类型的下拉选项。批量创建时,配合RegisteredKeys()可以写出一个简单的分发循环,把同一批消息按类型自动交给对应处理器。
4.4 一段性能实测心得
有朋友问我,注册表工厂那么多次find,会不会比直接 switch 慢很多?我实际测过一个简单场景:在unordered_map里创建十万个对象,每个对象内部就一个整型成员。结果发现注册表方案比 switch 方案慢不到 5%,而创建对象本身的make_unique开销已经占了大头。
真正影响性能的是把std::function作为工厂函数。std::function调用有一定间接开销,但在创建对象这个量级上通常可以忽略。只有当你的“工厂”在热循环里被频繁调用,并且每次调用只做很轻量的逻辑时,才需要考虑用函数指针数组之类更脆的方案。性能优化要基于测量,而不是基于感觉。先让工厂变体结构清晰,再考虑性能,是更稳的路线。
5. 常见问题与排查技巧
5.1 静态注册顺序的坑
全局变量和静态变量的初始化顺序在 C++ 里一直是敏感话题。如果你在HandlerFactory的构造函数里直接初始化unordered_map,而某个注册器在工厂初始化之前就调用了Register,结果就是未定义行为。常见表现是崩溃,或者注册数据丢失,但只在一部分构建环境下出现。
解决方案前面已经提到,核心是三点:
- 工厂
Instance()一定要用函数内static局部变量。 - 注册器不要在全局作用域里依赖其他全局对象。
- 如果注册器也是全局变量,注册动作要足够简单:只调
Register,不访问其他可能未初始化的东西。
我见过一个隐蔽问题:某个 AI 模型管理器也是一个 Meyer s单例,但在注册器里调用它的LoadConfig(),结果模型管理器初始化时依赖了日志系统,日志系统又依赖了本地时间库。在某个编译器版本下,本地时间库的静态初始化晚于注册器,启动时随机崩溃。后来把注册器的初始化挪到main函数开头手动触发,问题才消失。
5.2 CRTP 注册器里的类型和继承问题
用 CRTP 写自动注册时,很容易写错基类。假设你定义了一个模板注册器:
template<typename Derived> struct AutoRegister { AutoRegister() { HandlerFactory::Instance().Register(Derived::Key(), [] { return std::make_unique<Derived>(); }); } };如果你不小心传入了一个并不继承Handler的类,编译时会报错,但报错信息可能很长。这时候最好在AutoRegister里加一句static_assert:
template<typename Derived> struct AutoRegister { static_assert(std::is_base_of_v<Handler, Derived>, "Derived must inherit Handler"); ... };这样编译器给你的第一行错误信息就是清楚的业务描述,而不是一堆模板展开。CRTP 还有一个注意点:Derived::Key()必须是静态成员函数,如果你写成了虚函数,就无法在AutoRegister构造函数里使用。团队里常有新人踩这个坑,代码能编译但链接失败,查半天才意识到把Key()写成了非静态。
5.3 工厂被塞进太多逻辑变成上帝对象
工厂变体最容易被过度设计。一开始只是注册表,后来为了支持某些特殊对象,在Create里加配置读取、日志、缓存、埋点、权限校验,慢慢工厂变成一个什么都知道的上帝对象。这种代码比 switch 更难维护,因为所有横切关注点都挤在创建路径上。
我现在的原则是:工厂只负责“根据标识符创建对象并返回”,其他任何事都不做。日志应该在调用方记录,缓存应该放在代理层,权限校验应该交给拦截器。如果某个创建过程特别复杂,那就单独抽一个XxxBuilder或XxxAssembler,让工厂只管把 Builder 挂上去。保持单一职责,工厂变体才值得用。
5.4 变体选择的决策速查表
整理一张表,方便实际项目里快速选型。
| 变体 | 适用场景 | 不适用场景 | 主要代价 |
|---|---|---|---|
| 简单工厂 | 类型极少且稳定,团队规模小 | 类型持续扩张,需要开闭原则 | 每次加类型都要改 switch |
| 工厂方法 | 一个维度变化,依赖子类扩展 | 需要按字符串动态创建 | 类数量多,设计偏重 |
| 抽象工厂 | 产品族配套创建,需要保证一致性 | 单一对象创建,产品族不固定 | 接口层级多,复杂度高 |
| 注册表工厂 | 运行期动态创建,类型可扩展,插件化 | 类型集合编译期固定且追求零开销 | 运行期查找开销,无编译期检查 |
| 模板工厂 | 编译期已知类型,想省去重复代码 | 运行时才知道类型名 | 模板报错不友好 |
| 类型擦除工厂 | 创建函数需要捕获上下文或可替换 | 热路径高频率创建 | std::function 开销 |
| 编译期工厂 | 类型固定且可在编译期分派 | 外部运行时动态注入新类型 | 不支持动态扩展 |
这张表不是教条,因为实际项目里经常混用。比如注册表工厂的注册函数内部,完全可以再套一层模板工厂。选择变体的核心是“变化点到底在哪”,变化点是运行期的就上注册表,是编译期的就上模板或 if constexpr,两者同时存在就组合使用。
5.5 我的一点个人取舍
踩过这么多坑之后,我在大部分项目里会默认用“模板 + 注册表”的组合:模板函数负责注册和make_unique,注册表负责运行期查找,返回unique_ptr<Base>。这个组合在可扩展性、可读性和性能之间比较平衡。真正固定且性能敏感的地方,我才会单独写编译期工厂。
每次团队里有人提出要用反射或者更重的依赖注入框架时,我会先问一句:你的变化点真的需要那么动态吗?C++ 的工厂模式变体已经足够解决绝大多数创建问题,反射和大框架引入的复杂度,在很多项目里是不划算的。能把一个注册表工厂写好、写稳,比堆十个花哨模式更实在。