C++工厂方法模式详解:从new解耦到智能指针实战
做C开发这些年我发现一个很有意思的现象只要是聊到设计模式工厂方法模式几乎永远是第一个被拿出来讲的。面试要问项目要用的翻开《设计模式》这本书第一个创建型模式也是它。但很多朋友学完之后有个共同的困惑这个模式看起来很简单不就是把new抽出来放到一个函数里吗它到底解决了什么问题为什么大家这么重视它这篇文章我想用自己在实际项目中反复踩坑、重构、再优化的经历把C里的工厂方法模式讲透。不仅讲它是什么更重要的是讲清楚什么场景该用、怎么用才不显得生硬以及在使用过程中会遇到哪些坑。无论你是刚学C的入门者还是已经写了好几年业务代码的老手我相信都能从中找到一些有价值的东西。工厂方法模式简单说就是定义一个创建对象的接口让子类决定实例化哪一个类。它把对象的创建和使用解耦让代码在扩展新类型时不用修改已有逻辑。这件事听起来小但在真实的工程项目里做好这一层抽象代码的维护成本和扩展成本会天差地别。1. 先从痛点说起直接new到底惹了什么祸1.1 硬编码创建对象带来的连锁反应我们先设想一个最普通的场景。你在写一个日志模块最初只需要输出到控制台代码可能是这样class Logger { public: virtual void log(const std::string msg) 0; virtual ~Logger() default; }; class ConsoleLogger : public Logger { public: void log(const std::string msg) override { std::cout [Console] msg std::endl; } };调用的时候很自然std::unique_ptrLogger logger std::make_uniqueConsoleLogger();一切正常。但需求很快来了日志不仅需要输出到控制台还要写进文件、上报到远程服务器。于是你写了一个FileLogger一个RemoteLogger然后在所有创建Logger的地方开始加判断std::unique_ptrLogger logger; if (config.type console) { logger std::make_uniqueConsoleLogger(); } else if (config.type file) { logger std::make_uniqueFileLogger(config.filePath); } else if (config.type remote) { logger std::make_uniqueRemoteLogger(config.endpoint); }如果你只在main函数里写一次那还好。但真实项目里日志往往在好几个模块都要用。你很快会发现这段if-else被复制粘贴到了三四个地方。然后某个模块忘了更新新加了一种日志类型有一处漏掉了线上出了问题你花了一整晚排查最后发现不过是一个else if的分支没加上。这就是硬编码创建对象的第一个问题创建逻辑散落各处每次新增类型都要全局搜索修改。这个问题在日志场景下只是麻烦如果换成一个有几十种策略类、在好几个服务里都要创建的业务场景那简直是灾难。1.2 违背开闭原则改一个需求就要动老代码if-else判断还有个更深的隐性问题它违背了面向对象设计里非常重要的“开闭原则”——对扩展开放对修改关闭。什么叫“对修改关闭”我的理解是当系统需要增加一个新功能时理想情况下应该只写新代码而不去改动已经验证过的旧代码。因为旧代码一旦改动就意味着原来跑得好好的逻辑需要重新测试重新面临引入bug的风险。这个风险在大型项目里会被急剧放大。你可能会说加一个else if而已有什么大不了的。但在真实项目中一个类的构造函数参数可能很复杂比如FileLogger需要路径和缓存大小RemoteLogger需要端点、超时和重试次数。这些配置参数从哪里来从配置模块读。于是创建逻辑就不仅仅是new一个对象还牵扯到配置读取、参数校验、默认值兜底。这一堆逻辑放在业务调用处业务代码就被污染了。我见过一个项目一个策略创建函数里塞了七八种类型的判断函数长达两百多行里面既有配置读取又有日志记录还有错误处理。每次加新策略都要在这两百多行里找地方加分支生怕放错了位置。最后团队达成了一个默契新策略的类型判断只能加在末尾别动前面的代码。但即使这样代码的可读性和可维护性也已经差到极点了。工厂方法模式解决的就是这个问题把“创建逻辑”从业务逻辑中抽离出来让新增类型只影响工厂内部不影响业务调用方。2. 工厂方法模式的经典实现方式2.1 祖师爷版本纯虚函数创建器先看设计模式原著里的经典结构。工厂方法模式涉及四个角色抽象产品Product、具体产品ConcreteProduct、抽象创建者Creator和具体创建者ConcreteCreator。用一个更贴近实际业务的例子来说明假设你在做一个消息推送系统支持短信推送和邮件推送。// 抽象产品 class MessageSender { public: virtual void send(const std::string content) 0; virtual ~MessageSender() default; }; // 具体产品 class SmsSender : public MessageSender { public: void send(const std::string content) override { std::cout [SMS] content std::endl; } }; class EmailSender : public MessageSender { public: void send(const std::string content) override { std::cout [Email] content std::endl; } }; // 抽象创建者 class SenderFactory { public: virtual std::unique_ptrMessageSender create() 0; virtual ~SenderFactory() default; }; // 具体创建者 class SmsSenderFactory : public SenderFactory { public: std::unique_ptrMessageSender create() override { return std::make_uniqueSmsSender(); } }; class EmailSenderFactory : public SenderFactory { public: std::unique_ptrMessageSender create() override { return std::make_uniqueEmailSender(); } };调用方持有一个工厂基类指针void sendMessage(SenderFactory factory, const std::string content) { auto sender factory.create(); sender-send(content); } int main() { SmsSenderFactory smsFactory; EmailSenderFactory emailFactory; sendMessage(smsFactory, hello); sendMessage(emailFactory, hello); }这个版本是设计模式书上的标准答案大学期末考试、考研面试里考的也是这个结构。但说实话在实际项目中这种写法用得不算多。原因在于它为每一个产品都建立了一个对应的工厂子类类数量直接翻倍而且调用方仍然需要知道具体工厂是什么类型。如果新增一种推送方式你不仅要有新的产品类还要有新的工厂类代码量反而增加了。2.2 进化版本参数化工厂函数实际项目中更普遍的做法是把“具体工厂类”合并成一个工厂类通过参数来决定创建哪个产品。这个方法在《设计模式》里被称为参数化工厂方法parameterized factory method虽然它严格来说已经不是经典定义但实用性极高。class SenderFactory { public: enum class Type { Sms, Email }; std::unique_ptrMessageSender create(Type type) { switch (type) { case Type::Sms: return std::make_uniqueSmsSender(); case Type::Email: return std::make_uniqueEmailSender(); default: throw std::invalid_argument(unknown sender type); } } };调用方的代码就干净多了SenderFactory factory; auto sender1 factory.create(SenderFactory::Type::Sms); auto sender2 factory.create(SenderFactory::Type::Email);这种写法的好处非常明显创建逻辑收敛到了一个函数里。新增产品类型时只需要修改create函数内部一个分支调用方代码完全不用改。对于产品类型不是特别多、不太会频繁增加的场景这个版本是性价比最高的。当然这个写法也有它的边界。当产品类型非常多、创建逻辑非常复杂时create函数会膨胀成一个超级函数又回到了老问题。所以更进阶的方案是注册表式工厂后面我会专门讲。2.3 现代C的标配智能指针与make_unique在上面所有例子里我都用了std::unique_ptr而不是裸指针。这是现代C写工厂方法的一个关键细节。在C11之前工厂函数的返回值通常是裸指针调用方需要负责删除// 旧式写法不推荐 MessageSender* createSender(Type type) { switch (type) { case Type::Sms: return new SmsSender(); case Type::Email: return new EmailSender(); } return nullptr; } // 调用方 MessageSender* sender createSender(Type::Sms); // ...使用... delete sender; // 一旦忘记内存泄漏这个写法的隐患很明显稍不留神就忘记delete了或者在中间某个地方return提前退出delete语句没执行内存悄悄泄漏。如果工厂函数抛异常了delete也有可能执行不到。用std::unique_ptr作为返回值内存管理就交给了RAII机制。智能指针离开作用域时自动释放内存异常发生时会触发栈展开智能指针的析构函数照样会执行。这就把C最优雅的资源管理能力和工厂模式完美结合了。提示在C14以后建议使用std::make_unique而不是new加std::unique_ptr的组合。make_unique更安全——它把参数的完美转发和对象创建封装在一起避免表达式求值顺序带来的潜在内存泄漏问题。C11没有make_unique但你可以自己写一个简易版本或者直接用std::unique_ptrT(new T(...))到C14就可以正式使用标准库版本了。还有一个细节有人会问为什么返回unique_ptr而不是shared_ptr我的建议是默认返回unique_ptr只有在明确需要共享所有权时才转成shared_ptr。unique_ptr没有任何额外开销语义上也更明确——工厂创建出来的对象归属权交给了调用方。如果调用方想共享先接收unique_ptr再std::shared_ptrT(std::move(ptr))转一下就行成本只在需要时才付出。3. 手写一个完整案例跨平台日志系统3.1 场景设定理论说了不少我们来写一个更接近真实项目的完整案例。假设我要做一个跨平台日志系统需要支持三种输出方式文件日志、控制台日志和网络日志。每种日志的初始化参数还不一样文件日志需要日志文件路径和缓存大小控制台日志需要指定输出级别debug/info/error网络日志需要服务端地址和重试次数这种“同一个接口、不同参数初始化”的场景正是工厂方法模式的拿手好戏。因为有了工厂这个中间层不同产品的初始化差异都被封装在工厂内部调用方看到的永远是一个统一的createLog()接口。3.2 完整代码实现首先是产品接口和具体产品#include iostream #include memory #include string #include fstream #include stdexcept // 抽象产品 class LogSink { public: virtual void write(const std::string message) 0; virtual ~LogSink() default; }; // 具体产品一文件日志 class FileLogSink : public LogSink { public: FileLogSink(const std::string filePath, size_t bufferSize) : filePath_(filePath), bufferSize_(bufferSize) { file_.open(filePath_, std::ios::app); if (!file_.is_open()) { throw std::runtime_error(Failed to open log file: filePath_); } } void write(const std::string message) override { file_ message std::endl; } ~FileLogSink() override { if (file_.is_open()) { file_.close(); } } private: std::string filePath_; size_t bufferSize_; std::ofstream file_; }; // 具体产品二控制台日志 class ConsoleLogSink : public LogSink { public: enum class Level { Debug, Info, Error }; ConsoleLogSink(Level level) : level_(level) {} void write(const std::string message) override { std::cout [Console] message std::endl; } private: Level level_; }; // 具体产品三网络日志用输出到stderr模拟远程上报实际项目里是socket发送 class RemoteLogSink : public LogSink { public: RemoteLogSink(const std::string endpoint, int retryCount) : endpoint_(endpoint), retryCount_(retryCount) {} void write(const std::string message) override { std::cerr [Remote] message (endpoint: endpoint_ ) std::endl; } private: std::string endpoint_; int retryCount_; };接下来是工厂类。这里的重点是工厂需要根据配置信息来创建对应的产品而不同的产品需要不同的参数所以参数封装成了一个配置结构体// 日志配置 struct LogConfig { enum class SinkType { File, Console, Remote }; SinkType type SinkType::Console; std::string filePath application.log; size_t bufferSize 4096; ConsoleLogSink::Level consoleLevel ConsoleLogSink::Level::Info; std::string endpoint 127.0.0.1:8080; int retryCount 3; }; // 工厂类 class LogSinkFactory { public: static std::unique_ptrLogSink create(const LogConfig config) { switch (config.type) { case LogConfig::SinkType::File: return std::make_uniqueFileLogSink(config.filePath, config.bufferSize); case LogConfig::SinkType::Console: return std::make_uniqueConsoleLogSink(config.consoleLevel); case LogConfig::SinkType::Remote: return std::make_uniqueRemoteLogSink(config.endpoint, config.retryCount); default: throw std::invalid_argument(Unsupported log sink type); } } };调用方代码int main() { LogConfig config; config.type LogConfig::SinkType::File; config.filePath ./server.log; try { auto sink LogSinkFactory::create(config); sink-write(Server started); sink-write(Client connected); } catch (const std::exception e) { std::cerr Log initialization failed: e.what() std::endl; return 1; } return 0; }3.3 关键设计决策说明这段代码看起来不复杂但有几个设计点值得展开说说。为什么配置参数要封装成结构体如果工厂函数的参数直接散着传比如create(bool useFile, string path, size_t bufferSize, Level level...)调用方会被一堆参数淹没而且很难分辨哪个参数属于哪个产品。封装成LogConfig后调用方按需设置字段不相关的字段保留默认值清晰很多。这在参数超过三四个的时候几乎是必须的。为什么工厂方法抛异常而不是返回nullptr这是C和Java/C#的一个明显区别。Java里返回null很常见因为Java的null有特殊的语义。但C里nullptr往往意味着“空”调用方如果不检查就解引用直接就是未定义行为程序crash。相比之下抛异常让问题暴露得更明显调用方可以用catch统一处理也可以完全不处理让程序终止——至少错误不会无声无息地发生。实践中我的经验是创建失败抛异常查找失败也抛异常让失败尽可能“响亮”。那些返回nullptr然后悄无声息继续跑的代码往往就是线上疑难bug的温床。为什么FileLogSink的构造里要把文件打开这个动作做了这也是一种设计选择。把资源获取放在构造函数里配合RAII可以保证如果文件打不开对象构造直接抛异常工厂函数根本不会返回一个半成品。这就避免了“对象创建成功了但状态无效”的尴尬局面。4. 进阶玩法注册表式工厂与模板化工厂4.1 注册表式工厂按字符串动态创建对象基础版的工厂方法用的是switch分支产品类型一多或者不是编译期能确定的情况就捉襟见肘了。比如你希望插件系统可以注册新的日志类型但这些类型在编译时根本不存在是运行时动态加载的。这种场景switch分支是搞不定的。注册表式工厂的思路是用一个map把“类型标识”映射到“创建函数”支持从外部动态注册新的创建函数。#include functional #include unordered_map #include memory class LogSinkRegistry { public: using CreatorFunc std::functionstd::unique_ptrLogSink(const LogConfig); static LogSinkRegistry instance() { static LogSinkRegistry registry; return registry; } void registerCreator(const std::string typeName, CreatorFunc func) { creators_[typeName] std::move(func); } std::unique_ptrLogSink create(const std::string typeName, const LogConfig config) { auto it creators_.find(typeName); if (it creators_.end()) { throw std::invalid_argument(No creator registered for type: typeName); } return it-second(config); } private: LogSinkRegistry() default; std::unordered_mapstd::string, CreatorFunc creators_; };注册的方式也很自然// 在程序启动时或库加载时调用 void registerBuiltInSinks() { auto registry LogSinkRegistry::instance(); registry.registerCreator(file, [](const LogConfig config) { return std::make_uniqueFileLogSink(config.filePath, config.bufferSize); }); registry.registerCreator(console, [](const LogConfig config) { return std::make_uniqueConsoleLogSink(config.consoleLevel); }); registry.registerCreator(remote, [](const LogConfig config) { return std::make_uniqueRemoteLogSink(config.endpoint, config.retryCount); }); }这样调用方只需要知道字符串名字就行auto sink LogSinkRegistry::instance().create(file, config);这个写法的推荐理由在于新增一种日志类型时不需要改动工厂类本身只需要新增一个产品类然后在启动注册处加一行。插件式扩展成为可能业务代码一行都不用动。类型标识也从枚举升级成了字符串灵活性更高。4.2 模板化工厂把重复代码消灭到极致如果你的产品类有一个共同特征——构造函数参数完全一致——那么连具体工厂类都可以用模板来消灭。比如我们有一个Shape接口每个具体形状都用double类型的边长或半径构造class Shape { public: virtual double area() const 0; virtual ~Shape() default; }; class Circle : public Shape { public: explicit Circle(double radius) : radius_(radius) {} double area() const override { return 3.14159 * radius_ * radius_; } private: double radius_; }; class Square : public Shape { public: explicit Square(double side) : side_(side) {} double area() const override { return side_ * side_; } private: double side_; }; // 模板化工厂 template typename Product, typename... Args std::unique_ptrProduct makeShape(Args... args) { return std::make_uniqueProduct(std::forwardArgs(args)...); }这个模板工厂实际上把创建逻辑泛化成了任意类型都能用的通用工具。makeShapeCircle(2.0)返回一个CirclemakeShapeSquare(3.0)返回一个Square。这就是std::make_unique本身做的事情所以在现代C中如果你只是单纯想“创建对象”根本不需要自定义工厂直接用make_uniqueT就好。但有个区别如果你需要统一的“创建前校验”或“创建后登记”逻辑那么把make_unique封装在工厂函数中在前后加入额外逻辑就变得有意义了。否则过度设计一套模板工厂只会增加代码复杂度。4.3 两种进阶玩法的取舍注册表式工厂和模板化工厂并不是互斥的。实际项目中我的经验是分层配合底层用模板化的make_xxx函数保证基本的创建逻辑上层用注册表存储类型标识和创建函数的映射让外部可以动态扩展。比如框架内部把SmsSender创建逻辑封装成makeSmsSender(config)函数然后通过注册表把sms字符串映射到这个函数。新增一种AppPushSender时只需要写一个makeAppPushSender(config)函数并在启动时注册app_push。这种组合的灵活性是最高的。5. 实践中容易踩的坑问题排查与避坑指南5.1 对象切片工厂返回值最常见的事故这是新手最容易踩的坑而且踩了之后很隐蔽。看这段代码// 错误示范返回具体类型而不是基类指针 LogSink createLogSink(LogConfig config) { switch (config.type) { case LogConfig::SinkType::File: return FileLogSink(config.filePath, config.bufferSize); // 编译错误 ... } }如果createLogSink返回的是LogSink而不是LogSink*或unique_ptrLogSink那么返回FileLogSink时会发生对象切片FileLogSink只复制了基类LogSink的部分派生类成员全部丢失多态也就无从谈起了。这个错误在C中直接被编译器拦截了因为抽象基类不能按值返回。但如果基类不是抽象的只是普通类编译器不会报错程序却会静默丢失派生类信息。注意工厂方法返回基类指针或智能指针这是多态的前提。千万不要按值返回基类对象。如果发现工厂函数返回类型是具体类那说明这个工厂设计的思路有问题——你实际上绕过了多态。5.2 工厂函数里的异常安全工厂函数内部创建对象时如果不小心异常安全也会出问题。比如这个反面教材// 错误示范 std::unique_ptrLogSink badCreate(bool useFile) { LogSink* sink new ConsoleLogSink(ConsoleLogSink::Level::Info); if (useFile) { delete sink; sink new FileLogSink(app.log, 4096); } return std::unique_ptrLogSink(sink); }首先裸指针配合new非常容易出错。其次如果FileLogSink构造函数抛了异常sink还是指向一个已经释放的内存然后函数继续执行到return又对已释放内存调用unique_ptr接管双重释放直接crash。这种问题调试起来让人崩溃。正确的做法是让智能指针在所有分支中都负责管理std::unique_ptrLogSink goodCreate(bool useFile) { if (useFile) { return std::make_uniqueFileLogSink(app.log, 4096); } return std::make_uniqueConsoleLogSink(ConsoleLogSink::Level::Info); }这样每一个返回路径上的对象都由智能指针独立管理任何一个构造函数抛异常都不会污染其他路径。5.3 工厂方法的职责边界别把世界都塞进工厂里最后一个我特别想强调的坑是工厂职责膨胀。有些人觉得工厂模式好用就把所有对象的创建都往工厂里塞最后工厂变成了一个无所不包的大杂烩。我见过一个项目里的“服务工厂”创建数据库连接、缓存客户端、消息队列生产者、HTTP客户端等等几十种对象工厂函数上千行。工厂方法的正确边界是只负责“创建对象”这一件事。创建对象之后应该返回后续的初始化、配置校验、依赖注入都不应该放在工厂里。如果一个对象创建后需要很多额外的初始化步骤那更合适的是构建者模式Builder Pattern或者单独的初始化函数而不是强行塞进工厂。判断标准很简单工厂函数内部如果出现了和“创建这个对象”无关的逻辑比如写日志、发事件、更新状态、调用其他服务那基本就是职责越界了。把创建逻辑保持纯粹会让工厂变得极易测试和维护。6. 工厂方法模式的实战心得与扩展思考6.1 什么时候不要用工厂方法聊了这么多工厂方法的好处我也要说句公道话它不是万能的有些场景用了反而画蛇添足。如果这个类永远不会有多态的需求也就是说你只实现一个具体类并且看不到任何扩展的可能性那直接用构造函数创建就行不需要工厂。工厂模式节省的是“未来新增类型”时的修改成本如果这个未来不存在那提前写的工厂代码就是纯粹的负担。另外如果调用方和产品类是同一个模块内部的事情外部完全看不到那也没必要为了内部使用而抽象出工厂。过度设计是C项目里很常见的问题。工厂方法的核心价值在于解耦如果根本没有耦合自然也不需要解耦。6.2 跟“状态机设计模式”的配合工作中我发现工厂方法模式往往不是单独出现的它经常和状态机模式配合使用。比如一个网络连接状态机包含“已连接”“已断开”“重连中”几种状态每种状态对应一个handler类。class ConnectionState { public: virtual void handle(ConnectionContext ctx) 0; virtual ~ConnectionState() default; }; class ConnectedState : public ConnectionState { /* ... */ }; class DisconnectedState : public ConnectionState { /* ... */ }; class ReconnectingState : public ConnectionState { /* ... */ };状态对象从哪里来理想的方式就是用工厂。因为状态机在不同场景下可能需要不同的状态实现比如测试环境用一个Mock状态生产环境用真实状态。工厂让“根据运行上下文创建合适的状态对象”变得自然。状态模式负责状态的流转逻辑工厂模式负责状态的创建各司其职。6.3 跨领域启示多agent系统里的选择逻辑最近在研究多agent协作架构发现一个有意思的类比。在多agent系统里主agent会根据任务类型选择调用不同的subagent来处理。选择哪个subagent本质上和工厂方法要解决的问题一模一样——根据某个标识决定实例化哪个处理单元。有人把subagent类比成工具调用其实从设计模式角度看主agent就是一个“参数化工厂”任务类型是参数subagent是产品。把subagent像tool一样注册到主agent的调用列表里这简直就是注册表式工厂的自然延伸。设计模式的智慧确实是可以跨领域迁移的——你理解了工厂方法再看很多架构设计都能看到它的影子。6.4 现代C对工厂方法的影响从C11到C20现代C给工厂方法模式带来了不少便利。除了前面反复提到的智能指针还有几个特性值得关注lambda表达式让注册表式工厂的注册代码简洁了很多不需要专门写一个函数对象类。std::function可以轻松存储各种签名兼容的调用。auto返回值让工厂函数的书写更简洁尤其是在返回类型比较复杂时。C17的if constexpr可以在模板工厂中根据类型编译期分支进一步减少运行时开销。这些特性让工厂方法在C里实现起来越来越顺手代码越来越简洁。但不变的核心还是那句老话对象的创建和使用要解耦变化点要隔离。我在实际项目中的体会是设计模式不是考试题它是对“变化”的一种提前准备。工厂方法模式真正强大之处不是让你少写几个new而是让你在需求频繁变化时依然保持代码的稳定和清晰。找到项目中那些最容易变化的类型在它们周围加上工厂这个缓冲层你的代码就能从容应对变化而不是每次需求一改就到处救火。最后再分享一个小技巧写工厂函数时一定要记得给返回值加上std::unique_ptr而不是裸指针C14以后优先用std::make_unique。这个习惯养成了能帮你避免不知道多少内存管理的问题。