PERSONAL LAB / interview/从零开始图解
Interview 从零开始图解

设计模式:面试速记 + 从零学习

这份文档分两层用:

场景读哪里
面试前 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 TB
    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() 的合法性和行为都不同。原来靠散落的标志位判断,漏一处就在错误阶段发出了数据。我的做法是先把状态表列出来——哪些状态、哪些操作合法、转移条件是什么,然后才决定实现方式。状态少的时候 enumswitch 完全合理,也更容易一眼看全;状态多、每个状态行为差异大的时候,才拆成状态对象,让当前状态决定行为和下一个状态。代价是拆开后转移逻辑分散在各状态里,全局图反而不好看出来,所以那张表要留着。

策略

传输失败后等多久重试,原来只有固定等待,写个函数就够。后来要能选固定等待或逐次增加。如果每个发送流程各自判断,规则就散了。我把”计算等待时长”做成可替换的一项能力,传输流程只问”第几次重试该等多久”,不知道公式。加一种规则不改流程。代价是多一层间接和一处配置;而且选择用哪套规则的地方仍然可以有 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);
}

输出 hellovirtual 允许通过基类接口调到实际对象的实现;= 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。练习: 增加 JsonWriterJsonExporter,保持 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_ptrfstream、自定义 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
}

三个必答点:

  1. 不天然异步。 set() 里是同步调用回调,订阅者里做慢活会卡住调用线程。
  2. 不天然线程安全。 多线程要自己加锁,还要小心”持锁调用回调”造成的死锁。
  3. 回调寿命最容易出事。 回调捕获了某对象,而该对象先销毁又没退订,通知就打到已析构的对象上。上面用 token 显式退订;实际工程中常用 weak_ptr 检查目标是否还在,或者让订阅返回一个析构时自动退订的 RAII 句柄。

例子里先拷贝一份 handlers_ 再遍历,是因为回调内部可能退订甚至订阅,直接遍历原容器会让迭代器失效。

口述见 §2.4。练习: 让某个订阅者在自己的回调里调用 unsubscribe(自己),验证不会崩;再想清楚如果去掉那次快照拷贝会怎样。


3.9 状态 State:同一操作,随对象当前阶段改变行为

触发问题: 传输对象在未连接、已连接、关闭中三个阶段,send() 的合法性和行为都不同。靠散落的标志位判断,漏一处就会在错误阶段发出数据。

先列状态表,再决定实现方式。 这一步比选模式重要:

当前状态sendclose说明
未连接拒绝忽略还没有会话
已连接发送→ 关闭中正常工作
关闭中拒绝忽略等待收尾完成

状态少就用 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';
}

必答的三点:

  1. 线程安全只覆盖初始化,成员的并发读写还得自己加锁。
  2. = delete 掉复制和赋值,否则 Config c = Config::instance(); 就复制出第二份了。
  3. 销毁顺序: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) { /* ... */ }

代价与必答点:

  • 失效规则不会因为用了模式就消失:遍历 vectorpush_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常用上传过程太繁琐一个外观入口第三步失败能清理前两步
10Linux 与测试实现成套切换工厂提供整套依赖测试时间与输入可控

十轮之后的真实形状,只画确实有需要的关系

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 个完整例子里挑一个关文档重写

隔几天换个场景复述。能把”传输工具里的策略”迁移成”排序比较规则”,比反复看同一张图更能检验理解。


第六部分 闭卷自测

先遮住答案。每题至少写”模式或更简单方案 + 原因 + 一个代价”。

  1. 同一排序流程允许用户换升序降序,最直接怎么做?
  2. 旧库函数参数形式不同但能力已够,需要什么?
  3. 不改发送接口,可选地给某个对象加计时和日志,需要什么?
  4. 对象使用方式不变,但首次使用才创建真实远端服务,需要什么?
  5. 连接成功后要更新按钮、启动任务、调整取消入口,谁来统一协调?
  6. 进度变化时通知几个已订阅的显示器,是什么关系?它天然异步吗?
  7. 未连接和已连接时 send 行为不同,是否必须拆多个 State 类?
  8. 把用户点击上传变成一个明天可以执行的任务,要关注什么模式和什么寿命问题?
  9. 让 Linux 与测试版的 Reader、Timer 成套替换,是什么?
  10. 一个 create(type) 用 switch 返回不同 Sender,必然是经典工厂方法吗?
  11. 基类保留导出流程、让子类决定创建哪种 Writer,是哪个创建模式?
  12. 配置项很多,要分步准备并在最后校验得到对象,可以考虑什么?
  13. 复制已有任务模板生成新任务,和撤销当前配置修改,分别对应什么?
  14. 上传/下载与 TCP/串口两条变化方向,如何避免为每对组合增加类型?
  15. 单个任务与任务组统一 execute,哪种模式?
  16. 大量记录共用同一份只读图标数据,哪些状态不能共享?
  17. 节点类型稳定但要不断新增统计与导出操作,考虑什么?新增节点的代价?
  18. 沿一组错误处理器转交、找到合适者后停止,是什么?
  19. 一套小过滤语言由 AND、OR、条件节点组成树求值,可以考虑什么?
  20. 全应用共用一个 Config,是否必须 Config::instance()
  21. run 中固定读取、转换、写出,只有转换由子类决定,名字里的”模板”指 C++ template 吗?
  22. 调用方不想知道上传涉及哪些子组件,只想用 upload(path),是什么?
  23. 逐个访问集合而不暴露内部布局,是哪种模式?所有迭代器都能加 5 吗?
  24. 看到一层包装类,为什么不能直接判定它是代理?

答案

写完再点开
  1. 策略思想,用比较函数即可;只有固定排序时不用额外设计。
  2. 适配器;需要正确转换行为与错误,不只是改函数名。
  3. 装饰器;顺序、异常路径与包装深度都是代价。
  4. 代理;首次创建失败、重试与访问控制都要定义。
  5. 中介者可集中协作规则;参与者少时直接协调也可以,注意防止中介者膨胀。
  6. 观察者;不天然异步、也不天然线程安全,通知方式要自己定义。
  7. 不必须。先列状态机,少量状态用 enum + switch 完全合理。
  8. 命令;执行时参数与目标必须仍然有效,不能借用局部变量的引用。
  9. 抽象工厂;新增一种产品种类可能影响所有工厂。
  10. 不是,通常是简单工厂;要看是否存在由子类扩展的创建步骤。
  11. 工厂方法;父类流程与产品创建决定分开。
  12. 建造者;参数少时普通构造或配置结构体更简单。
  13. 原型与备忘录;前者得到新对象,后者保存与恢复原对象状态。
  14. 桥接;拆分两个方向后组合,但仍需检查组合是否都合理。
  15. 组合模式;组的失败汇总、取消传播与子节点所有权要另行定义。
  16. 享元;每条记录的进度、路径等差异绝不能进共享状态。
  17. 访问者;新增节点种类要求所有已有访问者补上对应操作。
  18. 责任链;顺序与”无人处理”的行为必须明确。
  19. 解释器;大语言优先用成熟工具,解析与求值是两件事。
  20. 不必须。创建一份并显式传入就能共享,单例额外引入全局访问和测试困难。
  21. 模板方法;“模板”指流程骨架,不是语言的 template
  22. 外观;简化入口不代表内部错误清理自动正确。
  23. 迭代器;能力分档,随机访问不是所有迭代器都有。
  24. 形状相同也可能是装饰器或适配器,必须看它解决的需求。

过关标准

可以认为某个模式初步掌握了,当你能做到这五条:

  1. 不看名称,说出它的触发问题
  2. 画出真实的调用关系(含寿命,不只是类型)
  3. 说出新增需求要改哪里、改几处
  4. 写一个能编译的小例子
  5. 给出一个不该使用它的反例

第 5 条最能区分”背过”和”用过”。


SOLID 之类的设计原则先不背缩写。先练三个具体问题:这个职责为什么放在这里、调用者依赖了哪些约定、替换实现后是否仍满足行为契约。开闭原则不意味着系统永远不用改代码——把变化集中、让稳定部分少受影响,比宣称零修改有意义得多。

最后留一个习惯:每次想加一个模式,先写下”没有这层会发生的具体问题”。写不出来,就先用简单代码。

Related · 从零开始图解
⎇ main interview/从零开始图解 42 节 253 notes UTF-8