1. 简单工厂的硬伤:工厂方法模式诞生的真正原因
我最早接触设计模式时,工厂方法模式是被我当成“简单工厂的升级版”来理解的。后来在几个真实项目里把两种写法都完整做了一遍,才发现这个理解虽然方向没错,却特别容易让人忽略它真正解决的问题——创建逻辑的变化点到底该放在哪。这篇我打算把工厂方法模式这个创建型设计模式从头到尾拆一遍,用C++和Java各写一份完整实现,再结合最近多Agent架构里很常见的subagent调度场景聊聊它的现代生命力,最后把我这些年踩过的坑一并翻出来。
1.1 简单工厂:所有判断集中在一个类里
先看绝大多数人第一次接触的写法。假设系统里需要记录日志,有文件日志和控制台日志两种实现,用简单工厂写出来是这样的:
cpp复制// 简单工厂:所有创建逻辑集中在一个静态方法里
class LogFactory {
public:
static Log* create(const std::string& type) {
if (type == "file") {
return new FileLog();
} else if (type == "console") {
return new ConsoleLog();
}
return nullptr;
}
};
这段代码本身没有毛病,小项目里跑得飞快,逻辑一眼到底。但它把所有日志类型的创建逻辑都绑在了create这个静态方法里。今天加一个网络日志,明天加一个数据库日志,都要回来改这个函数,加一个else if分支。
这不是代码风格问题,而是设计上的结构问题。LogFactory这个类同时承担了“知道有哪些日志类型”和“决定创建哪个日志类型”两件事,每次扩展都等于在改一个已经稳定运行的模块。这就是设计模式教材里反复强调的:对扩展开放,对修改关闭。简单工厂在这一点上天然不达标——它把变化点集中在一个函数里,看似方便,实则每加一种产品就要动一次老代码。
1.2 硬伤在哪:新增类型要改动已有代码
可能有读者想:改一行else if而已,有那么严重吗?单独看一次修改确实不严重,严重的是这个行为会反复发生。
我见过一个比较极端的案例。一个网关系统里有个消息解析器,用简单工厂管理了十几种消息类型的创建,每个if分支还带一些类型特有的初始化参数。前三个月没什么感觉,到后来需求密集时,每次上线都要动这个工厂类,代码审查时reviewer看到这个文件的改动记录比项目里其他任何文件都多。后来出了个线上事故,一个分支里的类型判断写错了,导致线上消息路由错误,回滚时整个工厂类都得跟着回滚,因为所有创建逻辑都在同一个类里,根本没法只回滚某一种消息类型的改动。
这就是简单工厂在真实工程里的代价:它不是不能工作,而是把所有可以独立变化的东西强行绑在了一起。当产品种类少、变化频率低时,这个绑定无伤大雅;一旦产品种类多起来,每增加一种产品都要改动共享代码,风险就会成倍累积。
1.3 工厂方法模式的解题思路:延迟到子类
工厂方法模式换了个思路。它不再让一个工厂类去判断该创建谁,而是把“创建产品”这个动作声明为抽象方法,交给不同的子类工厂去实现。每个具体工厂只负责创建一种具体产品,客户端面对的是抽象工厂接口,代码里不再出现任何if else类型判断。
可以用一个生活化的类比来解释。简单工厂相当于一个综合外卖平台,用户点餐时平台来匹配哪个商家负责哪道菜,新增一个商家就要平台改匹配规则。工厂方法模式则相当于用户直接对接各个商家,每个商家自己接单、自己出餐,平台只负责提供一个统一的“下单入口”接口。新增商家时,平台入口不用动,只要商家自己实现了下单接口就能接入。
这个类比背后是一个很重要的思维转变:从“集中的选择”变成“分散的实现”。选择权下放了,调用方的代码就可以稳定下来,不再受产品类型扩展的影响。这正是工厂方法模式在创建型设计模式中最核心的价值。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三个核心角色与职责边界
工厂方法模式的结构里有三个核心角色,把它们的职责边界理清楚,代码写出来就顺了。很多初学者容易把抽象工厂和工厂方法搞混,或者把具体工厂写成一坨,根因就是对这三个角色的边界理解不到位。
2.1 抽象产品:把公共行为抽出来
抽象产品定义了所有具体产品必须实现的接口。它不是可有可无的,而是整个模式的基石——没有它,工厂方法返回什么类型都没法定。
cpp复制// 抽象产品:所有日志类型的公共接口
class Log {
public:
virtual ~Log() = default;
virtual void write(const std::string& message) = 0;
};
这里有个容易忽略的细节:抽象产品的粒度决定了工厂方法的上限。如果你的抽象产品定义得不合理,比如把一些只有部分产品才有的行为硬塞进接口,那后续新增产品时就会被迫实现一堆无用方法。我一般会在定义抽象产品时问自己一个问题:如果新增一种产品,这个接口还需要改动吗?只要答案是“需要”,就说明抽象产品还没有抽出真正的共性。
2.2 抽象工厂:只声明不实现
抽象工厂的角色是纯粹的行为声明者,它只负责定义创建产品的方法签名,不包含任何“该创建谁”的判断逻辑。
cpp复制// 抽象工厂:只声明创建方法,不实现
class LogFactory {
public:
virtual ~LogFactory() = default;
virtual std::unique_ptr<Log> createLog() = 0;
};
注意这里我用了std::unique_ptr而不是裸指针。这是C++实现工厂方法模式的一个关键实践:工厂创建出来的对象生命周期应当清晰可控,用智能指针作为返回类型可以在大多数场景下避免内存泄漏。这也是为什么工厂方法模式在C++项目里落地时,很多人说“模式本身不难,难的是资源管理”。
2.3 具体产品与具体工厂:一一对应的绑定关系
具体产品和具体工厂是成对出现的,一个具体工厂只负责创建一种具体产品。它们的关系是工厂方法模式里最核心的绑定:
| 角色 | 抽象层 | 具体实现 | 关键行为 |
|---|---|---|---|
| 产品 | Log |
FileLog、ConsoleLog |
实现具体业务逻辑 |
| 工厂 | LogFactory |
FileLogFactory、ConsoleLogFactory |
返回指定类型的日志对象 |
为什么书上总说工厂方法和产品是一一对应的?因为这个模式的意图就是让每个具体工厂成为一个独立的、可单独演进的模块。FileLogFactory只关心怎么创建一个配置好的FileLog,ConsoleLogFactory只关心怎么创建ConsoleLog,它们之间没有任何耦合。新增产品类型时,只需要新增一个具体产品类和一个具体工厂类,现有代码一行都不用改。
这个一一对应的关系也带来一个很实际的工程收益:每一对具体工厂和产品都可以独立测试、独立发布。如果某个工厂的实现出了问题,影响范围被限定在它对应的那一种产品上,而不是整个创建模块。
3. 代码落地:C++与Java各写一遍完整实现
讲清楚角色之后,代码落地才是真正的考验。我分别用C++和Java写一份完整实现,两份代码解决同一个问题:创建不同存储介质的数据存储客户端。
3.1 C++版本:文件存储与云存储客户端工厂
先看抽象产品和接口定义。注意析构函数声明为虚函数,这是C++里处理继承体系的基本要求,否则通过基类指针删除派生类对象时会产生未定义行为。
cpp复制#include <memory>
#include <string>
#include <iostream>
// 抽象产品:存储客户端接口
class StorageClient {
public:
virtual ~StorageClient() = default;
virtual void upload(const std::string& path) = 0;
virtual void download(const std::string& path) = 0;
};
// 具体产品:本地文件存储客户端
class FileStorageClient : public StorageClient {
public:
void upload(const std::string& path) override {
std::cout << "[FileStorage] Upload: " << path << std::endl;
// 实际逻辑:写入本地文件系统
}
void download(const std::string& path) override {
std::cout << "[FileStorage] Download: " << path << std::endl;
// 实际逻辑:从本地文件系统读取
}
};
// 具体产品:云存储客户端
class CloudStorageClient : public StorageClient {
public:
void upload(const std::string& path) override {
std::cout << "[CloudStorage] Upload: " << path << std::endl;
// 实际逻辑:上传到云服务
}
void download(const std::string& path) override {
std::cout << "[CloudStorage] Download: " << path << std::endl;
// 实际逻辑:从云服务下载
}
};
// 抽象工厂:存储客户端工厂接口
class StorageClientFactory {
public:
virtual ~StorageClientFactory() = default;
virtual std::unique_ptr<StorageClient> createClient() = 0;
};
// 具体工厂:文件存储客户端工厂
class FileStorageClientFactory : public StorageClientFactory {
public:
std::unique_ptr<StorageClient> createClient() override {
// 可以在创建时注入配置、初始化连接池等
return std::make_unique<FileStorageClient>();
}
};
// 具体工厂:云存储客户端工厂
class CloudStorageClientFactory : public StorageClientFactory {
public:
std::unique_ptr<StorageClient> createClient() override {
// 可以在创建时配置云厂商认证信息
return std::make_unique<CloudStorageClient>();
}
};
// 客户端代码:通过抽象工厂获取产品
void useStorage(StorageClientFactory& factory) {
auto client = factory.createClient();
client->upload("example.txt");
client->download("example.txt");
}
这个版本里有一个C++特有的点值得展开说:为什么具体工厂的createClient返回std::unique_ptr<StorageClient>而不是直接返回StorageClient*。如果返回裸指针,调用方必须记得delete,一旦忘记或者出现异常路径就会泄漏。配合智能指针,对象的生命周期管理就清晰了——谁拿到智能指针,谁就拥有这个对象,超出作用域自动释放。这也是现代C++项目里落地工厂方法模式时被问得最多的一个问题。
3.2 Java版本:MySQL连接与Redis连接客户端工厂
Java版本我用数据库客户端来做例子,顺便展示一下Java接口和C++纯虚类的对应关系。
java复制// 抽象产品
public interface DbClient {
void connect();
void query(String sql);
void close();
}
// 具体产品:MySQL客户端
public class MySqlClient implements DbClient {
@Override
public void connect() {
// 建立MySQL连接
}
@Override
public void query(String sql) {
// 执行SQL查询
}
@Override
public void close() {
// 关闭连接
}
}
// 具体产品:Redis客户端
public class RedisClient implements DbClient {
@Override
public void connect() {
// 建立Redis连接
}
@Override
public void query(String sql) {
// Redis没有SQL,这里只是示例——实际是执行命令
}
@Override
public void close() {
// 关闭连接
}
}
// 抽象工厂
public interface DbClientFactory {
DbClient createClient();
}
// 具体工厂:MySQL客户端工厂
public class MySqlClientFactory implements DbClientFactory {
@Override
public DbClient createClient() {
return new MySqlClient();
}
}
// 具体工厂:Redis客户端工厂
public class RedisClientFactory implements DbClientFactory {
@Override
public DbClient createClient() {
return new RedisClient();
}
}
// 客户端代码
public class App {
public static void main(String[] args) {
DbClientFactory factory = new MySqlClientFactory();
DbClient client = factory.createClient();
client.connect();
client.query("SELECT 1");
client.close();
}
}
Java版本和C++版本在结构上几乎一一对应,但有一个显著的差异:Java不用手动管理内存,所以不用担心返回对象怎么销毁的问题。但Java多了一个C++里不太明显的优势——Java的接口天然支持运行时多态,配合Spring之类的依赖注入容器,具体工厂的装配可以完全交给配置完成,客户端代码里连new MySqlClientFactory()都不用写。
3.3 两版实现对照:差异点也是重点
| 对比维度 | C++ | Java |
|---|---|---|
| 抽象层语义 | 纯虚类 | 接口或抽象类 |
| 返回对象生命周期 | std::unique_ptr管理,自动释放 |
由JVM垃圾回收,无需手动释放 |
| 常见实现方式 | 传统虚函数继承 + 智能指针 | 接口多态 + 依赖注入容器 |
| 新增产品时要改的地方 | 新增产品类 + 新增工厂类 | 新增产品类 + 新增工厂类 |
两者在“新增产品不需要改动现有代码”这一点上是一致的,这是工厂方法模式跨语言的通用收益。但C++的坑明显更多一些:智能指针类型的选择、拷贝语义的处理、析构函数的正确性,这些都需要额外注意。Java则因为语言本身提供了更多保障,可以让开发者把注意力完全放在业务建模上。
4. 当工厂方法遇到多Agent架构:subagent工厂的实战映射
最近几年多Agent系统非常火,我在这块的实践里发现一个很有意思的现象:很多人在做Agent路由和调度时,其实是在重新发明轮子——他们用一堆if else分支去判断应该派发哪个subagent,本质上就是在重复简单工厂的写法。而工厂方法模式在Agent场景下的映射,比很多教程里举的“日志系统”例子要鲜活得多。
4.1 多Agent主从模式:subagent本质上是另一种tool调用
在多Agent系统里,主从模式是目前最常见的一种架构。主agent接收用户请求,拆解任务,然后把子任务分配给不同的subagent执行。很多人一开始觉得“subagent调度”是个很高深的东西,直到他们意识到一个本质:在大多数实现里,subagent就是被当作一种工具(tool)来调用的——主agent根据任务类型选择调用哪个工具,然后拿到工具执行结果,再整理成最终回复。
这就和工厂方法模式的思路完全对上了。如果把每个subagent看作是工厂生产的一种产品,那么“根据什么创建哪个subagent”这个变化点,正是工厂方法模式最擅长处理的。主agent不关心具体的subagent类名和构造参数,它只需要一个抽象的AgentFactory接口,调用createAgent()拿到一个可以执行任务的subagent,剩下的交给多态。
4.2 一个可落地的Agent工厂设计
假设系统里有三种subagent:代码评审agent、数据分析agent、文案生成agent。主agent根据用户请求类型决定派发哪种。
java复制// 抽象产品:所有subagent的公共接口
public interface SubAgent {
AgentResult execute(AgentTask task);
}
// 抽象工厂:subagent工厂接口
public interface AgentFactory {
SubAgent createAgent();
}
// 具体产品
public class CodeReviewAgent implements SubAgent {
@Override
public AgentResult execute(AgentTask task) {
// 代码评审逻辑
return new AgentResult("review done");
}
}
public class DataAnalysisAgent implements SubAgent {
@Override
public AgentResult execute(AgentTask task) {
// 数据分析逻辑
return new AgentResult("analysis done");
}
}
public class CopywritingAgent implements SubAgent {
@Override
public AgentResult execute(AgentTask task) {
// 文案生成逻辑
return new AgentResult("copy done");
}
}
// 具体工厂
public class CodeReviewAgentFactory implements AgentFactory {
@Override
public SubAgent createAgent() {
// 这里可以注入代码评审agent需要的额外配置,比如语言模型实例
return new CodeReviewAgent();
}
}
public class DataAnalysisAgentFactory implements AgentFactory {
@Override
public SubAgent createAgent() {
return new DataAnalysisAgent();
}
}
public class CopywritingAgentFactory implements AgentFactory {
@Override
public SubAgent createAgent() {
return new CopywritingAgent();
}
}
// 主agent调度侧:通过工厂拿subagent
public class MasterAgent {
private final Map<String, AgentFactory> factoryRegistry;
public MasterAgent(Map<String, AgentFactory> factoryRegistry) {
this.factoryRegistry = factoryRegistry;
}
public AgentResult dispatch(String taskType, AgentTask task) {
AgentFactory factory = factoryRegistry.get(taskType);
if (factory == null) {
throw new IllegalArgumentException("Unsupported task type: " + taskType);
}
SubAgent subAgent = factory.createAgent();
return subAgent.execute(task);
}
}
这套设计和经典的工厂方法模式在结构上完全同构,但它解决了Agent场景下的一个实际问题:当新增一种subagent时,主agent的调度代码完全不用改。只要注册一个新的工厂到factoryRegistry,主agent就能立即调度新的subagent类型。这比在主agent里堆一个if taskType == "codeReview"的分支要干净得多。
4.3 工厂方法给Agent系统带来的三个实际收益
第一是解耦。主agent和subagent之间的依赖关系被抽象工厂切断,主agent只依赖接口,不依赖具体的subagent类名和构造方式。这在Agent系统快速迭代时尤其重要,因为subagent的实现经常会变,构造参数也经常调整,如果直接在调度逻辑里new,每次变动都得去改主agent的代码和重新测试。
第二是扩展性。注册一个工厂相当于向系统里注入一种新的“创建能力”,这是完全符合开闭原则的扩展方式。对于任何以“多类型、高变化”为核心的系统,这个能力都特别值钱——Agent系统恰好就是这种系统。
第三是可测试性。在单元测试里,工厂方法模式配合一个mock的工厂实现非常方便。测试主agent的调度逻辑时,不需要真去初始化一堆LLM调用,只需要注入一个返回模拟subagent的测试工厂。这在Agent系统的回归测试里能省下大量时间和算力成本。
5. 工厂方法的四个坏味道与我的排坑经验
模式用对了能省事,用过头了或者没注意边界,也会带来一堆麻烦。我在这里梳理四个在实际项目中真实踩过或见过的坑,每一个都对应着一段实实在在的调错经历。
5.1 坏味道一:工厂类爆炸,什么时候该停手
工厂方法模式被诟病比较多的一个问题是“一个产品配一个工厂”,产品类型一多,类数量直接翻倍。之前一个项目里产品有二十几类,我照着教科书建了二十几个工厂类,每个文件就十几行,项目结构里一片小碎块。
后来我意识到这个做法不理性。工厂方法模式的价值在于让每种产品的创建逻辑独立演进,但如果创建逻辑本身就几行代码,根本不存在独立演进的需求,那每个工厂类就是纯粹的噪声。合理做法是:产品的创建逻辑简单到一眼看穿时,用注册表+函数指针的方式替代工厂类;当产品创建逻辑复杂到需要配置、需要初始化依赖,才值得单独为它建一个工厂类。
5.2 坏味道二:产品参数不一致,抽象返回类型带来的问题
抽象工厂返回的是产品接口,但不同产品的构造参数经常不一致。文件存储客户端需要一个路径前缀,云存储客户端需要一组认证凭据。如果这些初始化参数都塞进具体工厂的构造函数,那客户端代码在调用createClient()之前就得先知道要创建哪个具体工厂,抽象工厂的意义就打了折扣。
我的处理思路是:把参数区分为固定参数和调用期参数。固定参数在具体工厂构造时注入,比如云存储的AK/SK、文件存储的根路径;调用期参数通过传入一个配置对象来传递。这样既保持了抽象工厂的统一签名,又让不同产品可以各自拿到自己需要的参数。
5.3 坏味道三:模板方法+工厂方法,一对容易被忽略的组合拳
工厂方法模式经常和模板方法模式搭配使用,这是我后来才体会到的。模板方法定义业务逻辑的算法骨架,其中某些步骤交给工厂方法完成对象的创建。这样同一个算法流程可以复用于多种具体产品。
举例来说,一个数据导入器的完整流程是“建立连接 -> 读取数据 -> 转换格式 -> 写入目标 -> 清理连接”,其中“建立连接”和“写入目标”需要根据目标系统不同而变化,这就是工厂方法模式的用武之地。用模板方法固定流程骨架,用工厂方法开放变化点,两个创建型/行为型模式配合起来,代码结构比硬编码if分支要清晰得多。
5.4 一个我踩过的真实坑:Factory不加缓存,反复创建产品对象
最后说一个我印象最深的坑。早年在做支付渠道对接时,我用工厂方法模式创建支付客户端,每个具体工厂的createClient()方法里都new一个全新的支付客户端对象。表面上没有问题,直到线上出现了连接池耗尽——因为每次支付请求都会创建一个新的客户端连接,用完就丢弃,连接池被频繁申请释放拖垮了。
后来我在具体工厂里针对连接型产品做了对象复用:createClient()返回一个已经初始化好的、可复用的实例,不再每次新建。具体做法是创建完成后缓存起来,第二次调用直接返回缓存的对象。这个修复提醒我一个工厂方法模式容易被忽略的实践:工厂返回的对象不一定非得是全新的,它也可以是缓存里的复用品。具体怎么实现,取决于产品的生命周期成本。
这里也想给做类似系统的读者提个醒:使用工厂方法模式时,一定要把“产品对象的生命周期”和“产品对象的创建方式”放在一起考虑。创建只是第一步,产品对象创建之后怎么管理、怎么复用、怎么销毁,这些工程问题解决到位,模式才算真正落地。
