pusidun
← All posts

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 的上传标题分别写作“访问器”“解析器”,本系列采用常用名称“访问者模式”“解释器模式”。

一个会画线和矩形的程序,为什么还不算设计完成

第一讲没有先介绍某个模式,而是展示一个小型绘图程序。

初版分别定义 LineRect,窗口也分别保存直线集合、矩形集合。鼠标按下和抬起时记录坐标,根据当前工具创建对象;重绘窗口时,先遍历所有直线调用画线函数,再遍历所有矩形调用画矩形函数。

这段程序能够工作。问题要等需求变化以后才显现。

假设现在增加圆形,需要新增圆的数据结构、圆的容器、鼠标事件中的圆形创建分支,还要在重绘逻辑中增加一个绘制循环。以后若再增加选择、移动、导出等操作,每一种形状都可能在多个地方产生新分支。

这里的复杂性不主要来自某一个函数太长,而来自同一个变化要在多个位置重复表达。窗口知道每一种具体形状,也知道各自怎样绘制,于是形状种类增加就不断向窗口传播。

把共同的承诺提取出来

第二版引入抽象 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 的处理方式可能更合适。抽象是否值得建立,取决于预期的变化方向。

原则怎样落到一次修改上

第二讲强调隔离变化、各司其职和接口标准化。原则的价值不在于给代码贴标签,而在于帮助我们解释为什么某个改动会扩散。

依赖倒置:让稳定流程不依赖具体实现

窗口原来依赖 LineRect 等具体类型;引入 Shape 后,窗口依赖绘制契约,具体形状也要遵守这份契约。

运行时仍然是窗口调用具体形状,但源码层面的依赖方向改变了。不能把依赖倒置误解成颠倒函数调用顺序,也不能把“通过构造函数传一个对象”自动等同于依赖倒置。如果参数仍是某个具体供应商类型,依赖仍可能没有隔离。

“高层稳定、低层变化”也是针对当前问题的判断,不是身份标签。写一个名为 Interface 的类,并不会让它自动稳定;如果接口到处泄露实现细节,底层一变,接口和所有调用者仍然要一起改。

开闭原则:针对一种变化保护已有代码

增加圆形时,希望扩展新的实现,而不改重绘循环。这就是在“增加形状种类”这条轴上争取对扩展开放。

它不意味着任何文件从此都不能修改。修复错误、改变业务规则、调整一个错误的抽象,仍然需要改已有代码。开闭是有范围的设计目标,必须说清楚对哪一种变化封闭。

单一职责:按变化原因划分,而不是按方法数量划分

一个类有十个共同维护同一业务规则的方法,未必职责过多;一个只有两个方法的类,如果同时决定税务政策和数据库连接方式,也可能背负了两种独立变化原因。

回到绘图程序,窗口管理交互和重绘时机,具体形状负责几何绘制。职责的拆分来自变化方向,而不是追求每个类只有一个方法。

里氏替换:能编译通过,还不等于可以替换

派生对象可以赋给基类指针,是语言机制;派生对象能否满足调用方对基类的预期,是设计契约。

如果一个 Shape 的约定是可绘制,而某个派生类要求调用者先识别自己的具体类型再调用特殊初始化,调用者就被迫绕开抽象。以后讨论组合模式时也会碰到类似问题:叶子节点是否应该继承一个承诺能够添加子节点的接口?

异常本身不自动违反替换原则。关键是基类承诺了什么,子类有没有额外收紧前提、破坏结果保证或改变约定的错误语义。

接口隔离:让调用者只依赖自己需要的能力

如果绘制流程只需要 draw,就不应被迫依赖保存文件、连接服务器等无关操作。接口应围绕使用场景形成小而完整的能力集合。

“小”也不是越碎越好。把每一个方法拆成一个接口,却让所有调用者都必须一起拿到它们,可能只是增加装配成本,没有真正隔离变化。

组合、封装变化点与面向接口

继承把实现放进类型层次,组合把可替换的协作者放进对象。选择组合,常常能够让两部分独立演化,避免为了每一种组合创建子类。

但组合不会自动消除耦合:协作者的协议、所有权和调用顺序仍要设计。桥接与装饰都使用组合,却分别处理独立变化轴和可叠加职责;只有看清动机,才能分辨相似结构。

课程关于接口标准化的比喻,可以落实成一个朴素要求:双方同意怎样协作以后,尽量允许各自改变内部实现。这个接口既包括函数签名,也包括参数含义、错误处理和生命周期。

第二讲用雕版与活字的对比帮助理解这个问题:一整块板上刻好“设计模式”,换成“模式设计”就得重做;如果字块有一致的尺寸和装配方式,就能重新组合。这里值得带走的是“可替换的部件需要约定接口”这一类比,不必把历史比喻当作软件设计的证明。

不要把三个层次混在一起

课末区分了设计习语、设计模式和架构模式。它们可以配合,但关注的尺度不同:

层次 主要关注 本系列中的例子
语言习语 某种语言里怎样正确表达和管理资源 RAII、移动所有权、虚析构
设计模式 对象之间如何分工协作、隔离变化 工厂、策略、组合、访问者
架构模式 子系统职责和系统级协作关系 整个服务或应用的模块边界

unique_ptr 修复内存泄漏,不等于解决了整个系统的依赖设计;使用了策略模式,也不代表服务边界已经合理。弄清当前问题处于哪个层次,才能选对讨论对象。

先有一段能工作的代码,再识别值得隔离的变化

课程倡导通过重构理解模式。一个可操作的学习过程是:先实现最小需求,提出具体变更,观察改动传播的位置,再寻找可以保护的稳定部分。

例如,给绘图程序增加圆形是一次有信息量的实验。它揭示出形状存储、绘制和创建之间的不同变化点。单纯照着类图从零搭一批空类,往往看不到这些区别。

整理者的补充是:重构还需要行为保护。改变结构之前先保留能够验证现有结果的例子;每一步确认行为没有意外改变,再继续拆分。不要把“以后可能变化”当成无限添加接口的理由。

判断一次抽象是否有帮助,可以问:新增需求时,哪些已有代码真的不用改了?调用者是否需要知道更多内部细节?错误处理和生命周期是否更清楚?新增的类、间接调用和装配步骤,是否值得承担?

本站早期的设计原则笔记也围绕稳定与变化展开。这次系列将继续把这些原则放回具体案例,并补足容易被简化的语言和工程边界。

学完二十三种模式,再把名字遮住

最后一讲提出一个有意思的练习:把模式图里的类名都改成 A、B、C,把方法名改成 f1、f2,再比较它们。

很多图会变得非常相似:一个对象持有某种接口,通过接口调用另一个对象;有时持有一个,有时持有一组。也正因为结构如此相近,单凭成员里有没有基类指针,不能判断它究竟是策略、桥接、代理还是装饰。

必须把具体问题放回来:这个接口保护哪一段稳定逻辑?被委托的对象承担什么变化?创建者、使用者和生命周期分别由谁负责?模式名是这些关系的缩写,而不是代码外形的识别结果。

五种转换是思考线索,不是机械替换规则

课程总结了静态到动态、早绑定到晚绑定、继承到组合、编译时依赖到运行时依赖、紧耦合到松耦合五种转变。它们描述了许多教学案例怎样获得运行时替换能力。

整理者需要补上一条边界:现代 C++ 仍然大量使用值语义、模板和编译期选择。一个成员按值保存,并不自动代表坏设计;模板可以在编译期隔离算法与具体类型,也不必为了“晚绑定”改成虚函数。

如果目标是运行时替换派生实现,通过指针或引用间接访问是一种合适手段;如果对象类型固定、生命周期相同,按值组合可能更直接。继承与值组合的内存布局也不能笼统认定完全相同,实际布局取决于类型和实现。我们要保留课程关于变化的判断方法,而不是把某种对象布局提升为所有模式的规定。

一个模式的限制,往往就是它要求保持稳定的部分

访问者便于增加操作,代价是元素种类需要相对稳定;抽象工厂便于增加产品族,代价是产品种类变化时要修改工厂协议。它们不是一般意义上的“更先进”,而是在某一方向上降低变化成本。

如果这个前提后来改变,就应重新评估设计。不能通过不断叠加模式,承诺系统所有部分无论怎样变化都无需调整。反过来,一个没有相关扩展需求的小程序,也可能完全不需要这层间接关系。

什么时候先不要使用模式

总结课列出的提醒很实用,可以整理成六种情况:

  1. 代码还读不清楚。 先整理命名、重复逻辑和职责,再讨论更复杂的协作结构。
  2. 需求理解还浅。 先做能验证想法的版本,通过使用和反馈认识问题。
  3. 变化尚未显现。 不要把随意想象的扩展可能都提前做成框架。
  4. 不是关键依赖位置。 优先处理反复阻碍开发的部分,避免平均用力。
  5. 看不到复用收益。 是否值得抽象,要看后续维护和使用场景。项目交付后别人仍需维护,不能只以自己是否继续参与来判断。
  6. 临近发布。 大规模结构调整会引入风险,正确性与验证应先有保障。

这些情况不是永远不能重构的禁令,而是在提醒我们计算收益和成本。模式不会抵消糟糕的命名、不清楚的需求和缺失的测试。

从使用扩展点,走到设计扩展点

课程最后区分了 Application 和 Framework 两种视角。使用框架时,我们理解它已有的扩展点,完成一个策略实现或订阅某个通知。设计框架时,则需要决定要给别人哪些扩展点、哪些顺序由框架掌握、哪些错误必须拦在边界内。

这两种工作不一定属于不同的人。一个应用中提取出来的小模块,也可能逐渐成为其他模块依赖的库。关键是开始为调用者考虑:它需要了解多少内部细节,怎样实现一个最小扩展,哪些行为允许改变。

从不能识别模式,到能模仿结构,再到能够解释依赖并设计扩展点,最终以内化的原则判断方案,是总结课给出的学习路径。它不是读完课程自动获得的等级,也不必用记得多少模式名称衡量。

课堂把重构过程压缩进几十分钟,真实项目可能经历数周乃至数月。给变化留下被观察的机会,再用明确的证据改进设计,会比期待第一版就画出最终类图更可靠。

来源与阅读说明

整套课程的自动转录用于梳理讲解,再以课件抽样校正术语与代码;自动文字稿不是可靠的逐字引文。文章省略了口语重复和与技术主线无关的插话,保留案例的演化、适用前提与模式之间的联系。对使用频率、性能和具体语言实现的时代性判断,采用了必要的补充与限定。

下一篇:流程、算法、状态与通知,从一个稳定流程需要调用哪些变化步骤开始。