☰
C++17实现简单工厂模式:现代C++设计模式实战指南
2026/10/15 15:35:13 网站建设 项目流程

《大话设计模式》在程序员圈子的地位不用多说,很多人第一次接触“面向对象设计”“开闭原则”“依赖倒置”,都是从它的计算器例子开始的。但真轮到自己动手,把书里的案例用 C++ 重写一遍时,几乎每个人都会卡在同一个地方:书里的代码是 Java 风格,return new AddOperation()之后有垃圾回收器兜底,完全不用管内存;换成 C++ 之后,谁来 delete?工厂返回裸指针还是智能指针?基类要不要虚析构?网上的教程还各有各的写法,小白很容易看晕。这篇文章就做一件事:把《大话设计模式》第一章的核心——简单工厂模式——用现代 C++ 完完整整落地一遍,讲清楚每一步为什么这么写,再补充工程里真正用得上的一套改造方案。无论你是刚看完书想动手练,还是准备面试想把这几个创建型模式捋清楚,这篇都值得花十分钟读完。

1. 计算器案例背后的设计动机:为什么第一个模式从“创建”入手

1.1 先回放书里那个场景:从过程式 switch 到面向对象

书里第一章的引子,是一个不管谁学编程都写过的程序:控制台计算器。用户输入两个数和一个运算符,程序输出结果。初学版本的写法通常长这样:

double result = 0.0; if (op == '+') { result = a + b; } else if (op == '-') { result = a - b; } else if (op == '*') { result = a * b; } else if (op == '/') { if (b == 0.0) std::cerr << "除数不能为 0"; else result = a / b; } else { std::cerr << "不支持的运算符"; }

这段代码本身没错,但它把三种东西耦合在一起:运算逻辑、运算类型判断、结果输出。哪天要加一个幂运算,就得在 main 函数里再塞一个 else if;哪天要把流程改成图形界面,这段逻辑还得跟着搬。代码越写越长,分支越加越多,直到某天你把 main 函数翻到第三页还找不到入口时,就会开始想“面向对象”这件事了。

书里那个面试场景的关键转折点就在这里:把每一种运算独立成类,每个类只负责一件事。加法有加法类,减法有减法类,未来加幂运算就加一个幂运算类。这样每个类的代码都短,main 函数的逻辑也清清爽爽。可是问题来了:谁来决定当前到底 new 哪个类?如果把这个判断放在 main 函数里,那 main 还是认识所有具体类,跟原来用 switch 判断时的本质没有区别。

简单工厂模式解决的就是这个“创建决策”的问题。

1.2 三个角色和一个中心思想:让客户端只见抽象,不见实现

简单工厂由三个角色组成,我直接用一个对照表来拆:

角色本例中的对应物职责
抽象产品Operation基类定义所有运算类的公共接口
具体产品OperationAdd、OperationSub等实现各自的运算逻辑
工厂OperationFactory根据参数决定创建哪一个具体产品并返回

这个结构有一个核心思想;客户端只知道抽象接口Operation,完全不需要知道OperationAdd这些具体类的存在。它只向工厂提需求:“给我一个能处理+的对象”,然后拿着返回的对象调用calculate就行。

打个生活化的比方,简单工厂很像外卖平台的分单系统。你下订单时只说你想要什么东西,平台根据你在哪个区域、点什么菜,把订单派给不同的餐厅。你不需要知道做黄焖鸡的店叫什么名字,也不需要知道它在哪条街,最终送到你手上的那碗饭就是你的产品。如果哪天冒出来一家新的炸鸡店要接入,平台的分单规则就得更新,这就是简单工厂扩展性薄弱的根源——我们后面专门讲这个。

理解周期稍微拉长一点会发现,简单工厂实际上是后面所有“工厂类模式”的地基。它就是给人练手和入门用的,严格来说并不在 GoF 那 23 种经典模式里,却往往是理解工厂方法、抽象工厂最顺滑的跳板。因为它把“依赖抽象”这件事先立住了,而“抽象”恰恰是整个设计模式学习的起点。

2. C++17 完整实现:运算类、工厂类和调用方,逐行说明关键点

2.1 抽象产品:Operation 基类的虚接口设计

先写最底层的抽象基类。这里我用 C++17 的写法,紧贴现代工程习惯:

// Operation.h #pragma once class Operation { public: virtual ~Operation() = default; virtual double calculate(double numA, double numB) const = 0; };

开头第一行virtual ~Operation() = default;是我最想强调的部分。在 Java 里你根本不用考虑它,因为 JVM 负责回收对象;但 C++ 里,工厂返回的是一个Operation*或者智能指针,外界迟早要通过基类指针去销毁对象。如果基类析构函数不是 virtual,那么销毁一个派生类对象时就只调用基类析构函数,如果派生类里有自己的资源、或者析构逻辑需要收尾,这些逻辑全部不会执行。更严重的是,通过基类指针 delete 非虚析构的派生类对象,标准里直接定义为未定义行为,你指望不出来的那些诡异崩溃,很多就是这么来的。

calculate声明成纯虚函数并且是const成员函数,从接口层面就保证“计算不改变对象状态”,这样同一个运算对象可以被多个调用方共享使用不会互相污染。这里的= 0意味着 Operation 是抽象类,不能直接实例化,只能作为基类使用。

2.2 具体产品:加减乘除四个实现类

// OperationAdd.h #pragma once #include "Operation.h" class OperationAdd final : public Operation { public: double calculate(double numA, double numB) const override { return numA + numB; } };

注意我在具体类上加了final。如果这些运算类没有“被继续继承”的预期,加final能让编译器做更好的优化,也让阅读者一眼看出这是继承链的末端,不需要在这个层级继续设计接口。减法、乘法类形式一致,就不重复贴了,重点看除法的实现:

// OperationDiv.h #pragma once #include <stdexcept> #include "Operation.h" class OperationDiv final : public Operation { public: double calculate(double numA, double numB) const override { if (numB == 0.0) { throw std::invalid_argument("除数不能为 0"); } return numA / numB; } };

这里有一个常见的争议点:判断浮点数等于 0 到底要不要用 epsilon?我的观点是,在计算器这种直接拿用户输入做运算的场景,== 0.0就足够清晰。epsilon 比较通常用在物理计算、数值分析这类“误差累积明显”的场景里;而计算器里如果用户输入了 0.0,它就是要表达“除数确实是零”这个含义,用阈值去宽容反而会掩盖真实的输入问题。

另外注意,除零校验放在calculate方法内部,而不是放在工厂创建对象时。工厂只负责回答“你要什么类型的对象”,至于这个类型在运算时会抛出什么异常,那是产品类自己的职责边界。这种职责划分是让简单工厂保持“简单”的关键,我后面还会单独讲。

2.3 工厂类:一个 switch 和一个统一入口

// OperationFactory.h #pragma once #include <memory> #include <stdexcept> #include "Operation.h" #include "OperationAdd.h" #include "OperationSub.h" #include "OperationMul.h" #include "OperationDiv.h" class OperationFactory { public: static std::unique_ptr<Operation> create(char op) { switch (op) { case '+': return std::make_unique<OperationAdd>(); case '-': return std::make_unique<OperationSub>(); case '*': return std::make_unique<OperationMul>(); case '/': return std::make_unique<OperationDiv>(); default: throw std::invalid_argument("不支持的运算符"); } } };

两个关键的现代 C++ 设计决策解释一下。

第一,工厂方法返回std::unique_ptr<Operation>,而不是裸指针Operation*。裸指针意味着调用方必须在某个时刻负责 delete;如果调用方忘了,内存泄漏;如果代码在中间抛了异常,delete 那行可能直接跳过。而unique_ptr是 RAII 的产物,它一旦离开作用域就会自动释放所管理的对象,异常路径也能正确清理。这是现代 C++ 写工厂的底线,谁再教你当年那种Operation* op = new ...; delete op;的写法,当体验工程序就好。

第二,工厂方法声明成static,因为工厂本身不需要保存任何状态,它就是一套“根据输入给输出”的纯逻辑。写成静态方法让调用变成OperationFactory::create('+'),比先构造一个工厂对象再调用的方式更直白。当然,如果你预见到未来要给工厂注入配置、或者在测试时需要换掉默认工厂,那可以改成实例方法,这个我们第 3 节也会涉及。

switch缺省分支抛出std::invalid_argument,明确告诉调用方“你传了一个我处理不了的类型”,而不是静默返回 nullptr 然后把隐患埋到下一层去判断。异常比空指针更容易调试,这个习惯在写所有工厂类时都适用。

2.4 客户端代码:业务方只认识抽象接口

// main.cpp #include <iostream> #include <memory> #include "OperationFactory.h" int main() { double a = 10.0; double b = 3.0; char op = '/'; try { auto operation = OperationFactory::create(op); double result = operation->calculate(a, b); std::cout << a << " " << op << " " << b << " = " << result << std::endl; } catch (const std::exception& e) { std::cerr << "运算失败:" << e.what() << std::endl; } return 0; }

这段代码最值得注意的一点:main 函数里看不到OperationAdd、OperationDiv这些具体类名,它 include 的只有OperationFactory.h。也就是说,客户端在编译期就只依赖抽象基类的接口,和具体实现完全解耦。哪天你要把加法从OperationAdd换成另一个实现,只要工厂内部改了返回对象,main 这边一行都不用动。

这个“调用方不认识具体类”,就是简单工厂对依赖倒置原则最朴素的应用:高层模块不依赖低层模块,两者都依赖抽象。你现在可能觉得就四五个类没什么了不起,但当产品类膨胀到几十个、并且这些类分布在不同的模块里时,这种解耦带来的编译速度和维护体验完全是指数级的差距。

3. 从示例到工程:智能指针、注册表和可测试性,把代码推到生产级

3.1 看看裸指针写法会带来什么麻烦

上一节的代码已经足够跑通,但它还停留在“教学版”水平。为什么要强调这一点?因为如果你去看十几年前网上的各种 C++ 设计模式教程,十有八九写的是这种风格:

Operation* op = OperationFactory::create('+'); if (op) { double result = op->calculate(a, b); delete op; // 祈祷不会忘了这一行,祈祷中间没有异常 }

问题恰恰出在“祈祷”上。假如calculate函数内部抛了异常,delete op这行就不会执行,new 出来的对象直接泄漏;即使不抛异常,调用方也可能因为某个分支提前 return 而漏掉 delete。在长期运行的服务进程里,这种漏一次两次不算啥,漏上百次就是内存只涨不降,最后 OOM。

std::unique_ptr的意义就是把delete从程序员手记里彻底抹掉。工厂返回它,调用方不用管释放,栈上变量自动管理生命周期。改造只需要把工厂返回类型改成std::unique_ptr<Operation>,客户端代码根本不需要变化,这笔账怎么算都是划算的。

3.2 用注册表替代 switch:当“加一种运算”不再等于“改工厂类”

另一个从“教学版”到“工程版”的关键升级,是把你那个 switch 的剪刀手换掉。这会触碰到很多人的直觉:“如果产品只有四个,搞什么 map 不是过度设计吗?”对,如果产品永远只有四个,你别改工程。但实际情况通常是:你刚写好加减乘除,产品经理就说要支持幂运算、开方、取模;你刚加完,又说要支持表达式模式、要支持十六进制位运算。这时候你每次都要回到工厂函数里加一个 case,工厂函数会越来越像乱炖,而且很容易误改到别的 case。

注册表方案的思路是:工厂本身不写死有哪些产品,产品类把“自己如何被创建”主动注册到工厂里。代码长这样:

// OperationFactory.h #pragma once #include <functional> #include <memory> #include <stdexcept> #include <unordered_map> #include "Operation.h" class OperationFactory { public: using Creator = std::function<std::unique_ptr<Operation>()>; static bool registerCreator(char op, Creator creator) { registry()[op] = std::move(creator); return true; } static std::unique_ptr<Operation> create(char op) { auto it = registry().find(op); if (it == registry().end()) { throw std::invalid_argument("不支持的运算符"); } return (it->second)(); } private: static std::unordered_map<char, Creator>& registry() { static std::unordered_map<char, Creator> reg; return reg; } };

注册表用了一个函数内的静态局部变量,第一次访问时初始化,这种写法通常叫 Meyers Singleton,它相比全局对象的好处是在多线程环境下标准保证了初始化是线程安全的,并且不会出现跨编译单元的静态初始化顺序问题——而全局对象恰恰经常踩那个坑。

注册动作要找一个入口,最稳的是在 main 开始处放一个初始化函数:

// OperationRegistry.cpp #include "OperationFactory.h" #include "OperationAdd.h" #include "OperationSub.h" #include "OperationMul.h" #include "OperationDiv.h" namespace { void ensureRegistered() { static bool done = [] { OperationFactory::registerCreator('+', [] { return std::make_unique<OperationAdd>(); }); OperationFactory::registerCreator('-', [] { return std::make_unique<OperationSub>(); }); OperationFactory::registerCreator('*', [] { return std::make_unique<OperationMul>(); }); OperationFactory::registerCreator('/', [] { return std::make_unique<OperationDiv>(); }); return true; }(); (void)done; } } void initOperationFactories() { ensureRegistered(); }

creator用std::function包装,意味着你注册的不一定非得是一个类的构造函数调用,你甚至可以注册一个返回“带有额外配置的对象”的 lambda,比如某些运算需要读全局配置,你就可以在注册时把配置捕获进去,灵活性比 switch 大不少。

这套方案真正的收益并不是代码更“高级”,而是把“新增产品”这个动作从“修改工厂类”变成“新增产品类 + 新增注册一行”。前者每次修改都是对已有代码的触碰,风险点就在已稳定的代码上;后者是纯增量,旧的逻辑完全不动,回归面瞬间小了很多。

3.3 可测试性:依赖抽象带来的第一个甜头

很多初学者意识不到,简单工厂对项目最大的贡献其实是“让业务代码变得可以单测”。举个具体例子:假如你有另一个模块依赖Operation来做费用计算,它内部不是直接 new 一个加法类,而是接收一个Operation对象作为参数。测试的时候你可以传一个假实现,返回值是写死的,这样能精确验证调用方逻辑对不对,而不必操心具体的运算实现是什么。

TEST(CalculatorClientTest, ReportGeneratorUsesOperationResult) { auto fake = std::make_unique<FakeOperation>(42.0); // 一个自定义测试替身 auto report = generateReport(std::move(fake), 10.0, 20.0); EXPECT_EQ(report.result(), 42.0); }

放在简单工厂语境下,它让你能把“工厂返回了正确的产品类型”和“业务模块正确调用了抽象接口”两件事分开测试。工厂只需保证类型映射正确,业务模块只需关心如何调用抽象接口,粒度清晰,出问题一查就知道是创建链路的问题还是业务逻辑的问题。

4. 简单工厂的适用边界:什么场景该用,什么时候该换工厂方法

4.1 什么时候用简单工厂是“划算”的

我见过不少工程师在学完简单工厂后的几天内,把所有类都套上一层工厂,结果代码量翻倍,维护成本直线上升。我来给出一个相对务实的判断标准:同时满足下面三条时,简单工厂是划算的;缺一条,你都要再想想。

  • 产品类数量少,比如十个以内,并且短期内不会大规模增长;
  • 创建逻辑简单,基本就是“new 一下就返回”,没有复杂的参数组合、初始化步骤;
  • 你已经明确感受到“客户端直接依赖具体类”这个事实带来的不便,比如编译器依赖太重、要在多个地方写同样的 if 分支。

为什么强调“产品数量少”?因为简单工厂的核心成本都集中在那个工厂函数内部:所有产品的创建逻辑挤在一起,产品数量越多,工厂内部的分支越复杂,工厂本身就成了新的“上帝类”。产品数量少的时候,这个缺点不致命,你只是在一个点位上集中了一小段选择逻辑,读起来甚至比分布式散落更好理解。

另一个常见误判是把“要不要用工厂”和“要不要用抽象基类”混为一谈。如果你的产品永远只有一种实现,那抽象基类就是纯浪费。我自己的经验是:先写一版不用模式的普通代码,等第二个相似分支真正出现时再提取接口加工厂。提前设计出来的抽象层次,绝大多数都会变成没人维护的尸骸,挂在代码里徒增阅读负担。

4.2 工厂“膨胀”的报警信号

简单工厂是有明确的生命周期的,可能用着用着就到了该退役的阶段。我列几个我实际项目中识别出来的报警信号:

报警信号具体表现该往哪个方向走
switch 分支膨胀工厂里的 case 超过十个,而且还在涨改用注册表,或者直接升级为工厂方法
创建参数复杂化create 方法从一个 char 变成五个参数先引入 Builder 或参数对象,再看是否需要拆分工厂
类型判断泄漏到业务代码业务代码里出现“如果是加法就……”的散碎逻辑把判断集中回工厂,或考虑策略模式
工厂内部开始做特殊处理某个产品创建前要初始化一堆外部资源初始化逻辑应该挪进产品类自身,工厂只负责调用

最危险的不是这些信号本身,而是你明明看见了还在继续往原来的工厂里堆代码。“反正多一个 case 也不难”是很多人踩坑时的真实想法,然后某天工厂变成了一坨需要看半天才能改动的泥巴。程序设计里最难的事不是写出新代码,而是识别出“这个结构已经承受不住增长”的那一刻。

4.3 工厂方法:下一步该怎么走

当简单工厂无法满足扩展需求时,最常见的升级路径是工厂方法模式。它的思路是:把“创建哪个产品”的决策从一个大工厂类里拆出去,每个产品对应一个工厂子类。

// IFactory.h #pragma once #include <memory> #include "Operation.h" class IFactory { public: virtual ~IFactory() = default; virtual std::unique_ptr<Operation> createOperation() const = 0; }; // AddFactory.h #pragma once #include "IFactory.h" #include "OperationAdd.h" class AddFactory final : public IFactory { public: std::unique_ptr<Operation> createOperation() const override { return std::make_unique<OperationAdd>(); } };

客户端使用的时候,先持有具体工厂子类,再让工厂告诉我们产品:

std::unique_ptr<IFactory> factory = std::make_unique<AddFactory>(); auto op = factory->createOperation();

两种模式区别很明显,值得做成一张对照表放在手边:

对比维度简单工厂工厂方法
产品创建逻辑所在集中在一个工厂类里分散到每个工厂子类
新增一种产品时改动需要修改已有工厂类只需新增产品类和对应工厂类
类总数量少,工厂只有一个多,每个产品多一个工厂类
对开闭原则的符合度对扩展不友好,要改旧代码对扩展友好,对修改关闭
适合的产品规模小规模、变化慢中大规模、变化频繁

所以真实建议其实很简单:四个运算类用简单工厂就行,等产品到二十个、并且还不停增加的时候,你早该切到工厂方法了。中间的转换过程并不痛苦,因为简单工厂的模式结构天然能作为跳板——你只需要把工厂方法里的“工厂类”拆成子类,客户端从“传参给工厂”改成“选择工厂类”,接口熟悉的程度会让你几乎感觉不到迁移成本。

4.4 顺带理清抽象工厂

和工厂方法合并学习时,初学者还会经常把抽象工厂模式弄混。抽象工厂解决的不是“单个产品”的创建,而是“一个产品族”的创建。比如你的界面系统要支持 Windows 风格和 Mac 风格,Windows 风格下要同时创建 Windows 按钮、Windows 输入框、Windows 对话框,Mac 风格下要创建 Mac 对应的一套控件;这两个产品族不能混搭,抽象工厂就是用来保证你拿到的是一整套同一风格的产品。而简单工厂从头到尾只关心“你给我一个运算符,我给你一个对应的运算对象”,维度完全不同。先把这个边界理清,后面学抽象工厂时就不会绕晕。

5. 换成 C++ 之后最容易翻车的五个细节,以及我个人的体会

5.1 虚析构不是可选项

上一节实现里我已经加了virtual ~Operation() = default;,这里专门再强调一下:在 C++ 里,任何打算做多态删除的基类都必须是虚析构。漏掉它的后果在小型 Demo 里可能完全无感,因为你的派生类没有成员对象、没有自定义析构逻辑,编译器生成的析构函数什么都没做。但一旦某个派生类持有std::vector、std::string、或者自定义 RAII 资源,通过基类指针析构时这些成员根本不会被释放,内存泄漏和资源泄漏会在毫无关联的地方冒出来,排查时你会怀疑人生。规则很死,也很简单:基类要么有虚析构,要么明确禁止通过基类指针删除。

5.2 辨别“创建对象”和“执行业务逻辑”的边界

刚开始接触工厂的人容易把一堆校验逻辑塞进工厂。比如有人在create里判断除法参数,发现数字 B 是 0 就直接返回一个nullptr:

if (op == '/' && b == 0.0) return nullptr; // 不推荐

这种做法很拧巴。工厂的职责是回答“要哪种产品”,运算数据的正确性是产品自身业务的一部分,两个问题该分开管。如果你把校验放到了创建阶段,那么以后想增加“负数开方”的校验,也得去改工厂函数,工厂迟早变成一个什么都管的杂物间。好的习惯是:工厂只根据类型标记分发,产品内部负责自己的业务规则和异常抛出。边界越清晰,后续重构越顺畅。

5.3 工厂的标识参数怎么选:char、字符串还是枚举

早期的学风是用char表示运算类型,因为打印直观、代码短。但产品种类增多后,char的表达力很快就撑不住了,“幂运算”用'^'还是"pow"?“求平方”和“求平方根”怎么区分?一个字符远远不够用。

工程上我更推荐两种做法。一种是enum class OperationType { Add, Sub, Mul, Div };,它能获得编译期的类型检查和代码补全,输入写错在编译阶段就被拦住了。另一种是用std::string_view作为注册表 key,好处是灵活、可扩展,甚至可以直接和配置文件里的名字对应起来,代价是你必须自己接受运行时校验成本。

无论选哪种,都建议别在业务代码里散落裸字符串常量,集中定义在产品层的头文件里,统一引用。我见过太多因为写错拼写、大小写不一致导致工厂抛出不支持异常的事故了,把标识收进集中常量,这种问题可以消灭一大半。

5.4 头文件依赖方向要保持单向

简单工厂有一个天然依赖结构:main知道工厂 → 工厂知道具体产品 → 具体产品知道抽象基类。这个依赖方向是单向的,可以顺着往下数,不能出现逆向。最容易失控的场景是:为了让产品类自动注册,有人在产品头文件里 include 工厂头文件,然后产品类在静态初始化时调用registerCreator,这就形成了“产品依赖工厂、工厂依赖产品”的循环包含,最终编译阶段各种重定义、未定义错误够你忙活一整天。

如果确实想让产品自动注册,可以用 CRTP 模板或者单独的注册器类打破循环,而不是让产品和工厂互相 include。不过对于绝大多数中小项目,我仍然推荐注册集中在一个初始化函数里,可读性比“自动注册”更直观,排查问题时也能顺着一个入口看完所有产品列表。

5.5 需求没有出现第二个分支之前,别急着上工厂

记得有一次我接手一个内部工具,里面所有数据解析逻辑都在一个函数里,看起来毫无设计感。有人建议我立刻用简单工厂重构成“每个格式一个解析类”。我当时忍住了没动,先把代码读了一遍,发现整个系统里真正支持的格式只有一种,而且代码量不过几十行。如果我用工厂去套它,光抽象基类、具体类、工厂类三个文件就得几十个很空的类,还不如原来的一个函数好读。

三个月后需求变了,确实出现了第二种格式,这时我才把解析逻辑抽成接口,用工厂做类型分发,整个过程顺理成章,新来的人看代码也不需要猜太多设计意图。我对这种时机的体会是:设计模式的引入最好紧跟真实变化,提前引入的抽象不仅没有价值,还会给维护者留下“为什么这里要绕一层”的困惑。

看完整篇文章再回看这个例子你会发现,简单工厂就像设计模式里的“一元二次方程”——它本身不复杂,但它教给你的是看待代码的方式:把所有变化点想象成可能长大的孩子,一开始可能只是个小分支,你随手就能 handle;等到它长大成需要独立负责的组织时,你就要学会把职责分出去。真正重要的并不是记住工厂类的代码长什么样,而是建立那种“在何时需要抽象、在哪里隔离变化”的判断力。

我自己的练手建议也是最后一个建议:学完这个概念之后,先不要写模式版本,而是先写一版完全没有设计的过程式代码,然后一步一步把工厂引入进去,自己体会一下每一步改了什么、为什么这步能让某处代码变干净。亲手走完这个演化过程,你对简单工厂的理解会比背十遍 UML 图都扎实得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询