C++ 设计模式学习笔记(七):职责链与命令,请求交给谁、怎样保存
这篇笔记整理课程的第 22 讲:职责链与第 23 讲:命令模式。本文由 AI 阅读两讲完整音频转录,并核对课件与代码抽样画面后整理,归入 vibe watching。下面保留课堂的推导过程;完整代码是为说明问题重新编写的 C++20 示例。
一次调用通常看起来很简单:找到对象,调用方法,得到结果。但需求继续发展,就会出现两组不同的问题。
第一组是接收者的问题:同一个请求可能由不同对象处理,究竟交给谁,要等运行时才能知道。第二组是请求自身的问题:它能不能先存起来,稍后执行,组合执行,或者撤销?职责链关注第一组,命令关注第二组。把两者分清楚,比记住它们都含有一个 handle 或 execute 更有用。
来源档案
讲者:李建忠;课程:《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 很重要:它表示整条链都没有接手。模式提供了传递机会,没有承诺一定存在合适接收者。
稳定的是什么,变化的是什么
对发送者来说,稳定的是提交请求的接口。对基类来说,稳定的是判断与转发的骨架。变化的则是具体处理条件、处理动作以及链的组成。
这也是课程把职责链归入“数据结构”一组的原因:示例通过对象之间的链接表示处理顺序。它不是要求所有实现都必须暴露一个裸指针单链表。用容器组织一组处理器也能表达类似流程;更应检查的是发送者是否还依赖每个具体处理器,以及路由策略是否被妥善封装。
基类的固定流程调用子类钩子,也与模板方法的思想相通。但这只是实现上可以叠加的一层关系。职责链的主要问题仍然是接收者选择,而不是为某个算法固定步骤顺序。
顺序与未处理状态都是接口的一部分
假设两个处理者都能接手同一个请求,排在前面的节点就会遮住后面的节点。于是链的顺序不只是装配细节,也可能成为业务规则。调整顺序后,即使所有代码都能编译,行为仍然可能改变。
链末的处理策略同样需要明确。有些界面事件允许最终无人处理;某些业务请求则应返回“没有匹配规则”,由上层决定提示、重试还是拒绝。静默丢弃是否合理,要由使用场景决定。
还有几项具体问题需要在实现时回答:链能不能在处理中改变,节点是否会被重复连接,会不会意外成环,谁负责节点的生命周期?如果节点在处理过程中调用外部代码,还要考虑外部代码是否会改动链。模式的类图不会自动给出这些答案。
现代中间件常见另一种形式:每一层都可能处理一部分,然后继续向后执行,甚至在返回时再处理响应。这与经典职责链有联系,但不能把“所有层都执行”的管线,直接当成课堂“首个匹配即停止”的同一流程。
命令:把一次行为变成可以持有的东西
解决了接收者选择,也不等于解决了请求管理。
假设工具栏按钮直接调用编辑器的删除方法。只要求立即删除时,这样很自然。现在又要求快捷键触发同一操作、保留操作历史、稍后执行、组合多个动作,直接调用就开始承载不了这些需求。
命令模式的关键变化,是把一次请求表达为对象。对象可以携带接收者和参数,可以作为参数传递,可以保存在集合里。调用者不再必须了解这次请求的每个实施步骤。
这并不是说所有方法调用都应该套一层命令。如果没有延迟、组合、记录或统一调度的需求,直接调用通常更清楚。值得对象化的,是需要被当作数据管理的行为。
从两个具体命令到一个宏命令
课堂先定义一个带虚 execute 的 Command 接口,再建立两个具体命令。示例里的具体行为很简单:保存字符串参数,在执行时输出对应处理信息。
随后增加 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 不会使撤销需求消失。捕获文档引用后晚些执行,仍要保证引用有效;把闭包放进容器,也不会使它自动可以写入日志文件并在另一进程重放。需要持久化时,应设计稳定的操作名称、参数格式、版本与执行规则。
因此,课程关于“语言机制替代一些传统实现形式”的提醒,适合这样理解:先保留要解决的问题,再选择当前语言最直接的表达方式。
两种模式可以协作,也应该能够分开使用
| 问题 | 职责链 | 命令 |
|---|---|---|
| 主要变化 | 候选处理者、选择条件、顺序 | 请求内容、参数、执行与历史策略 |
| 客户面对的入口 | 向链的入口提交请求 | 持有并触发命令对象 |
| 核心职责 | 运行时找到处理者 | 把行为变成可管理对象 |
| 容易遗漏的结果 | 无人处理、前项遮住后项 | 部分执行、撤销失败、引用失效 |
同一个系统里,可以先把用户动作表达为命令,再交给职责链选择处理位置;也可以先用链判定请求归属,再创建相应命令。但没有这两组独立变化时,不必为了同时用上两个名字增加层次。
它们与观察者的差别也值得保留。观察者通常把变化通知多个订阅者;本讲职责链寻找一个处理者;命令把一次动作变成对象。三者都可能出现“通知”“回调”“执行”,目的却不同。
看完这两讲,更值得追问的是:发送者现在知道了多少不该知道的东西?这个请求是否真的需要保存和重放?如果执行到一半失败,谁来解释当前状态?这些问题能帮助判断抽象是否解决了实际依赖,而不是只让调用路径变长。
上一篇:唯一、共享与快照 · 下一篇:组合、遍历与操作扩展