ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

C++工厂模式高级应用:注册表、CRTP与并发安全实践

2026/10/7 17:44:17 拓冰建站 浏览量
C++工厂模式高级应用:注册表、CRTP与并发安全实践 前阵子重构一个跨平台消息网关产品类型从三种涨到二十三种工厂模式几乎成了我每天都要碰的东西。最开始我也以为 C 的工厂模式就是把 switch-case 换了个皮但真正把工厂模式用到高级阶段之后才发现它解决的不只是“怎么创建对象”而是“怎么让创建这个动作变得可扩展、可测试、可治理”。这篇文章把我这几年在 C 里做工厂模式高级应用的经验整理出来适合正在做插件化架构、中间件、游戏服务端或大型业务系统的同学参考。工厂模式这个东西网上讲基础版的文章很多但大多数停留在“用多态取代 switch”这个层面。而实际项目里遇到的问题要复杂得多产品类型可能来自动态库、构造参数可能有差异、对象有生命周期、注册时机有先后、多线程还要考虑并发安全。这些才是工厂模式高级应用真正要处理的事情。下面我按自己踩过的坑和最终落地的方案一层层拆开讲。1. 工厂模式的高级形态到底在解决什么1.1 简单工厂的极限当 switch 成为维护噪音很多项目刚开始都是这么写工厂的enum class ProductType { A, B, C }; class Product { public: virtual ~Product() default; virtual void Run() 0; }; class ProductA : public Product { public: void Run() override { /* ... */ } }; class ProductB : public Product { public: void Run() override { /* ... */ } }; std::unique_ptrProduct CreateProduct(ProductType type) { switch (type) { case ProductType::A: return std::make_uniqueProductA(); case ProductType::B: return std::make_uniqueProductB(); default: return nullptr; } }这种写法在产品类型少的时候完全够用甚至可以说是最直白的方案。但等类型多起来问题就明显了每新增一种类型都要回来改CreateProduct这个函数。改的人可能是另一个团队改完还要重新编译整个模块甚至产生合并冲突。所有产品的创建逻辑集中在一个函数里几百行的 switch 很容易漏掉default分支而且排查问题时一眼扫过去很痛苦。这种工厂天然拒绝了“由业务方自己注册产品”的模式。业务模块想加一种新的Product它没法自己“报名”只能等工厂所在的核心模块为它开一个口子。不同产品的构造函数签名如果不一样这种集中式工厂就会越写越复杂。你可能会被迫把所有构造参数塞进一个Config结构体或者干脆用if加dynamic_cast处理特例。简单工厂不是不好它只是在规模变大后到了自己的极限。所谓“高级应用”本质上就是围绕这几个痛点做文章。1.2 高级工厂要跨越的四道坎我自己的体会是想把工厂模式真正用活必须同时考虑四件事第一是注册的分散化。理想状态下新增一个产品类型不需要改动公共代码。产品自己提供一个“创建回调”然后注册到工厂里。工厂只负责维护一张映射表不关心具体类型是什么。这样一来新增功能变成了“添加一个文件 一行注册”核心模块完全不用动。第二是构造参数的差异。真实项目里产品 A 可能需要一个数据库连接产品 B 可能只需要一个配置项产品 C 可能什么都不需要。工厂如果只用统一的Create()签名这些差异就会被强行抹平造成接口污染。高级工厂会用“创建函数参数包”或“工厂入参上下文”来解决而不是简单粗暴地规定所有构造函数必须长一个样。第三是生命周期和所有权。C 没有 GC谁创建谁释放这个规则在工厂场景里必须想清楚。返回值用裸指针还是智能指针如果对象要被多个模块共享用shared_ptr还是weak_ptr如果容器内部要缓存单例什么时候销毁这些都是工厂应用里最容易出 bug 的地方。第四是并发和动态能力。插件系统会在运行期加载动态库加载时可能并发注册。主业务线程可能同时在疯狂创建对象。注册表是否需要加锁用什么锁这些如果不提前设计上线后压力一上来就会出现偶发崩溃。把这四道坎迈过去工厂模式才算真正进入了“高级应用”的领域。2. 注册表工厂让创建逻辑从“编译期集中”走向“运行期分散”2.1 用 std::function 实现最小注册表我第一次把工厂改成注册表形式时代码量不算大但结构一下子清楚了很多。核心就是一个映射表键是产品名值是一个能创建产品的函数对象#include functional #include memory #include unordered_map #include string #include string_view class ProductFactory { public: using Creator std::functionstd::unique_ptrProduct(); void Register(std::string_view name, Creator creator) { auto key std::string(name); creators_[key] std::move(creator); } std::unique_ptrProduct Create(std::string_view name) const { auto it creators_.find(std::string(name)); if (it creators_.end()) { return nullptr; } return it-second(); } private: std::unordered_mapstd::string, Creator creators_; };Register接受一个字符串键和一个创建回调Create根据字符串键找到回调并执行。这样新增产品类型时可以完全绕开工厂本身void RegisterProductA(ProductFactory factory) { factory.Register(product_a, []() - std::unique_ptrProduct { return std::make_uniqueProductA(); }); }这段代码的好处是“闭包即工厂”。你可以在 lambda 里捕获设备上下文、配置对象、日志指针等相当于把产品构造时的复杂依赖在注册点就封装好了。工厂不再需要知道ProductA的构造函数长什么样。实际使用中我更推荐把注册和创建分开在两个类里一个ProductRegistry负责维护映射表一个ProductFactory只是Registry门面的创建接口。这样如果以后要做配置驱动的插件系统Registry可以直接序列化映射关系工厂只负责触发创建。2.2 静态注册里的初始化顺序陷阱与规避注册表工厂一旦配合静态变量做自动注册就会遇到一个经典陷阱跨编译单元的静态初始化顺序问题。比如你在某个.cpp文件里定义了一个静态注册变量// product_a.cpp namespace { bool registered [] { ProductFactory::Instance().Register(product_a, [] { return std::make_uniqueProductA(); }); return true; }(); }这段代码看着没问题但它依赖一个不成立的假设ProductFactory::Instance()返回的单例对象必须先于这个匿名 namespace 的静态变量被初始化。如果product_a.cpp的静态初始化先运行而ProductFactory还没构造那这里就会访问未初始化的对象程序直接崩溃。规避办法是让单例采用“函数内静态局部变量”。C11 之后函数内静态变量的初始化是线程安全的而且发生在第一次调用时。也就是说Instance()只会在第一次被真正调用时才构造工厂对象静态注册变量调用它时无论谁先谁后都会得到同一个完整实例class ProductFactory { public: static ProductFactory Instance() { static ProductFactory instance; return instance; } // ... };用这个模式之后静态注册就安全了。标准库保证首次调用Instance()时完成初始化之后的调用直接拿引用。这时候静态注册变量是早是晚就不重要了反正它触发的那一瞬间单例必然已经构造完成。还有一点值得提醒静态注册变量最好不要依赖其它模块的静态对象。比如注册 lambda 里捕获了某个全局Config对象而Config本身也是静态初始化的那么仍然存在顺序风险。最稳的做法是让注册回调“懒加载”所有依赖需要使用时再取。2.3 实际落地案例协议工厂/插件式编解码器注册表工厂在协议栈里特别好用。我之前做网关时每种报文格式对应一个编解码器协议号从 0x01 到 0x30 不等。如果写一个巨型 switch每次加协议都要改核心文件还要走一轮全员评审。改成注册表之后每个协议实现模块自己负责注册class Decoder { public: virtual ~Decoder() default; virtual std::string Decode(const std::string raw) 0; }; class LengthDecoder : public Decoder { public: std::string Decode(const std::string raw) override { /* ... */ } }; // 模块加载入口 void RegisterLengthDecoder(ProductFactoryDecoder factory) { factory.Register(length, []() - std::unique_ptrDecoder { return std::make_uniqueLengthDecoder(); }); }消息入口只要知道协议号和工厂auto decoder factory.Create(protocol_name); if (decoder) { auto result decoder-Decode(raw_bytes); }这样做以后新增协议不需要改动已有代码只需要在模块列表里加一个注册入口。如果将来协议放在动态库里插件加载器在dlopen之后调用一个统一符号RegisterDecoders(factory)整个过程会更干净。注册表工厂把“产品类型”和“创建行为”完全解耦这是它相比 switch 工厂最大的价值。3. 泛型与 CRTP把注册动作藏进模板让派生类“自动报名”3.1 CRTP 自动注册的骨架注册表工厂用起来已经很顺手但每次都要手写注册函数还是有点繁琐。尤其是产品类特别多的时候每个类都要写一个 lambda样板代码依然可观。这时可以用 CRTP奇异递归模板模式把注册动作直接嵌进基类模板让派生类定义出来就自动注册。一个常出现的写法是这样template typename Derived, typename Base class AutoRegister { public: virtual ~AutoRegister() default; protected: AutoRegister() { Base::Factory().Register(Derived::Key(), []() - std::unique_ptrBase { return std::make_uniqueDerived(); }); } };然后产品类继承class ProductA : public Product, private AutoRegisterProductA, Product { public: static std::string Key() { return product_a; } void Run() override { /* ... */ } };这个写法的问题在于注册动作发生在ProductA的构造函数里。如果某个模块在构造函数里又触发了Create(product_a)就会拿到一个构造到一半的对象行为不可预期。比较稳的做法是把注册从构造函数里挪出来放到一个独立的静态注册器里template typename Derived, typename Base class AutoRegister { struct Registrar { Registrar() { Base::Factory().Register(Derived::Key(), []() - std::unique_ptrBase { return std::make_uniqueDerived(); }); } }; public: virtual ~AutoRegister() default; private: static inline Registrar registrar_{}; };这里用到 C17 的inline变量。static inline Registrar registrar_{};保证了每个模板实例化只有一个注册器而且会在程序启动阶段、任何用户代码执行之前初始化。ProductA只要继承AutoRegisterProductA, Product就会自动触发注册。这个方案的妙处在于派生类什么都没做只靠类型信息就完成了“报名”。新增产品时只需要写类本身不需要额外维护一张注册表清单。对规模很大的业务系统来说省掉的样板代码和减少的遗漏概率相当可观。3.2 泛型工厂与抽象接口如何组合有了 CRTP 自动注册之后工厂可以进一步泛型化。比如我们不再只针对一个Product接口而是同时管理Decoder、Encoder、Handler等多种接口。每个接口都有自己的工厂。最简单的方式是给每种接口定义自己的FactoryRegistry然后让对应的派生类使用对应的AutoRegisterusing DecoderFactory RegistryDecoder; using EncoderFactory RegistryEncoder; class LengthDecoder : public Decoder , private AutoRegisterLengthDecoder, Decoder { public: static std::string Key() { return length; } std::string Decode(const std::string raw) override { /* ... */ } };RegistryT内部只需要一个std::unordered_mapstd::string, std::functionstd::unique_ptrT()模板参数决定返回类型。这样不同接口之间互不干扰每个产品家族都有自己的名字空间。如果产品种类非常多还可以给AutoRegister加一个单独的“注册中心”参数比如template typename Derived, typename Base, typename Registry RegistryBase class AutoRegister;但这样模板参数列表会变长对使用者不友好。我实际项目里通常一个接口配套一个工厂AutoRegister默认使用该接口对应的注册中心就够了。原则是模板参数尽量少减少调用方的认知负担。3.3 注册表工厂和 CRTP 怎么选择很多同学会问既然 CRTP 能自动注册为什么还要保留手写注册表我的经验是两者并不是替代关系它们适合不同场景对比维度手写注册表工厂CRTP 自动注册样板代码每个类型都要写注册函数类定义即可零额外代码可见性注册逻辑集中在模块入口容易审查注册逻辑散落在各类型定义中灵活性可以动态注册、条件注册、取消注册基本是编译期/启动期固定注册调试难度断点好下注册顺序直观需要熟悉模板展开机制适用场景插件系统、动态配置、运行时扩展静态产品家族、框架内置类型如果是团队里新人比较多我建议先用手写注册表把所有注册点放在一个文件里代码 review 时一目了然。等大家熟悉了再逐步用 CRTP 收敛样板代码。不要为了炫技一上来就模板套模板那会让调试成本直线上升。4. 工厂模式与依赖注入容器从“对象创建”升级为“依赖装配”4.1 一个可用的最小 DI 容器工厂模式再往前走一步就和依赖注入DI走到了一起。注册表工厂本质上维护的是“一个类型对应一个创建函数”而 DI 容器把这张表泛化成了“任意接口对应一个实现工厂”。换句话说DI 容器是一个更通用的工厂。一个最小可用的 DI 容器是这样的#include any #include functional #include memory #include typeindex #include unordered_map class DIContainer { public: template typename T, typename F void Register(F factory) { std::type_index key(typeid(T)); factories_[key] [factory std::forwardF(factory)]() - std::any { return std::shared_ptrT(factory()); }; } template typename T std::shared_ptrT Resolve() { std::type_index key(typeid(T)); auto it factories_.find(key); if (it factories_.end()) { return nullptr; } return std::any_caststd::shared_ptrT(it-second()); } private: std::unordered_mapstd::type_index, std::functionstd::any() factories_; };这里RegisterICache([] { return std::make_sharedRedisCache(); });就是注册一个工厂ResolveICache()就是创建并返回实现。std::any在 C17 里用来做类型擦除把不同类型的shared_ptr统一存储解析时再转回真实类型。这个容器本质上就是一个“带类型键的注册表工厂”。它和普通工厂的差别在于普通工厂的键是字符串且返回值类型固定是某个产品基类DI 容器的键是 C 类型返回值类型是模板参数指定的任意类型。类型键相比字符串键有个天然优势接口改名时编译期就能发现不匹配而不是等运行期找不到创建函数才暴露。4.2 构造函数依赖的自动装配思路与简化实现完整的 DI 容器会自动分析构造函数参数并在容器里递归解析这些依赖。C 要做到这一步需要用到模板元编程来展开构造函数的形参类型代码量相当大而且编译速度会明显变慢。我个人的建议是除非团队里有元编程经验的人否则不要为了“全自动装配”硬上重型方案。更实用的做法是注册时把依赖关系显式写进 lambdaclass Logger { public: virtual ~Logger() default; virtual void Log(const std::string msg) 0; }; class ConsoleLogger : public Logger { public: void Log(const std::string msg) override { /* ... */ } }; class ServiceA { public: ServiceA(std::shared_ptrLogger logger) : logger_(std::move(logger)) {} private: std::shared_ptrLogger logger_; };注册时DIContainer container; container.RegisterLogger([] { return std::make_sharedConsoleLogger(); }); container.RegisterServiceA([container] { return std::make_sharedServiceA(container.ResolveLogger()); });ServiceA的工厂 lambda 在创建对象时主动从容器里拿Logger构造依赖在这里被“装配”起来。这个写法比起全自动解析只多写了半行代码但可读性和可调试性都高得多。万一ServiceA构造出错你能精确看到是哪个依赖没有注册。如果确实需要更自动的一方可以给容器增加一个CreateWithArgs辅助接口把显式参数传进去其它依赖由容器补齐。本质上还是人肉装配只是把顺序和集中管理交给了容器。4.3 生命周期管理单例、瞬时与作用域工厂和容器合流之后生命周期管理就必须提上日程。常见的生命周期有三种Transient瞬时每次Resolve都创建一个新实例适合无状态服务、轻量对象。Singleton单例整个进程共享一个实例适合配置管理、连接池。Scoped作用域一次请求或一个线程内共享适合数据库事务等场景。实现很简单容器内部为单例类型保存一个已创建实例template typename T std::shared_ptrT ResolveSingleton() { std::type_index key(typeid(T)); auto it singletons_.find(key); if (it ! singletons_.end()) { return std::any_caststd::shared_ptrT(it-second); } auto instance ResolveT(); if (instance) { singletons_[key] instance; } return instance; }注意这里singletons_通常要加锁因为可能多个线程同时走到“没命中单例”分支。我一般用std::mutex简单保护或者干脆把所有单例在启动阶段一次性初始化运行期不再创建。后者更稳也更容易排查。5. 并发环境下的工厂加锁、无锁与“别抢同一把锁”5.1 读多写少的注册表该用什么锁工厂注册表的一个典型特征是注册操作集中在启动阶段创建操作是日常高频调用。也就是说写少读多。这种场景用std::shared_mutex比普通独占锁好得多。class ThreadSafeFactory { public: void Register(std::string_view name, Creator creator) { std::unique_lock lock(mutex_); creators_[std::string(name)] std::move(creator); } std::unique_ptrProduct Create(std::string_view name) const { std::shared_lock lock(mutex_); auto it creators_.find(std::string(name)); if (it creators_.end()) { return nullptr; } return it-second(); } private: mutable std::shared_mutex mutex_; std::unordered_mapstd::string, Creator creators_; };Register用独占锁Create用共享锁。多个线程可以同时调用Create只有注册时要独占。这个改动对并发提升非常明显。我测试过一个有 300 个类型、每秒创建上万次对象的服务从普通std::mutex换成shared_mutex后吞吐大概提高了两成。但要注意如果Create里的产品构造函数本身很耗时锁的优化就很不明显。因为shared_lock会在创建对象期间一直持有读锁其它线程注册仍然被阻塞。一个更极致的设计是先在锁内查找到创建回调把回调拷贝出来然后释放锁在锁外执行回调std::unique_ptrProduct Create(std::string_view name) const { Creator creator; { std::shared_lock lock(mutex_); auto it creators_.find(std::string(name)); if (it creators_.end()) { return nullptr; } creator it-second; // 拷贝 std::function } return creator(); // 锁外创建降低临界区 }这种做法把临界区从“查表 构造对象”缩小到“只查表”。拷贝std::function有一点点开销但远小于把整个构造过程放进锁里。这个方法很值得推荐。5.2 多线程环境下的注册动作与 call_once/单例模式如果注册动作发生在多个线程并发加载插件时Register本身要有并发保护。有的项目会在加载插件前统一加一个“配置锁”保证同一时间只有一个线程在注册。这个方案最简单也最容易推理。如果不想引入注册专用锁可以给Register加一个std::once_flag的变体确保某个模块只注册一次class PluginRegistrar { public: void RegisterOnce(std::string_view name, Creator creator) { std::call_once(flag_, [] { factory_.Register(name, std::move(creator)); }); } private: std::once_flag flag_; ThreadSafeFactory factory_; };单例工厂本身的实现也值得提一下。C11 之后用函数内静态局部变量实现单例是线程安全的static ThreadSafeFactory GetFactory() { static ThreadSafeFactory instance; return instance; }标准库保证这个静态变量只初始化一次且初始化过程是线程安全的。不要用std::shared_ptr加make_shared的方式手写单例那容易写出双重检查锁的 bug。函数内静态局部变量简单、可靠是工厂单例的首选。5.3 性能取舍不要为了“无锁”而无锁网上有不少讲无锁工厂的文章用原子变量维护版本号或者用 RCU 思想做注册表副本。级别确实很高但绝大多数项目用不上。我自己的判断标准是如果你的注册表大小在启动后基本不再变化完全可以在启动阶段构建一张不可变的std::unordered_map运行期只读不加锁。这比任何无锁算法都简单而且性能更好。实际操作中我会这样做启动阶段单线程加载全部插件写满unordered_map。加载完成后调用BuildReadOnly()把unordered_map的内部缓冲区标记为“只读”后续只执行find。如果确实需要动态注册才引入shared_mutex。很多“并发性能差”的担忧其实都源于把只读数据当成了热点写数据来处理。先想清楚注册表是不是真的要在运行期动态变化很多时候答案是不需要。那时连shared_mutex都可以省掉直接用普通只读map多个线程并发find也没问题。6. 工厂模式落地时的测试、调试与工程避坑6.1 用注入点做单元测试工厂模式带来的一个直接好处是测试变得容易了。以前业务代码里直接new一个具体类测试时很难替换成 mock。有了工厂之后被测代码依赖的是产品和工厂接口测试代码可以注册 mock 产品class MockProduct : public Product { public: void Run() override { calls_; } int calls_ 0; }; TEST(ServiceTest, RunProductViaFactory) { ProductFactory factory; factory.Register(mock, [] { return std::make_uniqueMockProduct(); }); Service service(factory); service.DoSomething(mock); // 断言 mock 被调用 }工厂在这里变成了依赖注入的关口。产品怎么创建、由谁创建都被统一入口管理起来测试只需要换注册内容就能让业务代码和真实实现解耦。这个模式在写集成测试时也很有用。比如某些产品依赖外部硬件测试环境里没有硬件那就注册一个“虚拟产品”返回模拟数据。整个流程不需要改业务代码只要在测试初始化时往工厂里塞不同的实现。6.2 我踩过的几个坑裸指针、循环依赖、注册冲突工厂模式看起来简单落地时还是有不少坑。说几个我实际遇到过的。裸指针的坑。早期我在工厂里返回Product*调用方用完了必须自己delete。后来产品被投递到线程池里处理谁负责释放就说不清楚了。内存泄漏、重复释放、悬空指针全遇到过。后来统一改成std::unique_ptr作为返回值工厂创建的所有权方向明确调用方拿到后可以继续转移给shared_ptr或业务容器。对于需要共享的场景再显式封装成shared_ptr返回。循环依赖的坑。在 DI 容器场景里如果ServiceA依赖ServiceBServiceB又依赖ServiceA直接用构造函数注入就会爆栈。我当时的解决方案是把其中一个依赖改成 setter 注入或者用std::weak_ptr打破强引用环。工厂的注册 lambda 里也要注意不要在构造时就把容器里的所有单例都拉起来否则很容易隐式形成环。注册冲突的坑。两个模块恰好在写插件时不小心注册了同一个名字后注册的会覆盖先注册的。早期没有检查结果线上出现“为什么协议 0x12 的行为突然变了”这种诡异 bug。现在我在Register里默认检测靠冲突bool Register(std::string_view name, Creator creator) { auto key std::string(name); if (creators_.find(key) ! creators_.end()) { return false; // 或者打日志做合并策略 } creators_[key] std::move(creator); return true; }如果业务上允许覆盖也可以把返回值当“是否新增”的信号由调用方决定要不要告警。6.3 一个更稳的落地姿势工厂模式在我手上的最佳实践可以总结成几条硬规矩统一返回智能指针大部分情况返回unique_ptr共享再转shared_ptr。注册表键只用稳定字符串不要用枚举和字符串混着来。既然走注册表了就别再用编译期枚举限制它。注册点集中管理。哪怕是 CRTP 自动注册也要保持一张“所有注册列表”的说明文档方便 review 和排错。注册时检查冲突创建时提供“找不到产品”的明确错误信息。不要返回nullptr就让上层猜原因。工厂对象一律单例或显式传递。全局裸单例容易隐藏依赖关系也可以把工厂实例作为构造函数参数传进去测试时替换更灵活。这些规则不一定适合所有项目但如果你在设计新模块按这个方向走大概率能少踩不少坑。我自己在实际操作中的体会是工厂模式的高级应用不在于用了多少模板技巧而在于把“创建对象”这件事当成一条独立的架构边界来治理。注册表、CRTP、依赖注入容器、并发锁策略都只是这条边界上的具体工具。真正有价值的是你给工厂设定好清晰的契约什么时候注册、怎么创建、谁来释放、冲突了怎么办。把这些定清楚工厂模式就不只是代码设计题而是能让整个项目越迭代越顺的工程底座。