C++命令模式实战:解耦请求与实现,支持撤销重做

📅 2026/7/20 14:04:48
C++命令模式实战:解耦请求与实现,支持撤销重做
1. 项目概述为什么我们需要命令模式在软件开发的日常里我们经常会遇到一个头疼的问题一个对象比如一个按钮、一个菜单项需要执行一个操作但这个操作的具体内容、执行时机甚至执行者都可能随时变化。最直接的写法可能就是在一个按钮的点击事件里直接调用一个庞大的业务函数。代码写起来是快但维护起来简直是灾难。一旦业务逻辑变了你得去改按钮的代码想给这个操作加个撤销功能对不起得把整个调用链路重写一遍。这种紧耦合的设计让代码的扩展性几乎为零。命令模式Command Pattern就是为了解决这个问题而生的。它不是什么高深莫测的黑科技而是一种极其实用的设计思想将“请求”封装成一个独立的对象。这个对象里包含了执行操作所需的所有信息比如接收者是谁、要执行什么方法、需要什么参数。这样一来发出请求的对象调用者就和具体执行请求的对象接收者完全解耦了。调用者不需要知道接收者是谁也不需要知道具体要干什么它只需要知道“有一个命令对象去执行它”就行了。想象一下遥控器的场景。遥控器调用者上有各种按钮每个按钮都对应一个命令对象比如“开灯”、“关灯”、“调高音量”。当你按下按钮时遥控器只是触发这个命令对象的execute()方法。至于这个命令是控制客厅的灯还是卧室的音响遥控器根本不关心。哪天你想把“开灯”按钮改成“打开空调”也只需要换一个命令对象遥控器的代码一行都不用动。这就是命令模式的威力——它把“做什么”和“谁来做”、“什么时候做”分开了。在C中实现命令模式尤其能体现其价值。C的面向对象特性和对性能的控制让我们既能构建灵活、可扩展的命令架构又能通过值语义、移动语义等机制优化其性能避免不必要的动态内存分配。这对于构建需要高频触发命令的系统如游戏引擎、图形界面框架、交易系统至关重要。2. 命令模式的核心思想与结构拆解命令模式的核心在于把一个操作或请求抽象出来变成一个“一等公民”——命令对象。这个模式主要涉及四个关键角色理解它们之间的关系是灵活运用的前提。2.1 四大核心角色解析命令Command这是一个抽象基类或接口通常只声明一个execute()方法。它是整个模式的基石定义了所有具体命令的统一接口。在C中我们通常用一个包含纯虚函数的类来实现。具体命令ConcreteCommand这是命令接口的具体实现。它持有对一个“接收者”对象的引用并将一个或多个接收者的动作绑定到自己身上。在execute()方法中它会调用接收者的具体方法来执行真正的操作。一个具体命令对象本质上就是“接收者-动作”对的一个封装。接收者Receiver知道如何实施与执行一个请求相关的操作。任何类都可以成为接收者。它是真正干活的“苦力”但本身并不知道自己会被谁、在什么时候调用。调用者/触发者Invoker要求命令执行请求。它持有一个命令对象并在某个时间点比如按钮被点击、定时器到期调用命令对象的execute()方法。调用者完全不知道命令的具体内容它只和命令接口打交道。它们之间的关系可以用一个简单的比喻调用者是将军命令是军令接收者是士兵。将军调用者不需要知道前线是哪个连队接收者他只需要签发一道“进攻A高地”的军令具体命令。这道军令里已经写明了由第X连接收者执行冲锋动作。将军把军令发出去他的任务就完成了。2.2 UML类图与数据流虽然我们不能画图但可以清晰地描述这个结构Invoker类有一个Command*类型的成员或std::unique_ptrCommand通过setCommand()方法来装配命令。当Invoker需要执行操作时例如其buttonPressed()方法被调用它简单地调用command-execute()。ConcreteCommand类内部有一个Receiver*成员或引用并在其execute()方法中调用receiver-action()。Receiver类拥有实现业务逻辑的真正方法action()。数据流是单向且清晰的Invoker - Command::execute() - Receiver::action()。这种结构将请求的发起、封装、传递和执行清晰地分离。2.3 模式的优势与适用场景命令模式的优势非常明显解耦调用者与接收者调用者无需知道接收者的任何信息系统各部分独立性增强。易于扩展新命令增加新的命令只需创建新的具体命令类符合开闭原则。支持命令的组合宏命令可以将多个命令组合成一个复合命令一键执行一系列操作。实现命令队列与日志由于命令是对象可以轻松地将其放入队列中顺序执行或者序列化到日志中用于实现撤销/重做、事务、延迟处理等高级功能。它特别适用于以下场景GUI 操作按钮、菜单项点击。事务行为需要支持撤销Undo、重做Redo的操作。任务队列与线程池将任务封装为命令对象提交到队列。网络请求封装将不同的请求封装成命令统一发送和处理。游戏开发玩家输入、AI行为、动画序列等都可以用命令来封装。3. C实现命令模式的详细指南C为实现命令模式提供了多种工具从经典的面向对象方式到利用现代C特性的函数对象方式。选择哪种方式取决于你对性能、灵活性和代码简洁性的权衡。3.1 基础实现经典的面向对象方式这是最教科书式的实现清晰地体现了模式的所有角色。#include iostream #include memory // 1. 接收者真正执行操作的对象 class Light { public: void turnOn() { std::cout The light is ON.\n; } void turnOff() { std::cout The light is OFF.\n; } }; // 2. 命令抽象接口 class Command { public: virtual ~Command() default; virtual void execute() 0; // 为了支持撤销通常还会声明一个 unexecute() 或 undo() 虚函数 }; // 3. 具体命令开灯命令 class TurnOnLightCommand : public Command { public: // 构造函数中注入接收者 explicit TurnOnLightCommand(Light* light) : light_(light) {} void execute() override { if (light_) { light_-turnOn(); } } private: Light* light_; // 通常使用裸指针或引用表示命令不拥有接收者的生命周期 }; // 另一个具体命令关灯命令 class TurnOffLightCommand : public Command { public: explicit TurnOffLightCommand(Light* light) : light_(light) {} void execute() override { if (light_) { light_-turnOff(); } } private: Light* light_; }; // 4. 调用者 class RemoteControl { public: // 设置命令 void setCommand(std::unique_ptrCommand cmd) { command_ std::move(cmd); } // 触发命令执行 void pressButton() { if (command_) { command_-execute(); } } private: std::unique_ptrCommand command_; }; // 客户端代码 int main() { // 创建接收者 Light livingRoomLight; // 创建具体命令并绑定接收者 auto turnOn std::make_uniqueTurnOnLightCommand(livingRoomLight); auto turnOff std::make_uniqueTurnOffLightCommand(livingRoomLight); // 创建调用者 RemoteControl remote; // 配置并执行开灯命令 remote.setCommand(std::move(turnOn)); remote.pressButton(); // 输出: The light is ON. // 配置并执行关灯命令 remote.setCommand(std::move(turnOff)); remote.pressButton(); // 输出: The light is OFF. return 0; }实现要点与注意事项生命周期管理上面的例子中RemoteControl使用std::unique_ptrCommand来拥有命令对象。这表示遥控器“拥有”这个命令适合命令创建后不再变化或需要转移所有权的场景。如果命令需要被多个调用者共享不常见可以考虑std::shared_ptr。接收者指针具体命令中通常使用原始指针或引用指向接收者因为命令对象通常不负责接收者的生命周期。接收者的生命周期由更高层级的代码如客户端管理。务必确保命令执行时接收者对象仍然有效避免悬垂指针。虚析构函数基类Command的析构函数必须是虚函数以确保通过基类指针删除派生类对象时能正确释放资源。3.2 进阶实现利用std::function与Lambda现代C风格对于简单的命令为每个操作都创建一个具体的命令类显得有些繁琐。现代C的std::function和 Lambda 表达式提供了一种更轻量、更灵活的替代方案。我们可以把任何可调用对象函数、Lambda、成员函数指针绑定的对象当作命令。#include iostream #include functional #include memory // 接收者 class Light { public: void turnOn() { std::cout Light ON\n; } void turnOff() { std::cout Light OFF\n; } }; // 调用者现在它持有一个 std::function class RemoteControl { public: using CommandFunc std::functionvoid(); void setCommand(CommandFunc cmd) { command_ std::move(cmd); // 使用移动语义提升性能 } void pressButton() { if (command_) { command_(); } } private: CommandFunc command_; }; int main() { Light light; RemoteControl remote; // 方式1使用Lambda直接捕获接收者并调用其方法 remote.setCommand([light]() { light.turnOn(); }); remote.pressButton(); // 输出: Light ON // 方式2使用 std::bind 绑定成员函数略显古老但仍有其用途 #include functional // for std::bind using namespace std::placeholders; // for _1 remote.setCommand(std::bind(Light::turnOff, light)); remote.pressButton(); // 输出: Light OFF // 方式3甚至可以绑定普通函数 void globalBeep() { std::cout Beep!\n; } remote.setCommand(globalBeep); remote.pressButton(); // 输出: Beep! return 0; }这种方式的优缺点优点极其灵活和简洁无需定义大量的具体命令类。特别适合一次性使用的简单命令。缺点类型被擦除。所有命令都变成了std::functionvoid()失去了具体的类型信息。这使得实现某些高级功能如序列化命令、基于类型的命令分发变得困难。同时std::function可能带来轻微的性能开销通常可忽略不计并且Lambda捕获的生命周期需要仔细管理例如上面用[light]捕获了引用必须确保light的生命周期长于command_。3.3 支持撤销Undo与重做Redo的实现命令模式是实现撤销/重做功能的天然选择。核心思想是让命令对象不仅知道如何执行execute还知道如何反向执行undo。#include iostream #include stack #include memory class Light { public: void turnOn() { state_ true; std::cout Light is ON\n; } void turnOff() { state_ false; std::cout Light is OFF\n; } bool getState() const { return state_; } private: bool state_ false; }; // 支持撤销的命令接口 class UndoableCommand { public: virtual ~UndoableCommand() default; virtual void execute() 0; virtual void undo() 0; }; class ToggleLightCommand : public UndoableCommand { public: explicit ToggleLightCommand(Light* light) : light_(light) {} void execute() override { if (light_) { previousState_ light_-getState(); // 记录执行前的状态 if (previousState_) { light_-turnOff(); } else { light_-turnOn(); } } } void undo() override { if (light_) { // 根据记录的状态恢复 if (previousState_) { light_-turnOn(); } else { light_-turnOff(); } } } private: Light* light_; bool previousState_ false; }; // 支持撤销/重做的调用者通常称为 CommandManager 或 Invoker class CommandManager { public: void executeCommand(std::unique_ptrUndoableCommand cmd) { cmd-execute(); undoStack_.push(std::move(cmd)); // 执行新命令后重做栈需要清空 while (!redoStack_.empty()) { redoStack_.pop(); } } void undo() { if (undoStack_.empty()) return; auto cmd std::move(undoStack_.top()); undoStack_.pop(); cmd-undo(); redoStack_.push(std::move(cmd)); } void redo() { if (redoStack_.empty()) return; auto cmd std::move(redoStack_.top()); redoStack_.pop(); cmd-execute(); undoStack_.push(std::move(cmd)); } private: std::stackstd::unique_ptrUndoableCommand undoStack_; std::stackstd::unique_ptrUndoableCommand redoStack_; }; int main() { Light light; CommandManager manager; auto cmd1 std::make_uniqueToggleLightCommand(light); // 开灯 manager.executeCommand(std::move(cmd1)); // 输出: Light is ON auto cmd2 std::make_uniqueToggleLightCommand(light); // 关灯 manager.executeCommand(std::move(cmd2)); // 输出: Light is OFF manager.undo(); // 撤销关灯 - 输出: Light is ON manager.undo(); // 撤销开灯 - 输出: Light is OFF manager.redo(); // 重做开灯 - 输出: Light is ON return 0; }实现撤销的关键点状态记录undo()操作需要信息来恢复到之前的状态。有两种主要策略反向操作如果操作本身是对称的如Toggleundo()可以直接调用execute()。但很多操作并非如此。存储状态在execute()时保存受影响对象的关键状态如上面保存了previousState_在undo()时恢复。对于复杂对象这可能意味着存储一份快照Memento Pattern需权衡内存开销。命令栈使用两个栈undoStack_和redoStack_来管理命令历史。执行新命令会清空redoStack_这是大多数编辑器的标准行为。复合命令为了实现“宏命令”一次执行多个命令可以创建一个MacroCommand类它内部维护一个std::vectorstd::unique_ptrUndoableCommand。其execute()和undo()方法会顺序执行或逆序撤销所有子命令。这允许你将一系列操作作为一个原子单元进行撤销/重做。4. 性能考量、内存管理与设计陷阱在C中使用命令模式尤其是高性能场景下必须仔细考虑性能和资源管理。4.1 堆分配 vs 栈分配经典实现中命令对象通常通过new创建由智能指针管理。这带来了堆分配的开销。对于极高性能要求的场景如游戏循环中每帧处理大量输入命令可以考虑以下优化命令池预先分配一块内存池用于创建固定大小的命令对象。需要时从池中取用用完后放回避免频繁的new/delete。这要求所有命令对象大小相同或相近通常需要结合变体std::variant或小型缓冲区优化SBO技术。静态多态CRTP如果命令类型在编译期可知可以使用奇异递归模板模式CRTP来避免虚函数调用开销。但这会牺牲一些动态灵活性。template typename ConcreteCommand class CommandCRTP { public: void execute() { static_castConcreteCommand*(this)-executeImpl(); } }; class MyConcreteCommand : public CommandCRTPMyConcreteCommand { public: void executeImpl() { /* ... */ } };使用std::function的小对象优化大多数标准库实现的std::function会对小的可调用对象如捕获少量变量的Lambda进行内联存储避免堆分配。了解你所用编译器的实现细节。4.2 对象生命周期与智能指针选择unique_ptr默认选择。表示调用者独占命令对象的所有权。命令执行后通常就不再需要或者所有权转移给了历史管理器如撤销栈。shared_ptr仅在多个对象需要共享同一个命令对象的所有权时使用例如同一个命令同时被加入执行队列和显示在历史列表中。滥用shared_ptr会导致循环引用和生命周期模糊。原始指针/引用如果命令对象的生命周期由明确的更高层作用域管理例如在栈上创建或在某个全局/单例容器中那么具体命令中持有对接收者的原始指针或引用是安全且高效的。但必须百分百确保接收者比命令对象存活得更久。4.3 常见设计陷阱与规避方法命令过于庞大如果一个命令类开始膨胀包含了大量不相关的数据或逻辑说明它可能承担了过多职责。应该审视是否可以将部分逻辑移到接收者中或者拆分成多个更细粒度的命令。忽略const正确性如果命令的执行不修改接收者状态或者undo()是execute()的严格逆操作请将相关方法标记为const。这有助于编译器优化和代码理解。过度设计不是所有回调都需要用完整的命令模式。如果只是简单的回调使用函数指针、std::function或Lambda可能更合适。只有当需要将请求作为对象进行参数化、队列化、日志化或支持撤销时完整的命令模式才显示出其优势。线程安全问题如果命令可能在多线程环境中创建、执行或撤销需要仔细考虑数据竞争。确保接收者对象是线程安全的或者命令的执行包含了必要的同步机制如锁。命令管理器CommandManager的栈操作也需要加锁保护。5. 实战应用构建一个简单的文本编辑器命令系统让我们通过一个更复杂的例子将上述知识点串联起来一个支持插入、删除文本和撤销/重做的简易文本编辑器。#include iostream #include string #include stack #include memory #include vector // 接收者文档 class Document { public: void insert(size_t pos, const std::string text) { if (pos content_.size()) pos content_.size(); content_.insert(pos, text); std::cout 文档插入: \ text \ 在位置 pos \n; std::cout 当前文档: \ content_ \\n; } void erase(size_t pos, size_t len) { if (pos content_.size()) return; if (pos len content_.size()) len content_.size() - pos; deletedText_ content_.substr(pos, len); // 为撤销保存被删除的文本 content_.erase(pos, len); std::cout 文档删除: \ deletedText_ \ 从位置 pos 开始\n; std::cout 当前文档: \ content_ \\n; } const std::string getContent() const { return content_; } const std::string getLastDeletedText() const { return deletedText_; } private: std::string content_; std::string deletedText_; // 简单起见只保存最后一次删除的文本用于演示 }; // 命令接口 class EditorCommand { public: virtual ~EditorCommand() default; virtual void execute() 0; virtual void undo() 0; virtual std::string description() const 0; }; // 具体命令插入文本 class InsertCommand : public EditorCommand { public: InsertCommand(Document doc, size_t pos, std::string text) : doc_(doc), position_(pos), text_(std::move(text)) {} void execute() override { doc_.insert(position_, text_); } void undo() override { // 撤销插入等于在相同位置删除相同长度的文本 // 这里我们简化处理直接调用文档的删除。更严谨的做法是命令自己记录状态。 doc_.erase(position_, text_.size()); } std::string description() const override { return 插入: \ text_ \ 在位置 std::to_string(position_); } private: Document doc_; size_t position_; std::string text_; }; // 具体命令删除文本 class DeleteCommand : public EditorCommand { public: DeleteCommand(Document doc, size_t pos, size_t len) : doc_(doc), position_(pos), length_(len) {} void execute() override { doc_.erase(position_, length_); } void undo() override { // 撤销删除等于重新插入被删除的文本 // 这里需要访问文档内部状态演示了一种紧耦合。更好的设计是命令在执行时保存被删文本。 // 我们修改Document使其返回被删文本。 const std::string deleted doc_.getLastDeletedText(); if (!deleted.empty()) { doc_.insert(position_, deleted); } } std::string description() const override { return 删除: 从位置 std::to_string(position_) 长度 std::to_string(length_); } private: Document doc_; size_t position_; size_t length_; }; // 命令管理器调用者 class EditorCommandManager { public: void execute(std::unique_ptrEditorCommand cmd) { cmd-execute(); undoStack_.push(std::move(cmd)); redoStack_ std::stackstd::unique_ptrEditorCommand(); // 清空重做栈 std::cout 已执行: undoStack_.top()-description() \n; } void undo() { if (undoStack_.empty()) { std::cout 无法撤销历史为空。\n; return; } auto cmd std::move(undoStack_.top()); undoStack_.pop(); cmd-undo(); redoStack_.push(std::move(cmd)); std::cout 已撤销。\n; } void redo() { if (redoStack_.empty()) { std::cout 无法重做。\n; return; } auto cmd std::move(redoStack_.top()); redoStack_.pop(); cmd-execute(); undoStack_.push(std::move(cmd)); std::cout 已重做。\n; } void showHistory() const { std::cout 操作历史 \n; // 注意栈无法直接遍历这里仅示意。实际可能需要一个列表来存储历史。 std::cout (历史查看功能需额外数据结构实现)\n; } private: std::stackstd::unique_ptrEditorCommand undoStack_; std::stackstd::unique_ptrEditorCommand redoStack_; }; int main() { Document doc; EditorCommandManager manager; // 执行一系列操作 manager.execute(std::make_uniqueInsertCommand(doc, 0, Hello)); manager.execute(std::make_uniqueInsertCommand(doc, 5, , World!)); // 假设我们想删除 “, W” manager.execute(std::make_uniqueDeleteCommand(doc, 5, 3)); std::cout \n--- 开始撤销 ---\n; manager.undo(); // 撤销删除 manager.undo(); // 撤销插入 “, World!” manager.undo(); // 撤销插入 “Hello” std::cout \n--- 开始重做 ---\n; manager.redo(); // 重做插入 “Hello” manager.redo(); // 重做插入 “, World!” std::cout \n最终文档内容: \ doc.getContent() \\n; return 0; }这个例子展示了命令模式在真实场景中的应用。InsertCommand和DeleteCommand封装了文档的修改操作。EditorCommandManager作为调用者和历史管理者负责执行、撤销和重做命令。你会发现增加一个新的操作比如“替换文本”只需要新增一个ReplaceCommand类编辑器核心逻辑和命令管理器完全不用修改这正是设计模式带来的可扩展性。在实际项目中你可能会遇到更复杂的情况比如命令需要参数化、命令需要组合、命令执行需要异步处理等。但万变不离其宗核心思想始终是将请求封装为对象。掌握了这个核心你就能在合适的场景下用C写出既灵活又高效的命令模式代码。