ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++工厂模式进阶:注册表、抽象工厂与生命周期管理实践

C++工厂模式进阶:注册表、抽象工厂与生命周期管理实践 做C后端这些年工厂模式是我在项目里见到频率最高、但也用得最糙的设计模式。很多工程起步时很简单一个if-else或者switch就能搞定对象创建等产品迭代半年代码里就长出了一堆分支判断每加一种消息类型、协议或者组件就要改分发函数、补case、加头文件还得小心翼翼不碰坏别人刚提交的逻辑。这篇文章不打算从什么是工厂模式这种教科书定义讲起而是把C工程里真正能用上的工厂模式进阶玩法串一遍泛型注册表工厂、抽象工厂和依赖注入怎么结合、工厂返回的生命周期语义怎么设计、以及编译期工厂和运行期工厂怎么取舍最后用一个可扩展的消息分发系统把前面这些点落进一个完整项目。适合那些已经会用工厂模式、但想在真实工程里把它用得干净、可扩展、可测试的读者。1. 简单工厂到抽象工厂先把类型爆炸这个病根说清楚想聊高级应用得先搞清楚工厂模式到底在解决什么问题。很多人是把工厂当成一个static方法返回基类指针来用的这没问题但如果你不理解它背后那层扩展点逻辑代码写着写着就会变成新的混乱源。我在重构过的项目里最常见的情况是有人用简单工厂但路由逻辑全集中在一个函数里最后这个函数比小说章节还长。1.1 简单工厂、工厂方法、抽象工厂的核心差异基础三件套看起来都是帮你创建一个对象但它们的扩展策略完全不同。我习惯用一张表来区分工厂形式创建策略新增产品时的改动典型问题简单工厂一个静态函数集中创建修改工厂函数加分支违反开闭原则函数越来越臃肿工厂方法基类定义纯虚函数子类各自实现创建新增产品子类 新增对应的工厂子类产品一多工厂类也跟着膨胀抽象工厂一个接口里定义一组创建方法接口新增方法所有实现都要改产品族扩展时接口侵入大简单工厂适合产品类型数量少、且不太会变的场景比如一个日志系统里创建文件日志或控制台日志。工厂方法适合每个产品确实拥有独立初始化逻辑、又不希望调用方依赖具体类型的场景。抽象工厂则适用于产品有族概念的场景比如数据库访问层需要同时创建Connection、Statement、Transaction这时候三个create方法必须成套出现。但真实项目演进的路径根本不是教科书规划好的。往往一开始是简单工厂后来加了十几个类型工厂函数里一堆if再后来有人用工厂方法重构结果工厂类数量翻倍编译时间变长文件数量暴增。走到这一步才轮到高级玩法出场。1.2 类型爆炸的真实代价改老代码的隐形成本我一直认为每加一个类型就要改三处老代码这件事本身就是技术债的警报。第一改动点是在工厂函数里加分支第二改动点是在分发逻辑里加类型判断第三改动点是要把头文件include进工厂文件。这三个点每一个都意味着改代码的人和写老代码的人可能出现冲突测试要重新回归代码评审要仔细确认加分支时没影响已有行为。更隐蔽的成本是心智负担。工厂函数一旦被很多人改过团队对新成员讲解时就得说你在这个函数里找地方加一行这行分支放在哪里完全没有规则完全靠惯例。一个曾经接入过这种系统的同事跟我说他每次加完case都担心漏了某个隐蔽的else因为真实代码根本不像书里那么整齐。所以高级工厂设计的第一个原则就是把新增类型从修改已有代码变成新增一个独立文件这就是注册表模式背后的根本动机。我们追求的是一种登记制的扩展体验新类型自己报到而不是让老代码去认识它。1.3 为什么我排除了用switchif暴力演进的路线有时候最直接的方案不是坏的switch在类型很少时性能最好、代码也直观。但类型数量增长到一定程度这种方案会同时踩三个坑。一个是可读性每个case后面跟几十行构造参数读者很难聚焦出这个case到底和别的case有什么不同。另一个是编译耦合工厂文件include了所有具体产品头文件哪怕只是改其中一个产品的内部实现所有依赖工厂的编译单元都要重新编译。第三个是测试不方便如果一个case的分支逻辑很复杂你没法单独替身掉某个具体产品只能连着工厂函数一起测。我在实际项目里的经验是当case数量超过十个、或者产品类型可能会由外部插件继续扩展时就该立刻换成注册表方案不要等它变成二十个再回头还债。注册表方案会引入一点额外复杂度但换来的是扩展点在编译层面彻底解耦。2. 泛型注册表工厂把新增逻辑做成登记制而不是改旧代码注册表工厂的核心思路很简单用一个map把类型标识符映射到创建函数创建函数通常是一个lambda表达式。新类型要接入时只需要注册一个lambda进去工厂本身不用改。这个东西听上去不复杂但要在C里做得顺手有几个细节值得好好打磨。2.1 模板注册表的核心骨架先看一个最简版本#include functional #include memory #include string #include unordered_map #include stdexcept template typename Base class Factory { public: using Creator std::functionstd::unique_ptrBase(); template typename Derived void registerType(const std::string name) { creators_[name] []() - std::unique_ptrBase { return std::make_uniqueDerived(); }; } std::unique_ptrBase create(const std::string name) const { auto it creators_.find(name); if (it creators_.end()) { throw std::runtime_error(unknown type: name); } return it-second(); } private: std::unordered_mapstd::string, Creator creators_; };使用的时候调用方只需要拿到一个Factory实例注册几种类型然后就能按名字创建class Message { public: virtual ~Message() default; virtual void process() 0; }; class PingMessage : public Message { public: void process() override { /* ... */ } }; int main() { FactoryMessage factory; factory.registerTypePingMessage(PingMessage); auto msg factory.create(PingMessage); msg-process(); }这段代码里真正值得注意的点有两个。第一个是std::function的通用性任何可调用对象都能作为Creator不一定是lambda函数指针、绑定了状态的std::bind都行这让工厂在后续接对象池、接反射都是可能的。第二个是返回类型直接是std::unique_ptrBase从工厂层面就明确了所有权归属调用方拿到对象后不用担心什么时候该delete这个后面专门展开说。2.2 可变参数模板让工厂适配不同构造签名最简版本有个明显的短板所有Derived类型必须能用无参构造函数创建。但真实产品类常常需要构造参数比如消息处理器要持有配置对象数据库连接要传入连接串。一个可靠的做法是让工厂支持参数包template typename Base, typename... CreateArgs class ParameterizedFactory { public: using Creator std::functionstd::unique_ptrBase(CreateArgs...); template typename Derived void registerType(const std::string name) { creators_[name] [](CreateArgs... args) - std::unique_ptrBase { if constexpr (std::is_constructible_vDerived, CreateArgs...) { return std::make_uniqueDerived(std::forwardCreateArgs(args)...); } else { static_assert(std::is_constructible_vDerived, CreateArgs..., registered type cannot be constructed with given args); return nullptr; } }; } std::unique_ptrBase create(const std::string name, CreateArgs... args) const { auto it creators_.find(name); if (it creators_.end()) { throw std::runtime_error(unknown type: name); } return it-second(std::forwardCreateArgs(args)...); } private: std::unordered_mapstd::string, Creator creators_; };这里用到了C17的if constexpr在编译期判断Derived是否可以从参数包构造。用std::is_constructible_v做检查如果某个Derived类型不能用统一参数构造就触发static_assert把错误提前到编译期而不是运行时才抛出构造失败。不过这里有一个细节要注意static_assert在if constexpr的else分支里只有当该分支被实例化时才会触发。如果Derived能构造不会实例化else分支所以不会误伤。这个技巧我在好几个项目里用过真正解决了一组类型构造参数不一致的痛点。2.3 注册宏与静态初始化顺序的坑注册表方案要真正好用得让注册这件事能够自动发生否则调用方还得记得在main函数里手动调用registerType。常用的做法是定义注册宏通过全局对象的构造函数完成静态注册#define REGISTER_FACTORY(FactoryType, BaseType, DerivedType) \ namespace { \ const bool reg_##DerivedType ::RegisterHelperFactoryType, BaseType, DerivedType(#DerivedType); \ }但全局对象在main之前执行这里有个经典陷阱如果Factory实例本身也是全局对象它可能在注册动作执行之前还没构造好这就是静态初始化顺序问题。我踩过一次很深的坑两个源文件各自声明了全局工厂和全局注册对象链接顺序一调整程序启动就崩溃定位了大半天。解决方案是让工厂实例使用函数内局部静态对象也就是Meyers Singletontemplate typename Base FactoryBase defaultFactory() { static FactoryBase factory; return factory; }C11之后函数内局部静态对象的初始化是线程安全的。注册动作发生在main之前的单线程阶段首次调用defaultFactory()时构造工厂注册对象把自己的类型写进map顺序天然可控。跨编译单元的注册顺序虽然不确定但注册本身只是往map里插键值顺序不影响结果所以这个方案安全可靠。2.4 注册表的线程安全main之前注册运行期读取工厂注册好之后运行期创建对象往往是多线程并发的。如果工厂只提供注册和创建而注册只发生在单线程启动阶段那么生产环境只需要保证create的并发读安全即可。unordered_map的find在并发条件下只读是没问题的但如果插件系统允许运行期动态注册类型就必须给注册和查找加锁或者用读写锁。我通常采用一个简单的分层设计核心代码通过defaultFactory()拿到工厂并调用create插件在加载时注册注册函数内部用std::mutex保护map。读多写少的场景用std::shared_mutex更合适但要注意create返回的unique_ptr在锁外构造会更好不要把对象构造放进锁的临界区否则所有线程创建对象都会互相阻塞。3. 抽象工厂与依赖注入融合产品族不该让业务代码来拼装泛型注册表解决的是一个类型对应一个创建入口的问题但真实系统里常常需要成套创建相关对象。这时候抽象工厂依然有它的价值只是不能简单套教材写法要和依赖注入结合起来用。3.1 产品族接口如何切分才算稳定先举个例子。一个跨数据库的项目需要支持MySQL、PostgreSQL、SQLite三种数据库业务代码希望不直接依赖具体数据库实现于是我们抽象出三个产品接口Connection、Statement、Transaction。class Connection { public: virtual ~Connection() default; virtual void connect(const std::string dsn) 0; }; class Statement { public: virtual ~Statement() default; virtual void execute(const std::string sql) 0; }; class Transaction { public: virtual ~Transaction() default; virtual void begin() 0; virtual void commit() 0; virtual void rollback() 0; };这三个接口的粒度切分是否合理直接决定了抽象工厂能不能用。如果接口切得太细比如把Connection再拆成Connectable和Closeable产品族关系就会变得松散工厂维护成本飙升。我自己的经验是产品族接口应该和业务场景一一对应而不是跟着数据库供应商的API走。业务需要连接-执行-事务三件套接口就是这三个多一个都不加。3.2 把工厂接口注入业务对象抽象工厂的经典形式是定义一个虚接口下面挂各个数据库的具体工厂class DatabaseFactory { public: virtual ~DatabaseFactory() default; virtual std::unique_ptrConnection createConnection() 0; virtual std::unique_ptrStatement createStatement() 0; virtual std::unique_ptrTransaction createTransaction() 0; }; class MySqlFactory : public DatabaseFactory { public: std::unique_ptrConnection createConnection() override { return std::make_uniqueMySqlConnection(); } std::unique_ptrStatement createStatement() override { return std::make_uniqueMySqlStatement(); } std::unique_ptrTransaction createTransaction() override { return std::make_uniqueMySqlTransaction(); } };这里真正的进阶点不是这些实现而是业务对象如何拿到工厂。教科书里经常说客户端持有一个工厂引用但客户端到底从哪里获得工厂直接在构造函数里new一个MySqlFactory等于还是在硬编码具体实现。推荐的做法是把工厂作为依赖注入到业务对象中class ReportGenerator { public: explicit ReportGenerator(std::shared_ptrDatabaseFactory dbFactory) : dbFactory_(std::move(dbFactory)) {} void generate() { auto conn dbFactory_-createConnection(); auto stmt dbFactory_-createStatement(); // ... } private: std::shared_ptrDatabaseFactory dbFactory_; };ReportGenerator完全不知道数据库厂商是谁它只知道能拿到Connection、Statement、Transaction。要换数据库只需要把不同的具体工厂实例注入进来。这个组合比单独用抽象工厂强在一点创建时机和场景由业务方掌控工厂的生命周期也由客户端管理测试时可以轻松注入一个mock工厂。3.3 测试替身给工厂换掉真实实现用抽象工厂加依赖注入之后写单元测试会舒服非常多。以前测业务逻辑时要连真数据库现在只要做一个FakeDatabaseFactoryclass FakeDatabaseFactory : public DatabaseFactory { public: std::unique_ptrConnection createConnection() override { return std::make_uniqueFakeConnection(); } std::unique_ptrStatement createStatement() override { return std::make_uniqueFakeStatement(); } std::unique_ptrTransaction createTransaction() override { return std::make_uniqueFakeTransaction(); } }; TEST(ReportGeneratorTest, GenerateWorksWithFakeDb) { auto factory std::make_sharedFakeDatabaseFactory(); ReportGenerator generator(factory); EXPECT_NO_THROW(generator.generate()); }这个经验的本质是工厂模式的价值不只是运行时的多态创建它同时也是一个测试替身注入点。如果你发现某个类用了工厂但没法在测试里替换成替身那这个工厂的设计十有八九有问题要么工厂被静态方法邦死了要么业务代码在某个隐蔽的位置直接new了具体类。4. 工厂返回的生命周期语义从裸指针到池化回收工厂模式讨论得最多的往往是怎么创建对象但返回值怎么带走、销毁时谁来负责这个被忽略的问题在C里反而是事故高发区。我在Code Review里看过太多次裸指针从工厂里甩出来调用方用完忘记delete或者delete了之后又被别的地方继续使用。4.1 裸指针的麻烦所有权归属不清如果工厂返回Base*调用方拿到指针后根本无从判断这个对象是自己独占还是工厂还持有副本。更麻烦的是异常安全性创建对象过程中如果中间某个步骤抛异常裸指针很容易泄漏。C Core Guidelines里写得很明确从工厂创建出来的对象应该以智能指针返回因为工厂天然是所有权转移的源头。调用方和工厂之间的契约应该是我把所有权交给你你拿着用用完自动销毁。4.2 以unique_ptr作为工厂的统一出口unique_ptr是我在工厂里最常用的返回类型。它的语义是独占所有权对象生命周期完全由调用方控制析构发生在作用域结束时自动释放不需要手工delete。上面泛型注册表工厂示例里已经用了这个模式std::unique_ptrBase create(const std::string name) { auto it creators_.find(name); if (it creators_.end()) { throw std::runtime_error(unknown type: name); } return it-second(); }如果某些场景确实需要多个持有者共享对象那就返回std::shared_ptr。但要注意共享所有权意味着对象的析构时机变得不可预知对于持有锁、文件句柄、数据库连接这种资源型对象最好明确只让一个所有者持有其他角色只借不借所有权。工厂内部可以把shared_ptr进行转换比如返回值统一是shared_ptrBase但内部用make_sharedDerived创建。我习惯的约定是除非有明确的共享需求否则工厂一律返回unique_ptr。能用unique_ptr表达的语义就不要用shared_ptr。这个约定让工厂的语义极其清晰调用方看到返回类型就知道自己独占了这个对象。4.3 自定义删除器和对象池把销毁变成归还工厂模式的另一个进阶用法是通过自定义删除器实现资源复用。典型场景是数据库连接池连接对象创建开销大每次用完直接销毁很浪费更好的做法是构造一个池化的unique_ptr删除器不再调用delete而是把对象归还给池子。class ConnectionPool { public: std::unique_ptrConnection, std::functionvoid(Connection*) acquire() { std::lock_guardstd::mutex lock(mutex_); if (!idle_.empty()) { auto conn std::move(idle_.back()); idle_.pop_back(); return std::unique_ptrConnection, std::functionvoid(Connection*)( conn.release(), [this](Connection* raw) { release(raw); }); } auto raw new Connection(); return std::unique_ptrConnection, std::functionvoid(Connection*)( raw, [this](Connection* raw) { release(raw); }); } private: void release(Connection* raw) { std::lock_guardstd::mutex lock(mutex_); idle_.push_back(std::unique_ptrConnection(raw)); } std::mutex mutex_; std::vectorstd::unique_ptrConnection idle_; };这样调用方拿到的还是unique_ptr但销毁时对象不消失而是回到池子里下次acquire能直接复用。这套思路看起来是智能指针删除器的玩法但它需要工厂来统一创建和回收工厂不再只是一个创建者而是变成了资源管理器。我在网络服务里用这种模式管理数据库连接和客户端连接连接建立次数明显下降服务高峰期的延迟也稳定了不少。要小心的是归还池子时有遗漏就会导致连接泄漏。我的建议是回收函数幂等并且要防止同一个连接被重复归还。上面这个示例用unique_ptr转移所有权的方式天然保证了每个裸指针在同一时刻只有一个智能指针管理重复归还的问题在正常使用流程里不会出现。5. 实战拆解一个可扩展消息分发系统是如何落地的讲了不少理论接下来用一个真实项目里非常常见的场景把前面这些点串起来。假设我要设计一个网络消息分发系统服务器收到不同消息ID需要创建对应的Handler去处理。消息类型还在持续增加团队同时有多个人在开发不同的消息处理逻辑。5.1 需求拆解消息类型持续增多的场景需求本身不复杂收到一个消息根据消息类型ID找到对应的Handler调用handle()。但有几个约束让整体设计必须有讲究。第一消息类型非常多预计半年内会增加到上百种。第二不同类型Handler的构造参数并不相同有的需要配置对象有的需要数据库访问接口。第三有些人会并行开发新的Handler不能让他们去改公共代码。第四为了做联调测试时需要轻松替换某个Handler实现方便模拟异常场景。如果不用工厂注册表这个系统会演化成一个大switch而且越到后面越难维护。用注册表模式就是最合适的解法。5.2 核心实现注册宏局部静态单例类型擦除先定义Handler接口和基础工厂class MessageHandler { public: virtual ~MessageHandler() default; virtual void handle(const Message msg) 0; }; class Config; // 需要注入的配置对象 class MessageHandlerFactory { public: using Creator std::functionstd::unique_ptrMessageHandler(const Config); static MessageHandlerFactory instance() { static MessageHandlerFactory factory; return factory; } template typename Handler void registerHandler(const std::string name) { creators_[name] [](const Config config) - std::unique_ptrMessageHandler { return std::make_uniqueHandler(config); }; } std::unique_ptrMessageHandler createHandler(const std::string name, const Config config) { auto it creators_.find(name); if (it creators_.end()) { throw std::runtime_error(unknown handler: name); } return it-second(config); } private: std::unordered_mapstd::string, Creator creators_; }; #define REGISTER_HANDLER(HandlerType) \ namespace { \ const bool reg_##HandlerType []() { \ MessageHandlerFactory::instance().registerHandlerHandlerType(#HandlerType); \ return true; \ }(); \ }新同事加入项目要开发一个Ping消息的处理逻辑他只需要做两件事写一个继承Handler的类然后在自己的源文件底部加一行REGISTER_HANDLER(PingHandler)。每个人各写各的文件互不打扰公共的工厂代码一行都不用改。这里面的类型擦除体现在哪里注册时把具体的Handler类型放进一个lambdalambda被包装成std::function类型信息被擦除。工厂在运行时只需要知道字符串名字就能获取到那个lambda并调用而不需要知道究竟返回什么具体类型。这种把类型转换成可调用对象字符串标识的手法在C没有反射机制的现状下是解决运行期动态创建问题最自然的路径。5.3 配置驱动创建和测试替身另一个诉求是配置驱动创建。比如配置文件里写了把哪种消息ID映射到哪个Handler名字业务层启动时读配置然后让工厂按名字创建。这个写法的好处是同样的二进制包换个配置就能启用不同的处理逻辑不需要重新编译。测试的时候工厂的注册表同样能派上用场。想在测试里替换某个Handler不需要改业务代码只要在注册表里用同名的测试替身覆盖注册即可。不过要注意因为注册动作发生在全局初始化阶段覆盖顺序不保证稳妥的做法是让工厂的注册函数支持覆盖并允许在测试初始化时显式调用registerHandlerFakePingHandler(PingHandler)先注册真的再注册假的后者覆盖前者。工厂API要把覆盖行为明确写出来而不是隐式覆盖否则别人看代码时会困惑为什么同一个名字被注册了两次。我通常会给regiserter加一个返回值或者日志反馈这个名字之前已注册过将被覆盖这样踩坑的时候能立刻定位。6. 高级工厂的边界性能和可维护性之间的取舍工厂不是一个越多越好的模式。它在带来可扩展性的同时也有自己的代价。如果一个项目不分青红皂白到处套工厂反而会造成过度设计。6.1 哈希查找与虚函数工厂的开销到底有多大很多人担心工厂的性能其实拆开看开销主要有三块unordered_map的哈希查找、std::function的间接调用、多态虚函数调用。这三者在单次创建里的开销都是纳秒到亚微秒级别相比对象构造本身比如数据库连接、IO缓冲区分配几乎可以忽略不计。但如果你处在真正的热点路径上每秒要创建几十万个小对象工厂的哈希查找就不再是无所谓了。我有一次写网络网关时做过性能对比同样的状态机复用工厂创建对象profile下来unordered_map::find占了大概1.2%的CPU不算严重但却是工厂链路里最大的可省开销。优化方案通常有两个方向一个是把创建结果缓存起来减少频繁创建另一个是使用完美的查找结构比如基于字符串的perfect hash或者直接用枚举和数组索引。要注意的是这些优化都带来了额外复杂度不要一开始就做先profile再决定优化点。6.2 编译期工厂if constexpr 和类型列表如果创建时所需的类型信息在编译期就能确定那运行期工厂就是多余的。C17的if constexpr可以写出编译期分派template typename ProductId auto createById() { if constexpr (std::is_same_vProductId, ProductA) { return std::make_uniqueProductA(); } else if constexpr (std::is_same_vProductId, ProductB) { return std::make_uniqueProductB(); } }这段代码在编译期就会丢弃不必要的分支没有运行时查找也没有虚调用。这种编译期工厂适合的场景是类型列表已知、且每个分支的逻辑差异比较大。它的劣势是分支之外的代码如果对返回类型有统一要求很难做到真正的类型统一调用方几乎只能用auto推导不能把它当多态接口来用。更实用的混合方案是编译期用if constexpr做静态分发运行期用注册表工厂做动态创建两者同时存在互不冲突。编译期工厂用在类型已知的编译期调度路径运行期工厂用在与外部配置或协议数据交互的路径上。6.3 原型模式当替身什么时候clone比create更合适有些场景下复制已有对象比从头创建更能解决问题。如果对象构造代价很大或者对象需要保留某个初始化状态的模板值用原型模式更合适。原型模式和工厂模式可以结合起来注册表里不存创建函数而是存一个共享的原型对象指针创建时调用clone()。class PrototypeFactory { public: template typename Derived void registerPrototype(const std::string name, std::shared_ptrconst Derived sample) { prototypes_[name] [sample]() { return std::unique_ptrBase(sample-clone()); }; } };举个例子游戏引擎里一个怪物类型的初始属性、骨骼、贴图都加载好了每次生成新怪物只要clone一下就不再需要从头加载资源。这个做法本质上还是工厂模式只是把创建动作从构造换成了克隆。它在内存复用和初始化开销上的优势非常明显但前提是clone()要能正确实现深拷贝否则多个对象会共享内部状态出现诡异bug。6.4 别硬用工厂那些反例最后说说不该用工厂的反例。如果一个类型只有两个产品、产品构造参数完全一致、未来也没有扩展计划那直接switch反而更简单、更直接。工厂模式的价值在于应对需求变化而不是应对今天的代码规模。还有一类反例是把工厂套在简单值类型上比如Point、Color这种轻量数据结构。它们既不需要多态也不会有复杂的构造逻辑工厂只会增加调用方的心智负担读者一眼看不懂为什么要绕个弯去创建。我自己的判断标准很简单有三个迹象存在才值得用工厂模式。第一创建逻辑处在频繁变化的扩展点上第二调用方需要依赖抽象而不是具体类型第三测试时需要替身注入。如果三个都不占别犹豫直接new就好。工厂是工具不是装饰品。把工具用在真正需要它的位置才是高级应用的本意。最后分享一个我在实际项目里的小体会工厂模式的上限不是某个写法而是你能不能把类型标识、创建逻辑、缓存策略、生命周期这四个维度拆清楚。很多设计复杂的工厂根源其实是这四个维度揉在一起了。先把它们分开想再动手写大部分问题自己就消失了。
返回列表