C++工厂模式详解:从简单工厂到抽象工厂的工程实践指南
1. 工厂模式到底在解决什么问题先说一个我早年真实踩过的坑。那时候做一个跨平台图形渲染系统需要支持不同的图片加载后端有的走软解、有的走硬解后面还接入了自定义格式。一开始代码写得非常直接上层拿到一个文件路径先用后缀判断格式然后挨个调用各个加载器的构造函数再手动挂到资源缓存里。这个版本跑了两周就出问题了。新格式接入时要改上层判断逻辑上层代码里堆满了各个加载器的创建细节单测想单独测某个加载器得先构造一堆无关依赖更离谱的是有一次把两个加载器的初始化顺序搞错线上直接崩了。后来我把创建逻辑全部收拢到一个统一的入口里也就是工厂模式问题才真正解决。C里的工厂模式本质就一句话把“创建对象”这件事从使用方手里拿走集中到一个专门的地方去处理。你可以把它理解成餐厅里的点餐台客人不用知道后厨怎么备菜、谁炒的、用什么锅只需要报菜名出餐口把做好的菜端出来。这个出餐口就是工厂。这篇文章适合谁看如果你写C已经写过一阵子但每次看到“工厂”这个词都觉得是个很玄的概念或者你在项目里已经用了工厂却说不清它到底规避了什么风险那这篇内容对你会有价值。我会把三种常见的工厂形态拆开讲配上可直接用的代码再把我实际使用中踩过的坑和排查思路一并整理出来。工厂模式不是你代码里的装饰品它是你在面对“对象怎么来”这个问题时的一套工程决策。搞懂它你后续写插件系统、资源管理、消息分发这类代码会顺手非常多。2. 从简单工厂到抽象工厂形态变化背后的思考2.1 三种形态的核心差异很多教程把工厂模式分成简单工厂、工厂方法、抽象工厂却很少解释为什么会有这三种形态。我的理解是它们不是三个并列的招数而是应对“创建逻辑复杂度升级”的三种方案。简单工厂最朴素。它就是一个函数或者一个类根据传入的参数用if或者switch决定创建哪个对象。核心特点是把创建逻辑放在一处适合产品种类少、变化不频繁的场景。工厂方法在此基础上做了一步抽象。它把“创建哪个对象”的决策下放到子类基类只定义创建接口具体的创建过程由子类实现。这样的好处是上层业务不需要感知具体产品类需要扩展新类型时通常只需要新增一个子类和一个对应的具体工厂。抽象工厂则是针对“一系列相关产品”的场景。比如一套UI组件体系里按钮、输入框、对话框是一组产品而深色主题和浅色主题分别需要一套对应的实现。抽象工厂的核心是保证你拿到的是一整套风格统一的组件避免混搭。下面这张表是我平时给团队讲课时用的对照直接说人话版形态决策层级适合场景弱点简单工厂工厂内部产品少、逻辑简单扩展新品类要改工厂内部代码工厂方法具体工厂子类单个产品的创建逻辑需要自由扩展每加一个产品往往要加对应的工厂类抽象工厂产品族工厂需要保证一组产品之间的配套关系加一个新品类要动整个族接口2.2 为什么C开发者特别需要工厂这个问题我确实思考过很久。Java、C#里new一个对象很轻量工厂模式更多是为了解耦和测试但C里有几个独特的原因让工厂模式几乎是绕不开的。第一个原因是资源所有权。C里对象创建之后谁负责销毁、生命周期怎么管理是代码设计里绕不开的事。如果我们到处new对象所有权散落在各处很容易出现悬挂指针或者内存泄漏。工厂把所有创建入口收拢起来同时把返回类型做成智能指针所有权就变得清晰工厂负责造出来调用方拿unique_ptr或者shared_ptr去用用完自动释放。第二个原因是构造函数的问题。C的构造函数没法做到“按需返回不同子类型”或者“复用已有对象”。现实中我们经常需要这样的语义传入一个配置返回一个类型不确定的实例或者一个全局有且仅有一个实例。这些需求直接new是搞不定的必须有工厂在中间做一层转换。第三个原因是注册机制。C没有原生的反射想通过字符串或者ID动态创建对象必须自己维护一张“类型名到构造逻辑”的映射表。这个映射表天然就该放在工厂里。我后面会专门演示这个怎么做。3. 实战一用工厂统一图形资源加载3.1 场景设定与需求拆解我拿实际做过的图像资源加载模块来讲。需求是这样的系统支持PNG、JPG、自定义RAW格式三种图片格式间解码方式差异很大有的走CPU解码有的能走GPU快速解有的带额外元数据。上层业务模块才不管什么格式它只想知道一件事给我一个文件路径返回一个能用的Image对象。如果用最原始的写法业务层代码会长这样std::shared_ptrImage loadImage(const std::string path) { std::string ext getExtension(path); if (ext png) { return std::make_sharedPngImage(path); } if (ext jpg) { return std::make_sharedJpgImage(path); } if (ext raw) { return std::make_sharedRawImage(path); } return nullptr; }这段代码最大的问题不是它长了而是它把“判断格式 构造对象”这两件事焊死在了业务函数里。每加一种格式你都要回到这个函数里加一个分支而且这个函数放在哪层都尴尬放业务层则业务层要认识所有格式放底层则底层被业务规则污染。3.2 第一版简单工厂接入我把这段逻辑挪到了一个独立的ImageFactory里对外只暴露一个静态方法class ImageFactory { public: static std::shared_ptrImage create(const std::string path) { std::string ext getExtension(path); if (ext png) { return std::make_sharedPngImage(path); } if (ext jpg) { return std::make_sharedJpgImage(path); } if (ext raw) { return std::make_sharedRawImage(path); } throw std::runtime_error(unsupported image format: ext); } };你注意我给返回值加了shared_ptr。为什么不是裸指针因为调用方拿到裸指针后要自己记得delete一旦代码里有多个提前返回的分支很容易漏。用shared_ptr之后资源自动释放少一大类内存问题。这里的核心思路是工厂不只是决定“造哪个”还要决定“以什么方式交给对方”。3.3 构造函数不统一的难题上面那版看起来简单但真实项目里有一个隐藏的坎PngImage、JpgImage、RawImage的构造函数参数往往不一样。PngImage可能要从路径读取文件头做校验JpgImage可能要额外传入一个解码质量参数RawImage则需要知道宽高和像素格式。简单工厂的create方法如果根据参数不同去适配不同构造函数签名会变得很长很难看。我在项目里常用一个技巧让所有产品类的构造函数尽量统一接收一个“上下文结构体”或“配置结构体”。struct ImageLoadOptions { std::string path; int preferredWidth 0; int preferredHeight 0; QualityHint quality QualityHint::Balanced; }; class ImageFactory { public: static std::shared_ptrImage create(const ImageLoadOptions opts) { std::string ext getExtension(opts.path); if (ext png) return std::make_sharedPngImage(opts); if (ext jpg) return std::make_sharedJpgImage(opts); if (ext raw) return std::make_sharedRawImage(opts); return nullptr; } };这样做的理由很实际产品类的构造参数如果五花八门工厂的灵活性就废了。用统一的配置结构体新增参数时不用改工厂接口。这个方法也被很多人叫做参数对象模式它和工厂模式配合起来相当顺手。3.4 这一步解决的核心问题把创建逻辑收拢之后业务层代码从“了解所有格式细节”变成了“只认工厂接口”。新来一个BMP格式产品类写好之后工厂内部加一个分支就行业务层一行不用动。更重要的收获是测试变得好写了单测里可以注入一个模拟工厂或者让工厂返回一个假的Image对象不必真的去解码文件。4. 实战二注册表风格的工厂4.1 摆脱长长的if链简单工厂的硬伤是每加一个类型就要改工厂函数。这个在类型多、团队大的项目里很难维护merge的时候经常冲突。我后来在插件系统里换了一种做法——注册表风格工厂。注册表工厂的核心理念是工厂不直接认识所有产品类而是提供一个“登记处”每个产品类自己把自己登记进去。工厂根据key去登记处查查到就创建查不到就报错。先定义一个基础的产品接口和创建器类型class Decoder { public: virtual ~Decoder() default; virtual std::shared_ptrImage decode(const std::string path) 0; }; // 用一个函数对象包装创建逻辑 using DecoderFactoryFn std::functionstd::shared_ptrDecoder();然后工厂内部维护一个静态映射表class DecoderRegistry { public: static DecoderRegistry instance() { static DecoderRegistry reg; return reg; } void registerDecoder(const std::string format, DecoderFactoryFn fn) { registry_[format] std::move(fn); } std::shared_ptrDecoder create(const std::string format) { auto it registry_.find(format); if (it registry_.end()) { throw std::runtime_error(no decoder registered for: format); } return it-second(); } private: std::unordered_mapstd::string, DecoderFactoryFn registry_; };使用的时候每种解码器在某个初始化函数里调用注册void registerBuiltinDecoders() { DecoderRegistry::instance().registerDecoder(png, []() - std::shared_ptrDecoder { return std::make_sharedPngDecoder(); }); DecoderRegistry::instance().registerDecoder(jpg, []() - std::shared_ptrDecoder { return std::make_sharedJpgDecoder(); }); }业务代码只需要跟字符串打交道auto decoder DecoderRegistry::instance().create(png); auto image decoder-decode(/path/to/file.png);4.2 为什么用std::function而不是裸函数指针很多人会问注册表里存函数指针不行吗行但std::function有几个实际的优势。函数指针只能指向静态函数或者全局函数而std::function可以接收lambda表达式、函数对象、绑定表达式这样注册的时候就能顺便捕获一些上下文。比如某个解码器的创建需要读取一个全局配置lambda里直接捕获就行。另外std::function的类型擦除让注册表的接口更干净调用方不需要关心存进去的到底是个什么可调用对象。代价是一次虚函数调用或者类型擦除的开销但相比解码本身耗费的IO和计算这点开销可以忽略。4.3 自动注册的技巧上面的注册要记得调用registerBuiltinDecoders这个调用如果漏了运行时才报错。我后来用了一个静态注册的小技巧让注册动作在程序启动时自动执行class AutoRegister { public: AutoRegister(const std::string format, DecoderFactoryFn fn) { DecoderRegistry::instance().registerDecoder(format, std::move(fn)); } }; // 在cpp文件里定义静态变量 static AutoRegister s_register_png(png, []() - std::shared_ptrDecoder { return std::make_sharedPngDecoder(); });静态变量在main函数之前完成初始化所以s_register_png的构造函数会在这个阶段执行自动完成注册。用这个技巧新增解码器只需要添加一个新文件改一行都不需要。但要注意一个前提静态变量只在它所属的编译单元被链接进来时才会初始化。如果你把这些文件编成静态库某些没有显式引用的编译单元可能不会被链接注册就不会发生。这个坑我后面单独讲。5. 抽象工厂面对产品族的配套约束5.1 从单个产品到产品族图像解码的例子始终在解决单个产品。但真实系统里经常遇到的是一组产品需要成套出现。我给某个可配置界面框架写样式系统时遇到过这个问题主题需要同时提供按钮、输入框、弹窗三种控件的样式实现浅色主题和深色主题各一套。如果业务代码混着用浅色按钮和深色弹窗界面就会非常怪异。抽象工厂就是为了解决这种配套问题。它不再返回单个对象而是返回一组相关对象。每个具体工厂对应一个风格工厂内部保证它创建的各个产品是匹配的。class WidgetFactory { public: virtual ~WidgetFactory() default; virtual std::shared_ptrButton createButton() 0; virtual std::shared_ptrInputBox createInputBox() 0; virtual std::shared_ptrDialog createDialog() 0; }; class LightWidgetFactory : public WidgetFactory { public: std::shared_ptrButton createButton() override { return std::make_sharedLightButton(); } std::shared_ptrInputBox createInputBox() override { return std::make_sharedLightInputBox(); } std::shared_ptrDialog createDialog() override { return std::make_sharedLightDialog(); } }; class DarkWidgetFactory : public WidgetFactory { public: std::shared_ptrButton createButton() override { return std::make_sharedDarkButton(); } std::shared_ptrInputBox createInputBox() override { return std::make_sharedDarkInputBox(); } std::shared_ptrDialog createDialog() override { return std::make_sharedDarkDialog(); } };上层代码只需要持有WidgetFactory的基类指针是浅色还是深色由一个入口决定之后所有控件创建都从同一个工厂走风格就天然统一。5.2 抽象工厂与简单工厂的组合我发现很多人在写抽象工厂时把每个create方法做成纯虚函数然后每个派生工厂重复一遍创建代码。如果产品的数量很多这种写法会有点繁琐。我比较常用的是组合方式抽象工厂内部持有几个简单的注册表或者简单工厂对外暴露的业务接口保持不变。打个比方WidgetFactory内部把按钮、输入框、弹窗分别放在三个注册表里风格工厂初始化时向三个注册表分别注册自己那套产品。这样后续新增一个控件类型基类加一个纯虚函数所有风格工厂都要动一遍这个躲不开但如果不新增类型只是新增一个风格只需要新写一个风格工厂类初始化时注册好就行。5.3 抽象工厂在实际项目中的适用边界抽象工厂不是银弹。如果你的产品族很大新增一个产品类型意味着所有具体工厂都要改一遍这个开销不小。我在插件化设计里一般这样把握核心产品族固定不变时才用抽象工厂如果产品族本身还在快速演化先用简单工厂加注册表等稳定了再决定要不要上抽象工厂。6. 工厂与对象生命周期管理6.1 单例工厂的正确写法有些资源天然适合全局只创建一份。之前做配置管理器时整个进程只需要一个配置实例多创建就是浪费。把单例逻辑放进工厂里是非常自然的做法。C里实现单例工厂有一个注意点多线程环境下要保证线程安全。C11之后函数内的静态局部变量初始化是线程安全的所以推荐这种写法class ConfigFactory { public: static std::shared_ptrConfig getConfig() { static std::shared_ptrConfig instance std::make_sharedConfig(); return instance; } };这里static局部变量的初始化由编译器保证只发生一次而且线程安全。我第一次用这个写法时还担心并发下初始化两次后来看标准发现C11起这已经是标准保证的了不必自己加锁或者用双重检查锁。6.2 对象池与工厂的get-or-create语义还有一种常见需求一个对象创建成本很高比如数据库连接、大块内存缓冲我们希望反复使用而不是反复创建。这种情况下工厂的职责就不只是创建还涉及缓存和复用。我给网络模块写过一个连接池工厂接口是get不是create。请求一个连接时先在池里找空闲的找到就复用找不到再新建。这个语义用shared_ptr配合自定义删除器实现非常顺手class ConnectionFactory { public: std::shared_ptrConnection get() { std::lock_guardstd::mutex lock(mutex_); if (!idlePool_.empty()) { auto conn idlePool_.back(); idlePool_.pop_back(); // 设置自定义删除器释放时归还到池里而不是销毁 return std::shared_ptrConnection(conn, [this](Connection* c) { recycle(c); }); } return std::shared_ptrConnection(createNewConnection(), [this](Connection* c) { recycle(c); }); } private: void recycle(Connection* c) { std::lock_guardstd::mutex lock(mutex_); idlePool_.push_back(c); } std::vectorConnection* idlePool_; std::mutex mutex_; };shared_ptr的别名构造函数允许你指定一个不同的删除器对象真正需要释放时走的不是delete而是我们定义的回收逻辑。这样一来业务代码只看到“我借了一个连接用完自动归还”完全不需要知道对象池的存在。这个例子说明工厂模式里有一个经常被忽视的设计维度工厂不只是构造对象它还可以拦截对象的“生老病死”全过程。这个思路在写缓存、池化、限流组件时非常常见。7. 工厂模式与依赖注入的取舍7.1 什么时候工厂是多余的讲实话工厂模式在C社区里有被滥用的倾向。很多代码里为了“以后可能扩展”硬加一个工厂层结果抽象太多代码绕来绕去简单的事情反而复杂了。我自己的经验是如果满足以下几点直接用构造函数或者静态工厂方法就够了不必引入完整的工厂层次目前只有一种产品实现调用链很短没有动态加载和插件需求没有测试替换需求。写代码和写文章一样讲究的是言之有物不需要为了用模式而用模式。7.2 依赖注入如何与工厂共存在一些大型项目里工厂经常和依赖注入容器一起出现。容器负责把各种依赖装配好工厂负责具体产品的创建策略。它们不是互相替代的关系而是层次不同容器管的是“组件图怎么织起来”工厂管的是“某个组件如何产生”。在C里做依赖注入比Java、C#繁琐因为语言本身没有反射和统一的构造机制。我的经验是能用构造函数注入的地方就显式传参需要运行时决策的地方才用工厂。盲目引入一个“万能容器”往往会让代码的隐式依赖变多反而不利于维护。7.3 一个常见的反模式很多项目里会有这样的代码业务对象创建时从一个“服务定位器”里拿所有依赖。这个服务定位器本质上就是一个全局工厂。它的问题是隐式依赖太多代码可读性差测试困难。我处理这类代码时通常会把依赖从工厂参数里显式传进去而不是让对象自己去全局工厂里问。这个改动看起来笨但长期维护下来清爽很多。8. 常见问题与排查技巧实录8.1 静态库链接导致自动注册失效前面提到自动注册依赖静态变量的初始化如果代码编成静态库链接器不会把没有被引用的编译单元纳入最终可执行文件那些静态变量的初始化根本不会发生。我排查过很多次这种问题症状很典型程序不报错但运行时说找不到某个类型的工厂。解决办法有几个。一是把相关编译单元直接编入动态库动态库默认会保留全部符号。二是保留一个引用比如在一个统一的初始化函数里显式调用这些注册变量的地址。三是使用编译器的链接选项让链接器强制保留未引用的静态对象。我最后选择了显式注册的方式虽然多写几行但行为最可预期也方便排查。8.2 工厂返回类型选择unique_ptr优先工厂方法该返回unique_ptr还是shared_ptr我的建议是默认unique_ptr除非明确要共享所有权。原因很简单unique_ptr表达的是独占所有权语义清晰开销小业务方如果真变成共享的再转成shared_ptr也容易。工厂返回unique_ptr还有一个好处就是强迫调用方想清楚对象归谁所有。我见过一些代码工厂全返回shared_ptr结果对象本该用完就释放的被不知道哪里多出来的一个引用一直持有内存迟迟不释放。排查起来比直接的内存泄漏还费劲。8.3 循环依赖的问题写工厂的时候一个比较隐秘的问题是创建A需要先创建B而B的创建又依赖A。比如资源管理器要创建纹理缓存纹理缓存的初始化需要资源管理器提供路径解析服务。这种循环依赖用工厂时特别容易踩坑。我的排查套路是画出创建依赖图检查有没有环。如果出现循环依赖通常意味着职责边界没划清楚。比如纹理缓存根本不应该反向依赖资源管理器它的路径解析服务应该由独立的参数对象提供。调整职责划分比在工厂里加各种“懒加载”技巧要健康得多。8.4 工厂的异常安全工厂创建对象到一半抛异常资源会不会泄漏这个问题在C里尤其要小心。如果工厂里先new了一个对象A又去new对象BB抛异常A就没人管了。用裸指针写工厂这个问题很难避免用智能指针加RAII问题自动消失。我早期写过一个简陋的工厂创建步骤里用了裸指针结果后续步骤抛异常直接内存泄漏。后来改成“每一步都让智能指针持有”的写法问题就没了。基本原则是裸指针不要在工厂里逗留创建后立刻交给智能指针接管。9. 工厂模式与C新特性的化学反应9.1 泛型工厂与模板技巧C11之后的模板元编程让工厂可以做一些通用化改造。我之前写过一个小工具根据类型ID创建对象这个ID是从枚举值或字符串映射来的。C17的if constexpr配合std::variant可以写出更紧凑的分发逻辑。简单展示一个用模板做的注册表templatetypename Base, typename Key, typename... Args class GenericFactory { public: using FactoryFn std::functionstd::shared_ptrBase(Args...); templatetypename Derived void registerType(const Key key) { factories_[key] [](Args... args) - std::shared_ptrBase { return std::make_sharedDerived(std::forwardArgs(args)...); }; } std::shared_ptrBase create(const Key key, Args... args) { auto it factories_.find(key); if (it factories_.end()) { return nullptr; } return it-second(std::forwardArgs(args)...); } private: std::unordered_mapKey, FactoryFn factories_; };这个GenericFactory把注册和创建逻辑从具体类型中抽离出来新增产品类型时只需要在外部调用registerType模板方法。对我写插件系统帮助很大省掉了很多重复的注册代码。9.2 用std::any做类型擦除的厂C17引入了std::any可以安全地存储任意可拷贝类型。有些场景下工厂创建的对象类型不固定甚至不继承一个公共基类这时用std::any做返回值就非常灵活。但代价是类型安全需要在使用时显式转换回来所以这个办法一般用于接口边界清晰、转换逻辑集中的地方不适合到处滥用。我之前写一个事件分发器时用过类似思路事件对象由不同的事件源工厂创建创建结果用std::any包装消费者根据事件类型再把any转回具体类型。这种设计省去了一个大而全的公共基类代价是丢失了一部分编译期类型检查。用不用取决于你的团队对这个风格的接受度和代码库的实际情况。9.3 协程与异步工厂C20协程流行之后一些耗时对象的创建也变成异步了。比如一个纹理需要从磁盘加载、上传GPU整个过程可能跨越多个线程和多次异步操作。这时候工厂的接口不再是同步返回shared_ptr而是返回一个可等待的任务对象。我在实际项目里没怎么把协程式工厂做得特别极致因为异步资源管理的复杂度和风险都不小。但如果你确实要面对“上万个小对象需要批量异步创建”的场景协程加工厂的组合值得研究。把这个思路放在这里供有类似需求的朋友参考。10. 最终建议与我的实践心得工厂模式在C里的价值和在其他语言里不太一样。它不只是为了隔离变化更多的时候是在帮你处理资源所有权、生命周期、动态类型创建这些C特有的问题。我写了这么多年代码最大的体会是谈模式一定要回归到“我的代码里到底有什么痛点”这个原始问题上来。如果你现在接手一个代码库发现到处都是直接new对象、创建逻辑散落在各处、新加一个类型要改很多文件那引入工厂的收益会非常大。反过来如果代码里只有一两种类型对象创建链也很短那不下意识套工厂反而是更好的选择。最后分享一个小技巧我每写完一个工厂都会问自己三个问题。第一个问题创建逻辑是否真的从业务里抽出来了第二个问题返回对象的生命周期是否清晰第三个问题新增一个类型需要改哪些文件如果第三个问题的答案还是“要改很多东西”我会重新审视设计。这个问题看起来简单但它能帮你识别工厂是否真的完成了它的核心使命。