# C++设计模式:命令模式——把“请求”变成对象,支持撤销、队列和回放 > 当“点击按钮就执行逻辑”开始失控 很多团队第一次真正需要命令模式,不是在背设计模式八股文,而是在维护一套越来越难改的交互系统。 假设你在做一个文本编辑器,界面上有很多按钮: - 插入文本 - 删除文本 - 复制 - 粘贴 - 撤销 - 重做 一开始最直接的写法通常长这样: ```cpp #include #include class Editor { public: void insertText(const std::string& text) { std::cout << "插入文本: " << text << std::endl; } void deleteText(std::size_t count) { std::cout << "删除 " << count << " 个字符" << std::endl; } void copy() { std::cout << "复制选中内容" << std::endl; } void paste() { std::cout << "粘贴剪贴板内容" << std::endl; } }; class Toolbar { public: void onClick(const std::string& action, Editor& editor) { if (action == "insert") { editor.insertText("Hello"); } elseif (action == "delete") { editor.deleteText(5); } elseif (action == "copy") { editor.copy(); } elseif (action == "paste") { editor.paste(); } else { std::cout << "未知操作" << std::endl; } } }; ``` 刚开始看起来很直接,但很快问题就出现了。 **问题 1:调用者和执行者耦合过紧** ``` toolbar.onClick("insert", editor); ``` 按钮点击逻辑必须知道 `Editor` 里有哪些具体方法,甚至还要拼装参数。界面层本来只应该关心“用户点了什么”,结果却深度介入了“具体怎么执行”。 **问题 2:无法优雅支持撤销/重做** ``` editor.insertText("Hello"); editor.deleteText(5); ``` 这些调用执行完就结束了,请求本身没有被保存下来。以后如果要撤销,你就得额外记录: - 这次执行了哪个操作 - 操作的参数是什么 - 撤销时要恢复什么状态 如果没有统一抽象,这些记录逻辑通常会散落在各个角落。 **问题 3:队列、日志、回放都不好做** 比如你现在想实现这些需求: - 把用户操作放进任务队列里异步执行 - 记录用户操作日志,用于审计 - 把一串操作录制下来,用于“宏命令” - 在游戏里把操作序列保存下来,用于“回放” 如果操作只是一次普通函数调用,后续几乎无从下手。 **问题 4:同一种请求来源越来越多** 最初是按钮点击,后来可能又有: - 快捷键触发 - 菜单栏触发 - 网络消息触发 - 脚本系统触发 - 自动化测试触发 不同入口最终都在表达同一件事:“执行一个请求”。如果每个入口都直接调业务对象,系统会越来越散。 这类问题的本质不是“按钮太多”,而是:**请求没有被建模。** 命令模式就是为这个问题而生的。它把“做一件事”本身封装成对象,让请求可以: - 被传递 - 被排队 - 被记录 - 被撤销 - 被组合 - 被重放 ------ ## 命令模式详解 ### 模式定义 > **命令模式(Command Pattern)**的核心思想是:**将一个请求封装为对象,从而使你可以用不同的请求对客户进行参数化,对请求排队或记录请求日志,并支持可撤销操作。** 一句更接地气的话来理解: **把“调用哪个对象的哪个方法、传什么参数”这件事,打包成一个独立对象。** ### 核心角色 命令模式通常包含四个角色: 1. **Command(命令接口)**:定义统一执行接口,例如 `execute()`,有时还会包含 `undo()` 2. **ConcreteCommand(具体命令)**:真正封装某个请求,内部持有接收者对象和执行参数 3. **Receiver(接收者)**:真正干活的业务对象,比如编辑器、灯、角色、文档 4. **Invoker(调用者)**:触发命令执行的对象,比如按钮、菜单、遥控器、任务调度器 可以把它理解成这样: - `Invoker` 决定“什么时候发出请求” - `Command` 负责“把请求描述清楚” - `Receiver` 决定“具体怎么干” ### 结构关系 ```bash [Invoker] | v [Command 接口] <----- 多个具体命令 | ├── InsertCommand | ├── DeleteCommand | └── SaveCommand v [Receiver] ``` 最关键的变化在于:`Invoker` 不再直接依赖 `Receiver` 的具体业务接口,而是只依赖抽象的命令接口。 ### 基本实现(经典版本) 先看一个最小可用版本。场景是“遥控器控制灯”。 ```cpp #include #include class Command { public: virtual ~Command() = default; virtual void execute() = 0; }; class Light { public: void turnOn() { std::cout << "灯已打开" << std::endl; } void turnOff() { std::cout << "灯已关闭" << std::endl; } }; class LightOnCommand : public Command { public: explicit LightOnCommand(Light& light) : light_(light) {} void execute() override { light_.turnOn(); } private: Light& light_; }; class LightOffCommand : public Command { public: explicit LightOffCommand(Light& light) : light_(light) {} void execute() override { light_.turnOff(); } private: Light& light_; }; class RemoteControl { public: void setCommand(std::unique_ptr command) { command_ = std::move(command); } void pressButton() { if (command_) { command_->execute(); } } private: std::unique_ptr command_; }; int main() { Light light; RemoteControl remote; remote.setCommand(std::make_unique(light)); remote.pressButton(); remote.setCommand(std::make_unique(light)); remote.pressButton(); } ``` 这个例子看起来有点“绕”,但它体现了命令模式最核心的价值: - 遥控器只认识 `Command` - 它不知道灯的具体接口 - 也不知道当前执行的是开灯还是关灯 也就是说,**请求发起者和请求执行者已经被解耦了。** ### 命令模式适合解决什么问题 命令模式尤其适合下面这些场景: 1. **需要撤销/重做** 2. **需要把操作排队执行** 3. **需要记录操作日志** 4. **需要把多个操作组合成一个批处理** 5. **需要把用户操作录制并回放** 6. **需要让多个调用入口共享统一操作抽象** 如果你的系统里“操作”本身是重要的一等公民,命令模式通常就很有价值。 ------ ## 关键技术专题:撤销、重做与命令历史 命令模式最经典的应用就是撤销/重做。因为当请求被封装成对象后,我们就可以把它存进历史栈。 ### 最朴素的撤销思路 如果每个命令都实现: - `execute()` - `undo()` 那么执行一个命令之后,就可以把它压入撤销栈: ```cpp class Command { public: virtual ~Command() = default; virtual void execute() = 0; virtual void undo() = 0; }; ``` 调用流程通常是: 1. 调用 `execute()` 2. 执行成功后,把命令压入 `undoStack` 3. 用户点击撤销时,弹出栈顶命令并调用 `undo()` 4. 再把它压入 `redoStack` ### 为什么撤销并不只是“反着做一次” 很多人初看命令模式时,会把 `undo()` 理解为“执行反向动作”。这只说对了一半。 例如: ``` insert("abc") -> undo() 可以删除 3 个字符 move(10, 20) -> undo() 可以移回旧位置 rename("A","B") -> undo() 可以改回 A ``` 但前提是:**你必须提前保存足够的上下文。** 比如删除命令: ``` delete(5) ``` 撤销时不能只是“插回 5 个字符”,因为你还要知道: - 删除前的起始位置 - 被删掉的具体文本内容 所以一个可撤销命令通常需要保存: - 执行参数 - 执行前状态 - 必要时的执行后状态 ### 两种常见撤销策略 #### 1. 命令自身保存恢复信息 优点: - 实现直接 - 逻辑集中在命令对象里 缺点: - 命令可能变得比较重 - 对复杂对象图来说,状态保存成本高 #### 2. 命令 + 备忘录模式配合 复杂编辑器、流程引擎里更常见的做法是: - 命令负责“做什么” - 备忘录(Memento)负责“保存快照” 这样职责会更清晰。命令模式擅长描述“动作”,备忘录模式擅长保存“状态”。 ### 一个简单的撤销管理器 ```cpp #include #include class UndoableCommand { public: virtual ~UndoableCommand() = default; virtual void execute() = 0; virtual void undo() = 0; }; class CommandManager { public: void execute(std::unique_ptr command) { command->execute(); undoStack_.push(std::move(command)); while (!redoStack_.empty()) { redoStack_.pop(); } } void undo() { if (undoStack_.empty()) { return; } auto command = std::move(undoStack_.top()); undoStack_.pop(); command->undo(); redoStack_.push(std::move(command)); } private: std::stack> undoStack_; std::stack> redoStack_; }; ``` 这里有两个很重要的细节: - **只有执行成功的命令才应该进入历史栈** - **一旦执行了新的命令,通常需要清空 `redoStack`** 第二点很好理解。因为重做本质上是在“沿着原来的历史分支继续前进”,一旦插入了新操作,原来的“未来”通常就失效了。 ------ ## 关键技术专题:命令队列与宏命令 把请求封装成对象之后,第二个非常自然的能力就是:**请求可以被延迟执行。** ### 命令队列 如果命令对象能被放进队列,那么你就可以实现: - 任务排队 - 异步调度 - 限流执行 - 后台批处理 示意代码如下: ```cpp #include #include #include class Command { public: virtual ~Command() = default; virtual void execute() = 0; }; class CommandQueue { public: void push(std::unique_ptr command) { queue_.push(std::move(command)); } void runAll() { while (!queue_.empty()) { queue_.front()->execute(); queue_.pop(); } } private: std::queue> queue_; }; ``` 如果把这个思想放到实际系统里,就会很有用: - GUI 线程把请求扔给后台线程 - 网络层把请求扔给工作线程 - 游戏逻辑层把玩家输入整理成每帧执行的命令队列 ### 宏命令 宏命令(Macro Command)本质上是:**一个命令内部再组合多个子命令。** ```cpp #include #include #include class Command { public: virtual ~Command() = default; virtual void execute() = 0; }; class MacroCommand : public Command { public: void add(std::unique_ptr command) { commands_.push_back(std::move(command)); } void execute() override { for (auto& command : commands_) { command->execute(); } } private: std::vector> commands_; }; ``` 典型用途包括: - 一键“构建 + 测试 + 打包” - 编辑器里的批量格式化 + 保存 - 游戏里的连招回放 - 办公软件里的“录制宏” 宏命令的价值在于:**单个操作和组合操作可以用同一套接口被调用。** 这其实体现了组合思想,和组合模式在精神上非常接近。 ------ ## 现代 C++ 实现:除了继承,还可以用 `std::function` 传统命令模式喜欢用“接口 + 派生类”的写法,这在需要撤销、日志、序列化时依然非常合适。 但在很多轻量场景下,如果你只是想表达“把一个动作交给调用方稍后执行”,其实也可以用函数对象。 ### 轻量命令队列 ```cpp #include #include #include class TaskQueue { public: using Task = std::function; void push(Task task) { tasks_.push(std::move(task)); } void runAll() { while (!tasks_.empty()) { tasks_.front()(); tasks_.pop(); } } private: std::queue tasks_; }; int main() { TaskQueue queue; queue.push([] { std::cout << "执行任务 A" << std::endl; }); queue.push([] { std::cout << "执行任务 B" << std::endl; }); queue.runAll(); } ``` 这种写法的优点是: - 非常简洁 - 没有一堆小类 - 很适合一次性任务、调度器、线程池入口 但它也有边界。 ### 为什么复杂命令仍然建议使用类 如果需求涉及下面这些能力,类命令通常更稳妥: - 撤销/重做 - 保存执行前状态 - 操作序列化 - 命令日志审计 - 命令分类统计 - 多态扩展 原因很简单:`std::function` 只表达“能执行”,但不天然表达: - 这是什么命令 - 它的参数是什么 - 它如何撤销 - 它是否可重做 所以可以这样理解: - **轻量调度**:优先考虑 `std::function` - **完整命令系统**:优先考虑命令类 现代 C++ 的最佳实践不是“完全抛弃经典模式”,而是:**根据系统复杂度选择合适的抽象粒度。** ------ ## 命令模式与设计原则 命令模式之所以在工程上很常见,不只是因为它“能撤销”,更因为它天然契合多个设计原则。 ### 1. 单一职责原则 - `Invoker` 只负责触发 - `Receiver` 只负责业务执行 - `Command` 只负责封装请求 这比“按钮类直接写全部业务逻辑”清晰得多。 ### 2. 开闭原则 如果你要新增一个命令,例如: - `SaveDocumentCommand` - `ExportPdfCommand` - `ReplaceAllCommand` 通常只需要新增命令类,不必修改原有按钮和调度器逻辑。 ### 3. 依赖倒置原则 调用者依赖的是抽象命令接口,而不是具体业务类。 这意味着: - UI 层更容易测试 - 调度器更容易复用 - 替换不同业务实现更容易 ### 4. 组合优于继承 具体命令通常通过组合持有接收者对象,而不是通过继承把所有业务揉进一个大类里。 这让命令系统更灵活,也更符合现代 C++ 工程实践。 ------ ## 常见陷阱与最佳实践 命令模式很有用,但也不是“所有函数调用都值得抽一层命令对象”。下面是实践里最常见的坑。 ### 陷阱 1:为了模式而模式 如果系统里只有两三个简单按钮,而且没有: - 撤销 - 重做 - 队列 - 日志 - 宏命令 那直接函数调用可能更简单。 **判断标准不是“能不能套命令模式”,而是“操作对象化是否真的带来收益”。** ### 陷阱 2:`undo()` 没有保存足够上下文 这是最常见也最隐蔽的问题。 例如删除命令如果不保存被删除内容,撤销时根本无法恢复原状。 经验上,一个可撤销命令在设计时要先问自己: 1. 逆操作是什么 2. 逆操作需要哪些历史信息 3. 这些信息在 `execute()` 前后哪个阶段保存更合理 ### 陷阱 3:命令执行失败仍然进入历史栈 例如: - 文件保存失败 - 网络发送失败 - 参数校验失败 如果失败命令照样压入 `undoStack`,后续撤销逻辑就会变得非常混乱。 更稳妥的做法是: - `execute()` 返回成功/失败状态 - 只有成功命令才进入历史栈 ### 陷阱 4:命令对象持有悬空引用 很多命令对象会持有: - `Receiver&` - 资源指针 - UI 控件引用 如果命令被延迟执行,而接收者已经析构,就会出现悬空引用问题。 这在异步队列里尤其危险。 常见改进方式包括: - 明确命令生命周期不超过接收者 - 使用 `std::shared_ptr` - 使用弱引用并在执行前校验 ### 陷阱 5:一个命令做太多事 如果一个命令内部既做业务、又记日志、又发通知、又写缓存、又负责权限判断,它很快就会变成“上帝命令”。 更合理的拆法通常是: - 命令负责核心动作 - 其他横切逻辑交给装饰器、代理、事件系统或中间层 ### 最佳实践总结 1. **先识别“操作”是不是系统中的核心概念** 2. **需要撤销时,优先明确状态恢复策略** 3. **简单调度可用 `std::function`,复杂系统用命令类** 4. **只有执行成功的命令才进入历史** 5. **延迟执行场景要特别关注生命周期安全** 6. **宏命令适合做批处理和操作录制** ------ ## 何时使用命令模式 如果你正在面对下面这些问题,命令模式通常值得考虑: ### 适合使用 1. **需要撤销/重做** 2. **请求需要排队、延迟或异步执行** 3. **需要记录用户操作日志** 4. **需要把多个操作组合成一个操作** 5. **同一操作有多个触发来源** 6. **需要做操作回放、宏录制或脚本化** ### 不太适合使用 1. **操作非常少且长期稳定** 2. **没有历史记录、队列、回放等需求** 3. **系统规模很小,直接调用更直观** 4. **引入命令对象的成本明显高于收益** 一句经验判断: **如果“操作本身”需要被保存、传递、排队、记录或撤销,那就很适合命令模式。** ------ ## 写在最后 命令模式最容易被低估的地方在于,它看起来只是“多包了一层类”,但这一层类带来的不是语法变化,而是建模方式的变化。 一旦“请求”被对象化,系统的很多能力会自然长出来: - 撤销/重做 - 队列调度 - 宏命令 - 操作日志 - 行为回放 它真正解决的问题不是“怎么调用一个函数”,而是: **当操作本身需要被管理时,系统该如何设计。** 在 C++ 里,命令模式尤其适合这些方向: - 编辑器与 IDE - GUI 框架 - 游戏输入系统 - 后台任务调度 - 自动化脚本和批处理系统 如果策略模式解决的是“同一件事有多种做法”,那么命令模式解决的就是: **把“做这件事”本身,变成可以流转和管理的对象。**