C++ 设计模式学习笔记(一):从变化出发,而不是从类图出发
学设计模式最容易产生的错觉,是把记住二十三张类图当成学会设计。真正写程序时,问题却不会以“请使用某某模式”的形式出现。它通常是:又多了一种文件格式,要接第二家供应商,某个操作现在需要撤销,或者一个类每次改动都会牵连半个项目。
这组笔记来自 B 站《C++ 设计模式》课程。课程共有 26 讲,包含导论、设计原则、23 种 GoF 模式和总结。本文先建立贯穿全课的问题意识,后续文章按变化的性质重新分组。
本系列由 AI 辅助整理,归入
vibe watching。各讲先进行音轨自动转录,再结合课件抽样核对案例与术语。文章采用重新组织的解释和原创示例;“现代 C++ 补充”属于整理者的分析,不是课堂原话。
来源档案
讲者:李建忠;课程:《C++设计模式》,本次据带有 GeekBand 标识的 26 讲 B 站转载版本整理。本文对应:P1《设计模式简介》、P2《面向对象设计原则》、P26《设计模式总结》。
转载账号为“川中一郎”,合集标题《C++设计模式》,标识 BV15pxQzSE8j;上传者与原讲者分别署名。讲者账号发布的《C++设计模式》试听可核对课程署名;本次整理的转载合集用于定位上述分 P。链接失效时,可用“李建忠 C++设计模式 GeekBand”及讲次标题检索。
系列阅读路线
课程原有顺序有利于逐步建立概念,文章则按“什么在变化”重新组织。状态模式因此与策略放在一起比较;备忘录放到对象状态与生命周期一篇,再与命令的撤销联系起来。这是本文的编排,不是课程原有章节分类。
| 文章 | 主要问题 | 对应分 P 与内容 |
|---|---|---|
| 一、从变化出发(本文) | 怎样判断抽象是否有价值 | P1 导论、P2 设计原则、P26 总结 |
| 二、流程、算法、状态与通知 | 谁决定下一步,什么部分允许替换 | P3 模板方法、P4 策略、P5 观察者、P18 状态 |
| 三、把创建对象的变化放到合适的位置 | 选择类型、选择产品族、复制状态还是分步组装 | P8 工厂方法、P9 抽象工厂、P10 原型、P11 构建器 |
| 四、用组合拆开功能与变化方向 | 叠加职责与拆开独立变化轴有什么不同 | P6 装饰、P7 桥接 |
| 五、四种接口隔离,四种不同的边界 | 简化、转换、控制访问和集中协调 | P14 门面、P15 代理、P16 适配器、P17 中介者 |
| 六、对象的唯一性、共享与历史快照 | 什么应该只有一个、什么可以共享、什么需要独立保存 | P12 单件、P13 享元、P19 备忘录 |
| 七、职责链与命令,请求交给谁、怎样保存 | 谁接收请求,以及怎样记录、组合和撤销操作 | P22 职责链、P23 命令 |
| 八、组合、遍历与操作扩展 | 增加节点类型还是增加处理操作 | P20 组合、P21 迭代器、P24 访问者、P25 解释器 |
初学可以按文章顺序读;已经有具体问题时,也可以从表里的变化类型进入。视频 P24、P25 的上传标题分别写作“访问器”“解析器”,本系列采用常用名称“访问者模式”“解释器模式”。
一个会画线和矩形的程序,为什么还不算设计完成
第一讲没有先介绍某个模式,而是展示一个小型绘图程序。
初版分别定义 Line 和 Rect,窗口也分别保存直线集合、矩形集合。鼠标按下和抬起时记录坐标,根据当前工具创建对象;重绘窗口时,先遍历所有直线调用画线函数,再遍历所有矩形调用画矩形函数。
这段程序能够工作。问题要等需求变化以后才显现。
假设现在增加圆形,需要新增圆的数据结构、圆的容器、鼠标事件中的圆形创建分支,还要在重绘逻辑中增加一个绘制循环。以后若再增加选择、移动、导出等操作,每一种形状都可能在多个地方产生新分支。
这里的复杂性不主要来自某一个函数太长,而来自同一个变化要在多个位置重复表达。窗口知道每一种具体形状,也知道各自怎样绘制,于是形状种类增加就不断向窗口传播。
把共同的承诺提取出来
第二版引入抽象 Shape,要求每一种形状实现自己的 Draw。直线知道怎样画直线,矩形知道怎样画矩形;窗口只保存形状,并统一调用绘制操作。
增加圆形以后,圆负责实现自己的绘制行为,原来的容器和重绘循环不需要增加圆形专用分支。
两版在当时都能画出同样的结果。区别在于面对“增加一种形状”这个变化时,第二版需要修改的已有代码更少。
这也是课程所说的复用:不仅是把一个函数拿到另一个项目使用,也包括需求变化之后,原有流程仍能继续工作。复用的对象可以是稳定的业务算法,而不只是相同的几行代码。
创建位置为什么还没有消失
课程没有把第二版描述成彻底消除了变化。鼠标事件中仍要决定创建直线、矩形还是圆,那里暂时保留了具体类型判断。
这是理解设计边界的重要细节。抽象绘制行为,解决的是“怎样使用形状”;它没有自动解决“怎样选出并创建形状”。后面的工厂方法,正是继续处理这条依赖。
没有一种模式能够让系统完全不知道自己支持哪些能力。我们可以把选择集中在装配入口、注册表或配置中,让核心流程稳定,但必须有人提供新的实现并把它接入系统。
用现代 C++ 重写这个最小例子
下面是根据课堂思路重写的完整 C++20 示例。它把图形库替换成字符串输出,只保留多态绘制与所有权边界;不是视频源码的逐字抄录。
#include <cassert>
#include <memory>
#include <sstream>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
class Shape {
public:
virtual ~Shape() = default;
virtual void draw(std::ostream& out) const = 0;
};
class Line final : public Shape {
public:
void draw(std::ostream& out) const override { out << "line\n"; }
};
class Circle final : public Shape {
public:
explicit Circle(double radius) : radius_(radius) {
if (!(radius > 0)) throw std::invalid_argument("positive radius required");
}
void draw(std::ostream& out) const override {
out << "circle radius=" << radius_ << '\n';
}
private:
double radius_;
};
class Drawing {
public:
void add(std::unique_ptr<Shape> shape) {
if (!shape) throw std::invalid_argument("shape required");
shapes_.push_back(std::move(shape));
}
void draw(std::ostream& out) const {
for (const auto& shape : shapes_) shape->draw(out);
}
private:
std::vector<std::unique_ptr<Shape>> shapes_;
};
int main() {
Drawing drawing;
drawing.add(std::make_unique<Line>());
drawing.add(std::make_unique<Circle>(2.0));
std::ostringstream out;
drawing.draw(out);
assert(out.str() == "line\ncircle radius=2\n");
}
这里有三项属于 C++ 实现层面的决定。
第一,Drawing 独占图形对象,因此用 unique_ptr 表达所有权。窗口销毁时,图形也会释放。视频为解释结构省略了部分内存管理,文章不能把这个省略当成生产代码建议。
第二,通过基类指针删除派生对象需要正确的析构接口,因此 Shape 提供虚析构函数。第三,不能简单把这个集合改成 vector<Shape>:当前 Shape 是抽象类,不能按值实例化;即使基类变成可实例化类型,把派生对象复制进基类值也会失去派生部分,即对象切片。
这些底层问题和设计问题需要同时考虑。抽象正确但生命周期错误,程序仍然会坏;资源管理正确但需求一变就到处修改,也仍然难以维护。C++ Core Guidelines 对多态基类的析构接口给出了相应约束。
分解和抽象分别解决什么
课程把初版称为分解,把第二版称为抽象。
分解把大问题拆成小问题:分别处理直线、矩形和圆。抽象则寻找共同的承诺:无论是哪一种形状,都能够完成绘制。
两者不是只能选择一个。真实系统仍需要分解成模块,再在恰当的位置引入抽象。问题在于,只有按现有类型逐项分解,可能让未来的每一种新情况都穿透所有流程。
这里也不应该推导出“所有分支都要改成继承”。如果只有两个长期稳定的类型,分支足够清楚、测试充分,引入一组接口未必更划算。如果类型集合固定,而对这些类型的操作经常增加,访问者或基于 variant 的处理方式可能更合适。抽象是否值得建立,取决于预期的变化方向。
原则怎样落到一次修改上
第二讲强调隔离变化、各司其职和接口标准化。原则的价值不在于给代码贴标签,而在于帮助我们解释为什么某个改动会扩散。
依赖倒置:让稳定流程不依赖具体实现
窗口原来依赖 Line、Rect 等具体类型;引入 Shape 后,窗口依赖绘制契约,具体形状也要遵守这份契约。
运行时仍然是窗口调用具体形状,但源码层面的依赖方向改变了。不能把依赖倒置误解成颠倒函数调用顺序,也不能把“通过构造函数传一个对象”自动等同于依赖倒置。如果参数仍是某个具体供应商类型,依赖仍可能没有隔离。
“高层稳定、低层变化”也是针对当前问题的判断,不是身份标签。写一个名为 Interface 的类,并不会让它自动稳定;如果接口到处泄露实现细节,底层一变,接口和所有调用者仍然要一起改。
开闭原则:针对一种变化保护已有代码
增加圆形时,希望扩展新的实现,而不改重绘循环。这就是在“增加形状种类”这条轴上争取对扩展开放。
它不意味着任何文件从此都不能修改。修复错误、改变业务规则、调整一个错误的抽象,仍然需要改已有代码。开闭是有范围的设计目标,必须说清楚对哪一种变化封闭。
单一职责:按变化原因划分,而不是按方法数量划分
一个类有十个共同维护同一业务规则的方法,未必职责过多;一个只有两个方法的类,如果同时决定税务政策和数据库连接方式,也可能背负了两种独立变化原因。
回到绘图程序,窗口管理交互和重绘时机,具体形状负责几何绘制。职责的拆分来自变化方向,而不是追求每个类只有一个方法。
里氏替换:能编译通过,还不等于可以替换
派生对象可以赋给基类指针,是语言机制;派生对象能否满足调用方对基类的预期,是设计契约。
如果一个 Shape 的约定是可绘制,而某个派生类要求调用者先识别自己的具体类型再调用特殊初始化,调用者就被迫绕开抽象。以后讨论组合模式时也会碰到类似问题:叶子节点是否应该继承一个承诺能够添加子节点的接口?
异常本身不自动违反替换原则。关键是基类承诺了什么,子类有没有额外收紧前提、破坏结果保证或改变约定的错误语义。
接口隔离:让调用者只依赖自己需要的能力
如果绘制流程只需要 draw,就不应被迫依赖保存文件、连接服务器等无关操作。接口应围绕使用场景形成小而完整的能力集合。
“小”也不是越碎越好。把每一个方法拆成一个接口,却让所有调用者都必须一起拿到它们,可能只是增加装配成本,没有真正隔离变化。
组合、封装变化点与面向接口
继承把实现放进类型层次,组合把可替换的协作者放进对象。选择组合,常常能够让两部分独立演化,避免为了每一种组合创建子类。
但组合不会自动消除耦合:协作者的协议、所有权和调用顺序仍要设计。桥接与装饰都使用组合,却分别处理独立变化轴和可叠加职责;只有看清动机,才能分辨相似结构。
课程关于接口标准化的比喻,可以落实成一个朴素要求:双方同意怎样协作以后,尽量允许各自改变内部实现。这个接口既包括函数签名,也包括参数含义、错误处理和生命周期。
第二讲用雕版与活字的对比帮助理解这个问题:一整块板上刻好“设计模式”,换成“模式设计”就得重做;如果字块有一致的尺寸和装配方式,就能重新组合。这里值得带走的是“可替换的部件需要约定接口”这一类比,不必把历史比喻当作软件设计的证明。
不要把三个层次混在一起
课末区分了设计习语、设计模式和架构模式。它们可以配合,但关注的尺度不同:
| 层次 | 主要关注 | 本系列中的例子 |
|---|---|---|
| 语言习语 | 某种语言里怎样正确表达和管理资源 | RAII、移动所有权、虚析构 |
| 设计模式 | 对象之间如何分工协作、隔离变化 | 工厂、策略、组合、访问者 |
| 架构模式 | 子系统职责和系统级协作关系 | 整个服务或应用的模块边界 |
用 unique_ptr 修复内存泄漏,不等于解决了整个系统的依赖设计;使用了策略模式,也不代表服务边界已经合理。弄清当前问题处于哪个层次,才能选对讨论对象。
先有一段能工作的代码,再识别值得隔离的变化
课程倡导通过重构理解模式。一个可操作的学习过程是:先实现最小需求,提出具体变更,观察改动传播的位置,再寻找可以保护的稳定部分。
例如,给绘图程序增加圆形是一次有信息量的实验。它揭示出形状存储、绘制和创建之间的不同变化点。单纯照着类图从零搭一批空类,往往看不到这些区别。
整理者的补充是:重构还需要行为保护。改变结构之前先保留能够验证现有结果的例子;每一步确认行为没有意外改变,再继续拆分。不要把“以后可能变化”当成无限添加接口的理由。
判断一次抽象是否有帮助,可以问:新增需求时,哪些已有代码真的不用改了?调用者是否需要知道更多内部细节?错误处理和生命周期是否更清楚?新增的类、间接调用和装配步骤,是否值得承担?
本站早期的设计原则笔记也围绕稳定与变化展开。这次系列将继续把这些原则放回具体案例,并补足容易被简化的语言和工程边界。
学完二十三种模式,再把名字遮住
最后一讲提出一个有意思的练习:把模式图里的类名都改成 A、B、C,把方法名改成 f1、f2,再比较它们。
很多图会变得非常相似:一个对象持有某种接口,通过接口调用另一个对象;有时持有一个,有时持有一组。也正因为结构如此相近,单凭成员里有没有基类指针,不能判断它究竟是策略、桥接、代理还是装饰。
必须把具体问题放回来:这个接口保护哪一段稳定逻辑?被委托的对象承担什么变化?创建者、使用者和生命周期分别由谁负责?模式名是这些关系的缩写,而不是代码外形的识别结果。
五种转换是思考线索,不是机械替换规则
课程总结了静态到动态、早绑定到晚绑定、继承到组合、编译时依赖到运行时依赖、紧耦合到松耦合五种转变。它们描述了许多教学案例怎样获得运行时替换能力。
整理者需要补上一条边界:现代 C++ 仍然大量使用值语义、模板和编译期选择。一个成员按值保存,并不自动代表坏设计;模板可以在编译期隔离算法与具体类型,也不必为了“晚绑定”改成虚函数。
如果目标是运行时替换派生实现,通过指针或引用间接访问是一种合适手段;如果对象类型固定、生命周期相同,按值组合可能更直接。继承与值组合的内存布局也不能笼统认定完全相同,实际布局取决于类型和实现。我们要保留课程关于变化的判断方法,而不是把某种对象布局提升为所有模式的规定。
一个模式的限制,往往就是它要求保持稳定的部分
访问者便于增加操作,代价是元素种类需要相对稳定;抽象工厂便于增加产品族,代价是产品种类变化时要修改工厂协议。它们不是一般意义上的“更先进”,而是在某一方向上降低变化成本。
如果这个前提后来改变,就应重新评估设计。不能通过不断叠加模式,承诺系统所有部分无论怎样变化都无需调整。反过来,一个没有相关扩展需求的小程序,也可能完全不需要这层间接关系。
什么时候先不要使用模式
总结课列出的提醒很实用,可以整理成六种情况:
- 代码还读不清楚。 先整理命名、重复逻辑和职责,再讨论更复杂的协作结构。
- 需求理解还浅。 先做能验证想法的版本,通过使用和反馈认识问题。
- 变化尚未显现。 不要把随意想象的扩展可能都提前做成框架。
- 不是关键依赖位置。 优先处理反复阻碍开发的部分,避免平均用力。
- 看不到复用收益。 是否值得抽象,要看后续维护和使用场景。项目交付后别人仍需维护,不能只以自己是否继续参与来判断。
- 临近发布。 大规模结构调整会引入风险,正确性与验证应先有保障。
这些情况不是永远不能重构的禁令,而是在提醒我们计算收益和成本。模式不会抵消糟糕的命名、不清楚的需求和缺失的测试。
从使用扩展点,走到设计扩展点
课程最后区分了 Application 和 Framework 两种视角。使用框架时,我们理解它已有的扩展点,完成一个策略实现或订阅某个通知。设计框架时,则需要决定要给别人哪些扩展点、哪些顺序由框架掌握、哪些错误必须拦在边界内。
这两种工作不一定属于不同的人。一个应用中提取出来的小模块,也可能逐渐成为其他模块依赖的库。关键是开始为调用者考虑:它需要了解多少内部细节,怎样实现一个最小扩展,哪些行为允许改变。
从不能识别模式,到能模仿结构,再到能够解释依赖并设计扩展点,最终以内化的原则判断方案,是总结课给出的学习路径。它不是读完课程自动获得的等级,也不必用记得多少模式名称衡量。
课堂把重构过程压缩进几十分钟,真实项目可能经历数周乃至数月。给变化留下被观察的机会,再用明确的证据改进设计,会比期待第一版就画出最终类图更可靠。
来源与阅读说明
整套课程的自动转录用于梳理讲解,再以课件抽样校正术语与代码;自动文字稿不是可靠的逐字引文。文章省略了口语重复和与技术主线无关的插话,保留案例的演化、适用前提与模式之间的联系。对使用频率、性能和具体语言实现的时代性判断,采用了必要的补充与限定。
下一篇:流程、算法、状态与通知,从一个稳定流程需要调用哪些变化步骤开始。