《大话设计模式》在程序员圈子的地位不用多说很多人第一次接触“面向对象设计”“开闭原则”“依赖倒置”都是从它的计算器例子开始的。但真轮到自己动手把书里的案例用 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. C17 完整实现运算类、工厂类和调用方逐行说明关键点2.1 抽象产品Operation 基类的虚接口设计先写最底层的抽象基类。这里我用 C17 的写法紧贴现代工程习惯// 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_ptrOperation create(char op) { switch (op) { case : return std::make_uniqueOperationAdd(); case -: return std::make_uniqueOperationSub(); case *: return std::make_uniqueOperationMul(); case /: return std::make_uniqueOperationDiv(); default: throw std::invalid_argument(不支持的运算符); } } };两个关键的现代 C 设计决策解释一下。第一工厂方法返回std::unique_ptrOperation而不是裸指针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_ptrOperation客户端代码根本不需要变化这笔账怎么算都是划算的。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::functionstd::unique_ptrOperation(); static bool registerCreator(char op, Creator creator) { registry()[op] std::move(creator); return true; } static std::unique_ptrOperation create(char op) { auto it registry().find(op); if (it registry().end()) { throw std::invalid_argument(不支持的运算符); } return (it-second)(); } private: static std::unordered_mapchar, Creator registry() { static std::unordered_mapchar, 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_uniqueOperationAdd(); }); OperationFactory::registerCreator(-, [] { return std::make_uniqueOperationSub(); }); OperationFactory::registerCreator(*, [] { return std::make_uniqueOperationMul(); }); OperationFactory::registerCreator(/, [] { return std::make_uniqueOperationDiv(); }); return true; }(); (void)done; } } void initOperationFactories() { ensureRegistered(); }creator用std::function包装意味着你注册的不一定非得是一个类的构造函数调用你甚至可以注册一个返回“带有额外配置的对象”的 lambda比如某些运算需要读全局配置你就可以在注册时把配置捕获进去灵活性比 switch 大不少。这套方案真正的收益并不是代码更“高级”而是把“新增产品”这个动作从“修改工厂类”变成“新增产品类 新增注册一行”。前者每次修改都是对已有代码的触碰风险点就在已稳定的代码上后者是纯增量旧的逻辑完全不动回归面瞬间小了很多。3.3 可测试性依赖抽象带来的第一个甜头很多初学者意识不到简单工厂对项目最大的贡献其实是“让业务代码变得可以单测”。举个具体例子假如你有另一个模块依赖Operation来做费用计算它内部不是直接 new 一个加法类而是接收一个Operation对象作为参数。测试的时候你可以传一个假实现返回值是写死的这样能精确验证调用方逻辑对不对而不必操心具体的运算实现是什么。TEST(CalculatorClientTest, ReportGeneratorUsesOperationResult) { auto fake std::make_uniqueFakeOperation(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_ptrOperation createOperation() const 0; }; // AddFactory.h #pragma once #include IFactory.h #include OperationAdd.h class AddFactory final : public IFactory { public: std::unique_ptrOperation createOperation() const override { return std::make_uniqueOperationAdd(); } };客户端使用的时候先持有具体工厂子类再让工厂告诉我们产品std::unique_ptrIFactory factory std::make_uniqueAddFactory(); 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 就直接返回一个nullptrif (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 图都扎实得多。