恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
C++工厂方法模式实战:从原理到工程落地
首页
资讯中心
/
C++工厂方法模式实战:从原理到工程落地
C++工厂方法模式实战:从原理到工程落地
发布时间:2026/10/15 12:01:04
工厂方法模式这名字看着像教科书里的老古董但实际项目里它的出场率极高。凡是你要写框架、做插件系统、支持多平台渲染、按文件类型分发解析器基本都绕不开它。这篇文章我不会从头给你念教材定义而是从“为什么需要它”讲起直接给你一份能在工程里落地的 C 实现方案顺便把我踩过的那些坑一并交代清楚。适合谁看刚把 C 语法摸了个大概、开始在项目里琢磨设计模式的读者以及写过一阵子业务代码、觉得switch分支越堆越臭、想重构又不知道从哪下手的同学。看之前只需要一个前提你知道类继承、虚函数、智能指针是干什么的就行。1. 一个伪装成构造函数的问题1.1 工厂方法模式到底是什么先花两分钟把概念锚定住。工厂方法模式的官方定义是定义一个用于创建对象的接口让子类决定实例化哪一个类。这句话信息密度不低拆开了就是三层意思有一个统一的“创建入口”客户端只跟这个入口打交道入口本身是个接口不一定自己干活而是把“具体创建哪个对象”的决策权下放给子类创建出来的东西客户端只关心它的抽象类型基类接口不关心具体实现。打个比方。你开一家餐厅菜单上写着“今日甜点”。至于今天是提拉米苏还是抹茶蛋糕由后厨子类决定。客人不会走进厨房喊“给我来一份安佳淡奶油做的提拉米苏”他只需要对着菜单点“甜点”后厨自己知道今天该做什么。对应到代码里今日甜点就是工厂方法后厨就是具体工厂子类甜点就是产品对象客人就是客户端代码。1.2 它解决了什么痛点你要是不用工厂方法最常见的写法是直接在客户端里switch或者if-else判断类型然后挨个调用不同类的构造函数std::unique_ptrParser createParser(const std::string fileType) { if (fileType json) { return std::make_uniqueJsonParser(); } else if (fileType xml) { return std::make_uniqueXmlParser(); } else if (fileType yaml) { return std::make_uniqueYamlParser(); } else { throw std::invalid_argument(unknown type); } }这段代码能跑但问题很明显第一每新增一种文件格式你就要回来改这个函数再加一个else if。改着改着这个函数的职责就膨胀了今天加格式、明天加配置、后天加权限判断一个“创建对象”的简单动作最后变成几百行的业务杂烩。第二所有调用方都耦合到了这个具体判断逻辑上。一旦某个格式被废弃、某个分支要调整牵一发动全身。第三违反了一个重要原则对扩展开放对修改封闭。理想状态应该是新增一种产品类型时新建一个产品类、新建一个对应的创建者子类就够了。已有的稳定代码一行都不用动。工厂方法模式的思路就是把上面那段if-else从客户端代码里挪走放回到“具体的工厂子类”里让每个子类只负责创建自己那一类产品。客户端对着一堆不同的工厂子类调用同一个创建接口拿到的却是各自对应的产品。2. 模式核心四个角色一张网2.1 四个参与者的职责划分工厂方法模式听起来抽象其实角色非常固定就四个理解透了这模式也就拿下了。Product产品接口定义产品的公共接口。所有具体产品都必须实现这个接口这样客户端才能用统一的方式操作它们。ConcreteProduct具体产品实现产品接口的具体类。比如上文里的 JSON 解析器、XML 解析器各是各的实体。Creator创建者基类声明工厂方法createProduct()并定义依赖该工厂方法的其他逻辑。注意基类里的工厂方法可以是纯虚的让子类完全自己实现也可以提供一个默认实现子类选择覆写。ConcreteCreator具体创建者覆写工厂方法返回具体的产品实例。一个具体创建者通常只负责一种具体产品的创建。这四个角色的关系用一个很朴素的说法概括就是创建者基类定义“怎么用产品”的模板具体创建者负责“造什么产品”。至于产品内部的构造细节、初始化流程创建者不管那是具体产品自己的事。2.2 为什么说它是虚拟构造函数业内有个说法工厂方法模式的本质是“虚拟构造函数”virtual constructor。C 的语法规定构造函数不能是虚函数因为构造对象时对象还没产生虚函数机制压根无从谈起。但业务上你确实需要“多态的创建”同一个创建接口在不同上下文里返回不同类型的对象。工厂方法就是绕开语言限制、手工模拟虚构造函数的手段基类声明创建接口子类提供不同的实现行为上等效于“构造函数被虚化了”。理解到这一层很多问题就豁然开朗。比如我之前带过的一位新人他死活想不通“为什么不能直接在基类构造函数里调用工厂方法”。原因就在于基类构造函数执行时子类部分还没构造完成此时调用任何虚函数都不会走到子类实现这属于 C 的未定义行为而且编译器连警告都不一定给你出。工厂方法创造的“延迟绑定”恰恰规避了这个坑调用发生时对象已经完全构造好虚函数自然工作正常。再说一个工程层面的原因。构造函数后紧跟初始化逻辑的场景非常常见创建完对象要注册到管理器、要加载配置文件、要建立连接。这些逻辑放在构造函数里很别扭尤其是要抛异常的时候——构造函数里抛异常对象是没构造完成的资源回收就得格外小心。把“创建 后续处理”打包放到工厂方法里你的控制力会强得多异常安全也好设计。3. C落地从理论基础到可编译代码3.1 传统基础版的重点工厂方法模式本身历史悠久但 C 里的实现方式一直在进化。先看一个“教科书经典版”的核心框架// 产品接口 class Document { public: virtual ~Document() default; virtual void open() 0; virtual void save() 0; }; // 具体产品 A class TextDocument : public Document { public: void open() override { /* 打开文本文件的逻辑 */ } void save() override { /* 保存文本文件的逻辑 */ } }; // 具体产品 B class ImageDocument : public Document { public: void open() override { /* 打开图片文件的逻辑 */ } void save() override { /* 保存图片文件的逻辑 */ } }; // 创建者基类 class Application { public: virtual ~Application() default; // 工厂方法负责创建产品 virtual std::unique_ptrDocument createDocument() 0; // 使用产品的模板逻辑 void newDocument() { auto doc createDocument(); doc-open(); // 其他业务逻辑... m_docs.push_back(std::move(doc)); } private: std::vectorstd::unique_ptrDocument m_docs; }; // 具体创建者 A class TextApplication : public Application { public: std::unique_ptrDocument createDocument() override { return std::make_uniqueTextDocument(); } }; // 具体创建者 B class ImageApplication : public Application { public: std::unique_ptrDocument createDocument() override { return std::make_uniqueImageDocument(); } };这个例子是经典文档编辑器案例的精简版。你注意几个关键设计决策工厂方法的返回类型必须是产品基类或其抽象引用不能返回具体类型。否则客户端拿到的是具体类型就无法实现多态使用。这就是为什么返回std::unique_ptrDocument而不是std::unique_ptrTextDocument。基类要声明虚析构函数这是 C 的硬规矩。只要类里有虚函数尤其是这个类要通过基类指针管理派生类对象的生命周期析构函数就必须加virtual。忘了这一条delete基类指针时就不会调用派生类析构函数资源泄漏悄悄发生。返回值用智能指针我第一次正经写这个模式时用的是裸指针每次调用工厂都要记得delete。业务一复杂漏删一处内存泄漏就埋伏上了。现在统一用std::unique_ptr工厂方法天然表达“创建出来的对象所有权归调用者”调用者用完自动释放连异常安全都顺带解决了——因为unique_ptr的析构是 RAII 的栈展开时一定会执行。使用者在面对抽象Application::newDocument()里从头到尾没有出现任何具体产品类名。它只知道createDocument()会返回一个Document于是就能调用Document接口上的方法。这就是“依赖于抽象不依赖于具体”的直观体现。3.2 现代 C 写法的几个改进上面这个版本已经能用了但工程上还有几个可以打磨的点。用override关键字显式标注覆写这是 C11 就有的特性但我在实际 code review 里还是见过有人不写。override让编译器帮你检查如果函数签名和基类不一致比如漏了const编译直接报错不会等你运行时才发现“咦怎么调的是基类的实现”。工厂方法可以考虑提供默认实现不一定非得是纯虚的。有些创建者基类有“默认产品”的概念子类不覆写就用默认的覆写了就用定制的。这给了使用者更大的灵活性。参数化工厂方法前面提到按文件类型创建解析器的场景工厂方法可以接收一个参数根据参数返回不同产品。这其实是工厂方法模式里一种常见变体。不过要小心参数化工厂方法和“简单工厂”的边界容易模糊后面我会专门讲区别。配合auto简化客户端代码既然返回值类型是明确的客户端可以用auto承接减少类型书写的噪音void Application::openRecent(const std::string filePath) { auto doc createDocument(); doc-openFrom(filePath); m_docs.push_back(std::move(doc)); }3.3 对象切片最容易忽视的严重问题我在实际排查过一个非常隐蔽的 bug症状是工厂方法返回了一个派生类对象客户端拿到的却像是个“残废版”的基类对象调用派生类特有的接口直接崩溃。问题根源就是对象切片object slicing。有人贪图方便工厂方法返回类型写成了基类的值类型比如Document而不是std::unique_ptrDocumentDocument createDocument() { // 错误示范 return TextDocument(); // 切片发生了 }这里TextDocument会被切割成Document派生类部分的成员全部丢失。编译器偶尔会给个警告但很多时候是静默的。而且这个“切片版对象”析构时不会调用派生类析构函数又是一个隐患。正确做法永远只有一个通过指针或引用的方式返回多态对象现代 C 首选std::unique_ptr等智能指针。记住在 C 里值和多态天生不兼容。4. 实战案例一套支持多格式的日志系统理论讲得再透彻不如把工程案例完整过一遍。我带过一个小型日志采集模块需求是这样的支持控制台输出、文件输出、网络上报三种日志目的地每种目的地有不同的配置参数文件路径、上报接口地址等日志模块是公共库使用方可能只想要其中一种也可能三种全要未来极大概率会新增目的地比如消息队列。你品一下这个需求“未来要新增 使用方按需选择 创建逻辑可能复杂”这就是工厂方法模式的典型温床。4.1 产品与创建者的设计先设计产品接口。所有日志目的地都得支持write(level, message)以及可选的flush()enum class LogLevel { Debug, Info, Warn, Error }; class LogSink { public: virtual ~LogSink() default; virtual void write(LogLevel level, const std::string message) 0; virtual void flush() {} };三个具体产品class ConsoleSink : public LogSink { public: void write(LogLevel level, const std::string message) override { std::cout formatMessage(level, message) std::endl; } void flush() override {} }; class FileSink : public LogSink { public: explicit FileSink(const std::string filePath) { m_stream.open(filePath, std::ios::app); if (!m_stream.is_open()) { throw std::runtime_error(failed to open log file: filePath); } } void write(LogLevel level, const std::string message) override { m_stream formatMessage(level, message) std::endl; } void flush() override { m_stream.flush(); } ~FileSink() override { if (m_stream.is_open()) m_stream.close(); } private: std::ofstream m_stream; }; class NetworkSink : public LogSink { public: explicit NetworkSink(const std::string endpoint) : m_endpoint(endpoint) { // 实际项目里这里会建立连接、做鉴权等 } void write(LogLevel level, const std::string message) override { // 发送数据到远端 } void flush() override {} private: std::string m_endpoint; };注意到FileSink的构造函数里有一处文件打开失败检查抛异常。这个细节等会还会提到因为异常会穿过工厂方法考验你的异常安全设计。接着是创建者。这里我选择用参数化工厂方法因为“按目的地类型创建”本身就是模块对外的主要入口enum class SinkType { Console, File, Network }; class SinkFactory { public: virtual ~SinkFactory() default; virtual std::unique_ptrLogSink createSink(SinkType type) 0; }; class DefaultSinkFactory : public SinkFactory { public: std::unique_ptrLogSink createSink(SinkType type) override { switch (type) { case SinkType::Console: return std::make_uniqueConsoleSink(); case SinkType::File: return std::make_uniqueFileSink(m_defaultFilePath); case SinkType::Network: return std::make_uniqueNetworkSink(m_defaultEndpoint); default: throw std::invalid_argument(unsupported sink type); } } // 允许使用者修改默认参数 void setDefaultFilePath(std::string path) { m_defaultFilePath std::move(path); } void setDefaultEndpoint(std::string endpoint) { m_defaultEndpoint std::move(endpoint); } private: std::string m_defaultFilePath app.log; std::string m_defaultEndpoint http://127.0.0.1:8080/log; };为什么这里仍然抛出异常而不是返回nullptr这是工程上一个值得斟酌的点。nullptr会把错误悄然传递给调用方调用方如果忘了判空后续解引用直接崩溃而异常则迫使调用方处理或让异常继续上抛错误在更早的阶段暴露。而且现在调用方拿到的是unique_ptr如果返回nullptr调用方直接sink-write(...)在大多数编译器和运行时环境下就是段错误问题定位成本很高。抛异常虽然让调用方多写一个try-catch但从工程可靠性上看是值得的。4.2 调用方的视角使用这个日志模块的代码长这样class Logger { public: explicit Logger(std::unique_ptrSinkFactory factory) : m_factory(std::move(factory)) {} void initialize(SinkType type) { m_sink m_factory-createSink(type); } void log(LogLevel level, const std::string message) { if (m_sink) { m_sink-write(level, message); } } private: std::unique_ptrSinkFactory m_factory; std::unique_ptrLogSink m_sink; };客户端使用int main() { auto factory std::make_uniqueDefaultSinkFactory(); Logger logger(std::move(factory)); logger.initialize(SinkType::File); logger.log(LogLevel::Info, application started); return 0; }你看Logger完全不知道世界上存在ConsoleSink还是FileSink它面对的只有SinkFactory::createSink()和LogSink::write()。将来新增一个KafkaSink代码改动范围严格限制在SinkType枚举加一个值新增KafkaSink类DefaultSinkFactory::createSink()加一个case。其他地方一行都不用动。这就是开闭原则带给你的红利。我在这个项目中测过从“写一个新的日志接入方”到“编译通过 功能验证”一个熟悉代码库的工程师大概不到半小时就能完成而且不会碰坏任何旧功能。4.3 构造失败时怎么办这节专门展开讲异常因为这里藏着一个容易被忽视的设计决策。看这个场景FileSink构造时文件打开失败抛出了std::runtime_error。这个异常会从工厂方法里穿出来到达Logger::initialize()再往上到main()。问题来了Logger对象此时处于什么状态这取决于你怎么写initializevoid initialize(SinkType type) { m_sink m_factory-createSink(type); }在createSink抛异常时m_sink的赋值根本不会发生m_sink保持原状要么是nullptr要么是之前那个正常工作的 sink。这个行为是由 C 的异常安全保证决定的如果赋值操作本身不抛异常且右侧的求值先于赋值发生那么异常发生时赋值压根不会执行。这就给了一个很好的兜底策略如果当前 logger 已经在用控制台输出尝试切换到文件输出时文件打开失败logger 可以优雅地继续用控制台而不是直接崩溃。用户最多看到一条“文件打开失败”的上层错误提示程序还能正常运行。我在另一个项目里见过反面教材工厂方法内部捕获所有异常然后返回nullptr。结果调用方拿到nullptr后仍然继续调用write()段错误发生在生产环境里排查了半天才发现是“临时目录没权限”这种小事。所以我在代码注释里专门写了一条工厂方法不要吞异常把决策权留给调用方。5. 常见误区和排查实录5.1 误区一为造对象而造对象过度设计工厂方法模式不是银弹。它的核心价值是解耦和扩展性但引入的代价是类数量翻倍每多一个产品就要多一个创建者子类或者多一个分支。判断“值不值得用”有一个简单标准如果创建对象的逻辑只有一行return make_uniqueThing();而这个 Thing 在未来可预期的两三年内不会有第二种子类那用工厂方法纯粹是给自己加戏。等产品种类真的多起来了再从简单创建重构到工厂方法成本一点也不高。我个人的经验阈值是产品种类大约到 3 类以上或者客户端明确存在“面向接口调用、不关心具体类型”的需求才上工厂方法。1~2 种类型时直接在构造函数里做好或者用命名构造函数都行。5.2 误区二构造函数里搞魔法有人会把工厂逻辑塞进构造函数期望通过某个成员变量来判断该创建哪种产品。这在 C 里几乎必然翻车原因前面提到过构造函数执行期间虚函数不派发到派生类。还有一个更隐蔽的变种基类构造函数里调用一个普通的、非虚的init()函数这个init()里再调用虚的工厂方法。看起来好像绕过了“构造期间调用虚函数”的问题实际上没有。在基类构造函数执行init()时对象的动态类型仍然是基类虚函数照样不会走到派生类版本。这个坑我在一次代码评审里看到过写代码的人一脸笃定地说“我测过没问题的”——那是因为他的测试场景恰好没有触发那个分支。规则很简单也很暴力不要在构造函数里调用任何虚函数也不要在构造函数里调用最终会调度到虚函数的间接函数。5.3 误区三工厂方法返回类型写错把返回类型写成具体产品或者写成基类值类型都会出问题。前者让多态失效客户端硬编码依赖具体类型后者直接对象切片运行时行为完全不可预期。排查这个问题的技巧如果发现“对象的类型对了但行为不对”比如调不到派生类扩展的接口或者虚函数调用表现异常优先检查工厂方法的返回类型是不是指针/引用形式的基类。别先怀疑业务逻辑这种低级错误往往逍遥得很久。5.4 误区四单例工厂和全局工厂我曾经一度想把工厂做成单例觉得“一个工厂全局用多方便”。后来在并发测试里撞了墙工厂内部有状态比如默认文件路径、默认连接端点多线程同时调用createSink()时状态被互相覆盖创建出来的产品配置错乱。教训就是工厂最好保持“无状态”或仅持有不可变配置如果要动态配置给每个线程或每个使用者持有自己的工厂实例或者引入同步机制。全局唯一工厂 可变状态是并发 bug 的温床。另外还要警惕“服务定位器”式的全局工厂——把所有类型的创建逻辑都塞进一个巨大的静态工厂类里。那本质上又是一个变种的上帝类复杂度会随着产品种类线性膨胀还不如老老实实用几个互相解耦的工厂子类。5.5 一份问题排查速查表现象可能原因排查方向创建出的对象调用方法崩溃对象切片 / 返回 nullptr 未判空检查工厂返回类型是否为基类指针检查调用方是否判空虚函数行为异常调用到基类实现构造函数内调用虚函数检查构造函数中是否有直接或间接的虚调用新增产品类型后旧代码编译失败工厂方法和产品接口耦合过紧新增类型漏改接口检查产品接口是否有纯虚函数未实现并发创建时产品配置混乱工厂内部有可变状态检查工厂成员的线程安全性改为无状态或加锁内存泄漏裸指针管理工厂返回值 / 基类缺虚析构改用 unique_ptr给抽象基类加 virtual ~新增产品需要改很多地方可能用错了模式简单工厂写成了全局分支重新评估产品维度考虑按维度拆分工厂层次6. 工厂方法 vs 简单工厂 vs 抽象工厂这三个概念同居一室很多人容易搞混。我给出一个非常朴素的区分方式。简单工厂不是一个 GoF 设计模式只是一个编码习惯。它通常是一个静态函数或一个不做多态的类内部用switch或if-else决定创建什么产品。客户端不直接 new但静态函数本身没法被继承扩展新增产品还是要改它。简单工厂适合产品种类较少、变化不频繁的小场景。工厂方法就是本文的主角。它把创建逻辑做成虚函数每个子类决定创建什么。扩展方式是继承。抽象工厂解决的问题是“一族相关产品”的整体创建。比如跨平台 UI 库一族的控件按钮、输入框、列表必须在同一个平台上保持一致风格你不能一个用 Windows 风格按钮、配一个 Mac 风格输入框。抽象工厂接口里声明一组工厂方法每个工厂方法创建一个族内产品。啥时候用哪个一句话版本就一个产品维度、少量类型懒得引入太多类 → 简单工厂一个产品维度、类型可能扩展、客户端要解耦 → 工厂方法多个产品维度、且产品之间存在配套约束 → 抽象工厂。要注意的是实际项目里这三种写法经常共存。比如抽象工厂的每个创建方法内部可能又组合了工厂方法或简单工厂逻辑。设计模式不是互斥的教条它们是可以嵌套搭配的工具。7. 我在工程中实践后的一些体会最后分享几个实实在在的体感。工厂方法模式不是写出了“一个工厂类”就算会用真正的判断标准是当需求变化到来时你的改动是不是被限制在一个很小的范围内。如果你的工厂方法实现得很好新增一种产品类型应该像“往书架里插一本新书”一样自然而不是要把整个书架拆了重建。C 实现这个模式的现代姿势绕不开三个关键词unique_ptr、虚析构、override。这三个点做到位内存和异常安全就有了基本盘。我见过太多人把工厂方法模式学了个形似返回裸指针、基类析构没加virtual用起来每天都在雷区边缘试探。另外模式学习不要止步于“会写代码”。试着把工厂方法放在更大的上下文里看配合模板方法模式创建者基类可以定义一套完整的“使用产品的流程”配合注册表/原型模式可以实现基于配置的反射式创建配合策略模式工厂创建出来的产品可以携带不同的行为算法。设计模式之间是互相咬合的齿轮单看一个是片面的把它们组合起来才是真正的架构能力。如果你正打算重构一段满屏if-else的创建逻辑动手前建议先在纸上画出四个角色标清楚谁创建谁、谁依赖谁再落代码。图本身不需要多美观但画的过程会逼你想清楚职责边界。这一步省了后面调试多花的时间往往远比画图多。