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