设计模式:面试速记 + 从零学习
这份文档分两层用:
| 场景 | 读哪里 |
|---|---|
| 面试前 10 分钟 | 第一部分速记页 + 第二部分易混对照,两屏看完 |
| 面试前一天 | 第三部分 23 段口述稿,出声念一遍 |
| 平时学习 | 第四部分逐个模式详解,一天两三个,写代码 |
| 写 C++ 之前 | 第五部分落地检查表 |
| 检验是否真会 | 第六部分闭卷自测 |
全文只围绕一个判断:这次的新需求,让哪一段代码变得难改? 模式是给”把变化隔离出去”的常见办法起的名字,不是新语法,也不是必须套上的模板。
第一部分 速记页
0.1 先记住三类,别一次记 23 个名字
flowchart TB
A[新需求让哪里难改] --> B[创建型 5 种:对象怎样得到]
A --> C[结构型 7 种:对象怎样接起来]
A --> D[行为型 11 种:工作怎样分配]
B --> E[创建过程复杂,或具体类型到处出现]
C --> F[接口对不上,或组合关系难扩展]
D --> G[算法要换,或对象协作难维护]
看图: 先判断问题属于”造、接、做”哪一类,再找具体模式。分类只是检索用的,一个真实设计可以同时涉及多类。
0.2 23 个模式一页表
面试前只需要看这一张。第二列是触发条件,第三列是能脱口而出的动作,第四列提醒你别答错成隔壁那个。
| 模式 | 什么变化让代码难改 | 一句话动作 | 最易答错成 |
|---|---|---|---|
| 工厂方法 | 通用流程要复用,但产品类型该由扩展点决定 | 子类决定造谁 | 简单工厂 / 抽象工厂 |
| 抽象工厂 | 一组相关产品必须成套替换 | 换全套 | 工厂方法 |
| 建造者 | 构造参数多,要分步准备、最后统一校验 | 分步组装再收尾 | 大构造函数 / 配置结构体 |
| 原型 | 要从一个现成样本产出新对象 | 按样本复制 | 备忘录 |
| 单例 | 要限制实例数量并给出统一访问入口 | 控制一份 | 普通全局对象 / 享元 |
| 适配器 | 能力已经够用,接口形式对不上 | 接口翻译 | 装饰器 |
| 桥接 | 两条变化方向相乘,导致类型爆炸 | 两维拆开再组合 | 适配器 / 策略 |
| 组合 | 单个对象和一组对象要用同一种方式处理 | 一件一组同用 | 普通对象组合 |
| 装饰器 | 要可选地叠加职责,同时不改调用接口 | 同接口加职责 | 代理 / 适配器 |
| 外观 | 调用者被迫知道子系统的调用顺序 | 一个常用入口 | 中介者 |
| 享元 | 海量对象各存一份相同的只读数据 | 相同共享,不同外带 | 单例 / 对象池 |
| 代理 | 要控制、推迟或转发对真实对象的访问 | 替身把关 | 装饰器 |
| 责任链 | 请求要沿一组处理者找到归属 | 沿链找处理者,找到即停 | 观察者 |
| 命令 | 一次操作要能保存、排队、重放、撤销 | 操作装包 | 策略 |
| 迭代器 | 遍历不该让调用者知道容器内部结构 | 换走法,不露底 | — |
| 中介者 | 参与者两两互相调用,交叉依赖 | 少互找,找协调 | 外观 / 观察者 |
| 备忘录 | 要能撤销回到上一个状态 | 存快照,再交回恢复 | 原型 |
| 观察者 | 一处变化要通知多个关心它的人 | 变化后通知订阅者 | 责任链 |
| 状态 | 同一操作在不同阶段行为或合法性不同 | 随阶段换行为 | 策略 |
| 策略 | 同一件事要能换一种做法 | 换算法 | 状态 / 模板方法 / 命令 |
| 模板方法 | 大流程固定,个别步骤要可替换 | 固定骨架换步骤 | 策略(且不是 C++ template) |
| 访问者 | 类型基本稳定,却要不断新增操作 | 类型稳,操作外加 | — |
| 解释器 | 一门小语言要按语法树求值 | 语法树求值 | — |
0.3 如果只背一行
策略:事情还是那件事,做法可以换。
这是全部 23 个里最常被问、也最容易讲清的一个。下面用它演示”模式是怎么被需要出来的”,两分钟看完,能回答就说明你抓到了整套文档的方法。
一个排序程序的四步
第一步。 输入 3、1、2,要求从小到大得到 1、2、3。写好了,能用。这里不需要任何设计模式。
第二步。 用户又要能从大到小。你可以复制一份改成倒着排——能工作,但两份程序的取数字、比较、调整位置全都相同,以后修一个共同错误要改两遍。
能不能保留一份排序过程,只更换”谁排前面”的规则?
第三步。 把规则拿出来。比较 a 和 b 时,从小到大的规则是”a 小于 b 就让 a 在前”,从大到小是”a 大于 b 就让 a 在前”。排序程序负责排列,每次要比较就问规则:谁该排前面?
flowchart TB
A[同一份数字:3、1、2] --> B[同一个排序程序]
C[本次选一条规则] --> D[小的排前面]
C --> E[大的排前面]
D -->|二选一,交给排序程序| B
E -->|二选一,交给排序程序| B
B --> F[按所选规则得到结果]
看图:不是两条规则一起用。 本次选”小的排前面”得到 1、2、3,下次换”大的排前面”得到 3、2、1,排序程序始终是同一份。
第四步,现在才说名字。 把一种做法单独拿出来、让使用它的程序可以换另一种做法,这就是策略模式的基本思想。它不要求你写一堆类——一条比较函数就够表达这个例子。
这里也不是说 if 不好。只有两个很短的分支时直接判断就行。要解决的是重复和难改,不是为了消灭 if 硬套模式。
自测
如果现在要”按离 10 的距离由近到远排列”,你准备换排序程序,还是换比较规则?
先自己想,再点开
换比较规则:谁离 10 更近谁排前面,距离相同时再约定按数值升序。排序过程继续复用。
关键不是记住”策略模式”四个字,而是看出这次变化的是比较规则。
第二部分 易混对照
答错的绝大多数情况不是”没听过这个模式”,而是”把它答成了隔壁那个”。这部分是全文密度最高的地方。
1.1 四种”包一层”,到底在包什么
四个模式的代码形状几乎一样:都持有另一个对象、都转发调用。只能靠目的区分,不能靠形状。
flowchart LR
Q[看见一个包装对象,先问它在解决什么] --> A[适配器:原接口对不上,做翻译]
Q --> B[装饰器:接口保持不变,叠加职责]
Q --> C[代理:控制、推迟或转发对真实对象的访问]
Q --> D[外观:给子系统的常用操作提供简化入口]
| 包装前后的接口 | 目的 | 典型追问 | |
|---|---|---|---|
| 适配器 | 变了(旧→新) | 消除不兼容 | 错误码怎样映射到新接口的返回? |
| 装饰器 | 不变 | 可选叠加职责,可多层 | 内层抛异常时收尾逻辑还执行吗? |
| 代理 | 不变 | 管住访问权和访问时机 | 首次创建失败怎么办?代理活着不等于真实对象活着 |
| 外观 | 变了(多步→一步) | 简化调用顺序 | 第三步失败时前两步谁清理? |
看图与看表: 不靠”有个成员指针”辨认。同一个真实类可能同时承担多种角色,但职责太多时要警惕。
1.2 六组高频对比
| 容易混 | 真正要问的区别 | 用同一个场景区分 |
|---|---|---|
| 策略 / 状态 | 是换做法,还是对象所处阶段改变了合法行为 | 选择重试算法 / 连接已关闭时拒绝发送 |
| 策略 / 模板方法 | 是持有一个可替换能力,还是固定流程把步骤留给子类 | 换等待函数 / 读取→转换→写出的骨架 |
| 策略 / 命令 | 是封装可选择的做法,还是封装一次待执行的操作 | 这个任务用哪种重试规则 / 一个排队等待上传的任务 |
| 简单工厂 / 工厂方法 | 是集中一处选择具体对象,还是把创建步骤交给子类扩展 | create(type) / Exporter::create() 可被重写 |
| 工厂方法 / 抽象工厂 | 是一个产品的创建扩展点,还是一整套配套产品 | 选一种 Writer / 平台的 Reader+Timer+Notifier 全套 |
| 原型 / 备忘录 | 是复制出一个新对象,还是保存并恢复原对象的状态 | 从模板造新任务 / 撤销刚才的配置修改 |
1.3 其余五组
| 容易混 | 区别 |
|---|---|
| 适配器 / 桥接 | 兼容已有接口的差异 / 主动拆分两条变化维度 |
| 外观 / 中介者 | 给外部提供简化入口 / 协调内部参与者之间的关系 |
| 观察者 / 责任链 | 通知所有订阅者(都收到)/ 请求沿链转交(找到一个就停) |
| 单例 / 享元 | 限制实例数与访问入口 / 共享可复用的重复状态(可以有很多种共享对象) |
| 普通对象组合 / 组合模式 | 持有另一个对象是通用手段 / 统一处理树形的单项与整体 |
记忆检查: 遮住模式名,只看”用同一个场景区分”那一列,能说出目的再揭晓。看到类图直接报名字,往往是在认形状,不是在理解设计。
第三部分 23 段面试口述稿
2.1 四句话结构
被问”讲讲 XX 模式”时,不要背定义。按这四句说:
1. 原来遇到什么变化,哪段代码因此难改
2. 我把哪项职责移到了哪里
3. 之后再来新需求,改哪些地方
4. 代价是什么,什么时候我不用它
第 4 句是拉开差距的地方。只会说优点,听起来像背的;说得出代价和边界,听起来像写过。
下面 23 段都用同一个场景——一个文件传输工具——好处是你只需记住一个项目,23 个模式在里面各占一个位置。面试时把场景换成你自己项目里的对应位置,四句话的骨架不变。
2.2 创建型 5 种
工厂方法
我有一个导出流程,固定是”拿到写入器 → 写出报告”,流程本身不该重复。但产品类型要能扩展。我把创建动作定义成基类里一个可被重写的
create(),run()的流程留在基类,子类只决定造哪种 Writer。新增一种格式就加一个子类,run()不动。代价是多了一层继承;如果只需要在一处集中选择,一个create(type)简单工厂就够了,不必为了对上模式图硬加子类。
抽象工厂
跨平台部分需要读写器、计时器、通知器三样。之前各处分别判断平台,出现过选了 Linux 读写器却混进另一个平台通知器的问题,测试也没法整体替换。我定义了一个平台工厂接口,提供三个 create,业务只持有这个接口,具体工厂负责产出配套的一套。换平台或换成测试实现是换一个工厂。代价是产品种类容易膨胀——新增一套平台实现很集中,但新增一种所有家族都要支持的产品,比如锁,要改工厂接口和每个具体工厂。只有一个产品的时候,普通工厂就够。
建造者
传输任务的配置项很多,还有”必须先设目标再设并发数”这类顺序要求和最后的整体校验。全塞构造函数会变成一长串同类型参数,容易传错位。我把准备过程拆成分步设置,最后
build()里统一校验并产出不可变对象,校验不过就直接失败,不产出半成品。代价是多一个类和一次构建期;参数少的时候,具名的配置结构体加聚合初始化更简单也更好读。
原型
用户想”照着这个任务再来一个”。让调用者逐字段复制会在加字段时漏掉,而且它也不知道哪些字段该复制。我让对象自己提供
clone(),由它决定复制语义。加字段时只改它自己。代价是必须先定义清楚复制语义——句柄、连接、缓存这类东西是浅拷贝共享还是重新获取,得逐项想清楚,否则复制出来的对象会共享不该共享的资源。
单例
全局配置希望只有一份,并且到处能取到。代价我先说,因为它是最容易被追问的:它同时引入了全局访问和隐藏依赖,单元测试里没法替换成测试配置。C++ 里我用函数内的
static局部对象,C++11 起初始化是线程安全的,也顺带避免了不同翻译单元的初始化顺序问题;但线程安全只保证初始化,成员的并发访问还得自己加锁。所以我的实际选择通常是:创建一份,显式传给需要它的对象。能不用单例就不用,这句话本身就是答案的一部分。
2.3 结构型 7 种
适配器
旧串口库只提供
write_bytes(指针, 长度),新业务统一用send(string)。旧库能力是够的,只是调用形式对不上。我加了一层适配器,对外实现新接口,对内翻译参数。业务不用知道旧库的参数形式。代价是语义映射可能有损:旧库返回错误码、新接口返回 bool,我得先列清哪些码算成功,不能把任意非零当成同一种错误;如果旧库只支持阻塞、新接口要求可取消的异步,薄薄一层改名是补不出来的。能低成本直接统一接口时,也不必长期留着适配层。
桥接
有上传/下载两种业务流程,又有 TCP/串口两种底层通道。按组合写类型会变成四个,再加一种就乘一遍。我把这两条变化方向拆开:业务侧持有一个通道接口,两边各自独立扩展,用的时候组合起来。加一种通道不影响业务侧。代价是多一层间接调用,而且拆开之后还要检查每种组合是否真的都合理,不是所有组合都有意义。
组合
任务树里有单个文件任务,也有目录组成的任务组,界面希望对两者都用同一个
execute()。我让单项和组实现同一接口,组内部持有子节点并转发。递归结构对调用者透明。代价是统一接口会掩盖差异:一个组的失败要怎么汇总、取消传不传给子节点、子节点归谁所有,这些都得单独定义,接口一样不等于语义一样。
装饰器
普通发送之外要能可选加日志、计时、统计。按组合写子类会出现”日志发送者""计时发送者""日志加计时发送者”,组合一多类就爆。我让包装类也实现 Sender,内部持有另一个 Sender,在转发前后加职责,可以再套一层。调用者看到的还是 Sender。代价是链深了难调试、而且顺序敏感——计时包在日志外面和里面,量出来的数不一样;还有异常路径:内层抛出时后置逻辑不会自然执行,要”无论成败都记录”得专门写收尾。只加一处固定日志,不值得引入一个装饰器类。
外观
每次上传,调用者都要依次准备凭据、打开文件、建立会话、发送、记录结果、释放资源。多个调用点都知道这套顺序,流程一调整就要一起改。我提供一个
upload(path)入口,把顺序和清理收在里面。代价是入口简化不代表内部失败处理自动正确:第三步失败时前两步谁清理、要不要回滚,还是得在外观里明确写;另外外观容易长成什么都往里塞的大类。
享元
几万条任务记录各自存了一份相同的协议描述和图标,重复占内存。但每条的进度和目标路径又不同。我把只读的、可复用的那部分抽出来共享,把随使用变化的部分留在各条记录里或作为参数传入。代价是要维护查找结构和键,还要想缓存寿命;关键是划分不能错——把进度这种可变状态放进共享对象,改 A 会影响 B。数据量小根本没必要,另外它和”复用活跃连接的对象池”不是一回事。
代理
真实的远端连接建立成本高,希望首次使用时才建;另外发送前要检查权限,不能让调用者绕过检查直接拿到真实对象。我让代理暴露和真实对象一样的接口,在转发前决定放行还是拒绝、以及何时创建。调用者用法不变。代价是多了隐藏工作和新的失败路径——首次创建失败、创建成功后连接又断、权限拒绝,这三条都要有明确行为;代理对象还在,不等于真实连接还活着。还有一点:如果真实对象仍能被直接拿到,这层权限检查不构成安全边界。
2.4 行为型 11 种
责任链
一个错误要依次交给几个处理者试,谁能处理就停下。原来写成一长串 if-else,加一种处理要改这个函数,顺序也埋在里面看不出来。我把每个处理者做成链上一环,自己决定处理还是往下传。加一种处理是插一环。代价是传播规则和终止行为必须写清:顺序是什么、找到一个是否就停、没人处理时怎么办——最后这条最容易漏,静默丢掉就成了 bug。链长了也不容易看出请求实际走到了哪。
命令
用户点上传,但要能排队、明天再执行、还要支持撤销。原来点击处理函数直接干活,没法保存也没法重放。我把”要做什么 + 参数”打包成一个对象放进队列,提交和执行就解开了。加一种操作是加一个命令。代价是寿命问题:命令执行时它引用的参数和目标必须还有效,界面已经关了也不能失效,所以不能捕获局部变量的引用;再加上撤销要额外记录反向操作所需的信息。
迭代器
调用者只想逐个看任务,却被迫知道内部是数组还是链表,换存储结构遍历代码就得跟着改。我提供访问当前元素和推进位置的遍历接口,遍历状态放在迭代器里。代价是失效规则:遍历 vector 时追加元素可能导致迭代器失效,结束位置不能解引用,并发修改要另行设计——模式提供的是访问方式,不会消除容器规则。另外迭代器能力不同,不是所有迭代器都能随机跳转。实际写 C++ 我直接用标准容器和范围 for,不会为了模式再造一套。
中介者
连接按钮、任务队列、进度面板、取消入口互相直接调用,一个控件状态一变可能连带触发其他所有控件,改一处要查一圈。我让参与者只把事件报给一个协调者,由协调者决定谁该做什么,比如”连接成功后启动任务、取消后禁用按钮”。参与者之间不必知道彼此。代价是原来散落的复杂度会全挤到协调者里,容易长成上帝对象;参与者之间只有简单直接关系时不必引入。它和观察者常一起用:观察者管通知,中介者管协作规则由谁决定。
备忘录
编辑任务配置要支持撤销。让界面直接读取并复制所有内部字段,会破坏封装,字段一变就得改界面。我让配置对象自己生成快照,外部只保存不解释内容,撤销时交回给它恢复。代价是快照可能占不少内存、版本升级后的兼容要想、敏感字段是否进历史要明确;还有一条边界:快照只恢复内存字段,不恢复外部世界——已经发出的网络请求、运行中的 socket 不能靠恢复字段撤销。
观察者
进度变化要同时更新两个显示位置,以后还可能加日志。原来传输主体直接调用界面,加一个显示位置就要改主体。我改成显示器订阅进度,传输主体只负责发通知,加接收者不动主体。代价里有两个必答点:它不天然异步、也不天然线程安全,通知在哪个线程执行、是不是同步阻塞在回调里,都要自己定义;还有回调寿命,订阅者先销毁而没退订,通知就会打到已析构的对象上,所以退订语义和通知期间能否增删订阅者必须写清。
状态
传输对象在未连接、已连接、关闭中三个阶段,
send()的合法性和行为都不同。原来靠散落的标志位判断,漏一处就在错误阶段发出了数据。我的做法是先把状态表列出来——哪些状态、哪些操作合法、转移条件是什么,然后才决定实现方式。状态少的时候enum加switch完全合理,也更容易一眼看全;状态多、每个状态行为差异大的时候,才拆成状态对象,让当前状态决定行为和下一个状态。代价是拆开后转移逻辑分散在各状态里,全局图反而不好看出来,所以那张表要留着。
策略
传输失败后等多久重试,原来只有固定等待,写个函数就够。后来要能选固定等待或逐次增加。如果每个发送流程各自判断,规则就散了。我把”计算等待时长”做成可替换的一项能力,传输流程只问”第几次重试该等多久”,不知道公式。加一种规则不改流程。代价是多一层间接和一处配置;而且选择用哪套规则的地方仍然可以有 if,模式没让判断消失。只有一个稳定公式时,一个普通函数就够。另外真实重试还需要次数上限、总时间上限、以及业务操作能否重复执行,这些不是叫了策略就自动有的。
模板方法
导出流程固定是读取、转换、写出,只有转换随格式不同。我把这个顺序写在基类的一个方法里,把转换定义成子类必须实现的步骤。子类只填空,不会写错顺序。代价是流程被固定在基类,子类要改顺序就得动基类,继承也带来耦合;步骤多了还容易出现”子类忘了调基类某一步”。顺便说一句,这里的”模板”指流程骨架,和 C++ 的 template 没关系——如果只需要换一个步骤而不需要继承,传一个函数进去(也就是策略)常常更轻。
访问者
任务树的节点类型基本定了:文件节点、目录节点。但要不断加统计、导出、检查这些操作,每加一种就往每个节点类里塞一个方法,节点越来越杂。我把一种操作集中成一个访问者,节点提供
accept()把自己的具体类型交出去。加一种操作是加一个访问者,节点不动。代价正好反过来:新增一种节点类型,可能要求所有已有访问者都补上对应处理,所以它只适合类型稳定、操作多变的情况;另外访问者要干活往往需要节点暴露内部数据。只有一两个简单操作时,普通成员函数或一个遍历函数更自然。
解释器
有一套小过滤语言,由 AND、OR 和条件节点组成。我把语法规则表示成树,每个节点自己会求值,整棵树递归算出结果。加一种运算是加一种节点。代价是解析和求值是两件事,这个模式管的是求值那半;语言一复杂,节点类和优先级处理会迅速膨胀,性能也不如编译式方案。真要做规模稍大的语言,我会用成熟的解析工具,而不是手写一棵解释器树。
第四部分 逐个模式详解
3.0 先补五个词和编译方式
| 词 | 用这个传输工具解释 |
|---|---|
| 类 | 一种对象的定义,例如 Sender 规定发送者提供什么操作 |
| 对象 | 实际创建出来的一个发送者,内部可能有状态和资源 |
| 接口 | 使用者能调用的操作与行为约定:send 接受什么、失败怎么表示 |
| 实现 | send 内部究竟怎么完成工作 |
| 依赖 | A 要调用 B,或需要知道 B 的类型;B 变化可能影响 A |
C++ 没有必须使用的 interface 关键字。抽象基类是一种接口表达方式,普通函数和模板约束也可以表达约定。
继承表达”这个类型可以当那个类型用”(TcpSender 是一种 Sender);组合在本文的宽泛用法是”某对象持有另一个对象,把部分工作交给它”(Transfer 持有一个 Sender)。“是一种”和”用一个”不同,两者可以一起使用。
下面标为完整例子的代码都各自独立保存为 main.cpp,不要拼进同一个文件:
c++ -std=c++17 -Wall -Wextra -Wpedantic main.cpp -o app && ./app
先看一个最小的”通过同一接口做事”,后面所有例子都建立在这个形状上。
完整例子 0
#include <iostream>
#include <string>
class Sender {
public:
virtual ~Sender() = default;
virtual void send(const std::string& text) = 0;
};
class ConsoleSender : public Sender {
public:
void send(const std::string& text) override {
std::cout << text << '\n';
}
};
void transfer(Sender& sender) {
sender.send("hello");
}
int main() {
ConsoleSender sender;
transfer(sender);
}
输出 hello。virtual 允许通过基类接口调到实际对象的实现;= 0 表示具体类型必须提供实现;override 让编译器检查是否真的重写了。Sender& 是借用,不复制也不负责销毁。
虚析构用于支持通过基类指针正确销毁派生对象——这是 C++ 里几乎每个模式都会踩的点。后面用 unique_ptr<Sender> 独占管理对象:拥有者结束时释放,make_unique 创建,std::move 把拥有关系交出去。先理解”谁负责释放”。
自测: transfer 为什么不用知道具体发送者怎样输出?答:它只使用 Sender 的约定。至于这种分离算哪种模式,要继续看它解决的是哪一类变化。
3.1 策略 Strategy:同一件事,换一种做法
触发问题: 传输失败后要决定等多久再试。最初只有固定等待,一个函数就够。后来用户要能选”固定等待”或”逐次增加”。如果每个发送流程都自己判断策略类型,规则就散在各处。
flowchart TB
A[传输流程] -->|询问本次等多久| B[等待策略]
B --> C[固定等待]
B --> D[逐次增加]
E[配置或调用者] -->|选择一种| B
看图: 传输流程只提问”第几次重试该等多久”,不知道具体公式。是选一种做法,不是通知所有策略。
完整例子 1——用函数表达策略,不增加继承层次:
#include <functional>
#include <iostream>
#include <utility>
class RetryPlan {
public:
using Delay = std::function<int(int)>;
explicit RetryPlan(Delay delay) : delay_(std::move(delay)) {}
int seconds(int attempt) const { return delay_(attempt); }
private:
Delay delay_;
};
int main() {
RetryPlan fixed([](int) { return 2; });
RetryPlan increasing([](int attempt) { return attempt * 2; });
std::cout << fixed.seconds(3) << '\n'; // 2
std::cout << increasing.seconds(3) << '\n'; // 6
}
std::function<int(int)> 表示”接收一个 int、返回一个 int 的可调用对象”,Lambda 是就地写的小函数。策略也可以在构建时选定,不一定必须运行时切换;模板参数是另一种零开销的表达方式。
口述见 §2.4。练习: 新增”始终不等待”,不修改 RetryPlan。然后解释为什么选择策略的代码仍可能需要条件分支。
3.2 工厂方法 Factory Method:把创建步骤留给子类决定
先分清简单工厂。 工具支持 TCP、串口两类发送者。若界面、任务队列、测试代码各自创建具体类型,创建方式一变要改很多处。集中成一个 create_sender(type),这是简单工厂——把分支集中起来,没有消灭分支,也不在经典 23 种里。
触发问题(经典工厂方法): 有一个通用导出流程”获得写入器 → 写出报告”,希望复用流程,但让子类决定写入器类型。
flowchart TB
A[Exporter:固定的 run 流程] -->|通过 create 获得产品| B[Writer 产品接口]
C[TextExporter 子类] -->|实现创建步骤| D[TextWriter]
E[其他 Exporter 子类] -->|实现创建步骤| F[其他 Writer]
D -->|满足统一约定| B
F -->|满足统一约定| B
看图: 关键不是”有个函数返回对象”,而是通用创建者保留流程、把产品创建决定交给扩展点。
完整例子 2
#include <iostream>
#include <memory>
#include <string>
class Writer {
public:
virtual ~Writer() = default;
virtual void write(const std::string& text) = 0;
};
class TextWriter : public Writer {
public:
void write(const std::string& text) override {
std::cout << "text: " << text << '\n';
}
};
class Exporter {
public:
virtual ~Exporter() = default;
void run() { // 流程固定
auto writer = create();
writer->write("report");
}
protected:
virtual std::unique_ptr<Writer> create() const = 0; // 扩展点
};
class TextExporter : public Exporter {
protected:
std::unique_ptr<Writer> create() const override {
return std::make_unique<TextWriter>();
}
};
int main() {
TextExporter exporter;
exporter.run(); // text: report
}
protected 表示留给类内部和子类用,不是公共入口。工程里有人把任何返回对象的函数都叫工厂函数,面试时说清你讨论的是哪一种结构。
口述见 §2.2。练习: 增加 JsonWriter 与 JsonExporter,保持 run() 不变。再指出”新增一个选择入口”要改哪里——不要说”任何文件都不用改”。
3.3 抽象工厂 Abstract Factory:换一整套相关产品
触发问题: 跨平台工具需要读写器、计时器、通知器。若业务各处分别判断平台,可能选了 Linux 读写器却混入另一平台通知器,也难以整体替换成测试版本。
flowchart TB
A[业务只持有平台工厂接口] --> B[Linux 工厂]
A --> C[测试工厂]
B --> D[Linux Reader]
B --> E[Linux Timer]
C --> F[内存 Reader]
C --> G[可控 Timer]
看图: 不是”从许多产品里挑一个”,而是选择产品家族。测试工厂的可控时间和内存输入必须配套使用。
完整例子 3
#include <iostream>
#include <memory>
class Reader {
public:
virtual ~Reader() = default;
virtual int read() = 0;
};
class Timer {
public:
virtual ~Timer() = default;
virtual int now() = 0;
};
class Platform { // 工厂接口
public:
virtual ~Platform() = default;
virtual std::unique_ptr<Reader> create_reader() const = 0;
virtual std::unique_ptr<Timer> create_timer() const = 0;
};
class MemoryReader : public Reader {
public:
int read() override { return 42; }
};
class FixedTimer : public Timer {
public:
int now() override { return 1000; } // 时间可控,便于断言
};
class TestPlatform : public Platform { // 一整套测试实现
public:
std::unique_ptr<Reader> create_reader() const override {
return std::make_unique<MemoryReader>();
}
std::unique_ptr<Timer> create_timer() const override {
return std::make_unique<FixedTimer>();
}
};
void run(const Platform& p) { // 业务只认工厂接口
auto reader = p.create_reader();
auto timer = p.create_timer();
std::cout << reader->read() << " at " << timer->now() << '\n';
}
int main() {
TestPlatform platform;
run(platform); // 42 at 1000
}
换平台就是换 Platform 的实现。注意扩展方向的不对称: 加一套平台实现只是加一个类;加一种所有家族都要支持的产品(比如锁),要改 Platform 接口和每一个具体工厂。
口述见 §2.2。练习: 画两行三列——Linux/测试两行,Reader/Timer/Notifier 三列。沿一行替换是换家族,加一列是扩产品种类。说清哪种改动更贵。
3.4 适配器 Adapter:现成能力能用,接口对不上
触发问题: 传输工具期待 send(string),旧库只提供 write_bytes(pointer, length)。旧库已经能做事,只是调用方式、参数或返回规则不一致。
flowchart LR
A[新业务:调用 send] --> B[适配器:转换参数与结果]
B --> C[旧库:write_bytes]
看图: 适配器连接两套约定,不重新实现传输协议。实际还要转换错误表示和编码,不能只改个函数名。
完整例子 4
#include <cstddef>
#include <iostream>
#include <string>
class LegacyPort { // 旧库,不改它
public:
void write_bytes(const char* data, std::size_t size) {
std::cout.write(data, static_cast<std::streamsize>(size));
}
};
class Sender {
public:
virtual ~Sender() = default;
virtual void send(const std::string& text) = 0;
};
class PortAdapter : public Sender {
public:
explicit PortAdapter(LegacyPort& port) : port_(port) {}
void send(const std::string& text) override {
port_.write_bytes(text.data(), text.size());
}
private:
LegacyPort& port_; // 借用:port 必须活得更久
};
int main() {
LegacyPort port;
PortAdapter adapter(port);
adapter.send("hello\n");
}
这里用引用借用旧库对象,port 必须活得比 adapter 的使用时间长。旧接口在例子里只是同步输出,不是会保存指针供将来使用的异步接口——那种情况寿命要求更严。
口述见 §2.3。练习: 假设旧库返回错误码、新接口返回 bool。先列清哪些码表示成功,再设计翻译,不要把任意非零都当同一种错误。
3.5 装饰器 Decorator:保持接口,给对象叠加职责
触发问题: 普通发送之外要加日志、计时、统计。为每种组合写子类会出现”日志发送者""计时发送者""日志加计时发送者”,组合越多类越多。
flowchart LR
A[调用者] --> B[计时包装:Sender]
B --> C[日志包装:Sender]
C --> D[真实发送者:Sender]
看图: 外层内层都提供 send。一次调用沿链向内,再逐层返回。日志和计时的先后顺序会影响测量结果。
完整例子 5
#include <iostream>
#include <memory>
#include <string>
#include <utility>
class Sender {
public:
virtual ~Sender() = default;
virtual void send(const std::string& text) = 0;
};
class ConsoleSender : public Sender {
public:
void send(const std::string& text) override {
std::cout << "send: " << text << '\n';
}
};
class LoggedSender : public Sender { // 同接口,加职责
public:
explicit LoggedSender(std::unique_ptr<Sender> next)
: next_(std::move(next)) {}
void send(const std::string& text) override {
std::cout << "before\n";
next_->send(text);
std::cout << "after\n"; // 内层抛异常时这行不执行
}
private:
std::unique_ptr<Sender> next_;
};
int main() {
auto base = std::make_unique<ConsoleSender>();
LoggedSender sender(std::move(base));
sender.send("hello"); // before / send: hello / after
}
构造前提是传入非空 Sender。异常路径要专门想: 内层抛出时 after 不会执行,需要”无论成败都记录”就得用析构或 try-catch 写收尾。
口述见 §2.3。练习: 再加一个计数包装,连续发送两次应计数为 2;分别把它放在日志层内、外,画出调用顺序。
3.6 代理 Proxy:通过替身控制访问
触发问题: 真正的远端连接创建成本高,希望首次使用才建立;或者发送前要检查权限,不能让调用者绕过检查。
flowchart LR
A[调用者] --> B[代理:鉴权 + 延迟创建]
B -->|允许且已准备好| C[真实服务]
B -->|拒绝| D[明确返回失败]
看图: 代理的重点是”怎样访问真实对象”。它的形状可能和装饰器一样,区别看目的。
完整例子 6
#include <iostream>
#include <memory>
#include <string>
class Service {
public:
virtual ~Service() = default;
virtual bool send(const std::string& text) = 0;
};
class RealService : public Service {
public:
RealService() { std::cout << "[建立连接,成本高]\n"; }
bool send(const std::string& text) override {
std::cout << "real: " << text << '\n';
return true;
}
};
class ServiceProxy : public Service {
public:
explicit ServiceProxy(bool allowed) : allowed_(allowed) {}
bool send(const std::string& text) override {
if (!allowed_) {
std::cout << "denied\n"; // 拒绝也是一条明确路径
return false;
}
if (!real_) { // 首次使用才创建
real_ = std::make_unique<RealService>();
}
return real_->send(text);
}
private:
bool allowed_;
std::unique_ptr<Service> real_; // 可能一直为空
};
int main() {
ServiceProxy denied(false);
denied.send("a"); // denied,从未建立连接
ServiceProxy ok(true);
ok.send("b"); // 建立连接 + real: b
ok.send("c"); // 复用,不再建立
}
必答的边界: real_ 为空可能是”还没用过”也可能是”上次创建失败”,这两种要区分;代理对象还活着,不等于真实连接还活着;如果真实服务仍能被直接拿到,这层检查不构成安全边界。远程代理还要把本地调用转成远端请求,网络失败、超时、部分成功不能伪装成和本地调用一样。
口述见 §2.3。练习: 列出首次使用成功、首次创建失败、第二次复用、权限拒绝四条路径各自的行为。
3.7 外观 Facade:给复杂子系统一个常用入口
触发问题: 每次上传都要让调用者依次准备凭据、打开文件、建立会话、发送、记录结果、释放资源。多个调用者都知道这些细节,流程一调整就要一起改。
flowchart TB
U[调用者:只想 upload path] --> F[上传外观]
F --> A[准备凭据]
F --> B[打开文件]
F --> C[建立会话]
F --> D[发送并记录]
F --> E[释放资源]
看图: 外观把调用顺序和清理收进来。它不禁止调用者在需要时直接用子系统,只是提供常用路径。
关键骨架(外观本身没有特别的 C++ 技巧,重点在错误清理):
class UploadFacade {
public:
bool upload(const std::string& path) {
auto creds = auth_.acquire();
if (!creds) return false;
auto file = files_.open(path);
if (!file) return false; // creds 由 RAII 自行释放
auto session = net_.connect(*creds);
if (!session) return false; // file 也是
return session->send(*file); // 失败也不泄漏
}
// 子系统成员:auth_ / files_ / net_
};
要点: 每一步失败都要让前几步正确收尾。C++ 里的正确做法是让每个中间结果都是 RAII 对象(unique_ptr、fstream、自定义 guard),提前 return 时自动释放;靠手写 goto cleanup 或层层嵌套 if 很容易漏。入口简化不代表内部错误处理自动正确。
口述见 §2.3。练习: 写出”第三步失败”时,前两步分别由谁释放、在哪一行释放。
3.8 观察者 Observer:一件事发生,通知关心它的人
触发问题: 进度变化要更新两个显示位置,以后还可能加日志。原来传输主体直接调用界面,加一个显示位置就要改主体。
flowchart TB
T[传输任务] -->|进度变化,逐个通知| S1[进度条]
T -->|同一次通知| S2[状态栏]
S1 -->|订阅 / 退订| T
S2 -->|订阅 / 退订| T
看图: 是所有订阅者都收到(对比责任链是找到一个就停)。订阅关系可以随时增删,所以退订必须能做到。
完整例子 7——用 token 表达退订,直面寿命问题:
#include <functional>
#include <iostream>
#include <map>
#include <string>
class Progress {
public:
using Handler = std::function<void(int)>;
int subscribe(Handler h) { // 返回 token 用于退订
int id = next_id_++;
handlers_.emplace(id, std::move(h));
return id;
}
void unsubscribe(int id) { handlers_.erase(id); }
void set(int percent) {
auto snapshot = handlers_; // 通知期间对方可能退订
for (const auto& [id, h] : snapshot) {
(void)id;
h(percent);
}
}
private:
int next_id_ = 1;
std::map<int, Handler> handlers_;
};
int main() {
Progress progress;
int bar = progress.subscribe([](int p) { std::cout << "bar " << p << "%\n"; });
progress.subscribe([](int p) { std::cout << "status " << p << "%\n"; });
progress.set(50); // 两个都收到
progress.unsubscribe(bar);
progress.set(100); // 只剩 status
}
三个必答点:
- 不天然异步。
set()里是同步调用回调,订阅者里做慢活会卡住调用线程。 - 不天然线程安全。 多线程要自己加锁,还要小心”持锁调用回调”造成的死锁。
- 回调寿命最容易出事。 回调捕获了某对象,而该对象先销毁又没退订,通知就打到已析构的对象上。上面用 token 显式退订;实际工程中常用
weak_ptr检查目标是否还在,或者让订阅返回一个析构时自动退订的 RAII 句柄。
例子里先拷贝一份 handlers_ 再遍历,是因为回调内部可能退订甚至订阅,直接遍历原容器会让迭代器失效。
口述见 §2.4。练习: 让某个订阅者在自己的回调里调用 unsubscribe(自己),验证不会崩;再想清楚如果去掉那次快照拷贝会怎样。
3.9 状态 State:同一操作,随对象当前阶段改变行为
触发问题: 传输对象在未连接、已连接、关闭中三个阶段,send() 的合法性和行为都不同。靠散落的标志位判断,漏一处就会在错误阶段发出数据。
先列状态表,再决定实现方式。 这一步比选模式重要:
| 当前状态 | send | close | 说明 |
|---|---|---|---|
| 未连接 | 拒绝 | 忽略 | 还没有会话 |
| 已连接 | 发送 | → 关闭中 | 正常工作 |
| 关闭中 | 拒绝 | 忽略 | 等待收尾完成 |
状态少就用 enum + switch,这是正确答案而不是偷懒——三个状态两个操作,一张表一眼看全,转移逻辑集中:
enum class Phase { Idle, Connected, Closing };
class Transfer {
public:
bool send(const std::string& text) {
switch (phase_) {
case Phase::Connected: /* 真正发送 */ return true;
case Phase::Idle:
case Phase::Closing: return false;
}
return false;
}
private:
Phase phase_ = Phase::Idle;
};
状态多、每个状态行为差异大的时候才拆成状态对象:当前状态决定行为,并决定下一个状态。
完整例子 8
#include <iostream>
#include <memory>
#include <string>
class Transfer;
class State {
public:
virtual ~State() = default;
virtual const char* name() const = 0;
virtual bool send(Transfer& t, const std::string& text) = 0;
virtual void close(Transfer& t) = 0;
};
class Transfer {
public:
Transfer();
void set_state(std::unique_ptr<State> s) { state_ = std::move(s); }
bool send(const std::string& text) { return state_->send(*this, text); }
void close() { state_->close(*this); }
const char* state_name() const { return state_->name(); }
private:
std::unique_ptr<State> state_;
};
class Closing : public State {
public:
const char* name() const override { return "closing"; }
bool send(Transfer&, const std::string&) override {
std::cout << "closing: 拒绝发送\n";
return false;
}
void close(Transfer&) override {} // 已在关闭中,忽略
};
class Connected : public State {
public:
const char* name() const override { return "connected"; }
bool send(Transfer&, const std::string& text) override {
std::cout << "connected: 发送 " << text << '\n';
return true;
}
void close(Transfer& t) override {
t.set_state(std::make_unique<Closing>()); // 转移
}
};
class Idle : public State {
public:
const char* name() const override { return "idle"; }
bool send(Transfer&, const std::string&) override {
std::cout << "idle: 拒绝发送\n";
return false;
}
void close(Transfer&) override {}
};
Transfer::Transfer() : state_(std::make_unique<Idle>()) {}
int main() {
Transfer t;
t.send("a"); // idle: 拒绝
t.set_state(std::make_unique<Connected>());
t.send("b"); // connected: 发送 b
t.close();
std::cout << t.state_name() << '\n'; // closing
t.send("c"); // closing: 拒绝
}
C++ 注意: close() 里把新状态装进 Transfer 会销毁当前状态对象,而 this 正在它的成员函数里。上面的写法安全是因为赋值后立即返回、不再触碰成员;一旦转移之后还要用 this 的状态数据,就要先把旧状态移出来暂存,等函数结束再释放。这是状态模式在 C++ 里最真实的坑。
另外注意代价反过来了:拆成对象后转移逻辑分散在各状态里,全局图不好看出来——所以那张状态表要留在文档或注释里。
口述见 §2.4。练习: 加一个”重连中”状态,先改表再改代码。然后回答:三个状态时你会选 enum 还是状态对象,为什么。
3.10 模板方法 Template Method:大流程固定,少数步骤可换
触发问题: 导出流程固定是读取、转换、写出,只有转换随格式不同。各处各写一遍容易把顺序写错或漏掉一步。
flowchart TB
A[基类 run:读取 → 转换 → 写出] --> B[read:基类实现]
A --> C[convert:子类必须实现]
A --> D[write:基类实现]
看图: 骨架在基类、写死顺序;变化点是其中一步。
完整例子 9
#include <iostream>
#include <string>
class Report {
public:
virtual ~Report() = default;
void run() { // 骨架:顺序不给改
std::string raw = read();
std::string body = convert(raw);
write(body);
}
protected:
virtual std::string convert(const std::string& raw) = 0; // 唯一变化点
private:
std::string read() { return "3,1,2"; }
void write(const std::string& body) {
std::cout << "out: " << body << '\n';
}
};
class JsonReport : public Report {
protected:
std::string convert(const std::string& raw) override {
return "[" + raw + "]";
}
};
int main() {
JsonReport r;
r.run(); // out: [3,1,2]
}
名字的坑(高频): 这里的”模板”指流程骨架,和 C++ 的 template 关键字毫无关系。面试被问到务必主动点出这一句。
和策略的区别: 模板方法用继承、变化点在子类、流程固定在基类;策略用组合、变化点是一个可替换对象。只需要换一个步骤而不需要整套继承时,传一个函数(策略)通常更轻——run() 收一个 std::function<std::string(const std::string&)> 就够了。
口述见 §2.4。练习: 把上面的例子改成策略版(run 接收转换函数),对比两版各自在”要改流程顺序”和”要加一种格式”时的代价。
3.11 命令 Command:把一次操作变成可以保存和传递的东西
触发问题: 用户点上传,但要能排队、延后执行、支持撤销。点击处理函数直接干活,就没法保存也没法重放。
flowchart LR
U[界面点击] -->|打包成命令| Q[队列]
Q -->|稍后取出执行| E[执行]
E -->|记入历史| H[历史栈]
H -->|撤销时反向执行| E
看图: 提交和执行被解开了,中间隔着一个能保存的对象。
完整例子 10
#include <iostream>
#include <memory>
#include <string>
#include <vector>
class Command {
public:
virtual ~Command() = default;
virtual void run() = 0;
virtual void undo() = 0;
};
class Upload : public Command {
public:
Upload(std::string path, std::vector<std::string>& done)
: path_(std::move(path)), done_(done) {} // 参数按值存下来
void run() override {
done_.push_back(path_);
std::cout << "upload " << path_ << '\n';
}
void undo() override {
if (!done_.empty()) done_.pop_back();
std::cout << "undo " << path_ << '\n';
}
private:
std::string path_; // 不是引用,也不是指针
std::vector<std::string>& done_; // 目标必须活得更久
};
int main() {
std::vector<std::string> done;
std::vector<std::unique_ptr<Command>> queue;
queue.push_back(std::make_unique<Upload>("a.txt", done));
queue.push_back(std::make_unique<Upload>("b.txt", done));
for (auto& c : queue) c->run(); // 稍后统一执行
queue.back()->undo();
std::cout << "剩余 " << done.size() << '\n'; // 剩余 1
}
寿命是这个模式的全部难点。 path_ 用 std::string 按值存,而不是 const std::string&——因为界面早就关了,命令还要执行。规则:命令必须自己拥有执行所需的参数,只能借用确定活得更久的目标。 用 Lambda 表达命令时同理:捕获局部变量的引用是经典 bug,[&] 在这里几乎总是错的。
撤销还要额外记录反向操作所需的信息——上面 undo 只是示意,真实撤销要记住”我到底改了什么”。
口述见 §2.4。练习: 把 path_ 改成 const std::string&,构造时传一个临时字符串,观察延后执行时读到什么。
3.12 责任链 Chain of Responsibility:请求沿链寻找处理者
触发问题: 一个错误要依次交给几个处理者试,谁能处理就停。写成一长串 if-else 后,加一种处理要改这个函数,顺序也埋在里面看不出来。
flowchart LR
R[请求] --> A[重试处理者]
A -->|处理不了,往下传| B[降级处理者]
B -->|处理不了,往下传| C[记录并放弃]
A -->|能处理,停止| S[结束]
看图: 找到一个就停(对比观察者是所有人都收到)。链尾”没人处理”时的行为必须定义。
完整例子 11
#include <iostream>
#include <memory>
#include <string>
#include <utility>
class Handler {
public:
virtual ~Handler() = default;
void set_next(std::unique_ptr<Handler> next) { next_ = std::move(next); }
bool handle(const std::string& err) {
if (try_handle(err)) return true; // 处理了就停
if (next_) return next_->handle(err);
std::cout << "无人处理:" << err << '\n'; // 链尾必须有明确行为
return false;
}
protected:
virtual bool try_handle(const std::string& err) = 0;
private:
std::unique_ptr<Handler> next_;
};
class RetryHandler : public Handler {
protected:
bool try_handle(const std::string& err) override {
if (err != "timeout") return false;
std::cout << "重试\n";
return true;
}
};
class AuthHandler : public Handler {
protected:
bool try_handle(const std::string& err) override {
if (err != "denied") return false;
std::cout << "重新登录\n";
return true;
}
};
int main() {
auto retry = std::make_unique<RetryHandler>();
retry->set_next(std::make_unique<AuthHandler>());
retry->handle("timeout"); // 重试
retry->handle("denied"); // 重新登录
retry->handle("disk_full"); // 无人处理:disk_full
}
最容易漏的一条: 没人处理时静默返回,就成了”错误消失了”的 bug。上面显式打印并返回 false。链长了也不容易看出请求实际走到了哪,需要日志。
口述见 §2.4。练习: 加一个”无论什么错误都记录、但不终止传播”的处理者,说明它应该放在链的哪个位置,以及它为什么不能返回 true。
3.13 建造者 Builder:分步骤组装复杂对象
触发问题: 传输任务的配置项很多,还有”必须先设目标再设并发数”这类顺序要求,以及最后的整体校验。全塞进构造函数会变成一长串同类型参数,容易传错位置。
完整例子 12
#include <iostream>
#include <optional>
#include <string>
class Task { // 产出物:构造后不可变
public:
Task(std::string target, int workers)
: target_(std::move(target)), workers_(workers) {}
void show() const {
std::cout << target_ << " x" << workers_ << '\n';
}
private:
std::string target_;
int workers_;
};
class TaskBuilder {
public:
TaskBuilder& target(std::string t) { target_ = std::move(t); return *this; }
TaskBuilder& workers(int n) { workers_ = n; return *this; }
std::optional<Task> build() const { // 统一校验,不产半成品
if (!target_ || target_->empty()) return std::nullopt;
if (workers_ < 1 || workers_ > 64) return std::nullopt;
return Task(*target_, workers_);
}
private:
std::optional<std::string> target_;
int workers_ = 4;
};
int main() {
auto ok = TaskBuilder().target("host:9000").workers(8).build();
if (ok) ok->show(); // host:9000 x8
auto bad = TaskBuilder().workers(8).build(); // 没设 target
std::cout << (bad ? "built" : "rejected") << '\n'; // rejected
}
要点: 每个设置函数返回 *this 的引用,才能链式调用。校验集中在 build(),失败就不产出对象,用 std::optional 表达”可能没有”比返回一个半成品安全。
边界: 参数少的时候,一个具名字段的配置结构体加聚合初始化更简单、更好读,不必上建造者。
口述见 §2.2。练习: 加一个”必须先 target 再 workers”的硬性顺序要求,想清楚这该在 builder 里检查,还是根本不该做成硬要求。
3.14 原型 Prototype:从一个现成样本复制新对象
触发问题: 用户想”照着这个任务再来一个”。让调用者逐字段复制,加字段时会漏,而且调用者也不知道哪些字段该复制。
完整例子 13
#include <iostream>
#include <memory>
#include <string>
class Task {
public:
virtual ~Task() = default;
virtual std::unique_ptr<Task> clone() const = 0; // 自己决定复制语义
virtual void show() const = 0;
};
class FileTask : public Task {
public:
explicit FileTask(std::string path) : path_(std::move(path)) {}
std::unique_ptr<Task> clone() const override {
return std::make_unique<FileTask>(*this); // 复制构造在这里生效
}
void show() const override { std::cout << "file " << path_ << '\n'; }
void rename(std::string p) { path_ = std::move(p); }
private:
std::string path_;
// 若这里有 socket / 文件句柄,clone 必须决定:共享?重新获取?还是禁止复制?
};
int main() {
auto tpl = std::make_unique<FileTask>("a.txt");
auto copy = tpl->clone();
static_cast<FileTask*>(copy.get())->rename("b.txt");
tpl->show(); // file a.txt —— 原件未被影响
copy->show(); // file b.txt
}
这个模式的全部重量在”复制语义”。 clone() 里用 *this 会走复制构造函数,如果成员里有 unique_ptr(不可复制)、原始指针(浅拷贝会双重释放)、连接或缓存(不该共享),就必须手写。这是最常被追问的一点:你的 clone 是深拷贝还是浅拷贝,哪些资源重新获取。
口述见 §2.2。练习: 给 FileTask 加一个 std::unique_ptr<int> 成员,看编译错误出现在哪里,再手写 clone 修好它。
3.15 单例 Singleton:限制实例并提供统一访问
触发问题: 全局配置希望只有一份,并且到处能取到。
先说代价,因为它是最容易被追问的模式: 它同时引入了全局访问和隐藏依赖——看函数签名看不出它用了配置,单元测试也没法替换成测试配置。所以正确的答法是先讲代价,再讲实现。
C++ 里的标准写法(函数内 static 局部对象,C++11 起初始化线程安全,也顺带解决了不同翻译单元的初始化顺序问题):
#include <iostream>
#include <string>
class Config {
public:
static Config& instance() {
static Config c; // 首次调用时初始化,C++11 起保证线程安全
return c;
}
Config(const Config&) = delete;
Config& operator=(const Config&) = delete;
const std::string& host() const { return host_; }
private:
Config() : host_("localhost") {} // 私有构造,外部造不出第二个
std::string host_;
};
int main() {
std::cout << Config::instance().host() << '\n';
}
必答的三点:
- 线程安全只覆盖初始化,成员的并发读写还得自己加锁。
= delete掉复制和赋值,否则Config c = Config::instance();就复制出第二份了。- 销毁顺序:static 局部对象在程序退出时按构造逆序销毁,多个单例互相引用时可能读到已销毁对象(“static 析构顺序问题”)。要避免就别让单例在析构里依赖别的单例。
更常见的替代做法: 创建一份,显式传给需要它的对象。共享的效果一样,但依赖写在签名里,测试能替换。能判断不用单例,也是学会了。
口述见 §2.2。练习: 说出一个”必须用单例”的场景,然后试着用”创建一份显式传入”改写它,看是否真的必须。
3.16 桥接 Bridge:两个独立变化方向,拆开组合
触发问题: 有上传/下载两种业务流程,又有 TCP/串口两种底层通道。按组合写类型会变成四个,各加一种就乘一遍。
flowchart TB
A[业务抽象:上传 / 下载] -->|持有一个通道接口| B[通道实现接口]
B --> C[TCP 通道]
B --> D[串口通道]
看图: 两侧各自独立扩展,用的时候组合。这和适配器不同:适配器是被动兼容一个已存在的不同接口,桥接是主动把两条变化维度拆开。
完整例子 14
#include <iostream>
#include <memory>
#include <string>
#include <utility>
class Channel { // 变化方向二:怎么传
public:
virtual ~Channel() = default;
virtual void put(const std::string& data) = 0;
};
class TcpChannel : public Channel {
public:
void put(const std::string& data) override {
std::cout << "[tcp] " << data << '\n';
}
};
class SerialChannel : public Channel {
public:
void put(const std::string& data) override {
std::cout << "[serial] " << data << '\n';
}
};
class Job { // 变化方向一:做什么
public:
explicit Job(std::unique_ptr<Channel> ch) : ch_(std::move(ch)) {}
virtual ~Job() = default;
virtual void run() = 0;
protected:
Channel& channel() { return *ch_; }
private:
std::unique_ptr<Channel> ch_;
};
class UploadJob : public Job {
public:
using Job::Job;
void run() override { channel().put("upload payload"); }
};
int main() {
UploadJob a(std::make_unique<TcpChannel>());
UploadJob b(std::make_unique<SerialChannel>());
a.run(); // [tcp] upload payload
b.run(); // [serial] upload payload
}
加一种通道,Job 一侧完全不动;加一种业务,通道一侧完全不动。边界: 拆开之后还要检查每种组合是否真的都合理——不是所有组合都有意义(比如某通道不支持断点续传)。
口述见 §2.3。练习: 写出”四个组合类”的版本,数一下加第三种通道各需要改几处。
3.17 组合 Composite:单个对象和一组对象按统一方式处理
触发问题: 任务树里有单个文件任务,也有目录组成的任务组,界面希望对两者都用同一个 execute()。
flowchart TB
G[任务组:也实现 Task] --> F1[文件任务]
G --> G2[子任务组:也实现 Task]
G2 --> F2[文件任务]
看图: 组自己也满足单项的接口,所以递归结构对调用者透明。
完整例子 15
#include <iostream>
#include <memory>
#include <string>
#include <utility>
#include <vector>
class Task {
public:
virtual ~Task() = default;
virtual int execute() = 0; // 返回失败个数
};
class FileTask : public Task {
public:
FileTask(std::string name, bool ok) : name_(std::move(name)), ok_(ok) {}
int execute() override {
std::cout << name_ << (ok_ ? " ok\n" : " FAILED\n");
return ok_ ? 0 : 1;
}
private:
std::string name_;
bool ok_;
};
class TaskGroup : public Task {
public:
void add(std::unique_ptr<Task> t) { children_.push_back(std::move(t)); }
int execute() override {
int failed = 0;
for (auto& c : children_) failed += c->execute(); // 汇总,不提前退出
return failed;
}
private:
std::vector<std::unique_ptr<Task>> children_; // 组拥有子节点
};
int main() {
auto group = std::make_unique<TaskGroup>();
group->add(std::make_unique<FileTask>("a.txt", true));
group->add(std::make_unique<FileTask>("b.txt", false));
auto root = TaskGroup();
root.add(std::move(group));
root.add(std::make_unique<FileTask>("c.txt", true));
int failed = root.execute(); // 先执行,再打印汇总
std::cout << "失败 " << failed << " 个\n"; // 失败 1 个
}
统一接口会掩盖差异,这几条必须单独定义: 组的失败怎么汇总(这里是累加,也可以”一个失败就整组失败”)、取消要不要传给子节点、子节点归谁所有(这里用 vector<unique_ptr>,组拥有它们)。接口一样不等于语义一样。
口述见 §2.3。练习: 把汇总改成”遇到第一个失败就停止”,说明哪一种更符合”上传一个目录”的直觉,以及为什么这是业务决定而不是模式决定。
3.18 享元 Flyweight:大量对象共享不变的重复部分
触发问题: 几万条任务记录各自存了一份相同的协议描述和图标,重复占内存。但每条的进度和目标路径又不同。
flowchart TB
A[记录 A:路径 + 进度] --> S[共享的只读协议描述与图标]
B[记录 B:路径 + 进度] --> S
C[记录 C:路径 + 进度] --> S
看图:不是把所有状态都共享。 划分错了——比如把进度放进共享对象——改 A 就会影响 B。
关键骨架
class DescPool { // 内在状态:只读、可共享
public:
std::shared_ptr<const Desc> get(const std::string& key) {
auto& slot = pool_[key];
if (!slot) slot = std::make_shared<Desc>(load(key));
return slot;
}
private:
std::map<std::string, std::shared_ptr<const Desc>> pool_;
};
struct Record {
std::shared_ptr<const Desc> desc; // 共享
std::string path; // 外在状态:每条不同
int progress = 0; // 外在状态:绝不能进共享对象
};
要点: 共享对象标成 const 是最省事的正确性保证。shared_ptr 只管理寿命,不会让可变数据变得并发安全。查找结构、键设计、缓存寿命都有成本,数据量小根本没必要。它和”复用活跃连接的对象池”不是一回事——池里的对象是有状态且被独占使用的。
口述见 §2.3。练习: 估算重复描述数据占多少空间,再决定值不值得拆。
3.19 迭代器 Iterator:按顺序访问元素,不暴露内部结构
触发问题: 调用者只想逐个看任务,却被迫知道内部是数组还是链表;换存储结构,遍历代码就得跟着改。
关键骨架——C++ 里通常不是”造一套迭代器类”,而是让自己的容器支持范围 for:
class TaskList {
public:
// 暴露遍历协议,不暴露 items_ 是什么结构
auto begin() const { return items_.begin(); }
auto end() const { return items_.end(); }
private:
std::vector<Task> items_; // 换成 deque / list,上面两行不变
};
for (const auto& t : list) { /* ... */ }
代价与必答点:
- 失效规则不会因为用了模式就消失:遍历
vector时push_back可能导致扩容,所有迭代器失效;end()不能解引用;并发修改要另行设计。 - 迭代器能力分档:
vector的支持随机跳转(it + 5),list的只能逐步前进。不能假设所有迭代器都能加 5。
实际写 C++ 直接用标准容器和范围 for,不要为了对上模式图再造一套遍历类。
口述见 §2.4。练习: 对 vector 遍历时追加元素,写出为什么可能失效,以及 reserve 能否让它安全(想清楚”能”的条件)。
3.20 中介者 Mediator:复杂协作集中协调
触发问题: 连接按钮、任务队列、进度面板、取消入口互相直接调用,一个控件状态一变就连带触发其他所有控件,改一处要查一圈。
flowchart TB
A[连接组件] <-->|报告事件 / 接收指令| M[会话协调者]
B[任务队列] <-->|报告事件 / 接收指令| M
C[进度面板] <-->|报告事件 / 接收指令| M
D[取消入口] <-->|报告事件 / 接收指令| M
看图: 协调者里写的是协作规则——“连接成功后启动任务、取消后禁用按钮”。参与者不必知道彼此。
关键骨架
class Session { // 协调者
public:
void on(Event e) {
switch (e) {
case Event::Connected:
queue_.start();
cancel_.enable();
break;
case Event::Cancelled:
queue_.stop();
cancel_.disable();
panel_.reset();
break;
}
}
// 成员:queue_ / cancel_ / panel_ —— 参与者之间没有互相引用
};
和外观的区别(高频): 外观给外部提供简化入口,中介者协调内部参与者之间的关系。代价: 原来散落的复杂度会全挤进协调者,容易长成上帝对象;参与者之间只有简单直接关系时不必引入。它常和观察者一起用——观察者管通知机制,中介者管”由谁决定大家怎么配合”。
口述见 §2.4。练习: 分别画出”连接失败”和”用户取消”各改变哪些组件;注意协调者不应接管每个组件的内部实现。
3.21 备忘录 Memento:保存状态,以后恢复
触发问题: 编辑任务配置要支持撤销。若界面直接读取并复制对象所有内部字段,破坏封装,字段一变就得改界面。
flowchart LR
A[配置对象] -->|生成快照| B[备忘录:外部看不懂内容]
B -->|压入| C[历史栈]
C -->|撤销时取出| B
B -->|交回给它恢复| A
看图: 外部只保存快照,不解释内容;恢复必须交回给状态拥有者。
完整例子 16
#include <iostream>
#include <string>
#include <vector>
class Config {
public:
class Snapshot { // 外部拿得到,但看不进去
friend class Config;
std::string host_;
int workers_;
Snapshot(std::string h, int w) : host_(std::move(h)), workers_(w) {}
};
Snapshot save() const { return Snapshot(host_, workers_); }
void restore(const Snapshot& s) { host_ = s.host_; workers_ = s.workers_; }
void set(std::string h, int w) { host_ = std::move(h); workers_ = w; }
void show() const { std::cout << host_ << " x" << workers_ << '\n'; }
private:
std::string host_ = "localhost";
int workers_ = 4;
};
int main() {
Config cfg;
std::vector<Config::Snapshot> history;
history.push_back(cfg.save());
cfg.set("host-a", 8);
history.push_back(cfg.save());
cfg.set("host-b", 16);
cfg.show(); // host-b x16
cfg.restore(history.back()); history.pop_back();
cfg.show(); // host-a x8
cfg.restore(history.back());
cfg.show(); // localhost x4
}
要点: 快照的字段是私有的,friend class Config 让只有拥有者能读写——这就是”不泄漏内部表示”的具体实现。历史栈只负责存放。
边界(必答): 快照只恢复内存字段,不恢复外部世界。 已经发出的网络请求、运行中的 socket、写出去的文件,都不能靠恢复字段撤销。另外快照可能占大量内存,版本升级后的兼容要想,敏感字段是否该进历史要明确。
口述见 §2.4。练习: 给 Config 加一个 socket 成员,说明为什么它不该进快照,以及”恢复配置”时它该怎么处理。
3.22 访问者 Visitor:对象种类较稳定,给它们增加多种操作
触发问题: 任务树的节点类型基本定了(文件、目录),但要不断加统计、导出、检查这些操作。每加一种就往每个节点类里塞一个方法,节点越来越杂。
flowchart TB
A[遍历到一个节点] --> B[node.accept visitor]
B -->|第一次分派:确定节点真实类型| C[visitor.visit 具体类型]
C --> D[统计访问者:累加大小]
C --> E[导出访问者:另一套操作]
看图: 第一步按实际节点类型选中 accept,第二步把具体类型带给相应的 visit——这叫双分派。
完整例子 17
#include <iostream>
#include <memory>
#include <string>
#include <utility>
#include <vector>
class FileNode;
class DirNode;
class Visitor {
public:
virtual ~Visitor() = default;
virtual void visit(const FileNode&) = 0;
virtual void visit(const DirNode&) = 0;
};
class Node {
public:
virtual ~Node() = default;
virtual void accept(Visitor&) const = 0;
};
class FileNode : public Node {
public:
FileNode(std::string name, int size) : name_(std::move(name)), size_(size) {}
void accept(Visitor& v) const override { v.visit(*this); } // *this 类型已确定
const std::string& name() const { return name_; }
int size() const { return size_; }
private:
std::string name_;
int size_;
};
class DirNode : public Node {
public:
void add(std::unique_ptr<Node> n) { children_.push_back(std::move(n)); }
void accept(Visitor& v) const override { v.visit(*this); }
const std::vector<std::unique_ptr<Node>>& children() const { return children_; }
private:
std::vector<std::unique_ptr<Node>> children_;
};
class SizeVisitor : public Visitor { // 加一种操作 = 加一个类
public:
void visit(const FileNode& f) override { total_ += f.size(); }
void visit(const DirNode& d) override {
for (const auto& c : d.children()) c->accept(*this);
}
int total() const { return total_; }
private:
int total_ = 0;
};
int main() {
auto dir = std::make_unique<DirNode>();
dir->add(std::make_unique<FileNode>("a", 10));
dir->add(std::make_unique<FileNode>("b", 32));
SizeVisitor v;
dir->accept(v);
std::cout << v.total() << '\n'; // 42
}
扩展方向正好和别的模式相反: 加一种操作只是加一个访问者类;加一种节点类型,要在 Visitor 接口上加一个 visit,于是所有已有访问者都得补实现——这正是它的取舍,也是面试必问的那句。所以它只适合”类型稳定、操作多变”。
边界: 访问者要干活往往需要节点暴露内部数据(上面 size()、children() 都被迫公开)。只有一两个简单操作时,普通成员函数或一个遍历函数更自然。C++17 起用 std::variant + std::visit 可以在不写继承层次的情况下达到类似效果。
口述见 §2.4。练习: 加一个 SymlinkNode,数清要改哪几个文件、几处。再加一个”导出为文本”访问者,对比两种扩展的成本。
3.23 解释器 Interpreter:用语法规则表示并求值一门小语言
触发问题: 有一套小过滤语言,由 AND、OR 和条件节点组成,要能按规则求值。
flowchart TB
A[AND] --> B[size 大于 100]
A --> C[OR]
C --> D[后缀是 log]
C --> E[后缀是 txt]
看图: 语法规则被表示成一棵树,每个节点自己会求值,整棵树递归算出结果。
关键骨架
class Expr {
public:
virtual ~Expr() = default;
virtual bool eval(const Item& item) const = 0;
};
class SizeGreater : public Expr {
public:
bool eval(const Item& i) const override { return i.size > limit_; }
private:
int limit_;
};
class And : public Expr {
public:
bool eval(const Item& i) const override {
return lhs_->eval(i) && rhs_->eval(i); // 短路求值也是语义的一部分
}
private:
std::unique_ptr<Expr> lhs_, rhs_;
};
必答点: 解析和求值是两件事——这个模式管的是”树已经建好之后怎么求值”那一半,从文本得到树是解析器的工作。语言一复杂,节点类和优先级处理会迅速膨胀,性能也不如编译式方案。真要做规模稍大的语言,用成熟的解析工具,别手写解释器树。
口述见 §2.4。练习: 加一个 Not 节点。再说明 And 的短路行为在什么情况下会成为一个可观察的语义差异。
第五部分 C++ 落地检查表
4.1 模式图省略了资源和线程
模式图只画类型关系。真写代码时,下面六行每一行都要有答案——面试的追问几乎全在这张表里:
| 问题 | 具体要写清 |
|---|---|
| 谁拥有谁 | 值成员 / unique_ptr 独占 / shared_ptr 共享 / 引用借用 |
| 谁先销毁 | 回调、代理、命令、适配器都不能使用已销毁的目标 |
| 复制意味着什么 | 复制配置、共享资源、禁止复制(= delete)还是显式 clone |
| 失败在哪里出现 | 构造、回调、创建产品、包装链内部都可能失败 |
| 哪个线程调用 | 单例、观察者、状态、装饰器本身都不提供任何同步保证 |
| 能否替换测试对象 | 显式依赖比隐藏的全局访问容易验证得多 |
三条通用结论:通过基类指针销毁对象时析构必须 public 且 virtual(或 protected 且非 virtual);尽量把资源交给明确的拥有者;不要为了画模式图把所有关系都改成 shared_ptr——那只是把寿命问题藏起来。对应条款见 C++ Core Guidelines C.35。
4.2 各模式最容易被追问的那一点
按被问频率排的速查表,每行都是”讲完模式后紧跟的下一个问题”:
| 模式 | 追问 |
|---|---|
| 单例 | 线程安全覆盖什么?(只覆盖初始化)销毁顺序问题呢? |
| 观察者 | 订阅者先销毁怎么办?通知在哪个线程?回调里能退订吗? |
| 命令 | 延后执行时参数还有效吗?(不能捕获局部变量的引用) |
| 装饰器 | 内层抛异常时后置逻辑还执行吗?包装顺序影响什么? |
| 状态 | 转移时销毁了当前状态对象,this 还能用吗? |
| 原型 | clone 是深拷贝还是浅拷贝?句柄类成员怎么处理? |
| 代理 | 首次创建失败怎么表达?代理活着等于真实对象活着吗? |
| 适配器 | 错误码到 bool 怎么映射?阻塞接口怎么适配成可取消的异步? |
| 访问者 | 加一种节点类型的代价是多少? |
| 抽象工厂 | 加一种所有家族都要支持的产品,要改几处? |
| 组合 | 组的失败怎么汇总?子节点归谁所有? |
| 迭代器 | 遍历时修改容器会怎样?所有迭代器都能随机跳转吗? |
| 模板方法 | 这个”模板”和 C++ template 是一回事吗?(不是) |
| 工厂方法 | create(type) + switch 是工厂方法吗?(是简单工厂) |
| 享元 | 哪些状态绝对不能进共享对象? |
| 备忘录 | 快照能撤销已发出的网络请求吗?(不能) |
4.3 放回同一个项目里:只在需求出现时增加
这是练习路线,不是要求把 23 个模式装进一个工程:
| 轮次 | 新需求 | 先试的最小变化 | 怎样验证 |
|---|---|---|---|
| 1 | 只输出传输文本 | 一个函数 | 固定输入得到固定输出 |
| 2 | 两种重试等待规则 | 函数参数表达策略 | 相同次数、不同等待结果 |
| 3 | 创建具体发送者到处重复 | 集中创建入口 | 新创建规则只改集中处 |
| 4 | 接旧串口库 | 适配接口 | 新业务不用知道旧参数形式 |
| 5 | 可选日志和计时 | 按需要包装 | 顺序明确,失败也有规定行为 |
| 6 | 两个进度显示位置 | 订阅通知 | 加接收者不改主体,退订语义明确 |
| 7 | 连接阶段限制操作 | 先列状态表 | 未连接和关闭中不能乱发 |
| 8 | 操作需要排队 | 保存操作及参数 | 界面关闭后任务参数仍有效 |
| 9 | 常用上传过程太繁琐 | 一个外观入口 | 第三步失败能清理前两步 |
| 10 | Linux 与测试实现成套切换 | 工厂提供整套依赖 | 测试时间与输入可控 |
十轮之后的真实形状,只画确实有需要的关系:
flowchart TB
U[界面] --> F[上传外观]
F -->|提交操作| Q[命令队列]
Q --> T[传输任务]
T -->|选择等待规则| R[重试策略]
T -->|发送| D[可选日志装饰器]
D --> A[旧库适配器]
A --> L[旧传输库]
T -->|进度通知| O[订阅者]
看图: 适配器解决旧库接口,装饰器加日志,策略提供算法,命令保存一次工作,观察者通知变化,外观简化使用。它们解决的不是同一个问题,所以可以共存;没有对应需求的节点应该删掉。
这里没有默认加入单例、享元、解释器、访问者。能判断不用某种模式,也是学会了。
回到真实项目时用”具体类和函数 + 需求 + 变化位置 + 代价”来核实,不要因为类名里有 Factory、Manager、Observer 就往简历上加模式清单。
4.4 怎样记住:靠提取,不靠重复读
每个模式只做一张四行卡片,不抄定义:
触发问题:哪项需求变化让原代码难改?
对象关系:谁调用谁,变化被放进哪个对象或函数?
代价:新增了什么复杂度,什么时候不值得?
区别:它和最像的那个,目的差在哪?
例:适配器的卡片写”旧库接口不同 → 中间对象翻译 → 语义映射可能有损 → 装饰器是在同接口上加职责”。
一周节奏(是练习安排,不是保证一周精通):
| 天 | 内容 | 动作 |
|---|---|---|
| 1 | 基础 + 策略 + 工厂 | 关文档画策略图,改一次算法 |
| 2 | 适配器 / 装饰器 / 代理 / 外观 | 给四个需求各选一个,并说为什么不是另三个 |
| 3 | 观察者 / 状态 / 命令 | 画对象寿命与调用顺序,不只画类型 |
| 4 | 模板方法 / 责任链 / 建造者 / 原型 | 各做一个正常用例和一个失败用例 |
| 5 | 桥接 / 组合 / 享元 / 迭代器 | 区分”一般组合”与”组合模式” |
| 6 | 中介者 / 备忘录 / 访问者 / 解释器 / 单例 | 练习说出不使用它们的理由 |
| 7 | 只做第六部分自测,回到错题对应节 | 从 17 个完整例子里挑一个关文档重写 |
隔几天换个场景复述。能把”传输工具里的策略”迁移成”排序比较规则”,比反复看同一张图更能检验理解。
第六部分 闭卷自测
先遮住答案。每题至少写”模式或更简单方案 + 原因 + 一个代价”。
- 同一排序流程允许用户换升序降序,最直接怎么做?
- 旧库函数参数形式不同但能力已够,需要什么?
- 不改发送接口,可选地给某个对象加计时和日志,需要什么?
- 对象使用方式不变,但首次使用才创建真实远端服务,需要什么?
- 连接成功后要更新按钮、启动任务、调整取消入口,谁来统一协调?
- 进度变化时通知几个已订阅的显示器,是什么关系?它天然异步吗?
- 未连接和已连接时
send行为不同,是否必须拆多个 State 类? - 把用户点击上传变成一个明天可以执行的任务,要关注什么模式和什么寿命问题?
- 让 Linux 与测试版的 Reader、Timer 成套替换,是什么?
- 一个
create(type)用 switch 返回不同 Sender,必然是经典工厂方法吗? - 基类保留导出流程、让子类决定创建哪种 Writer,是哪个创建模式?
- 配置项很多,要分步准备并在最后校验得到对象,可以考虑什么?
- 复制已有任务模板生成新任务,和撤销当前配置修改,分别对应什么?
- 上传/下载与 TCP/串口两条变化方向,如何避免为每对组合增加类型?
- 单个任务与任务组统一
execute,哪种模式? - 大量记录共用同一份只读图标数据,哪些状态不能共享?
- 节点类型稳定但要不断新增统计与导出操作,考虑什么?新增节点的代价?
- 沿一组错误处理器转交、找到合适者后停止,是什么?
- 一套小过滤语言由 AND、OR、条件节点组成树求值,可以考虑什么?
- 全应用共用一个 Config,是否必须
Config::instance()? run中固定读取、转换、写出,只有转换由子类决定,名字里的”模板”指 C++ template 吗?- 调用方不想知道上传涉及哪些子组件,只想用
upload(path),是什么? - 逐个访问集合而不暴露内部布局,是哪种模式?所有迭代器都能加 5 吗?
- 看到一层包装类,为什么不能直接判定它是代理?
答案
写完再点开
- 策略思想,用比较函数即可;只有固定排序时不用额外设计。
- 适配器;需要正确转换行为与错误,不只是改函数名。
- 装饰器;顺序、异常路径与包装深度都是代价。
- 代理;首次创建失败、重试与访问控制都要定义。
- 中介者可集中协作规则;参与者少时直接协调也可以,注意防止中介者膨胀。
- 观察者;不天然异步、也不天然线程安全,通知方式要自己定义。
- 不必须。先列状态机,少量状态用
enum+switch完全合理。 - 命令;执行时参数与目标必须仍然有效,不能借用局部变量的引用。
- 抽象工厂;新增一种产品种类可能影响所有工厂。
- 不是,通常是简单工厂;要看是否存在由子类扩展的创建步骤。
- 工厂方法;父类流程与产品创建决定分开。
- 建造者;参数少时普通构造或配置结构体更简单。
- 原型与备忘录;前者得到新对象,后者保存与恢复原对象状态。
- 桥接;拆分两个方向后组合,但仍需检查组合是否都合理。
- 组合模式;组的失败汇总、取消传播与子节点所有权要另行定义。
- 享元;每条记录的进度、路径等差异绝不能进共享状态。
- 访问者;新增节点种类要求所有已有访问者补上对应操作。
- 责任链;顺序与”无人处理”的行为必须明确。
- 解释器;大语言优先用成熟工具,解析与求值是两件事。
- 不必须。创建一份并显式传入就能共享,单例额外引入全局访问和测试困难。
- 模板方法;“模板”指流程骨架,不是语言的
template。 - 外观;简化入口不代表内部错误清理自动正确。
- 迭代器;能力分档,随机访问不是所有迭代器都有。
- 形状相同也可能是装饰器或适配器,必须看它解决的需求。
过关标准
可以认为某个模式初步掌握了,当你能做到这五条:
- 不看名称,说出它的触发问题
- 画出真实的调用关系(含寿命,不只是类型)
- 说出新增需求要改哪里、改几处
- 写一个能编译的小例子
- 给出一个不该使用它的反例
第 5 条最能区分”背过”和”用过”。
SOLID 之类的设计原则先不背缩写。先练三个具体问题:这个职责为什么放在这里、调用者依赖了哪些约定、替换实现后是否仍满足行为契约。开闭原则不意味着系统永远不用改代码——把变化集中、让稳定部分少受影响,比宣称零修改有意义得多。
最后留一个习惯:每次想加一个模式,先写下”没有这层会发生的具体问题”。写不出来,就先用简单代码。