首页 / 资讯中心 / 文章详情

C++工厂模式实战:从手写分支到注册表与模板

C++工厂模式实战:从手写分支到注册表与模板 ★ FEATURED ARTICLE
1. 为什么每个C项目最终都需要一个工厂1.1 从一段没人敢改的旧代码说起我以前接手过一个模拟图像处理Demo的代码库核心模块里到处是这种写法if (type BMP) { auto reader std::make_uniqueBmpReader(); ... } else if (type JPEG) { auto reader std::make_uniqueJpegReader(); ... } else if (type PNG) { auto reader std::make_uniquePngReader(); ... }这段代码散落在五六个地方每处都有一模一样的if-else链。头一天老板说加一种WebP格式的支持我数了一下需要改动的位置有七处三处读取逻辑、两处测试代码、一处命令行工具、一处日志统计模块。改完当天晚上就收到几条反馈说某些路径下新格式没有被正确处理——原因很简单有个配置分发的地方漏掉了新的分支判断。这段经历带来的教训很直接当“根据某个条件创建不同类型对象”这件事散落在代码库各个角落时改一处漏一处是必然的不是偶然的。工厂模式解决的就是这个具体问题——把“创建对象”的逻辑从业务逻辑中抽离出来让调用方只需要表达“我要什么”不需要关心“怎么造出来的、具体是哪个类”。1.2 关于分类依据工厂模式真正在管理的东西很多讲设计模式的文章一上来就贴UML图什么Creator、ConcreteCreator、Product看起来很高深实际写代码时大部分人记不住这些名词。我觉得更接地气的理解方式是工厂模式管理的是“分类依据”和“构建过程”之间的关系。拿上面图像读取的例子来说分类依据是文件格式字符串。有了这个字符串程序需要知道两件事第一这种格式对应哪个Reader类第二这个Reader对象怎么初始化——是读配置、检查文件头、还是设置缓冲区大小。没有工厂的时候这两件事散落在每个调用点有了工厂这两件事被收拢到一处调用方拿到的是可以直接使用的产品对象。C里的工厂模式和Java、Python里的有个明显差别C需要明确处理对象的生命周期和所有权。对象是栈上的还是堆上的返回裸指针还是智能指针拷贝还是移动这些问题是C工厂绕不开的细节也是这篇文章后面大部分内容的焦点。1.3 写这篇实战文章的由来最近我在某跨平台系统的重构里又做了一次工厂模式选型顺手把几种常见实现路线都走了一遍——从最基础的手写分支到多态工厂再到注册表驱动和模板工厂。过程中踩了几个坑也总结了一些判断场景的经验。这篇文章就按我实际走过的路把这几种写法、它们的适用边界、以及落地时要注意的细节梳理一遍给打算在项目里用工厂模式的朋友做一个参考。2. 从手写工厂到多态工厂最朴素路线的完整拆解2.1 最简单也最容易失控的手写分支工厂先看一个最原始的工厂函数长什么样。假设我们有一个抽象基类MessageProcessor三种具体的处理器工厂函数长这样// MessageProcessor.h class MessageProcessor { public: virtual ~MessageProcessor() default; virtual void process(const std::string data) 0; }; // 具体产品类 class JsonProcessor : public MessageProcessor { public: void process(const std::string data) override; }; class XmlProcessor : public MessageProcessor { public: void process(const std::string data) override; }; class CsvProcessor : public MessageProcessor { public: void process(const std::string data) override; }; // ProcessorFactory.h #include memory #include string #include stdexcept class ProcessorFactory { public: static std::unique_ptrMessageProcessor create(const std::string type) { if (type json) { return std::make_uniqueJsonProcessor(); } else if (type xml) { return std::make_uniqueXmlProcessor(); } else if (type csv) { return std::make_uniqueCsvProcessor(); } throw std::invalid_argument(Unknown processor type: type); } };这种写法的优点是直白不引入任何额外机制一分钟就能写完。但它有两个很要命的问题。第一个问题是扩展困难每加一种新的MessageProcessor类型都要打开工厂函数修改这个if-else链。如果工厂函数本身在一个很稳定的模块里这种改动就破坏了“开闭原则”——对扩展开放、对修改关闭。第二个问题是分类依据和产品类之间的映射关系被硬编码在代码里而且是分布式的。如果系统里有两个地方调用这个工厂但一个传json另一个传JSON调用方就会踩到字符串大小写不一致的地雷。这种问题在现实项目里非常常见我在模拟项目X里就亲眼见过因为大小写不统一导致的线上故障。2.2 多态工厂把变化收敛到一处多态工厂的核心思路是不再让工厂函数直接依赖具体产品类而是依赖一个“注册接口”。具体产品类自己负责把自己注册进去工厂只需要维护一张映射表。这里要区分一下两个容易混淆的概念简单工厂Simple Factory和工厂方法Factory Method。上面那段 if-else 版本就是简单工厂它是类里的一个静态工具方法。而工厂方法模式是把创建动作下沉到子类每个具体工厂负责创建自己对应的产品。但在这个基础上有了现代C的std::function和std::unordered_map我们可以做一个更灵活的“注册表工厂”// ProcessorRegistry.h #include functional #include memory #include string #include unordered_map class MessageProcessor; class ProcessorRegistry { public: using Creator std::functionstd::unique_ptrMessageProcessor(); static ProcessorRegistry instance() { static ProcessorRegistry registry; return registry; } void registerCreator(const std::string type, Creator creator) { creators_[type] std::move(creator); } std::unique_ptrMessageProcessor create(const std::string type) const { auto it creators_.find(type); if (it creators_.end()) { throw std::invalid_argument(Unknown processor type: type); } return it-second(); } private: std::unordered_mapstd::string, Creator creators_; };配合每个具体类的静态注册// JsonProcessor.cpp #include JsonProcessor.h #include ProcessorRegistry.h bool g_jsonProcessorRegistered [] { ProcessorRegistry::instance().registerCreator( json, [] { return std::make_uniqueJsonProcessor(); }); return true; }();这里g_jsonProcessorRegistered是一个文件级静态变量初始化时执行lambda完成注册。这样做的好处是新增一种处理器时只需要新增一个.cpp文件不需要改动任何已有文件。完全符合开闭原则。2.3 多态工厂的边界和适用场景这种注册表工厂不是万能的它适合一个特定场景产品类型数量较多、并且经常增长的项目。举个例子某插件式系统里有十几种格式的解析器团队每周都要加一两种新的。用注册表工厂每个人只需要写自己的解析器类和对应的注册代码不会跟别人的代码产生合并冲突。但如果产品类型就三五种、一年都不变一次用if-else手写工厂反而更简单——类多了以后再去重构也不迟过早设计往往得不偿失。另外一个判断维度是产品对象的依赖情况。如果每种产品对象的构造参数差异很大——比如A需要两个字符串B需要一个整型加一个回调函数——用统一的Creator签名std::functionstd::unique_ptrMessageProcessor()就不够用了。这时候需要模板工厂或带可变参数的工厂后面第5章会详细讨论。3. unique_ptr与抽象基类现代C工厂实现的关键动作3.1 为什么返回值必须是unique_ptr在工厂函数里产品对象的返回方式直接决定了调用方的使用体验和资源管理是否正确。先看两种不合适的返回方式。第一种是返回裸指针static MessageProcessor* create(const std::string type);这种方式把所有权交给调用方但调用方是否记得delete是个问题。如果调用方代码里恰好有一个异常抛出来裸指针就会变成泄漏源。第二种是返回引用static MessageProcessor create(const std::string type);这暗示对象由工厂本身管理生命周期但它可能是局部对象、堆对象调用方无法确认。真要用的话还得在工厂里维护一个对象池复杂度明显上升。推荐的做法是返回std::unique_ptrMessageProcessor把所有权明确转移给调用方。这个思路很像生活里的取快递——快递到了驿站驿站给你一个取件码这个取件码就是所有权凭证。收到快递之后你什么时候拆、什么时候处理都由你决定驿站不再负责。用智能指针还有一个好处即使业务逻辑里抛了异常unique_ptr的析构函数也能保证堆对象被正确释放。这是所有现代C工厂实现的基本盘。3.2 抽象基类析构函数的细节陷阱抽象基类MessageProcessor的接口里有一个容易被忽视的细节——析构函数必须声明为virtual而且最好是defaultclass MessageProcessor { public: virtual ~MessageProcessor() default; virtual void process(const std::string data) 0; };为什么这很重要因为调用方拿到的通常是std::unique_ptrMessageProcessor。当这个智能指针销毁时它调用的析构函数是基于静态类型MessageProcessor的。如果基类析构函数不是虚函数那派生类JsonProcessor里面的资源永远不会被释放这就是典型的“切片泄漏”。我在某次代码评审里见过一个真实案例某个同事没在基类里写virtual测试时也没报错看起来一切正常。后来用内存检测工具一跑每个处理器对象都有泄漏报告。排查了一整天才定位到是析构函数的问题。3.3 现代C风格代码示意把前面两个要点结合起来一个规整的工厂代码大概是这个模样的#include memory #include string class Logger { public: virtual ~Logger() default; virtual void log(const std::string msg) 0; }; class ConsoleLogger final : public Logger { public: void log(const std::string msg) override; }; class FileLogger final : public Logger { public: FileLogger(const std::string path, bool flushImmediately); void log(const std::string msg) override; }; class LoggerFactory { public: std::unique_ptrLogger createConsole() { return std::make_uniqueConsoleLogger(); } std::unique_ptrLogger createFile(const std::string path) { return std::make_uniqueFileLogger(path, /*flushImmediately*/true); } };这样写有几个好处createConsole和createFile是两个独立方法参数清晰地表达各自需要的依赖返回类型统一为unique_ptrLogger调用方拿到对象后无需关心具体实现类不需要再给LoggerFactory加分支逻辑新增一种日志类型只需新写一个类和一个工厂方法。这种“独立工厂方法”模式适合产品类型固定、参数差异明显的场景它比if-else工厂更清晰又比注册表工厂更简单。4. 注册表驱动大批量扩展时的解耦方案4.1 直观做法switch里继续加case很多项目初期都是这么干的。假设有一个命令分发系统支持几十条命令bool CommandDispatcher::dispatch(const std::string cmd, const std::vectorstd::string args) { if (cmd ping) { return handlePing(args); } else if (cmd echo) { return handleEcho(args); } else if (cmd time) { return handleTime(args); } // 继续加越来越多 logError(Unknown command: cmd); return false; }当命令数量超过十个这个分发函数就会变成一座屎山。每加一条命令都要修改这个函数而且不同命令的参数解析逻辑全混杂在一起。这时候注册表模式就该上场了。4.2 注册表工厂的核心原理注册表模式的核心是把“命令名”和“处理函数”的对应关系集中到一个容器里。C里常用的容器就是std::unordered_map。每个命令在自己的.cpp文件里完成注册分发函数只需要查表。// CommandRegistry.h class CommandRegistry { public: using Handler std::functionbool(const std::vectorstd::string); static CommandRegistry instance(); void registerHandler(const std::string name, Handler handler) { handlers_[name] std::move(handler); } bool dispatch(const std::string name, const std::vectorstd::string args) const { auto it handlers_.find(name); if (it handlers_.end()) { return false; } return it-second(args); } private: std::unordered_mapstd::string, Handler handlers_; };注册动作可以放在每个命令文件的顶部// PingCommand.cpp static bool pingHandler(const std::vectorstd::string args) { // 实现 ping 逻辑 ... return true; } static bool registered [] { CommandRegistry::instance().registerHandler(ping, pingHandler); return true; }();这种方式的优越性主要体现在团队协作上每个人的命令都写在自己的文件里几乎不会跟别人的代码产生冲突。而且删除一条命令只需要删掉对应文件不用去改动分发逻辑。4.3 自动注册技巧的细节与陷阱上面代码里的static bool registered []{...}();是一个文件级静态变量初始化技巧。它利用了静态初始化的时机程序启动时所有文件级静态变量会按翻译单元.cpp文件的顺序进行初始化。每个命令文件里的registered变量在main()运行之前就已经把对应的Handler注册进了CommandRegistry。这个技巧目前被广泛使用但它有一个隐患如果某个命令文件只包含静态变量初始化没有其他符号被引用链接器可能把它整个优化掉导致注册不生效。在静态库.a 文件场景下尤其容易踩坑。遇到这种情况可以给链接参数加上--whole-archive或者用一个明确的registerAll()函数在main()开头统一调用把注册动作变成显式行为。显式注册的可读性更好排查问题更方便。自动注册适合插件数量极多的场景但要确保链接器不会裁剪掉这些翻译单元。我在实际项目里倾向于先用显式注册等数量多到实在影响编码效率时再考虑自动注册。当然自动注册也有一个变体做法是在类的构造函数里注册但构造函数分散在运行期执行时机更难控制。4.4 注册表工厂和工厂模式的关系严格来说注册表模式可以看作工厂模式的一种扩展变体。它把“创建对象”的工厂方法也注册进了查找表只不过查找表的键从“类型名”变成了“命令名/处理器名”值从“创建函数”变成了“处理函数”。从使用感受来说这种模式比纯工厂模式更贴近业务——调用方关心的不是“创建一个对象”而是“让这个处理器干活”。如果你在做一个插件化架构的系统注册表几乎是最佳起点。它为将来接入动态加载比如把每个插件编译成独立的动态库打下了基础只需要在加载库的时候调用对应的注册函数不用修改主程序。5. 模板工厂与可变参数消除重复代码的更高阶玩法5.1 模板工厂的抽象模型上面的注册表工厂有个局限所有注册的创建函数签名必须是std::functionstd::unique_ptrMessageProcessor()也就是无参数。但现实世界里的产品对象往往带参数——日志需要文件路径网络客户端需要IP和端口数据库连接需要主机、用户名、密码。这时候模板工厂就能救场。模板工厂的核心思路是把“构造产品类所需的参数”作为可变参数模板让工厂方法能适配任意构造函数签名。template typename Product, typename... Args std::unique_ptrProduct createWithArgs(Args... args) { return std::make_uniqueProduct(std::forwardArgs(args)...); }举例说明。假设有这样一个HttpClientclass HttpClient { public: HttpClient(const std::string host, uint16_t port, bool useTls); ... };用模板工厂创建auto client createWithArgsHttpClient(example.com, 443, true);这样createWithArgs就变成了一把万能钥匙任何带构造参数的类都能通过它创建不需要为每个类写专门的工厂方法。如果要做得更进一步可以把工厂方法本身也放进一个注册表支持参数不同的多种注册项。C17以后可以借助std::variant或类型擦除来存放返回不同类型的工厂函数。但这种设计的复杂度会明显上升适用于产品类型和参数组合特别丰富的场景。5.2 模板工厂带来的代价模板工厂并没有看起来那么完美主要有三个代价。第一是代码膨胀。每用一次createWithArgsProduct, Args...编译器都会实例化一份对应的工厂代码。如果参数组合特别多生成的机器码体积会变大最终可执行文件的加载和缓存表现会受影响。第二是编译期错误信息难以阅读。当模板参数不匹配时编译器给出的报错经常是几十行的模板展开新人根本看不懂。我记得有次在模板工厂里传错了一个参数类型Clang输出了一整屏的报错信息最后还是靠static_assert和概念C20 Concepts才把问题定位出来。第三是无法很好地支持运行时参数解析。上面createWithArgs只能处理编译期已知的参数类型。如果参数来自运行时配置比如从JSON配置文件里读取的一堆键值对模板工厂就无能为力了——那些参数需要先转换成具体类型才能传给模板函数。5.3 什么时候值得上模板我的经验是模板工厂最适合中间件、SDK、算法插件这类产品类型多、构造参数组合复杂的场景。例如某图像处理Demo里有几十种滤镜算法每种算法的初始化参数都不一样调用方通过统一的接口创建滤镜对象。这时候手写每个算法的工厂方法会很累注册表又无法容纳不同的参数签名模板工厂就成了折中且高性价比的方案。但如果只是做一个业务模块产品类就三五个参数也不复杂那手写工厂方法或注册表就够用了。模板工厂不是银弹它是工程复杂度和编译期复杂度的交换。6. 实战排雷工厂模式落地时的常见坑6.1 构造失败与异常安全很多工厂函数直接返回make_unique但产品对象的构造函数很可能抛异常——比如文件不存在、端口被占用、网络握手失败。C里异常在make_unique内部会被正确处理不会泄漏内存但问题在于工厂的调用方不一定做了异常处理。我在某跨平台系统里就遇到过工厂函数创建了一个网络客户端对象构造函数里尝试连接远程服务器连接失败直接抛出std::runtime_error。而调用方代码在try块外面异常一路传到最顶层整个程序直接退出。这暴露出来的是异常设计问题不是工厂模式本身的问题。排雷建议如果产品对象构造可能失败让工厂显式处理异常并给调用方一个中性的错误码或者返回std::optionalstd::unique_ptrProduct如果产品对象构造失败是被允许的业务状态比如某种格式解析器不支持当前数据用异常作为控制流会很糟糕应该用“返回值 错误对象”的模式如果决定用异常确保工厂函数的文档里写清楚“可能抛出哪些异常”方便调用方处理。我在实际代码里更倾向于第一种做法因为异常在日志里打印出来的堆栈往往已经丢失了业务上下文调试起来很麻烦。6.2 注册时机和静态初始化顺序自动注册的static bool registered []{...}();有一个隐形的雷如果注册动作依赖某种全局配置而配置文件加载得比静态初始化晚那注册就会失效或注册错内容。考虑一个通用场景某配置模块在main()里读取配置文件初始化日志系统。而某个产品类的注册lambda用到日志系统来输出调试信息。由于静态初始化发生在main()之前日志系统还没准备好注册时调用日志接口可能引发空指针或者未定义行为。解决方案有三种把注册动作改成显式调用放在main()开头、配置加载之后注册lambda里不依赖全局状态只做纯粹的“登记”动作把依赖推迟到实际创建对象时使用“惰性注册”——对象在第一次被创建时才完成注册但这需要额外的锁保护比较复杂。我自己比较推崇第二方案注册动作保持纯粹不让它做任何有副作用的事情构造函数里的依赖等对象创建时再去解析。这样既保留了自动注册的便捷又避开了静态初始化顺序的坑。6.3 多线程环境下的懒加载如果工厂采用“懒加载”——第一次调用某个类型时才真正构造产品对象——那么多线程环境下需要保证线程安全。C11以后局部静态变量的初始化是线程安全的所以最省心的做法就是利用这个机制class ProcessorFactory { public: static MessageProcessor getJsonProcessor() { static auto processor std::make_uniqueJsonProcessor(); return *processor; } };如果产品对象被多个线程共享使用而且不可重入那就不能这样搞。这种情况下工厂应该每次都返回新的unique_ptr由业务层自己决定是否加锁或使用线程局部存储。在工厂层面做线程安全的对象池是另一套复杂系统非必要不要自己实现。排查这类问题时最快的手段是给工厂加一个拨测接口在压力测试下反复调用创建逻辑再用多线程调试工具检查是否有数据竞争。我见过有人在单线程测试下一切正常一上多线程平台就随机崩溃就是因为共享了同一个不带锁的处理器实例。6.4 选型决策总结不同工厂实现方式的取舍可以收集在一张表里方便在不同项目里快速决策实现方式适用场景主要缺点扩展成本手写if-else工厂产品类型固定、数量极少违反开闭原则改一处漏一处中独立工厂方法参数差异明显、类型不多每新增类型都要新增方法中注册表工厂类型多、增长快、团队协作自动注册有静态初始化风险低模板工厂参数组合复杂、类型多编译时间变长报错难读低注册表模板融合插件系统、SDK复杂度最高新手难维护低如果让我给一个起步建议从注册表工厂开始显式注册产品构造函数尽量保持无参或单参。等出现参数组合复杂的需求再引入模板工厂——注意不要一上来就追求最高级玩法工厂模式的核心不是写得炫而是让代码扩展时少改文件、少出事故。最后补一个我在实际代码里的经验习惯工厂函数所在的头文件尽量只暴露Product抽象基类和工厂接口不要暴露具体的产品类。这样调用方根本看不到具体的类名想绕过工厂直接实例化都没有途径。这个“最小可见性”原则配合工厂模式能极大降低模块之间的耦合度。
阅读完成 · 觉得有帮助?
咨询建站