做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_ptr<Logger> logger = std::make_unique<ConsoleLogger>();一切正常。但需求很快来了:日志不仅需要输出到控制台,还要写进文件、上报到远程服务器。于是你写了一个FileLogger,一个RemoteLogger,然后在所有创建Logger的地方开始加判断:
std::unique_ptr<Logger> logger; if (config.type == "console") { logger = std::make_unique<ConsoleLogger>(); } else if (config.type == "file") { logger = std::make_unique<FileLogger>(config.filePath); } else if (config.type == "remote") { logger = std::make_unique<RemoteLogger>(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_ptr<MessageSender> create() = 0; virtual ~SenderFactory() = default; }; // 具体创建者 class SmsSenderFactory : public SenderFactory { public: std::unique_ptr<MessageSender> create() override { return std::make_unique<SmsSender>(); } }; class EmailSenderFactory : public SenderFactory { public: std::unique_ptr<MessageSender> create() override { return std::make_unique<EmailSender>(); } };调用方持有一个工厂基类指针:
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_ptr<MessageSender> create(Type type) { switch (type) { case Type::Sms: return std::make_unique<SmsSender>(); case Type::Email: return std::make_unique<EmailSender>(); 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++写工厂方法的一个关键细节。
在C++11之前,工厂函数的返回值通常是裸指针,调用方需要负责删除:
// 旧式写法,不推荐 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++最优雅的资源管理能力和工厂模式完美结合了。
提示:在C++14以后,建议使用
std::make_unique而不是new加std::unique_ptr的组合。make_unique更安全——它把参数的完美转发和对象创建封装在一起,避免表达式求值顺序带来的潜在内存泄漏问题。C++11没有make_unique,但你可以自己写一个简易版本,或者直接用std::unique_ptr<T>(new T(...)),到C++14就可以正式使用标准库版本了。
还有一个细节:有人会问,为什么返回unique_ptr而不是shared_ptr?我的建议是:默认返回unique_ptr,只有在明确需要共享所有权时才转成shared_ptr。unique_ptr没有任何额外开销,语义上也更明确——工厂创建出来的对象,归属权交给了调用方。如果调用方想共享,先接收unique_ptr再std::shared_ptr<T>(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_ptr<LogSink> create(const LogConfig& config) { switch (config.type) { case LogConfig::SinkType::File: return std::make_unique<FileLogSink>(config.filePath, config.bufferSize); case LogConfig::SinkType::Console: return std::make_unique<ConsoleLogSink>(config.consoleLevel); case LogConfig::SinkType::Remote: return std::make_unique<RemoteLogSink>(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::function<std::unique_ptr<LogSink>(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_ptr<LogSink> 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_map<std::string, CreatorFunc> creators_; };注册的方式也很自然:
// 在程序启动时或库加载时调用 void registerBuiltInSinks() { auto& registry = LogSinkRegistry::instance(); registry.registerCreator("file", [](const LogConfig& config) { return std::make_unique<FileLogSink>(config.filePath, config.bufferSize); }); registry.registerCreator("console", [](const LogConfig& config) { return std::make_unique<ConsoleLogSink>(config.consoleLevel); }); registry.registerCreator("remote", [](const LogConfig& config) { return std::make_unique<RemoteLogSink>(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_ptr<Product> makeShape(Args&&... args) { return std::make_unique<Product>(std::forward<Args>(args)...); }这个模板工厂实际上把创建逻辑泛化成了任意类型都能用的通用工具。makeShape<Circle>(2.0)返回一个Circle,makeShape<Square>(3.0)返回一个Square。这就是std::make_unique本身做的事情,所以在现代C++中,如果你只是单纯想“创建对象”,根本不需要自定义工厂,直接用make_unique<T>就好。
但有个区别:如果你需要统一的“创建前校验”或“创建后登记”逻辑,那么把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_ptr<LogSink>,那么返回FileLogSink时会发生对象切片:FileLogSink只复制了基类LogSink的部分,派生类成员全部丢失,多态也就无从谈起了。这个错误在C++中直接被编译器拦截了,因为抽象基类不能按值返回。但如果基类不是抽象的,只是普通类,编译器不会报错,程序却会静默丢失派生类信息。
注意:工厂方法返回基类指针或智能指针,这是多态的前提。千万不要按值返回基类对象。如果发现工厂函数返回类型是具体类,那说明这个工厂设计的思路有问题——你实际上绕过了多态。
5.2 工厂函数里的异常安全
工厂函数内部创建对象时,如果不小心,异常安全也会出问题。比如这个反面教材:
// 错误示范 std::unique_ptr<LogSink> badCreate(bool useFile) { LogSink* sink = new ConsoleLogSink(ConsoleLogSink::Level::Info); if (useFile) { delete sink; sink = new FileLogSink("app.log", 4096); } return std::unique_ptr<LogSink>(sink); }首先,裸指针配合new非常容易出错。其次,如果FileLogSink构造函数抛了异常,sink还是指向一个已经释放的内存,然后函数继续执行到return,又对已释放内存调用unique_ptr接管,双重释放直接crash。这种问题调试起来让人崩溃。
正确的做法是让智能指针在所有分支中都负责管理:
std::unique_ptr<LogSink> goodCreate(bool useFile) { if (useFile) { return std::make_unique<FileLogSink>("app.log", 4096); } return std::make_unique<ConsoleLogSink>(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++对工厂方法的影响
从C++11到C++20,现代C++给工厂方法模式带来了不少便利。除了前面反复提到的智能指针,还有几个特性值得关注:
lambda表达式让注册表式工厂的注册代码简洁了很多,不需要专门写一个函数对象类。std::function可以轻松存储各种签名兼容的调用。auto返回值让工厂函数的书写更简洁,尤其是在返回类型比较复杂时。C++17的if constexpr可以在模板工厂中根据类型编译期分支,进一步减少运行时开销。
这些特性让工厂方法在C++里实现起来越来越顺手,代码越来越简洁。但不变的核心还是那句老话:对象的创建和使用要解耦,变化点要隔离。
我在实际项目中的体会是,设计模式不是考试题,它是对“变化”的一种提前准备。工厂方法模式真正强大之处,不是让你少写几个new,而是让你在需求频繁变化时依然保持代码的稳定和清晰。找到项目中那些最容易变化的类型,在它们周围加上工厂这个缓冲层,你的代码就能从容应对变化,而不是每次需求一改就到处救火。最后再分享一个小技巧:写工厂函数时,一定要记得给返回值加上std::unique_ptr而不是裸指针,C++14以后优先用std::make_unique。这个习惯养成了,能帮你避免不知道多少内存管理的问题。