资讯详情

现代C++工厂模式实战:从简单工厂到依赖注入

📅 2026/10/15 23:20:58 | 华诺云谱 👁 阅读
现代C++工厂模式实战:从简单工厂到依赖注入
工厂模式大概是被讨论得最多的设计模式但说实话我在实际项目里见到的工厂代码有一半都写得不怎么对劲。要么是为了用模式而用模式把简单的创建逻辑硬套上三层继承要么就是工厂函数满天飞调用方根本搞不清该用哪一个。今天想结合我自己的实战经验把C里工厂模式的几种形态、各自适合的场景以及现代C编程里怎么把它写得既安全又好维护一次性聊透。这篇东西适合已经开始写C、对设计模式有基本概念但还没在项目里系统用过的开发者也适合那种看了一堆理论、真上手写代码时却犹豫“这里到底该不该用工厂”的朋友。工厂模式的核心价值说白了就一句话把对象的创建和使用拆开。这个“拆”字听起来简单做起来却牵扯到接口设计、内存管理、扩展方式等一系列问题。这篇文章我会从最原始的“没有工厂”的代码讲起一步步推导出工厂为什么存在再对比三种经典写法最后给出我目前在项目里最常用的现代C工厂写法包括注册表工厂、基于std::function的工厂以及和依赖注入结合的方式。整个过程有完整代码、有参数选择解释、有踩坑记录照着抄就能用。1. 工厂模式到底在解决什么问题先说一个我常遇到的争论有人觉得工厂就是“把new包一层”多此一举。这种看法其实是因为没见过真正需要工厂的代码。工厂解决的不是“创建对象”这个动作本身而是“谁来决定创建哪个对象”这个责任归属。1.1 没有工厂的日子new出来的耦合我们从一个实际场景说起。假设你在写一个跨平台的日志模块Windows下用Win32API写文件Linux下用syslog调试时还要能输出到控制台。最朴素的做法是直接在业务代码里判断平台std::unique_ptrILogger createLogger() { #ifdef _WIN32 return std::make_uniqueWinFileLogger(app.log); #elif defined(__linux__) return std::make_uniqueSyslogLogger(app); #else return std::make_uniqueConsoleLogger(); #endif }这其实是工厂的雏形。但如果把这堆宏判断散落在十来个业务模块里每次增加一个新的输出目标比如远程日志服务你就要跑去改所有调用点。更头疼的是测试你想在单元测试里注入一个假的Logger如果业务代码里到处都是new你根本没有下手的地方。我见过最离谱的代码是一个支付网关项目里面处理订单的类直接new了具体的短信服务类、邮件服务类和数据库访问类。后来要接入新的短信渠道商业务代码改了几十个文件。这就是典型的“创建逻辑污染使用逻辑”。工厂模式在这种情况下不是锦上添花而是保命手段。1.2 工厂的本质把“创建”从“使用”中剥离工厂模式想做的事情用一句话概括就是调用方只依赖抽象接口工厂负责实现细节。客户端代码看起来永远是auto logger LoggerFactory::create(config); logger-info(hello);至于这个create内部是判断平台、解析配置还是反射创建调用方一概不关心。这带来的直接好处有三个第一调用方代码稳定。只要ILogger接口不变底层Logger的实现怎么换、怎么加调用方的代码一行都不用改。第二测试友好。因为调用方依赖的是接口测试时可以很方便地替换成Mock实现。没有工厂的代码测试时得靠各种奇技淫巧去替换全局对象有了工厂一切都在可控范围内。第三集中管理创建逻辑。创建对象时的参数解析、依赖组装、生命周期设置全部收拢到工厂一个地方。想给所有Logger加一个公共的初始化步骤工厂里加一行就行。1.3 什么时候该上工厂什么时候不该用工厂不是银弹。我给自己定了几条判断标准分享出来供参考创建逻辑复杂需要根据条件、配置或参数决定具体类型时该用。需要把“替换实现”作为一等公民来支持时比如测试桩、插件机制该用。仅仅是为了“以后可能扩展”而加工厂不该用。等真正出现第二个实现类时再重构也来得及。创建的类型本身就是一个简单的值对象比如一个Point直接构造即可工厂只会增加噪音。简单说工厂的引入要能带来实际的“不稳定因素”才值得。什么是不稳定因素接口有多个实现、创建参数因环境而异、你希望调用方完全不知道具体类型。这些都满足不了的时候老老实实new就好。2. 三种经典工厂的C实现与取舍教科书上通常把工厂模式分成三种简单工厂、工厂方法、抽象工厂。很多文章把它们讲得云里雾里其实它们解决的是不同维度的问题理解到位了选型就是顺理成章的事。2.1 简单工厂最轻量但有个明显的坑简单工厂也叫静态工厂就是上面日志例子里那种写法。一个静态函数根据参数决定返回哪个具体类型class LoggerFactory { public: static std::unique_ptrILogger create(LogType type) { switch (type) { case LogType::File: return std::make_uniqueFileLogger(app.log); case LogType::Console: return std::make_uniqueConsoleLogger(); case LogType::Remote: return std::make_uniqueRemoteLogger(log.example.com); default: throw std::invalid_argument(unknown log type); } } };优点很明显实现简单调用方一行搞定。缺点同样明显每加一个新类型就要改动这个switch。这违反了开闭原则——对扩展开放、对修改关闭。不过在我的实践经验里如果类型数量很少并且基本稳定简单工厂完全够用。它不是“坏味道”只要你的项目里有这个自知之明就行。提示简单工厂的switch里一定要处理default分支抛异常而不是返回nullptr或默认类型。返回默认类型会让错误悄悄埋进系统里排查的时候非常痛苦。2.2 工厂方法把创建延迟到子类工厂方法模式是把“创建”这件事本身抽象成接口让子类决定具体创建哪个对象。它解决的是简单工厂那个“改动switch”的痛点——不再有一个集中的创建函数而是每个工厂子类负责一种类型。class ILoggerFactory { public: virtual ~ILoggerFactory() default; virtual std::unique_ptrILogger create() const 0; }; class FileLoggerFactory final : public ILoggerFactory { public: std::unique_ptrILogger create() const override { return std::make_uniqueFileLogger(app.log); } }; class ConsoleLoggerFactory final : public ILoggerFactory { public: std::unique_ptrILogger create() const override { return std::make_uniqueConsoleLogger(); } };调用方式变成std::unique_ptrILoggerFactory factory std::make_uniqueFileLoggerFactory(); // 后续代码只跟factory打交道 auto logger factory-create();好处是扩展新增类型时不需要改已有工厂类只需新增一个工厂子类。代价是类的数量翻倍——每个产品类型都要对应一个工厂类。在C这种没有反射的语言里工厂方法模式还有一个实际用途在基类代码中定义一个“算法骨架”把对象创建的具体步骤推迟到子类实现这样基类代码既复用了流程又保持了对具体类型的隔离。我个人很少单独用工厂方法因为类膨胀太厉害。如果只是“创建不同产品”这个需求简单工厂加一个注册表往往更轻盈。工厂方法真正的用武之地是在模板方法模式配合使用时——基类的业务流程需要子类提供特定对象这时虚函数式的工厂方法简直是天作之合。2.3 抽象工厂创建一族相关对象抽象工厂解决的不是“一个对象”的创建而是一组相关对象的成套创建。典型场景是UI组件库一套主题下按钮、输入框、对话框必须风格一致。你不能让Windows风格的按钮搭配Linux风格的输入框。class IWidgetFactory { public: virtual ~IWidgetFactory() default; virtual std::unique_ptrIButton createButton() const 0; virtual std::unique_ptrITextBox createTextBox() const 0; virtual std::unique_ptrIDialog createDialog() const 0; }; class LightThemeFactory final : public IWidgetFactory { public: std::unique_ptrIButton createButton() const override { return std::make_uniqueLightButton(); } std::unique_ptrITextBox createTextBox() const override { return std::make_uniqueLightTextBox(); } std::unique_ptrIDialog createDialog() const override { return std::make_uniqueLightDialog(); } }; class DarkThemeFactory final : public IWidgetFactory { public: std::unique_ptrIButton createButton() const override { return std::make_uniqueDarkButton(); } std::unique_ptrITextBox createTextBox() const override { return std::make_uniqueDarkTextBox(); } std::unique_ptrIDialog createDialog() const override { return std::make_uniqueDarkDialog(); } };使用抽象工厂的关键是调用方只依赖IWidgetFactory这个“成套工厂”接口从不直接创建单个控件。这样切换主题就是切换工厂实例的问题整组控件的一致性天然得到保障。抽象工厂的缺点是结构重、扩展难。想新增一个“滑动条”控件所有工厂子类都要实现新的createSlider()方法。所以用抽象工厂之前一定要先把你需要成套支持的产品族列清楚宁多勿漏。2.4 三种工厂的对比与选型建议把三种方式放到一起对比选型维度就很清晰了维度简单工厂工厂方法抽象工厂创建对象维度单个产品单个产品一组相关产品扩展新产品的成本修改switch新增子类新增子类但所有工厂都要改类数量1个工厂类每个产品1个工厂类每个产品族1个工厂类调用方依赖依赖工厂静态方法依赖工厂接口依赖成套工厂接口典型场景类型少且稳定基类模板方法配合UI主题、跨平台控件我的选型习惯是不超过三四个类型且不会频繁增加直接用简单工厂创建过程需要被基类流程复用用工厂方法需要成套保证风格一致性用抽象工厂。多数项目里简单工厂加注册表的组合能覆盖八成需求。3. 现代C实战这样写工厂才像样前面的例子用的是基础C写法但现在是C17之后了工厂模式有了更优雅、更安全的写法。这节的内容是我在项目里真正用到的每一段都踩过坑。3.1 生命周期管理用unique_ptr消灭裸newC的工厂和Java、C#最大的不同在于内存管理。工厂返回裸指针的话谁负责delete就成了千古难题。现代C的答案很明确工厂统一返回std::unique_ptr所有权明确转移给调用方。class LoggerFactory { public: static std::unique_ptrILogger createFileLogger(const std::string path) { return std::make_uniqueFileLogger(path); } };这个写法的好处不只是“安全”更重要的是它把所有权语义表达得很清晰工厂不保留对象调用方是唯一所有者。后续如果调用方需要共享所有权可以显式std::shared_ptr转移但默认应该用unique_ptr性能好、语义准。注意工厂内部创建子对象时如果子对象之间有关联比如两个对象互相引用unique_ptr会带来组装麻烦。此时需要先把对象创建出来厘清所有权关系再用unique_ptr接管。这个顺序容易反我吃过亏。具体来说假设一个Session对象需要引用一个Connection对象但Connection需要回调Sessionauto conn std::make_uniqueConnection(); auto session std::make_uniqueSession(conn.get()); conn-setSession(session.get());这里conn和session相互裸指针引用但所有权仍是各自独立的unique_ptr。创建顺序是关键先创建conn再创建session最后回调。如果把session声明到conn前面conn析构时session可能已经没了回调就成空悬指针。3.2 注册表工厂摆脱switch地狱当简单工厂的switch越来越多或者你希望“注册”行为不在工厂代码里写死时注册表工厂就派上用场了。它的核心思路是用一个map把类型标识映射到创建函数创建函数统一为std::functionstd::unique_ptrILogger()。class LoggerFactory { public: using Creator std::functionstd::unique_ptrILogger(); static void registerCreator(const std::string name, Creator creator) { creators()[name] std::move(creator); } static std::unique_ptrILogger create(const std::string name) { auto it creators().find(name); if (it creators().end()) { throw std::invalid_argument(unknown logger type: name); } return it-second(); } static std::vectorstd::string registeredTypes() { std::vectorstd::string keys; for (const auto [key, _] : creators()) { keys.push_back(key); } return keys; } private: static std::mapstd::string, Creator creators() { static std::mapstd::string, Creator registry; return registry; } };注册可以在程序启动时集中完成也可以由各个模块自注册// 某个模块的.cpp文件里 static bool fileLoggerRegistered []() { LoggerFactory::registerCreator(file, [] { return std::make_uniqueFileLogger(app.log); }); return true; }();这种写法的好处是新增一个Logger类型不需要改动工厂本体只需要在某个地方调用registerCreator。甚至可以做到插件式扩展——插件动态库加载时注册自己的类型。调用方依然只依赖ILogger接口。有一个坑要提醒creators()函数里的static std::map是函数级静态变量它的初始化发生在第一次调用时。多个模块在静态初始化阶段文件加载时调用registerCreator如果此时creators()还没被初始化C11标准规定函数内静态变量线程安全地初始化一次所以这个用法是安全的。但要注意注册顺序不保证所以不要在注册时依赖其他注册已完成的假设。3.3 Lambda工厂创建逻辑就地封装有时候你不想定义一个完整的工厂类或者创建逻辑只在某处用到我就用Lambda把它包装起来。这在依赖注入风格的代码里特别常见class ServiceCollection { public: using ServiceCreator std::functionstd::shared_ptrIService(); void registerService(const std::string name, ServiceCreator creator) { services_[name] std::move(creator); } std::shared_ptrIService resolve(const std::string name) { auto it services_.find(name); if (it services_.end()) { throw std::invalid_argument(service not registered: name); } return it-second(); } private: std::mapstd::string, ServiceCreator services_; };使用起来非常灵活创建时捕获需要的配置或依赖ServiceCollection services; services.registerService(userService, [] { auto repo std::make_sharedUserRepository(); auto logger LoggerFactory::create(file); return std::make_sharedUserService(repo, logger); }); auto userService std::dynamic_pointer_castIUserService( services.resolve(userService));Lambda工厂的关键优势是“就地封装依赖组装逻辑”调用方看到的就是一个完整的服务装配过程。缺点是Lambda一旦复杂就很难读所以我在Lambda体超过五六行时会抽成一个具名函数或者工厂类。3.4 从工厂到依赖注入模式演进写多了之后会发现工厂模式的核心痛点从“创建谁”变成了“组装谁的依赖”。这时候工厂模式就自然过渡到依赖注入容器了。依赖注入容器本质上就是注册表工厂的泛化——它注册的创建函数可能更复杂支持构造函数注入、属性注入、单例管理等。我不想在这儿展开依赖注入容器的实现但想强调一个观点工厂模式是理解依赖注入的最佳跳板。你在工厂里手动组装依赖时感受到的那些麻烦参数传递、生命周期管理、循环依赖正是依赖注入容器想替你解决的问题。如果你已经在项目里大量手写工厂可以尝试抽一个极简容器出来把这些工厂的公共逻辑收拢起来。template typename T struct DIContainer { using Creator std::functionstd::shared_ptrT(); static void registerCreator(Creator creator) { creator_ std::move(creator); } static std::shared_ptrT resolve() { if (!creator_) { throw std::runtime_error(not registered); } return creator_(); } private: static Creator accessCreator() { static Creator creator; return creator; } };当然这是玩具级别生产环境的依赖注入容器更严格。但它足以说明工厂模式的演进方向——把创建和组装的复杂度不断向上抽象。4. 实战中的常见问题和排查技巧写工厂代码时最常见的错误其实不太在模式本身而在C的语言特性上。我把这些年踩过的坑汇总一下每一条都是真实教训。4.1 对象切片返回值的类型要小心这是C工厂最常见的问题。如果你把工厂返回类型声明为基类对象不是引用或指针而实际创建的是一个派生类对象那派生部分就被切掉了。class Animal { ... }; class Dog : public Animal { ... }; // 错误示范按值返回基类 Animal createAnimal() { return Dog(); // 切片Dog的部分被切掉 }正确做法是返回智能指针或裸指针现在基本都用智能指针了std::unique_ptrAnimal createAnimal() { return std::make_uniqueDog(); }这个坑在工厂模式里特别容易出现因为你本来就是想通过工厂隐藏具体类型稍不留神就把返回类型声明成基类值类型。切片的后果是虚函数调用全部变成了基类版本程序表现诡异但又不崩溃排查起来很浪费时间。4.2 工厂内部的异常处理工厂是一个“集中抛异常”的地方——因为创建可能失败、参数可能非法、类型可能未注册。我的建议是工厂的异常策略要统一并且在调用方文档里写清楚。几种常见策略未知类型抛std::invalid_argument调用方应该尽早暴露配置错误。资源获取失败打不开文件、连接失败让底层异常自然传播或者封装成领域异常。可选类型的创建返回nullptr用智能指针的话就是空unique_ptr由调用方决定是否降级。我见过把资源获取失败的情况直接吞掉、返回nullptr的写法这会让真正的错误被掩盖调用方的处理逻辑也会充满“if (!logger) { ... }”这种防御分支。权衡之下我的习惯是创建失败默认抛异常只有明确“可选依赖”的语义时才返回空指针。4.3 循环依赖的麻烦当你的工厂开始组装复杂对象时循环依赖就会出现。比如A需要BB需要A。这是个经典难题工厂模式本身解决不了只能靠设计规避。我常用的几种手段引入中间层。把A和B共同依赖的部分拆成C让A依赖C、B依赖C。用接口隔离。如果A和B都依赖对方的一个子集就把那个子集提取成接口。延迟注入。B先构造一个空引用A创建完后再填充B用setter完成闭环。延迟注入的实现长这样struct A; struct B; struct A { std::shared_ptrB b; }; struct B { std::shared_ptrA a; }; // 工厂里先创建A、B std::shared_ptrB b std::make_sharedB(); std::shared_ptrA a std::make_sharedA(); // 再互相注入 a-b b; b-a a;这段代码简单可靠但要小心线程安全。多线程环境下先创建再注入容易出现“看到半初始化对象”的问题此时建议把初始化过程放到构造函数里并用工厂的私有实现来保证或者用std::once_flag。4.4 排查技巧速查表现象可能原因排查思路工厂返回的对象调用虚函数没有预期效果对象切片检查返回类型是基类值类型还是引用/指针程序启动时崩溃堆栈在静态变量初始化注册表工厂的静态注册顺序问题检查registerCreator是否依赖其他静态变量不知道有哪些类型注册了缺少查询接口给注册表工厂加registeredTypes()方法调试时很有用多线程下工厂创建出重复实例工厂内部有共享状态未加锁确认工厂的注册表读写是否线程安全返回的unique_ptr无法转成shared_ptr类型不匹配或意图不明确明确工厂接口返回类型必要时统一返回shared_ptr排查工厂问题最重要的事情不是你写了多少调试代码而是把“工厂的输入”配置参数、注册信息和“工厂的输出”创建对象隔离清楚。给工厂加一个简单的日志输出——创建了什么类型、用了什么参数——能省掉大量头痛时间。5. 工厂模式在真实项目中的扩展姿势聊完语法和坑最后分享几个我在真实项目中把工厂模式用得比较顺的场景。这些场景不是教科书上的例子但我觉得更有参考价值。5.1 用工厂实现插件机制插件系统天生适合注册表工厂。核心程序只定义接口插件动态库在加载时把自己实现的产品注册到工厂里。主程序启动时扫描插件目录加载动态库然后通过create方法按名字创建插件实例。这里要注意插件和主程序的生命周期管理。动态库卸载后它创建的对象如果还在使用就会导致悬空。解决思路有两个要么保证插件对象在主程序退出前统一销毁要么把插件对象创建成shared_ptr并让插件工厂持有动态库句柄直到最后一个对象释放后再卸载。实践中我用过“先扫描、后创建、统一释放”的策略主程序启动时加载所有插件动态库按需创建插件对象程序退出前先行调用插件工厂的清理函数让所有插件对象析构完毕再卸载动态库。这个顺序错了崩溃就找上门了。5.2 配置驱动的对象创建工厂配置能让一个程序在不改代码的情况下改变运行行为。比如消息队列的序列化方式可以是JSON、Protobuf或自定义格式由配置文件指定serializer: protobuf log: type: remote endpoint: log.example.com:514配置文件会被解析成一个配置结构体工厂根据这个结构体创建对应的实现。新增一种序列化格式时只需要实现接口并注册到工厂配置文件里就能用了。这种写法的好处是系统集成测试特别舒服测试环境配置JSON生产环境配置Protobuf代码完全不需要分支判断。我在多个项目里用了这个模式它让“面向用户的可配置性”一下子提高了一个档次。5.3 工厂与测试桩注入工厂还有一个经常被忽略的价值让测试桩替换变得轻松。之前写过一个消息推送服务生产环境用真实的短信网关但单元测试里每次发送都会产生费用。我们定义了一个IPushGateway接口生产工厂创建真实网关测试代码注册一个假网关class MockPushGateway : public IPushGateway { public: void push(const std::string message) override { pushedMessages_.push_back(message); } std::vectorstd::string pushedMessages_; }; TEST(PushServiceTest, SendMessage) { auto mockGateway std::make_sharedMockPushGateway(); Registration::registerPushGateway(mockGateway); PushService service; service.send(hello); ASSERT_EQ(1, mockGateway-pushedMessages_.size()); }这个模式推广开来整个测试套件就不再依赖外部资源测试速度提升非常明显。工厂在这里扮演的角色是“依赖注入的入口”它把真实对象和测试桩的切换成本降到了最低。5.4 性能敏感的工厂优化工厂模式因为引入了间接层有人会担心性能损耗。在绝大多数场景里这点损耗微乎其微——std::function调用一次通常几十纳秒比起创建对象本身的成本几乎可以忽略。但如果你在写一个每帧创建几千个对象的引擎工厂的间接层就存在优化空间。我的几个优化策略是优先用std::make_unique避免先裸new再包装的重复内存分配。注册表工厂里用unordered_map而非map把字符串查找从对数复杂度降到常数期望复杂度。如果类型标识是枚举而非字符串直接用一个固定大小数组做索引映射效率最高。函数式注册的std::function必要时可以替换为裸函数指针减少一次间接调用。这些优化都属于“必要时才做”的级别。我见过有人为了几百纳秒的差异把工厂模式换成模板加宏的乱七八糟实现维护成本陡增性能提升却测不出来。先测是瓶颈再优化别反过来。写在最后的经验我早期踩过的最大一个坑是觉得用了工厂就“设计得很优雅”然后疯狂给所有类套上工厂。直到有一天一个小组件的构造函数从三个参数涨到八个工厂函数也跟着变得面目全非我才意识到问题是构造函数太复杂而不是工厂不够优雅。工厂模式不是万能的。它解决的是“选择哪个实现”的问题解决不了“实现本身太复杂”的问题。如果创建对象需要的参数多得离谱首先该做的是简化构造函数或者引入配置结构体、建造者模式而不是硬靠工厂。工厂模式在C里特别值得掌握因为它强迫你思考所有权、接口设计和扩展边界这些都是C程序员的基本功。你不一定每个项目都用工厂但理解它之后你写出来的普通代码也会更干净——你会本能地把“创建什么”和“用什么”的边界划清楚。这一点比模式本身更有价值。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑