pusidun
← All posts

C++ 设计模式学习笔记(七):职责链与命令,请求交给谁、怎样保存

返回系列目录与学习方法

这篇笔记整理课程的第 22 讲:职责链第 23 讲:命令模式。本文由 AI 阅读两讲完整音频转录,并核对课件与代码抽样画面后整理,归入 vibe watching。下面保留课堂的推导过程;完整代码是为说明问题重新编写的 C++20 示例。

一次调用通常看起来很简单:找到对象,调用方法,得到结果。但需求继续发展,就会出现两组不同的问题。

第一组是接收者的问题:同一个请求可能由不同对象处理,究竟交给谁,要等运行时才能知道。第二组是请求自身的问题:它能不能先存起来,稍后执行,组合执行,或者撤销?职责链关注第一组,命令关注第二组。把两者分清楚,比记住它们都含有一个 handleexecute 更有用。

来源档案

讲者:李建忠;课程:《C++设计模式》,本次据带有 GeekBand 标识的 26 讲 B 站转载版本整理。本文对应:P22《职责链》、P23《命令模式》。

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

职责链:让发送者不必知道最终接收者

课堂从界面事件举例。窗口里有容器,容器里有列表、输入框等控件;点击、拖动或触控发生以后,当前对象可能处理,也可能交给沿路的其他对象。对于发起操作的一方,没必要提前知道最终是哪一层接手。

这个例子不能理解成所有界面框架都采用同一种传播方向。它要说明的是一种关系:候选接收者不止一个,实际处理者取决于当前状态。课程讨论的是经典的“找到一个能处理的对象便停止”形式。

如果发送者直接写出所有具体对象及判断条件,它就同时承担了发起请求和分派职责。新增一个处理者,可能要回来修改发送端;处理者的先后顺序变化,也会影响这段集中判断。变化集中到了不该了解这么多细节的位置。

职责链把分派过程放进处理对象之间的关系中:发送者只向入口提交请求,沿途节点各自决定能否处理,不能处理就继续传递。

课堂的三级处理链怎样工作

视频没有实现一个完整 GUI 框架,而是使用了更小的示例:一个请求对象携带描述和枚举类型,三个处理者分别识别不同类型。

基类 ChainHandler 保存指向下一节点的指针,并提供两种可变行为:

  • canHandleRequest 判断当前节点是否具备处理条件。
  • processRequest 实现匹配之后的具体动作。

对外的 handle 保留稳定流程:先判断,能处理则执行,否则交给下一节点。主程序把三个对象装配为 h1 → h2 → h3,再把一个需要第三种处理能力的请求交给 h1。前两个节点让路,第三个节点处理。

枚举只是为了让流程容易观察。真实条件可以涉及当前权限、资源、请求内容或业务状态,并不要求请求直接写明某个具体处理者的名字。否则虽然有了链形结构,发送者仍然可能知道太多实现细节。

下面是机制的示意片段,省略具体处理类与对象装配,并非完整程序:

bool Handler::handle(const Request& request) {
    if (can_handle(request)) {
        process(request);
        return true;
    }
    return next_ != nullptr && next_->handle(request);
}

这里的 false 很重要:它表示整条链都没有接手。模式提供了传递机会,没有承诺一定存在合适接收者。

稳定的是什么,变化的是什么

对发送者来说,稳定的是提交请求的接口。对基类来说,稳定的是判断与转发的骨架。变化的则是具体处理条件、处理动作以及链的组成。

这也是课程把职责链归入“数据结构”一组的原因:示例通过对象之间的链接表示处理顺序。它不是要求所有实现都必须暴露一个裸指针单链表。用容器组织一组处理器也能表达类似流程;更应检查的是发送者是否还依赖每个具体处理器,以及路由策略是否被妥善封装。

基类的固定流程调用子类钩子,也与模板方法的思想相通。但这只是实现上可以叠加的一层关系。职责链的主要问题仍然是接收者选择,而不是为某个算法固定步骤顺序。

顺序与未处理状态都是接口的一部分

假设两个处理者都能接手同一个请求,排在前面的节点就会遮住后面的节点。于是链的顺序不只是装配细节,也可能成为业务规则。调整顺序后,即使所有代码都能编译,行为仍然可能改变。

链末的处理策略同样需要明确。有些界面事件允许最终无人处理;某些业务请求则应返回“没有匹配规则”,由上层决定提示、重试还是拒绝。静默丢弃是否合理,要由使用场景决定。

还有几项具体问题需要在实现时回答:链能不能在处理中改变,节点是否会被重复连接,会不会意外成环,谁负责节点的生命周期?如果节点在处理过程中调用外部代码,还要考虑外部代码是否会改动链。模式的类图不会自动给出这些答案。

现代中间件常见另一种形式:每一层都可能处理一部分,然后继续向后执行,甚至在返回时再处理响应。这与经典职责链有联系,但不能把“所有层都执行”的管线,直接当成课堂“首个匹配即停止”的同一流程。

命令:把一次行为变成可以持有的东西

解决了接收者选择,也不等于解决了请求管理。

假设工具栏按钮直接调用编辑器的删除方法。只要求立即删除时,这样很自然。现在又要求快捷键触发同一操作、保留操作历史、稍后执行、组合多个动作,直接调用就开始承载不了这些需求。

命令模式的关键变化,是把一次请求表达为对象。对象可以携带接收者和参数,可以作为参数传递,可以保存在集合里。调用者不再必须了解这次请求的每个实施步骤。

这并不是说所有方法调用都应该套一层命令。如果没有延迟、组合、记录或统一调度的需求,直接调用通常更清楚。值得对象化的,是需要被当作数据管理的行为。

从两个具体命令到一个宏命令

课堂先定义一个带虚 executeCommand 接口,再建立两个具体命令。示例里的具体行为很简单:保存字符串参数,在执行时输出对应处理信息。

随后增加 MacroCommand。它本身也实现 Command,内部保存一组命令;执行宏命令时,遍历这些命令并调用它们的 execute。客户把两个具体命令加入宏命令,再调用一次宏命令。

这里出现了与组合模式非常清楚的联系:单个命令和由多个命令构成的整体,都遵守同一个执行接口。组合者不必为“这是一个动作”与“这是一组动作”设计两套调用方式。

不过,能组合执行不代表能原子执行。第二个动作失败时,第一个动作可能已经生效。是否继续、是否回滚、回滚顺序是什么,都需要额外定义。视频只演示了组合执行,没有实现事务系统。

请求者、命令与接收者

课件中的角色关系可以理解为:请求者负责触发,命令保存怎样发起这次工作的信息,接收者提供实际业务能力。对象化把调用关系拆开,让请求者可以面向稳定接口。

接收者不一定需要单独建立一个复杂的继承体系。一个“插入文本”命令可以持有文档引用和待插入文本;文档负责自己的状态约束,命令负责这次动作的参数和历史。不要为了凑齐类图中的每个方框,再为已经足够简单的对象制造无意义的包装。

把命令放进队列之后,还会产生时间上的距离:创建时有效的对象,执行时可能已经不存在;创建时合法的条件,执行时可能已经改变。延迟执行不仅是“把对象先塞进容器”,也必须处理这种生命周期与状态变化。

撤销需要额外的信息与承诺

课堂用编辑器的剪切、删除、复制、粘贴说明命令历史的用途。把操作对象压入栈,需要撤销时再取出,是组织历史的一种方式。但“取出对象”只是找到这次操作,还没有说明怎样恢复状态。

有些操作可以计算逆操作。例如在确定的位置插入一段文本,可以通过删除相同范围撤销,但前提是后续修改没有让位置失效。有些操作需要保存旧值,或通过备忘录保留原状态。另一些操作一旦影响了外部世界,就未必能够真正撤回。

所以命令至少要回答:它是在准备执行、已经执行还是已撤销的状态?是否允许重复执行?撤销时依赖什么旧状态?失败后当前状态是什么?这些规则不同,命令接口也会不同。

一个完整例子:只能通过历史修改的文本

下面是原创的 C++20 程序。它实现两次追加与逐次撤销,使用状态快照,不实现 redo、分支历史、并发编辑或宏命令回滚。文档必须比命令历史活得更久,而且执行期间不允许绕过历史直接改写文档。

这两个限制让“恢复上一个字符串”具有明确含义。放到协作编辑器中,仅靠这样的快照会覆盖别人的修改,需要另外的版本与合并机制。

#include <cassert>
#include <memory>
#include <optional>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>

struct Document {
    std::string text;
};

class Command {
public:
    virtual ~Command() = default;
    // 本例约定:执行失败不改变文档;成功后的撤销不抛异常。
    virtual void execute() = 0;
    virtual void undo() noexcept = 0;
};

class Append final : public Command {
    Document& document_;
    std::string suffix_;
    std::optional<std::string> before_;
public:
    Append(Document& document, std::string suffix)
        : document_(document), suffix_(std::move(suffix)) {}

    void execute() override {
        if (before_) {
            throw std::logic_error("command already executed");
        }
        auto next = document_.text + suffix_;
        before_.emplace(document_.text);
        document_.text.swap(next);
    }

    void undo() noexcept override {
        if (before_) {
            document_.text.swap(*before_);
            before_.reset();
        }
    }
};

class History {
    std::vector<std::unique_ptr<Command>> done_;
public:
    void run(std::unique_ptr<Command> command) {
        if (!command) {
            throw std::invalid_argument("null command");
        }
        // 先取得存储位置,避免执行成功后因扩容失败丢失历史。
        done_.push_back(std::move(command));
        try {
            done_.back()->execute();
        } catch (...) {
            done_.pop_back();
            throw;
        }
    }

    bool undo_last() noexcept {
        if (done_.empty()) {
            return false;
        }
        done_.back()->undo();
        done_.pop_back();
        return true;
    }
};

int main() {
    Document document{"hello"};
    History history;
    history.run(std::make_unique<Append>(document, ","));
    history.run(std::make_unique<Append>(document, " world"));
    assert(document.text == "hello, world");
    const bool first = history.undo_last();
    assert(first && document.text == "hello,");
    const bool second = history.undo_last();
    assert(second && document.text == "hello");
    const bool third = history.undo_last();
    assert(!third);
}

这个例子里,Append 在修改文档前准备好新内容和旧快照。分配失败时,文档还未变化;准备成功后通过交换提交结果。History 又先确保命令能够进入历史,再执行它,防止出现“文档已经修改,但历史存不下来”的状态。

这不是命令模式天然提供的异常保证,而是本例额外设计的契约。以后增加删除、替换等命令,也必须满足它;不能只实现了同名函数,就认为已经可以安全接入历史。

unique_ptr 表达历史对命令的独占所有权,文档引用则表达非拥有关系。这里没有共享命令所有权的需要。这样的区分与 C++ Core Guidelines 的所有权建议一致,但对象活多久仍需由程序结构保证。

快照也有成本:每次追加都保存之前的完整文本。如果文本很长、历史很多,空间开销会很明显。保存编辑差量、共享不可变结构或限制历史容量,是进一步的实现选择,不能由“支持撤销”四个字省略过去。

函数对象与命令模式是什么关系

命令一讲的后半部分,重点比较了 GoF 的虚接口与 C++ 函数对象。二者都能把行为表示为对象:前者通常通过统一虚方法调用,后者通过 operator(),并可以与模板结合。

如果当前需求只是把一个动作交给算法执行,lambda 或函数对象通常已经足够;不一定需要一个带许多具体子类的 Command 层次。如果需要为异构操作提供统一历史、撤销协议和诊断信息,显式命令类型也可以很有价值。

这里要避免把两种实现硬排成永远固定的性能次序。模板通常给编译器更多静态优化机会,但实际成本取决于工作量、对象表示、分配和优化结果。std::function 还使用类型擦除,使用了函数对象并不等于所有调用都在编译期确定。

更重要的是,换成 lambda 不会使撤销需求消失。捕获文档引用后晚些执行,仍要保证引用有效;把闭包放进容器,也不会使它自动可以写入日志文件并在另一进程重放。需要持久化时,应设计稳定的操作名称、参数格式、版本与执行规则。

因此,课程关于“语言机制替代一些传统实现形式”的提醒,适合这样理解:先保留要解决的问题,再选择当前语言最直接的表达方式。

两种模式可以协作,也应该能够分开使用

问题 职责链 命令
主要变化 候选处理者、选择条件、顺序 请求内容、参数、执行与历史策略
客户面对的入口 向链的入口提交请求 持有并触发命令对象
核心职责 运行时找到处理者 把行为变成可管理对象
容易遗漏的结果 无人处理、前项遮住后项 部分执行、撤销失败、引用失效

同一个系统里,可以先把用户动作表达为命令,再交给职责链选择处理位置;也可以先用链判定请求归属,再创建相应命令。但没有这两组独立变化时,不必为了同时用上两个名字增加层次。

它们与观察者的差别也值得保留。观察者通常把变化通知多个订阅者;本讲职责链寻找一个处理者;命令把一次动作变成对象。三者都可能出现“通知”“回调”“执行”,目的却不同。

看完这两讲,更值得追问的是:发送者现在知道了多少不该知道的东西?这个请求是否真的需要保存和重放?如果执行到一半失败,谁来解释当前状态?这些问题能帮助判断抽象是否解决了实际依赖,而不是只让调用路径变长。

上一篇:唯一、共享与快照 · 下一篇:组合、遍历与操作扩展