聊到C++里的创建型设计模式,工厂方法模式(Factory Method)是我在工程实践中用得最频繁,也最容易被低估的一个。早些年我在面试候选人的时候,几乎每次都会问到一个场景:一段代码里有七八个if分支去创建不同类型对象,新加一种类型就要改一次调用方,这时候怎么重构?很多新手第一反应是“用简单工厂”,但追问一句“如果产品种类还会继续膨胀呢”,往往就答不上来了。工厂方法模式恰恰是应对这类问题的正解,它把对象的创建责任下放到子类,让调用方彻底脱离具体构造函数,只依赖抽象接口,这几乎是C++工程里解耦的起点。
这篇文章我会用一个完整的日志系统案例,把工厂方法模式的角色划分、C++特有的实现细节(智能指针、虚析构、纯虚函数)、从零配置VSCode编译调试环境,再到运行时库缺失和内存访问违例这类实战崩溃问题的排查思路,一条线讲完整。适合刚学完C++语法、想往工程实践靠拢的同学,也适合在写业务代码时被大量switch分支困扰的朋友参考。
1. 需求变化引发的思考:从简单工厂到工厂方法
1.1 最原始的痛点:调用方和具体类绑死
假设你要在Windows和Linux上分别输出日志,先写了一个ConsoleLogger,后来又要加FileLogger。最直接的做法是到处写std::make_unique<ConsoleLogger>()这类代码。刚开始几天还能接受,可一旦日志类型增加到四五种,或者构造函数开始收不同的参数(文件路径、刷新策略、格式模板),麻烦就来了:业务代码要同时认识所有具体类,任何一个构造函数签名变了,所有调用点都得跟着改。更麻烦的是编译期依赖,新增一个具体类,整个模块要重新编译,协同开发的效率会被拖垮。
我见过很多项目就是这样一点点变得没法维护的。业务逻辑里散落着if (type == 1) {...} else if (type == 2) {...},每次新增产品都要把调用的地方翻个底朝天。这种代码不是不能跑,是“不敢动”,一改就崩。
1.2 简单工厂能解一部分,但止不住膨胀
新手通常先接触简单工厂:一个静态方法里根据枚举值switch出对象。
class LoggerFactory { public: static std::unique_ptr<Logger> create(LoggerType type) { switch (type) { case LoggerType::Console: return std::make_unique<ConsoleLogger>(); case LoggerType::File: return std::make_unique<FileLogger>(); default: throw std::invalid_argument("unknown logger type"); } } };这段代码确实把创建逻辑集中起来了,调用方会清爽很多。但它的本质问题是违背开闭原则:每新增一种日志类型,你都不得不回到这个静态函数里加一个case,这个工厂类会随着产品线一起膨胀,最终变成一个什么都知道的“上帝对象”。
1.3 工厂方法的核心转变:把创建权交给子类
工厂方法模式的思路和简单工厂完全不同。它不搞一个集中的大switch,而是把“创建什么对象”这个决定,下沉到每个产品对应的工厂子类里。
- 抽象产品
Logger定义日志接口; - 具体产品
FileLogger、ConsoleLogger各自实现; - 抽象工厂
LoggerFactory定义一个virtual createLogger()方法; - 每个具体工厂(比如
FileLoggerFactory)重写这个方法,返回自己负责的那个产品。
调用方只要手里有一个LoggerFactory的引用,不需要知道运行时到底是哪个工厂,只需要调用createLogger(),拿到的就是某个具体产品。新加一个产品类型,就新加一对具体产品和具体工厂,已有的代码一行都不用改,这才是真正对扩展开放、对修改关闭。
2. C++工厂方法模式的架构拆解
2.1 四个角色的C++表达
先明确每个角色在C++里怎么落地。
产品接口(Product):在C++中没有语法层面的interface关键字,我们用抽象基类来表示。关键是给出纯虚接口,并把析构函数声明为virtual。很多人忽略这一点,导致通过基类指针删除派生类对象时只调用了基类析构,派生类的资源没释放——这在后面的崩溃排查里我再细说。
class Logger { public: virtual ~Logger() = default; virtual void log(const std::string& message) = 0; };具体产品(ConcreteProduct):实现产品接口。一个产品一个类,职责单一,各自维护自己的状态。比如FileLogger里持有文件流,ConsoleLogger里什么都不用管。
工厂接口(Creator):定义创建产品的抽象方法。注意这个方法的返回值类型,现代C++里强烈建议返回std::unique_ptr<Product>,而不是裸指针。裸指针容易丢失所有权,一旦调用方忘了delete就是泄漏;unique_ptr从函数出来后所有权清晰,配合std::make_unique实现强异常安全。
class LoggerFactory { public: virtual ~LoggerFactory() = default; virtual std::unique_ptr<Logger> createLogger() = 0; };具体工厂(ConcreteCreator):继承工厂接口,返回具体产品实例。这里有个容易踩坑的点:返回类型建议用std::unique_ptr<Logger>,这属于协变返回类型的现代替代方案。老代码里有人喜欢返回裸指针Logger*,接口和实现都要同时改,维护起来很别扭。
2.2 工厂方法 vs 抽象工厂怎么选
我经常被问到“工厂方法模式和抽象工厂模式看起来好像,什么时候用哪个”。其实区分标准很简单。
- 工厂方法模式解决的是单个产品族的创建问题。Creator只提供一个创建方法,创建一种产品。
- 抽象工厂模式解决的是多个产品族的创建问题,一个工厂接口里有一组创建方法,比如
createButton()、createDialog(),它们之间是有约束关系的。
你的新需求是“每一种日志都要有自己的专属工厂”,用工厂方法;如果下一个需求是“我要能切换一套风格统一的UI组件库,按钮、输入框、弹窗必须配套出现”,那就自然演进到抽象工厂。
不过我在工程里见的更多情况是,一开始用工厂方法,产品扩展到很多种以后,再把几个关系紧密的工厂方法合并进一个抽象工厂接口。所以这两个模式不是互斥的,而是演进关系。
2.3 设计到这一步,解决了什么问题
在客户端代码里,你只需要持有LoggerFactory&或std::unique_ptr<LoggerFactory>,通过接口调用创建方法,拿到的是std::unique_ptr<Logger>。整个过程不出现FileLogger、ConsoleLogger的构造函数,新增产品也不回改客户端。
这带来两个直接好处:一是依赖倒置,高层模块不再依赖低层模块的具体类,双方都依赖抽象;二是编译期解耦,客户端只用包含工厂接口和产品接口的头文件,不需要看见具体实现。在项目里,这两点意味着更快的增量编译、更清晰的模块边界,以及更少的合并冲突。
3. 实操过程:从VSCode环境到完整日志系统实现
3.1 环境准备:VSCode配置C/C++编译调试
先说环境。很多人问VSCode配C++环境怎么弄,我这次直接给出我日常使用的一套方案,不用命令行也能完成编译调试。
- 安装VSCode,扩展里搜索并安装C/C++(由Microsoft维护的那个)和Code Runner。
- 安装编译器。Windows下推荐MinGW-w64,使用g++;macOS直接安装Xcode Command Line Tools,自带clang++;Linux安装g++。这里最关键的是确认编译器能被系统找到。
- 创建
main.cpp和CMakeLists.txt。VSCode配合CMake工具链比手动配tasks.json要省心。新建.vscode/launch.json,配置program指向编译出来的可执行文件路径,preLaunchTask设为编译任务。 - 如果编译器没被找到,在
c_cpp_properties.json里把compilerPath手动指到g++的绝对路径。这一步最常卡人,多半是PATH环境变量没生效,重启VSCode或者直接写绝对路径即可。
我自己的习惯是能开编译警告就全开,-Wall -Wextra -Wpedantic三个选项加上,很多潜在问题在编译阶段就会暴露。后面聊到的虚析构问题,在这种警告级别下更容易被注意到。
3.2 场景设计:跨平台日志系统
为了把工厂方法模式讲出工程味道,我设计一个真实的场景。假设在做一个跨平台C++应用,需要三种日志:
- 控制台日志
ConsoleLogger:输出到stdout,带时间戳和级别; - 文件日志
FileLogger:输出到指定路径的文件,支持每天滚动; - 网络日志
NetworkLogger:把日志通过HTTP POST发送到远程日志服务。
三种日志的接入方式完全不同,构造参数也不一样。控制台无参;文件日志需要路径和写入策略;网络日志需要服务器地址、端口和认证token。客户端只想要“一个日志对象”,具体怎么创建不该是它操心的事。
3.3 完整代码实现
先把产品接口和三个具体产品写上。
// logger.h #pragma once #include <string> #include <memory> class Logger { public: virtual ~Logger() = default; virtual void log(const std::string& level, const std::string& message) = 0; }; class ConsoleLogger final : public Logger { public: void log(const std::string& level, const std::string& message) override; }; class FileLogger final : public Logger { public: explicit FileLogger(const std::string& filePath); ~FileLogger() override; void log(const std::string& level, const std::string& message) override; private: class Impl; std::unique_ptr<Impl> m_impl; }; class NetworkLogger final : public Logger { public: NetworkLogger(const std::string& host, int port, const std::string& token); ~NetworkLogger() override; void log(const std::string& level, const std::string& message) override; private: class Impl; std::unique_ptr<Impl> m_impl; };这里我做了一个很多人容易忽略的设计决定:FileLogger和NetworkLogger持有Impl指针,这是Pimpl惯用法。它的价值在于头文件里不暴露内部实现(文件流成员、连接池成员等),客户端只include这个头文件就能用对象,编译依赖被大幅削减。当然,代价是每个具体类多一层间接调用,但在现代C++里这个开销可以忽略。
看FileLogger的实现片段,重点是析构函数必须定义在cpp文件里,让unique_ptr<Impl>能在编译单元看到完整的Impl定义后再析构,否则会报incomplete type错误。
// file_logger.cpp #include "logger.h" #include <fstream> #include <chrono> #include <iomanip> class FileLogger::Impl { public: explicit Impl(const std::string& path) : m_stream(path, std::ios::app) {} std::ofstream m_stream; }; FileLogger::FileLogger(const std::string& filePath) : m_impl(std::make_unique<Impl>(filePath)) {} FileLogger::~FileLogger() = default; void FileLogger::log(const std::string& level, const std::string& message) { auto now = std::chrono::system_clock::now(); auto time = std::chrono::system_clock::to_time_t(now); m_impl->m_stream << std::put_time(std::localtime(&time), "%Y-%m-%d %H:%M:%S") << " [" << level << "] " << message << std::endl; }然后是工厂接口和三个具体工厂。
// logger_factory.h #pragma once #include "logger.h" class LoggerFactory { public: virtual ~LoggerFactory() = default; virtual std::unique_ptr<Logger> createLogger() = 0; }; class ConsoleLoggerFactory final : public LoggerFactory { public: std::unique_ptr<Logger> createLogger() override { return std::make_unique<ConsoleLogger>(); } }; class FileLoggerFactory final : public LoggerFactory { public: explicit FileLoggerFactory(std::string filePath) : m_path(std::move(filePath)) {} std::unique_ptr<Logger> createLogger() override { return std::make_unique<FileLogger>(m_path); } private: std::string m_path; }; class NetworkLoggerFactory final : public LoggerFactory { public: NetworkLoggerFactory(std::string host, int port, std::string token) : m_host(std::move(host)), m_port(port), m_token(std::move(token)) {} std::unique_ptr<Logger> createLogger() override { return std::make_unique<NetworkLogger>(m_host, m_port, m_token); } private: std::string m_host; int m_port; std::string m_token; };最后是客户端使用方式。客户端只依赖LoggerFactory和Logger两个抽象,完全不认识具体类。
// main.cpp #include "logger_factory.h" #include <iostream> #include <memory> std::unique_ptr<Logger> buildLoggerFromConfig(const std::string& type) { // 这里在实际项目中通常由一个配置中心或依赖注入容器来装配, // 而不是把工厂的创建逻辑写死在客户端代码里。 if (type == "console") { return std::make_unique<ConsoleLoggerFactory>()->createLogger(); } else if (type == "file") { return std::make_unique<FileLoggerFactory>("app.log")->createLogger(); } else if (type == "network") { return std::make_unique<NetworkLoggerFactory>("logs.example.com", 8080, "token123")->createLogger(); } throw std::invalid_argument("unknown logger type: " + type); } int main() { auto logger = buildLoggerFromConfig("file"); logger->log("INFO", "application started"); logger->log("INFO", "factory method pattern demo is running"); return 0; }有人会发现buildLoggerFromConfig里还是有if分支。这是很正常的:配置到具体工厂的映射本身就是一种变化维度。如果将来连这个映射都想省掉,可以考虑注册表模式,把工厂注册到map里。但那是另一个话题了,当前这个例子里,新增一种产品已经不需要改动任何已有产品代码了。
3.4 关键代码逐段解释
逐个说几处容易写错的地方。
第一处:为什么工厂方法返回unique_ptr而不是shared_ptr。从语义上讲,创建者只负责交付一个产品,产品所有权完整移交给调用方。unique_ptr语义最精确。如果调用方确实需要共享,可以后续std::shared_ptr<Logger>(std::move(ptr))再做转换,但单独创建时没必要一上来就shared。
第二处:FileLoggerFactory里构造参数存哪。具体工厂保存自己创建产品所需的配置项。这个设计让“配置”和“创建”分离,工厂本身可以作为一个配置化的对象在系统里传递。比如我可以定义不同路径的多个FileLoggerFactory实例,每个实例创建自己的日志文件。
第三处:make_unique和make_shared。优先用std::make_unique而不是std::unique_ptr<T>(new T(...))。前者在C++14之后就是标准做法,它可以避免因为构造顺序导致的内存泄漏风险,虽然现代编译器对直接new通常也会优化,但make系列写起来更简洁、意图更清晰。
4. 常见问题与排查技巧实录
4.1 崩溃典型:Access Violation C0000005
这部分是重头戏。C++工厂方法模式下最容易崩的地方,就是基类析构函数不是虚函数。比如这样一个场景:工厂方法返回std::unique_ptr<Logger>,但Logger类的析构没有加virtual,运行到main结束时智能指针自动析构,系统调用的是~Logger()而不是~FileLogger(),FileLogger内部的unique_ptr<Impl>压根没被触发,虽然unique_ptr本身不会泄漏底层Impl,但因为基类析构非虚,整个行为是未定义的,在Windows上最常见的表现就是access violation c0000005。
排查这类问题,我一般分三步:
- 看崩溃调用栈,如果栈顶是
Logger::~Logger()或者析构相关代码,优先检查析构函数是否虚。 - 打开编译器警告,g++加
-Wdelete-non-virtual-dtor,clang++默认就有这个警告,会把非虚析构的删除操作直接提示出来。 - 用AddressSanitizer跑一遍代码,命令是
g++ -fsanitize=address -g main.cpp,ASan会明确告诉你“delete called on non-final object that has virtual functions but non-virtual destructor”。
这个教训我在项目里踩过不止一次,真的不是教科书上说说而已。
4.2 内存泄漏:所有权转移不清
工厂方法还有一个常见问题,是返回裸指针造成的所有权混乱。如果一个接口设计成Logger* createLogger(),调用方到底该不该delete?有些调用方delete了,有些没delete,有些传进别人的容器里被delete了两次,崩溃和泄漏就都来了。
我的建议很简单:现代C++里,工厂方法一律返回unique_ptr。如果迫不得已对接老C接口必须返回裸指针,也要在文档里写明“返回值归调用方所有”,并且最好包一层scope_guard来自动释放。但从新代码起步,不要给自己留这个模糊地带。
4.3 运行环境问题:Visual C++ Redistributable缺失
很多人在自己电脑上编译好的程序,拷到别的机器上运行,弹窗提示缺少VCRUNTIME140.dll或者MSVCP140.dll。这在Windows C++开发里几乎是必经之路。道理其实不复杂:vc++运行库不是Windows自带的,编译出来的程序默认动态链接到这些库,如果没有安装对应版本的运行库就会启动失败。
解决办法有两个方向。
- 如果你的项目是给外部用户用的,最简单的是把VC++ Redistributable安装包一起分发,或者在安装程序里加上这个前置依赖。注意必须跟编译工具集版本对应,VS2019对应的是14.20到14.29那一批,VS2022对应14.30以上。
- 如果目标机器自己可控,也可以改用静态链接。MSVC下设置
/MT,MinGW下用-static-libgcc -static-libstdc++。缺点是exe体积变大,但省去了运行库分发的麻烦。
我在做内部工具时就喜欢静态链接,省心;做商业软件时反而选动态链接,因为惠及系统级的库更新和补丁。
4.4 其它工程实践坑位提醒
除了上面两类,工厂方法模式在真实项目里还有几个容易踩的细节。
空指针检查。具体工厂的createLogger里如果配置不合法,比如文件路径是空的,是返回nullptr还是抛异常?我倾向于抛异常。返回nullptr会把错误延迟到下游,而且一旦调用方忘了判空,directly崩溃。用C++的异常机制或std::expected把错误显式表达出来,比悄悄返回空指针要好得多。
NVI惯用法。如果所有具体工厂在创建产品前都要做统一的前置检查(比如校验配置、记录创建日志),可以考虑NVI(Non-Virtual Interface):在基类把createLogger()声明为非虚,调用一个保护的虚函数doCreateLogger(),前置逻辑全放在非虚外壳里。这样校验逻辑不会在多个具体工厂里重复。
不要为单一实现写工厂。有一种情况我看到很多新手过度设计:产品就一种,也套上工厂模式。如果短期内没有扩展的可能,先保持简单,等第二个产品出现再抽象也不迟。设计模式的落地永远是服务于真实需求,而不是为了套模式而套模式。
5. 从工程视角看工厂方法模式的边界与演进
5.1 什么时候真的该用工厂方法
我在代码评审里一般用两个问题来判断。
第一,调用方是否真的不需要知道具体类型?如果一个对象从头到尾只有一个具体实现,生产环境根本不会变化,那引入工厂就是白费功夫。第二,产品类型是否会持续增加?如果过去半年已经加了三次新类型,以后大概率还会加,那工厂方法能节省你大量改调用点的成本。
工程上都讲“恰好的设计”,这句话的意思不是不设计,而是设计深度要和变化频率匹配。工厂方法模式本身复杂度不高,属于创建型模式里性价比最高的那一档,值得在项目早期就引入,因为它给后续扩展留下了一条很干净的路径。
5.2 后续还能往哪走
一旦产品数量真的多起来,或者产品之间有强约束关系,下一步自然是演进到抽象工厂。又或者,所有工厂都注册到一个注册表里,通过类名或标识符查找工厂实例,这就是注册表模式,Spring和很多IoC容器干的事本质上就是这一套思路。
有人说工厂方法模式太简单、没什么技术含量,我完全不认同。恰恰是越基础的这种模式,越能看出一个工程师对依赖关系、生命周期、错误处理的理解深度。把每个角色的职责吃透,把C++特有的资源管理做到位,这个模式在工程里真的能稳定撑很多年。
5.3 一个实用的小技巧:把“创建配置”和“创建动作”分离
最后分享一个我实际使用的小技巧。具体工厂在构造时接收配置,而不只在createLogger时才传参。这样同一个工厂实例可以被多次调用,每次返回的产品共享同一套基础配置,而且工厂本身可以方便地被放进容器里传递。
auto factory = std::make_unique<FileLoggerFactory>("logs/service.log"); // 可以把 factory 传给任何想要日志的模块 auto logger = factory->createLogger();这种做法比把工厂方法做成静态函数要灵活得多。静态函数没法持有配置和状态,一旦需求变成“每个模块各自配置不同日志路径”,静态工厂就得改参数列表,而实例化工厂则只要在装配阶段多创建几个工厂对象就行。这个习惯我在很多项目里验证过,能让代码的结构清晰不少。