pusidun
← All posts

C++ 设计模式学习笔记(二):流程、算法、状态与通知

本文是 C++ 设计模式学习笔记系列的第二篇,对应课程 P3 模板方法、P4 策略模式、P5 观察者模式与 P18 状态模式。文章由 AI 辅助整理,依据各讲完整音轨的本地自动转录及课件抽样核对;代码示例为理解课程而重新编写,现代 C++ 的实现取舍会单独说明。

这四种模式都有一个相似的外观:稳定代码通过某个接口调用会变化的实现。区别在于,把什么交给了另一方?是流程中的一步、整套算法、生命周期中的一种行为,还是一次事件发生后的响应?沿着这个问题读代码,比只看几个带箭头的类图更容易分辨它们。

来源档案

讲者:李建忠;课程:《C++设计模式》,本次据带有 GeekBand 标识的 26 讲 B 站转载版本整理。本文对应:P3《模板方法》、P4《策略模式》、P5《观察者模式》、P18《状态模式》。

转载账号为“川中一郎”,合集标题《C++设计模式》,标识 BV15pxQzSE8j;上传者与原讲者分别署名。讲者账号发布的《C++设计模式》试听可核对课程署名;本次整理的转载合集用于定位上述分 P。链接失效时,可用“李建忠 C++设计模式 GeekBand”及讲次标题检索。

一、模板方法:把稳定的执行顺序留在框架里

P3 从一个框架与应用程序的合作场景开始。库已经提供 Step1Step3Step5,应用程序补充 Step2Step4。最初,应用程序还负责组织完整过程:先做第一步,根据第二步的结果决定是否做第三步,再循环执行第四步,最后做第五步。

这个方案能够运行,但有一个重复点:每个应用都要重新写一遍相同的调度顺序。库虽然提供了一些零件,稳定的流程却散落在库的各个使用者里。只要有人抄错执行次序、漏掉必要步骤,就会产生与业务定制无关的问题。

课堂的重构不是把应用代码一股脑搬进库,而是区分两件事:执行顺序相对稳定,某些步骤的具体行为需要由应用决定。

于是,库提供一个普通的 Run() 方法,完整保存算法骨架;其中会变化的步骤改成虚函数,由应用程序继承后实现。下面只展示这一结构,省略实际业务:

// 结构示意,不是独立程序。
void Framework::Run() {
    Step1();
    if (Step2()) {
        Step3();
    }
    for (int i = 0; i < 4; ++i) {
        Step4();
    }
    Step5();
}

这里的关键是 Run() 的控制权。以前是应用调用库的各个方法,现在是库掌握调度过程,在约定位置调用应用提供的实现。先写好的框架,可以通过虚调用执行后来写出的应用代码,这就是这段课程所强调的控制反转。

Step2Step4 不一定都是纯虚函数。如果框架有合理的默认行为,可以提供默认实现,允许应用按需覆盖;如果缺少应用实现就无法完成流程,则可以要求派生类实现。那些不应由外部单独调用的步骤适合放在 protected 区域,公开入口仍然是完整流程。

这也解释了为什么学习框架不能只研究自己需要覆盖的两个方法。只看局部钩子,很容易不知道谁调用它、何时调用、会调用几次、调用前后有什么条件。模板方法把这些约束放在骨架里,使用者仍然需要理解骨架。

“稳定”是相对于当前变化方向而言的。如果不同应用连执行次序、循环规则、收尾时机都完全不同,强行把它们塞进一个基类,最后往往得到大量开关和空钩子。此时应该重新划分流程,而不是继续增加可覆盖方法。这里的 Template Method 也不是 C++ 的 template 语法;函数回调同样可以实现类似的控制反转思想。

二、策略模式:订单不必知道各国税法怎样计算

P4 的案例是一套跨国订单系统。最初,SalesOrder 根据枚举区分中国、美国、德国,在 if/elseswitch 中分别计算税费。

如果只看当前功能,这种写法并没有必然错误。问题要放到时间轴上:增加法国时,需要扩展枚举,再打开已经存在的订单模块,找到对应分支位置插入新算法;下次增加日本,又重复一次。

需要变化的是税法,受到修改影响的却是整个订单计算入口。

重构首先提取 TaxStrategy,约定一个计算方法,把订单金额等输入作为参数传入。中国、美国、德国各自成为具体策略,将原分支中的算法移到对应实现里。订单只负责准备输入,然后执行一次多态调用。

课堂进一步把策略的创建交给外部传入的工厂,避免订单内部仍然写着 new CNTax。这一步很重要:如果左边声明成接口,右边却继续硬编码具体类型,订单仍然知道应该使用哪个国家的算法。把创建与选择移到装配位置,才让订单业务真正摆脱具体税法依赖。

增加法国以后,订单调用策略的那一段可以保持不变。系统仍需要新增法国策略,并让配置或工厂能够选择它;变化被集中到了合适的位置,并没有凭空消失。

策略与模板方法的分界

模板方法保留完整流程,在流程中开放定制步骤;策略把一个可替换的算法封装成独立协作者。前者在课堂中通过继承提供钩子,后者通过组合传入策略。

两者也可以一起使用。例如,导入框架固定“读取、校验、转换、保存”的顺序,转换这一步又委托给某个可替换策略。但只有这两个变化方向确实存在时,这样的组合才有意义。

课程也提醒,不是出现条件判断就必须引入策略。几个稳定、含义清楚的分支,可能直接写出来更合适。值得留意的是:同一种业务分类不断扩充,分支算法越来越独立,修改时总是牵动原本稳定的模块。

还有一个需要与课堂论述区分的地方:策略的主要收益是变化隔离,不能据此保证程序更快。虚调用、对象分配、编译器优化与缓存行为取决于具体实现,未执行的分支也不意味着相关代码必然占满 CPU 缓存。性能应通过测量判断,不应成为机械替换 switch 的理由。

三、观察者:文件分割器应该报告进度,而不是操作控件

P5 的文件分割器案例把依赖倒置讲得很具体。界面收集文件路径和分割份数,创建 FileSplitter,再调用 split()。后来文件变大,操作耗时变长,用户希望看到进度条。

最直观的实现,是让分割器保存一个 ProgressBar*。每处理完一部分文件,就调用控件的 setValue()

这让功能很快完成,也让文件处理逻辑绑定到了一个 GUI 控件。以后要把进度显示成文本百分比,或者在控制台打点,分割器就需要跟着改。

抽象不是机械地寻找父类

课堂先追问:既然不该依赖具体控件,是不是改为依赖 ProgressBar 的父类就行?

未必。控件父类可能根本没有设置进度的方法,而且仍然属于 GUI 世界。真正需要抽象的角色,是“接收进度通知”,不是“所有控件的共同祖先”。

因此课程提取了 IProgress::DoProgress(float)。分割器只报告一个进度值,不再知道接收者怎样展示它。MainForm 实现这个接口,在自己的回调中更新进度条,然后将自身作为通知接收者传给分割器。

这样,界面内部仍然可以紧密地管理自己的控件,文件处理模块则不再引用 GUI 类型。解耦发生在业务与展示之间,无需把所有对象关系都拆散。

从一个回调到多个独立订阅者

单个接口指针还不能满足所有需求。如果需要同时更新进度条、写控制台、记录任务监控,只有一个接收者就不够了。

当然,可以在界面回调中顺便完成全部操作。但这会让界面承担与自身无关的通知转发职责,也让每个新接收者都需要修改界面代码。

课程继续把单个指针改为观察者集合,提供添加和移除操作。分割器的 onProgress() 遍历当前集合,对每个订阅者调用统一接口。GUI 与 ConsoleNotifier 分别订阅,谁需要通知、谁退出订阅,由装配代码或相应对象决定。

GoF 类图通常把这部分公共职责放在 Subject 中,包括订阅、退订和通知。课堂示例直接将它们写在 FileSplitter 里,也可以成立。是否专门提取一个基类,要看是否存在多个需要复用通知机制的主体;模式的重点是一对多的通知关系,而不是必须凑齐图上的每个方框。

把生命周期写进约定

下面是一个完整的 C++20 示例,模拟任务分为四步,每步向两个观察者报告进度。它不读取文件,目的是验证订阅、退订和多接收者通知的语义。

#include <algorithm>
#include <cassert>
#include <stdexcept>
#include <vector>

struct ProgressObserver {
    virtual ~ProgressObserver() = default;
    virtual void update(double progress) noexcept = 0;
};

class SplitJob {
    std::vector<ProgressObserver*> observers_; // 借用,不拥有
public:
    void subscribe(ProgressObserver& observer) {
        if (std::find(observers_.begin(), observers_.end(), &observer)
            == observers_.end()) {
            observers_.push_back(&observer);
        }
    }

    void unsubscribe(ProgressObserver& observer) {
        std::erase(observers_, &observer);
    }

    void run(int parts) const {
        if (parts <= 0) {
            throw std::invalid_argument("parts must be positive");
        }
        for (int completed = 1; ; ++completed) {
            const double progress = static_cast<double>(completed) / parts;
            for (auto* observer : observers_) {
                observer->update(progress);
            }
            if (completed == parts) break;
        }
    }
};

struct Counter final : ProgressObserver {
    int calls = 0;
    double last = 0.0;
    void update(double progress) noexcept override {
        ++calls;
        last = progress;
    }
};

int main() {
    Counter screen;
    Counter monitor;
    SplitJob job;
    job.subscribe(screen);
    job.subscribe(screen); // 本例约定:重复订阅无效
    job.subscribe(monitor);
    job.run(4);
    assert(screen.calls == 4 && monitor.calls == 4);
    assert(screen.last == 1.0 && monitor.last == 1.0);

    job.unsubscribe(monitor);
    job.run(2);
    assert(screen.calls == 6 && monitor.calls == 4);
    job.unsubscribe(screen);
}

本例采用明确而有限的约定:单线程、同步通知;回调不抛异常;通知过程中不能增删订阅,也不能销毁观察者;观察者在订阅期间保持存活。Counter 只是记录次数,因此满足这些约定。引用传参表示订阅时必须提供一个对象,内部裸指针表达借用关系,不负责删除它。

这些不是所有观察者实现的天然保证。生产环境可能需要具有自动退订能力的连接对象、通知快照、延迟删除队列,或者特定线程上的事件调度。选择哪一种,要先定义“回调中退订是否立即生效”“异常是否阻止其他接收者”等规则。

课程中从 vector 换成 list,是为了讨论订阅管理的实现。这里也不能简单认定链表删除总是更快:如果仍要按值寻找观察者,查找本身依然需要遍历。容器选择不是观察者模式的核心。

同步回调同样不会自动让 GUI 保持响应。如果实际分割工作阻塞界面线程,换成观察者接口也不会顺便获得后台执行或线程切换能力。

四、状态模式:把同一阶段的行为放在一起

P18 的 NetworkProcessorOpenCloseConnect 等状态,多个操作分别根据当前状态执行不同逻辑,然后更新状态。课堂使用的是状态迁移教学示例,不是完整网络协议。

Operation1Operation2Operation3 都包含相似的状态判断时,增加一个 Wait 状态就需要逐个检查这些方法。某处忘记补分支,或者某次转换设置错状态,都可能造成不同操作对当前阶段的理解不一致。

状态模式改变了组织方向:原先“每个操作内部按状态分类”,现在“每个状态内部提供各个操作”。

具体做法是提取 NetworkState 接口,包含几个业务操作;OpenStateCloseStateConnectState 分别集中实现本状态下的行为。处理器保存当前状态对象,将操作委托给它,再根据执行结果迁移到后继状态。

课堂中,状态对象用 pNext 保存下一状态,处理器执行后读取它。这有助于展示流程,但现代实现需要重新考虑这个字段的位置。

下一状态属于谁

如果状态对象是共享单例,却在每次操作时修改自己的 pNext,多个处理器就可能互相干扰。并发调用还可能产生数据竞争。状态类是否无状态、是否能够共享,需要看全部成员,而不能只看名字叫“状态”。

一种简单改进,是让操作直接返回后继状态,或者把迁移结果写到当前处理器自己的上下文。下面是结构示意:

// 结构示意:各状态对象长期存活且不保存可变 pNext。
const State& next = current->handle(context, event);
current = &next;

这样,每个处理器维护自己的当前状态。若业务操作会失败,还要规定失败后保持旧状态、进入错误状态,还是回滚已产生的副作用。仅仅交换一个指针并不能替代这些语义。

课件提到状态转换的“原子性”,适合理解为用完整状态对象替代分散标志,减少概念上的不一致;它不意味着普通指针赋值自动提供 C++ 并发同步,更不意味着整个业务行为成为事务。

增加 WaitState 可以避免在处理器的每个操作里再添加一个状态分支,但如果已有状态需要增加“迁移到 Wait”的路径,相关转换逻辑仍可能修改。状态模式让变化更集中,不承诺任何状态图扩展都无需修改旧代码。

五、四种模式怎样一起判断

模式 主要变化单位 稳定方保留什么 常见选择或触发来源
模板方法 固定流程中的某些步骤 调用顺序与流程约束 框架在钩子位置调用
策略 一套可替换算法 算法使用方式 外部配置、装配或业务选择
观察者 事件发生后的响应者 发布通知的协议 主体发出事件,订阅者响应
状态 某阶段对应的行为与转换 对外操作入口 事件和当前状态共同决定

策略和状态的类图可能非常接近。区别通常要从需求读出来:选择一种税法是在选择算法;连接从关闭进入建立中,再进入可用阶段,是生命周期和迁移规则。策略常由外部选择,状态常由状态机自身驱动,但这只是常见组织方式,不是语法判定规则。

观察者和模板方法都可能表现为“框架回调应用代码”。模板方法强调一次算法执行中的固定骨架;观察者强调可独立增减的通知接收者。一个框架可以同时采用二者,无需竞争谁才是“真正的模式”。

从 C++ 实现看,也不必为每一个变化都创建堆对象。非拥有的多态依赖可以用引用,简单策略可以用函数对象或模板参数。本文的接口具有公开虚析构,是为了允许通过接口安全地销毁派生对象;并非所有基类都必须采用同样的析构设计。C++ Core Guidelines 的 C.35明确区分了公开虚析构与受保护非虚析构这两类选择。

来源与阅读位置

主要内容来自 P3 模板方法P4 策略模式P5 观察者模式P18 状态模式。整理时阅读了四讲完整自动转录,并结合课件抽样核对案例和接口关系;自动转录中的术语错误按代码与上下文校正。本文是概念与案例的重新组织,不是逐字实录。

回调生命周期、容器复杂度、状态共享与并发边界,以及完整 C++20 示例属于整理时的补充。

上一篇:变化、原则与模式选择 · 下一篇:对象创建与产品族