说实话,我最早接触工厂模式的时候,觉得这东西就是个"高级点的 switch 封装",把创建逻辑挪到一个静态函数里就算完事。真正改变我看法的是后来维护一个 RPC 网关项目:里面有几十种协议解析器,每种解析器的构造函数参数还不一样,配置中心还会在运行期把新的解析器类型名下发下来。那会儿我意识到,工厂模式在 C++ 里真正难的不是三种基本形态背熟,而是怎么应对运行期扩展、怎么保证并发安全、怎么在编译期能确定的场景里省掉虚函数的开销。这篇文章就顺着这条线展开,从基础形态快速过一遍,重点放在注册表工厂、编译期工厂、并发分配和工程选型这几个高级应用场景上,适合已经会用基本工厂、想在真实项目里把它用到位的 C++ 开发者。
1. 别把工厂模式当成"switch 的替代品"来用
1.1 简单工厂、工厂方法、抽象工厂的分工逻辑
先快速对齐一下基础。很多教程会把简单工厂、工厂方法、抽象工厂放在一起对比,但给人的感觉就是"多了一层层类",看不出使用差别。其实这三个变体解决的是不同粒度的变化问题。
简单工厂在 C++ 里通常就是在一个类的静态函数内部用 switch 或 if-else 分派,比如:
class LoggerFactory { public: static std::unique_ptr<ILogger> Create(LoggerType type) { switch (type) { case LoggerType::Console: return std::make_unique<ConsoleLogger>(); case LoggerType::File: return std::make_unique<FileLogger>("app.log"); case LoggerType::Remote: return std::make_unique<RemoteLogger>(); } return nullptr; } };它的特点是类型集合在编译期是已知的、相对固定的,新增一种 Logger 类型的代价是改函数体加一个 case。如果产品类型不多,一年加不了两三次,这个写法完全没问题。但一旦产品类型多到拆文件管理,或者创建逻辑本身需要依赖接口注入,简单工厂的静态函数就会越长越臃肿。
工厂方法模式把"创建动作"从具体产品类里抽出来,让子类决定实例化哪个产品。它的核心价值是:调用方只依赖一个抽象的CreateLogger()虚函数接口,产品的构造过程可以被子类覆盖。
class ILoggerFactory { public: virtual ~ILoggerFactory() = default; virtual std::unique_ptr<ILogger> Create() = 0; }; class ConsoleLoggerFactory : public ILoggerFactory { public: std::unique_ptr<ILogger> Create() override { return std::make_unique<ConsoleLogger>(); } };抽象工厂则更进一步,它保证一组相关产品之间的兼容性。比如 UI 框架里的 Windows 控件工厂、Linux 控件工厂、macOS 控件工厂,每个工厂都能生成一套风格一致的按钮、输入框、对话框。如果让调用方分别去创建这些控件,很容易混搭出按钮是 Windows 风格、输入框是 macOS 风格的"缝合怪"。抽象工厂就是把这种"产品族一致性"约束封装起来。
1.2 判断是否需要工厂的两条硬指标
深入工程实践之前,先解决一个很实际的问题:什么时候值得引入工厂?我的判断标准主要看两条。
第一条,类型是否会在编译期之外扩展。如果新类型的加入不是靠改代码、加一个 case 就能完成的,而是需要从配置、消息、插件目录里动态得知类型标识,那基本可以确定要用运行时注册表工厂。比如网络协议解析、GUI 控件扩展、命令处理器注册这类的场景。
第二条,对象的构造过程是否有跨类型的共性。如果创建对象不只是new T()这么简单,还涉及依赖注入、延迟加载、缓存复用、构造参数校验,那你需要把这些逻辑收集到一个统一的地方,避免每个调用点都重复写一遍。这时候工厂的价值就不在"创建对象"本身,而在"统一管理创建前后的一系列操作"。
这两条都不满足时,直接用std::make_unique<T>()。每次都套抽象工厂,只会让代码变得难追踪,这是过度设计最常见的症状。
2. 运行时注册表工厂,真正撑起插件架构的骨架
2.1 注册表工厂的完整形态
注册表工厂是高级应用里最常见、也最实用的一种形态。它把"注册"和"创建"两个动作拆开:产品类型在程序运行期的任意时间点注册到工厂里,调用方通过 key(通常是字符串或者枚举)来获取产品实例。这样新类型加入时,完全不用改工厂的代码。
我一般的写法是这样的:
template<typename Base, typename Key = std::string, typename... Args> class Factory { public: using Creator = std::function<std::shared_ptr<Base>(Args...)>; void Register(const Key& key, Creator creator) { std::unique_lock lock(mutex_); creators_[key] = std::move(creator); } std::shared_ptr<Base> Create(const Key& key, Args... args) const { std::shared_lock lock(mutex_); auto it = creators_.find(key); if (it == creators_.end()) { return nullptr; // 或者抛异常、返回 fallback,视业务决定 } return it->second(std::forward<Args>(args)...); } private: mutable std::shared_mutex mutex_; std::unordered_map<Key, Creator> creators_; };这里用了std::shared_mutex来同时支持并发读(创建)和写(注册)。Create被声明为const,但允许读锁,保证多个线程可以同时从工厂里拿对象,只有在比较罕见的注册动作发生时才需要独占写锁。
用std::function作为 Creator 的类型,是为了让注册方可以灵活捕获外部状态。比如某个产品的构造函数需要配置对象,注册时就可以绑定配置:
factory.Register("console", [](const AppConfig& cfg) -> std::shared_ptr<ILogger> { return std::make_shared<ConsoleLogger>(cfg.logLevel); });这里有个细节:为什么选择返回std::shared_ptr<Base>而不是std::unique_ptr<Base>?因为注册表驱动的工厂场景里,产品的生命周期经常要跨多个模块,unique_ptr的所有权转移在多层传递时比较麻烦。shared_ptr可以安全地让多个观察者共享对象。如果确定不需要共享所有权,用unique_ptr也没问题,看清楚场景再选。
2.2 用 CRTP 加静态成员实现自动注册
一个很爽的进阶玩法是让每个产品类自己"报名"进工厂,而不是写一个单独的注册函数列表。这样新增产品类型时,只需要新增一个类文件,不需要改动任何注册中心的代码。
思路是:搞一个注册助手模板,利用 C++17 的inline static成员初始化时机,在程序启动阶段(main 执行前的动态初始化)自动调用注册函数。
template<typename Base, typename Derived, auto TypeName> struct AutoRegister { inline static const bool kRegistered = RegisterImpl(); static bool RegisterImpl() { Factory<Base>::Instance().Register(TypeName, []() -> std::shared_ptr<Base> { return std::make_shared<Derived>(); }); return true; } }; class ConsoleLogger : public ILogger, private AutoRegister<ILogger, ConsoleLogger, "console"> { public: // 产品实现... };这里有几个值得注意的地方。
inline static是 C++17 引入的,保证这个静态成员在所有翻译单元里只有一份定义,也保证它会在程序启动阶段初始化一次。注册动作就发生在这一瞬间,等到main()开始执行的时候,"console"这个 key 已经在工厂里躺好了。
auto TypeName是非类型模板参数,C++17 之后可以直接传字符串字面量"console",编译器会构造一个静态存储期的字符串对象,不需要写死const char*常量,这也是自动注册能简洁落地的关键。上面代码里Factory<Base>::Instance()是一个单例访问函数,用局部静态变量实现即可(这个细节在后面的并发章节会展开)。
这种写法的好处是:产品的新增 = 写一个新类 + 继承AutoRegister。它把"注册"这个动作几乎隐藏在了类的声明里。我在编译器插件项目里用过这套模式,几十个内置插件,每个插件文件独立注册,核心框架代码一行不改。
2.3 注册表工厂最常见的三个坑
这个模式很好用,但坑也是一踩一个准。
跨模块(DLL/共享库)注册失效。如果你的工程是插件架构,插件各自编译成动态库,模板工厂被多个 DLL 引用时,问题就来了——模板在每个编译单元里都会实例化一份,不同 DLL 里的Factory<Base>::Instance()很可能各自持有一份独立的静态存储区,而不是共享同一个单例。结果是 A 插件注册到 A DLL 的工厂,主程序从主程序的工厂里查不到。解决思路有几个:把模板显式实例化并且导出符号,确保所有模块都链接到同一份实现;或者干脆不用模板单例,而是由主程序创建一个具体类型的工厂实例,通过接口指针传给各插件,让插件往这个实例里注册。
动态库卸载导致悬空的创建器。注册表里存的是std::function,背后是一段可执行代码。如果这段代码所在的 DLL 被FreeLibrary卸载了,但注册表里还留着对应的 Creator,之后再调用Create就会直接崩溃,而且崩溃点往往跟注册表代码没啥关系,定位起来非常痛苦。正确做法是:模块卸载之前,先遍历注册表,把该模块注册过的那批 key 全部移除。这也是我后来坚持提供UnregisterAllByToken接口的原因,每个模块注册时绑定一个 token,卸载时一次性撤销。
创建失败的错误处理策略不统一。很多团队不会先想清楚:Create遇到未知 key 到底应该返回nullptr、抛异常、还是返回一个 Null Object?我的建议是,根据调用方的容错能力来定。配置驱动且允许降级的场景,返回空指针或者 fallback 对象;如果创建失败意味着严重错误、调用方没有恢复手段,直接抛异常并带上 key 信息,方便日志定位。两种策略都行,但一定要在工厂的接口注释里写清楚,不然每个调用方都会自己猜,最后各种检查逻辑满天飞。
3. 编译期工厂:能静态确定就不付出动态开销
3.1 什么时候可以考虑编译期工厂
注册表工厂解决的是"运行期类型未知"的问题,但实际代码里有很多场景,类型在编译期就是完全确定的。比如一个内部小工具的枚举值和对应处理逻辑的映射、一批固定的事件类型对应的事件处理器。这种情况下还用虚函数分派、用哈希表查注册表,等于平白多付了运行时的查表和间接调用成本。
编译期工厂的核心思路:用模板让编译器在编译阶段就完成类型分发,最终生成的代码里既没有哈希查找,也没有虚函数调用,可能直接就是一次内联的构造调用。代价是类型必须能在编译期确定,扩展新类型等于改代码重新编译。
3.2 基于枚举加 if constexpr 的编译期分派
最直观的编译期工厂实现是枚举作为模板参数,配合if constexpr做分支选择:
enum class EventType { Mouse, Keyboard, Resize, Quit }; template<EventType E> std::unique_ptr<EventHandler> CreateEventHandler() { if constexpr (E == EventType::Mouse) { return std::make_unique<MouseEventHandler>(); } else if constexpr (E == EventType::Keyboard) { return std::make_unique<KeyboardEventHandler>(); } else if constexpr (E == EventType::Resize) { return std::make_unique<ResizeEventHandler>(); } else { return nullptr; // Quit 走默认分支 } } // 编译期使用 auto handler = CreateEventHandler<EventType::Mouse>();if constexpr的特殊之处在于,没有被选中的分支不会被实例化。也就是说,当E == EventType::Mouse时,KeyboardEventHandler、ResizeEventHandler的代码根本不会被编译器生成,甚至不需要它们在当前编译单元里可见。这一点可以用来做"按平台裁剪"的工厂——Windows 分支里调用 Windows API,Linux 分支里调用 POSIX API,不会因编译平台不同而报错。
这种写法适合类型枚举不太多、逻辑相对固定的场景。它的性能仅次于直接new ConcreteHandler(),因为整个分发在编译期就完成了。
3.3 用类型列表和折叠表达式搭一个编译期注册表
如果觉得枚举加if constexpr分支多、维护麻烦,可以用类型列表来模拟"注册表"。思路是把所有可能的处理器类型写进一个std::tuple,然后在调用时通过索引来查询和创建:
template<typename Base, typename... Handlers> class CompileTimeFactory { public: template<size_t Idx> static std::unique_ptr<Base> CreateById() { using Handler = std::tuple_element_t<Idx, std::tuple<Handlers...>>; return std::make_unique<Handler>(); } template<typename HandlerT> static std::unique_ptr<Base> CreateTyped() { return std::make_unique<HandlerT>(); } }; // 使用方把处理器集合集中列出 using HandlerFactory = CompileTimeFactory<IEventHandler, MouseEventHandler, KeyboardEventHandler, ResizeEventHandler>; auto h1 = HandlerFactory::CreateById<0>(); // MouseEventHandler auto h2 = HandlerFactory::CreateTyped<KeyboardEventHandler>();这种做法的价值在于:新增一个处理器时,不需要改if constexpr的分支,只需要把新类型加到Handlers...列表里。所有创建逻辑由模板统一生成。配合类型萃取或者static_assert,还可以在编译期检查某个类型是否真的继承了IEventHandler,把接口约束也前置到编译阶段。
3.4 编译期工厂的边界条件
编译期工厂不是银弹。它最大的限制是:类型集合必须在编译期确定,没法从配置或者插件目录加载新类型。所以在实际工程里,我经常是两层混用:核心框架内建的类型用编译期工厂,保证性能和类型安全;运行期才会出现的第三方插件类型用注册表工厂,保证扩展性。
另外需要留意的是,模板代码会带来编译时间的增长。类型列表一旦膨胀到几十上百个类型,实例化成本和错误信息可读性都会下降。我的经验是,超过十个类型时,先想想是否真的需要全部模板化,还是拆几个运行期工厂更合适。
4. 多线程分配环境下的工厂实现与隐蔽陷阱
4.1 单例工厂的线程安全实现其实很简单
工厂模式一旦用在多线程程序里,第一个绕不开的问题就是单例怎么实现才安全。很多 C++ 老项目还在用双重检查锁定加裸指针的方式,这在 C++98 时代是没办法的办法,但在现代 C++ 里完全没有必要。
C++11 开始,函数内局部静态变量的初始化是线程安全的。编译器会自动加上某种同步机制保证只有一个线程能执行初始化代码。所以单例工厂最简洁的写法就是:
Factory<ILogger>& LoggerRegistry() { static Factory<ILogger> factory; return factory; }不要自己写static Factory<ILogger>*加互斥锁的双重检查。代码少、安全性高,而且局部静态变量的析构时机比全局静态变量更可控——它在第一次被调用时构造,在程序退出时按逆序析构。
4.2 注册和创建的并发策略怎么选
单例安全之后,紧接着的问题是注册表和创建动作之间的并发策略。
如果整个程序的注册都发生在启动阶段,运行期不再注册新类型,那最简单的方式就是启动时单线程注册,运行期所有Create都是只读操作,连shared_mutex都可以不锁。这也是我最推荐的默认策略,它把并发问题直接消灭在设计层面。
但如果确实有运行期动态插拔插件的需求,shared_mutex是可接受的方案。注册是少数操作,创建是高频操作,读写锁的代价大部分被读端共享,实际压力不大。上面的Factory模板已经用std::shared_mutex覆盖了这种场景。
再极端一点,如果Create本身就是热点中的热点,每秒百万次级别,那么任何锁都可能成为瓶颈。这时候可以用 copy-on-write 策略:维护一个不可变的快照,注册时创建新快照并原子替换指针,创建时直接读快照,完全不加锁。这是典型的高性能读、低延迟写的模式,但要小心快照创建时的内存开销和释放时机,一般需要配合引用计数。
4.3 工厂与对象池组合:提高大批量小对象的分配效率
工厂解决的问题不只是"创建谁",还可以解决"怎么高效创建"。当你的系统需要在短时间创建成千上万个生命周期极短的小对象时,make_shared的堆分配成本会变得很刺眼。这时候把工厂和对象池结合,让工厂负责"分配工作负载",对象池负责"复用内存",效果立竿见影。
简化版的思路是这样:
template<typename T> class ThreadLocalPool { public: T* Acquire() { if (!freeList_.empty()) { T* obj = freeList_.back(); freeList_.pop_back(); return obj; } return new T(); } void Release(T* obj) { freeList_.push_back(obj); } private: std::vector<T*> freeList_; }; template<typename T> class PooledCreator { public: std::shared_ptr<T> Create() { T* raw = GetPool().Acquire(); return std::shared_ptr<T>(raw, [this](T* obj) { GetPool().Release(obj); }); } private: ThreadLocalPool<T>& GetPool() { thread_local ThreadLocalPool<T> pool; return pool; } };核心在用thread_local为每个线程维护独立的对象池,避免了多线程访问池本身时的互斥。shared_ptr的删除器绑定池的Release操作,对象引用计数归零时不是直接delete,而是放回池里复用。
这个方案我实际跑过的经验是:线程数不超过 CPU 核数、对象体积不大、申请释放频率高的情况下,吞吐量确实有可观的提升。但代价是池里的空闲对象会占用内存不还给系统,需要设计好池的上限,防止内存在高峰之后一直不回落。
4.4 析构顺序与模块生命周期
多线程环境下工厂的另一个隐蔽坑是析构顺序。如果工厂本身是局部静态对象,而有些产品的析构函数里还会调用这个工厂(比如日志、性能统计),那么在程序退出阶段,很可能工厂已经被销毁,产品对象才尝试调用工厂接口,导致崩溃。
我在一个服务进程里遇到过:某个插件在卸载时,其内部对象的析构函数里去工厂查询配置项,结果退出阶段多个模块的析构顺序因链接顺序发生了变动,直接访问了已释放内存。排查到最后就是典型的静态析构顺序问题。解决思路是:不要依赖静态对象的析构时机,显式提供Shutdown()方法,在 main 函数结尾、其他模块停止使用之前手动调用;或者把工厂的生存期绑定到一个shared_ptr,让所有使用者用shared_ptr<Base>持有工厂依赖,靠引用计数决定它的销毁时机。
5. 从"堆 if-else"到分层工厂的实战重构记录
5.1 一个支付网关项目的工厂演化复盘
为了把前面这些理论串起来,我拿一个虚拟但完全符合常见实践的支付网关项目举个例子(脱敏处理)。最初的版本,支付逻辑全在一个下单服务里,根据支付方式字符串走 if-else 创建不同的网关客户端:
std::unique_ptr<IGatewayClient> client; if (method == "alipay_wap") { client = std::make_unique<AlipayWapClient>(cfg.alipayAppId, cfg.alipayPrivateKey); } else if (method == "alipay_app") { client = std::make_unique<AlipayAppClient>(cfg.alipayAppId, cfg.alipayPrivateKey); } else if (method == "wx_jsapi") { client = std::make_unique<WechatJsapiClient>(cfg.wxMchId, cfg.wxApiKey); } else if (method == "unionpay_gateway") { client = std::make_unique<UnionpayClient>(cfg.upMerId, cfg.upCertPath); } // ... 后续用 client 发起下单这个代码最大的问题,不是 if-else 太多,而是每当支付方式变化,都要改动下单流程这个核心文件。下单流程本身关心的只是"拿到一个能发起支付的客户端",它根本不应该知道具体支付方式的存在。这是典型的依赖倒置做得不到位。
5.2 重构后的结构:配置驱动的注册表加策略对象
重构的第一步是把网关客户端的创建逻辑从下单服务里抽走。我用前面讲的注册表工厂来承载类型管理,但把它藏在一个GatewayFactory门面类的后面:
class GatewayFactory { public: std::unique_ptr<IGatewayClient> Create(const PayConfig& config) const { auto type = config.paymentMethod + "_" + config.channel; auto client = internalFactory_.Create(type, config); if (!client) { throw std::runtime_error("unsupported payment method: " + type); } return client; } private: static Factory<IGatewayClient, std::string, const PayConfig&> internalFactory_; };门面类把"配置项解析"、"key 拼接"、"错误类型转换"收拢到一起,对外只暴露一个根据配置创建客户的接口。具体的AlipayWapClient则通过自动注册助手声明自己:
class AlipayWapClient : public IGatewayClient, private AutoRegister<IGatewayClient, AlipayWapClient, "alipay_wap"> { public: explicit AlipayWapClient(const PayConfig& cfg) { /* ... */ } };下单服务的调用从十几行 if-else 变成了两行:读配置、调工厂、拿客户端。以后新增一个银行渠道,不需要碰下单服务,不需要碰工厂代码,只需要新写一个类文件,并让配置中心能下发新类型名。这就是工厂模式高级应用在真实项目里的落点。
5.3 防止"工厂爆炸":什么时候不该继续加工厂
这个项目做到后期,团队里出现了一个新趋势:几乎每个新类都要包一层工厂,理由是"以后可能用到注册表机制"。结果代码结构变得极其繁琐,一个简单的new外面套了两三层抽象,排查问题的时候要在多个文件里跳来跳去。
我后来给自己定了一条纪律:在只有一个实现类的时候,绝不引入工厂。哪怕预期未来有第二个实现,也等第二个真正出现的时候再做抽象。根据我的观察,"未来可能需要"的空想抽象,八成在设计出来后没有用上,反而成了后来人理解的负担。判断一个场景是否需要工厂,就回到第一章那两条指标:类型是否会在编译期之外扩展,构造过程是否有跨类型的共性。两条都不占,直接std::make_unique<T>()就是最诚实的设计。
5.4 工厂的测试策略
工厂代码通常不长,但它是很多模块的入口,值得单独做一轮测试。我的测试习惯是分三层。
第一层是注册与创建的完整性测试。用 mock 类型注册到工厂,验证注册后能创建出正确的类型、重复注册同 key 有没有告警、未知 key 的返回行为是否符合约定。
第二层是并发测试。对于实际会用到的工厂,开十几个线程并发调用Create,同时预留一个线程做周期性注册和注销,跑一段时间观察有没有崩溃或数据竞争。shared_mutex这种方案在低并发下没问题,高并发下的表现需要测试数据支撑。
第三层是集成测试。验证真实产品类型能在动态初始化阶段完成自动注册,并且Create出来的对象在完整业务链路里工作正常。这一步特别重要,因为自动注册依赖静态初始化时机,单纯做单元测试时容易因为测试环境的初始化方式不同而漏掉这类问题。
最后分享一点实际体会
工厂模式用到现在,我自己最大的感受是:高级应用不在于代码能写得多花哨,而在于能清楚地知道每一步的取舍。注册表工厂带来了运行期扩展的便利,代价是类型安全从编译期转移到了运行期,所以 key 的命名、unknown 情况的处理、日志输出这些细节都必须比普通代码更严谨。编译期工厂性能最好,但它只能服务那些类型集合固定的场景,硬把它套在需要动态扩展的系统里就是自找麻烦。多线程环境下,闭锁再简单也不如从设计上避免并发奇怪。
一个小技巧收尾:如果你用注册表工厂,调试插件加载不了或者创建失败的问题时,最省事的办法是在Create方法打日志时把当前已注册的所有 key 一并打印出来(debug 级别即可)。很多时候不是创建逻辑有问题,而是某个插件的自动注册根本没被执行,看到实际注册表里有哪些 key,定位速度会快很多。