ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

命令模式实战:从if-else到撤销重做与宏命令

命令模式实战:从if-else到撤销重做与宏命令 你写过这样的代码吗根据按钮类型、菜单项或者用户输入写一堆if-else或者switch-case每个分支去调用不同的业务方法。刚开始还好等需求多了分支越来越长新功能都不知道往哪里塞连测试都跟着遭殃。这时候命令模式Command Pattern就是那个让你把“动作”变成“数据”的设计思路一次请求被封装成一个独立对象发送者和执行者彻底解耦你可以把多个动作排队、撤销、重做甚至录制成宏。这篇文章我会用 Java 和 C 双语言讲解命令模式的核心角色、撤销重做的实战写法、游戏开发中的按键绑定与宏命令还会整理面试和期末作业里经常踩的坑。适合正在学设计模式、准备大作业或者项目里被一堆if-else折磨得够呛的朋友。1. 命令模式到底在解决什么问题—— 先搞懂它出现之前的痛点1.1 一个最朴素的需求把“动作”当成“数据”先想一个最简单的场景你做了一个文本编辑器界面上有“复制”“粘贴”“撤销”三个按钮。最直白的写法是这样的void onButtonClick(String buttonName) { if (copy.equals(buttonName)) { editor.copy(); } else if (paste.equals(buttonName)) { editor.paste(); } else if (undo.equals(buttonName)) { editor.undo(); } }看起来没毛病但需求一旦变化问题就冒出来了。比如“复制”这个动作不仅按钮能触发菜单项、快捷键、右键菜单也可以触发。这时候你打算怎么办是把同一个if-else复制三份还是写一个公共方法供各处调用前者是复制地狱后者是耦合噩梦。而且真正的“撤销”并不是简单的editor.undo()你得把之前执行过的动作的具体参数和状态都记录下来才能准确地回退。这要求你把“一次操作”本身当成一个可存储、可传参、可回放的对象——这就是命令模式的核心思想动作不是散落在各个调用点的代码而是被封装成命令对象可以被传参、排队、记录、回放。1.2 硬编码 if-else 的问题清单顺着上面的例子往下想硬编码请求调用到底有哪些麻烦调用方与执行方强耦合按钮点击处必须知道“复制按钮要调 editor.copy()”菜单点击处也得重复同一个逻辑。无法扩展请求类型每次加一个新操作都得在按钮分发逻辑里加一个else if改老代码容易影响现有功能。无法记录历史操作一旦执行完就消失了没有对象可保存撤销重做自然无从谈起。无法组合与排队你想实现“把一系列操作录制成宏”或者把多个操作放入队列异步执行用硬编码分支完全做不到。测试困难每一个操作分支都嵌套在 UI 事件处理函数里想单独测试复制、粘贴逻辑得先 mock 一堆 UI 环境。命令模式就是针对上面这些痛点设计的。它把“请求”建模成一个对象这个对象有自己的类型、参数和执行方法发起请求的人和真正干活的接收者之间不再直接认识而是通过一个中间人——调用者Invoker——把命令对象扔出去由命令对象自己调用接收者的方法。1.3 命令模式的解耦思路请求发起者与执行者分离一个非常恰当的比喻是餐厅点餐顾客Client把订单Command递给服务员Invoker服务员只负责把订单传给厨房Receiver厨房按订单做菜。服务员不需要知道菜是怎么做的厨房也不需要知道顾客长什么样。如果顾客突然想改了还可以换一张订单修改命令对象甚至把订单丢进废纸篓撤销。对应到代码里发送者比如按钮、菜单、快捷键处理器只负责创建/持有命令对象调用命令的某个统一入口。命令对象封装了“做什么、给谁做、需要什么参数”。接收者Receiver是真正执行业务逻辑的对象它完全不知道自己被命令包装了。这样一来添加新功能时只需要新写一个命令类发送者代码一行都不用改。这就是开闭原则的体现。2. 核心角色拆解Command、Invoker、Receiver、Client2.1 四个角色的职责边界命令模式一共有四类参与者我见过很多初学者把 Invoker 和 Client 搞混其实它们的区别很清晰角色英文名核心职责类比客户端Client创建具体的命令对象并绑定到调用者顾客决定吃什么、下单命令接口/抽象类Command定义统一的执行入口通常就是execute()订单上约定的“提供菜品”调用者Invoker持有命令对象在某个时机触发execute()服务员传递订单、叫厨房做菜接收者Receiver真正执行业务行为厨房里的厨师一个关键点命令对象只负责“发起调用”不负责具体实现。比如SaveCommand.execute()内部会去调用fileService.save()但SaveCommand自己不去写文件。这样文件服务可以做独立的单元测试命令对象也容易被 mock。2.2 一个最小可运行示例Java 和 C 双版本先用 Java 写一个最简单但也最完整的命令模式骨架。需求电灯有两个操作——开灯、关灯遥控器按钮触发。// 1. 命令接口 public interface Command { void execute(); } // 2. 接收者电灯 public class Light { public void on() { System.out.println(灯亮了); } public void off() { System.out.println(灯灭了); } } // 3. 具体命令 public class LightOnCommand implements Command { private Light light; public LightOnCommand(Light light) { this.light light; } Override public void execute() { light.on(); } } public class LightOffCommand implements Command { private Light light; public LightOffCommand(Light light) { this.light light; } Override public void execute() { light.off(); } } // 4. 调用者遥控器 public class RemoteControl { private Command slot; public void setCommand(Command command) { this.slot command; } public void pressButton() { slot.execute(); } } // 5. 客户端组装 public class Client { public static void main(String[] args) { Light light new Light(); RemoteControl remote new RemoteControl(); remote.setCommand(new LightOnCommand(light)); remote.pressButton(); // 灯亮了 remote.setCommand(new LightOffCommand(light)); remote.pressButton(); // 灯灭了 } }这个例子虽然简单但已经把命令模式的结构完整跑了一遍。再来看一个 C 版本因为 C 里多虚函数、智能指针的用法和 Java 差别不小期末大作业经常用 C 写设计模式我直接贴一个可编译的写法#include iostream #include memory // 命令接口 class Command { public: virtual ~Command() default; virtual void execute() 0; }; // 接收者 class Light { public: void on() { std::cout 灯亮了 std::endl; } void off() { std::cout 灯灭了 std::endl; } }; // 具体命令 class LightOnCommand : public Command { public: explicit LightOnCommand(std::shared_ptrLight light) : light_(std::move(light)) {} void execute() override { light_-on(); } private: std::shared_ptrLight light_; }; class LightOffCommand : public Command { public: explicit LightOffCommand(std::shared_ptrLight light) : light_(std::move(light)) {} void execute() override { light_-off(); } private: std::shared_ptrLight light_; }; // 调用者 class RemoteControl { public: void setCommand(std::shared_ptrCommand cmd) { slot_ cmd; } void pressButton() { if (slot_) slot_-execute(); } private: std::shared_ptrCommand slot_; }; int main() { auto light std::make_sharedLight(); RemoteControl remote; remote.setCommand(std::make_sharedLightOnCommand(light)); remote.pressButton(); remote.setCommand(std::make_sharedLightOffCommand(light)); remote.pressButton(); return 0; }C 里特别要注意内存管理我习惯全部用std::shared_ptr管理命令和接收者的生命周期避免裸指针泄露。如果追求性能可以用unique_ptr但注意setCommand时要用移动语义。2.3 为什么需要抽象的 execute()—— 命令接口的设计哲学很多初学者会问不就是多包了一层吗直接调用light.on()不香吗为什么要非要有Command.execute()关键在于“接口统一”带来的可能性。当所有命令都实现同一个execute()接口后调用者就不需要关心它手里拿的到底是“开灯命令”还是“关灯命令”甚至是一组命令的集合。这意味着一套调用代码可以通吃所有命令。这就好比所有家用电器都使用同一个三孔插座无论插的是电饭煲、洗衣机还是充电器插座只提供“供电”这个统一入口。命令模式的execute()就是那个插座孔具体的电器怎么工作插座不关心。你可以把execute()设计成带参数、带返回值甚至带泛型但核心思想不变统一入口隔离差异。这也是命令模式能支持撤销、队列、宏命令的逻辑基础——因为每个命令都是一个可以操作的对象了。3. 实战一Java 实现一个带撤销和重做的编辑器操作3.1 设计 CommandStack 与 Undo/Redo文本编辑器里撤销/重做是命令模式最经典的落地场景。我来拆解一下设计思路。撤销的本质是执行一个命令时把该命令压入“撤销栈”撤销时从撤销栈弹出命令调用命令的undo()方法同时把它压入“重做栈”重做时则相反从重做栈弹出命令再次调用execute()压回撤销栈。这里有一个隐含要求命令对象不仅要能执行还要能反执行。所以接口不只有execute()还需要undo()。每个具体命令都要维护足够的上下文确保undo()能准确恢复之前的状态。3.2 代码实现从命令接口到具体命令以文档编辑器为例定义一个文本缓冲区支持插入和删除操作。命令接口加上 undopublic interface Command { void execute(); void undo(); }接收者是一个简单的TextBufferpublic class TextBuffer { private StringBuilder content new StringBuilder(); public void insert(int position, String text) { content.insert(position, text); } public void delete(int position, int length) { content.delete(position, position length); } public String getContent() { return content.toString(); } }两个具体命令InsertCommand和DeleteCommand。为了让撤销可执行命令必须在创建时保存插入/删除的位置和文本。public class InsertCommand implements Command { private TextBuffer buffer; private int position; private String text; public InsertCommand(TextBuffer buffer, int position, String text) { this.buffer buffer; this.position position; this.text text; } Override public void execute() { buffer.insert(position, text); } Override public void undo() { buffer.delete(position, text.length()); } }删除命令稍微复杂一点execute()删除指定区间的文本但在删除前要先把被删除的文本抓出来存到字段里这样undo()才能把它插回去。public class DeleteCommand implements Command { private TextBuffer buffer; private int position; private int length; private String deletedText; // 注意execute() 之前这个字段是空的 public DeleteCommand(TextBuffer buffer, int position, int length) { this.buffer buffer; this.position position; this.length length; } Override public void execute() { deletedText buffer.getContent().substring(position, position length); buffer.delete(position, length); } Override public void undo() { buffer.insert(position, deletedText); } }这里有个细节很多人会忽略deletedText必须在execute()执行时才去读取不能在构造函数里读取。因为命令刚刚创建时文档内容和执行时可能已经不一样了尤其是命令被放入队列之后才执行。然后是编辑器核心使用两个栈维护撤销/重做历史import java.util.ArrayDeque; import java.util.Deque; public class TextEditor { private TextBuffer buffer new TextBuffer(); private DequeCommand undoStack new ArrayDeque(); private DequeCommand redoStack new ArrayDeque(); public void executeCommand(Command cmd) { cmd.execute(); undoStack.push(cmd); redoStack.clear(); // 一旦新命令执行重做历史作废 } public void undo() { if (undoStack.isEmpty()) { System.out.println(没有可撤销的操作); return; } Command cmd undoStack.pop(); cmd.undo(); redoStack.push(cmd); } public void redo() { if (redoStack.isEmpty()) { System.out.println(没有可重做的操作); return; } Command cmd redoStack.pop(); cmd.execute(); undoStack.push(cmd); } public String getContent() { return buffer.getContent(); } }测试一下public class EditorDemo { public static void main(String[] args) { TextEditor editor new TextEditor(); editor.executeCommand(new InsertCommand(editor.buffer, 0, Hello)); editor.executeCommand(new InsertCommand(editor.buffer, 5, World)); System.out.println(editor.getContent()); // Hello World editor.undo(); System.out.println(editor.getContent()); // Hello editor.redo(); System.out.println(editor.getContent()); // Hello World } }注意executeCommand里有一行redoStack.clear()。这是一个非常关键的语义一旦执行了新命令重做历史就应该清空。比如你先撤销了“插入 World”然后又执行了一个“插入 !”此时原来的“插入 World”已经回不去了重做栈必须清掉否则用户会看到极其困惑的乱序操作。3.3 撤销/重做的细节状态恢复与反操作上面这个实现能跑但真实编辑器比这复杂得多。有几种常见情况需要特别留意。反操作的精确性InsertCommand.undo()里要求“删除从 position 开始、长度为 text.length() 的内容”。但如果在这条命令之后又有其他命令在相同位置插入了内容直接按原 position 删除可能会删错。实际项目中更稳妥的做法是保存一个全局操作序号或版本号在 undo 时按真实状态计算偏移。这是命令模式与“状态管理”结合的难点也是大作业能拿高分的加分点。组合操作像“输入一个汉字 自动补全括号”这类一次交互包含多个原子命令时应该用一个CompositeCommand把多个子命令打包成一个命令压栈。这样撤销时能一次撤销整个组合操作而不是一半。快照 vs 反操作撤销有两种实现思路一是保存操作前的完整快照二是保存反操作。快照实现简单但极其占内存反操作省内存但实现复杂。真实 Word 系的编辑器通常是混合使用普通编辑用反向命令大型操作如批量替换用快照。4. 实战二C 游戏开发中的按键绑定与宏命令4.1 游戏开发里为什么命令模式特别好用命令模式在游戏开发中几乎是标配原因很简单游戏里的“输入”五花八门。同一个“攻击”动作可能是键盘 A 键触发可能是手柄 X 键触发可能是点击 UI 按钮触发也可能是一条脚本 AI 指令触发。如果不做中间层你会在每个输入处理的地方都写一遍攻击逻辑。命令模式让“触发方式”和“动作内容”彻底分离。你只需要创建AttackCommand然后把它绑定到任意输入源上。换键位、增加按键映射、手柄重映射全都变成修改配置表的事根本不用碰游戏逻辑。另外很多游戏支持“录制回放”和“宏指令”比如格斗游戏里输入一串连招其实就是一组命令的序列。用命令队列存下来回放时依次execute()比硬编码连招逻辑干净太多。4.2 用 C 实现按键绑定到命令的映射先定义一个Command抽象基类class Command { public: virtual ~Command() default; virtual void execute() 0; };游戏角色类扮演接收者class GameCharacter { public: void attack() { std::cout 角色攻击 std::endl; } void jump() { std::cout 角色跳跃 std::endl; } void defend() { std::cout 角色防御 std::endl; } };三个具体命令类和前面的灯开关命令同理这里不再重复。关键的改动在“输入处理器”上用一个std::unordered_map把键位映射到命令对象。#include unordered_map #include memory class InputHandler { public: void bindCommand(int key, std::shared_ptrCommand cmd) { keyBindings_[key] std::move(cmd); } void handleInput(int key) { auto it keyBindings_.find(key); if (it ! keyBindings_.end()) { it-second-execute(); } else { std::cout 按键 key 未绑定 std::endl; } } private: std::unordered_mapint, std::shared_ptrCommand keyBindings_; };使用起来非常直观int main() { auto character std::make_sharedGameCharacter(); InputHandler input; input.bindCommand(A, std::make_sharedAttackCommand(character)); input.bindCommand(J, std::make_sharedJumpCommand(character)); input.bindCommand(D, std::make_sharedDefendCommand(character)); input.handleInput(A); // 角色攻击 input.handleInput(J); // 角色跳跃 input.handleInput(X); // 按键 X 未绑定 return 0; }这种设计的好处是加新动作时只需新写一个命令类并绑定按键InputHandler完全不用改。换键位也只需要改bindCommand那几行甚至可以做成从配置文件加载键位表。4.3 宏命令批量执行与录制回放宏命令本质是一个“命令的集合”。在命令模式里可以设计一个MacroCommand它内部维护一个std::vectorstd::shared_ptrCommandexecute()时遍历执行每个子命令。class MacroCommand : public Command { public: void addCommand(std::shared_ptrCommand cmd) { commands_.push_back(std::move(cmd)); } void execute() override { for (auto cmd : commands_) { cmd-execute(); } } private: std::vectorstd::shared_ptrCommand commands_; };录制回放的游戏示例int main() { auto character std::make_sharedGameCharacter(); MacroCommand combo; combo.addCommand(std::make_sharedJumpCommand(character)); combo.addCommand(std::make_sharedAttackCommand(character)); combo.addCommand(std::make_sharedAttackCommand(character)); combo.execute(); // 依次跳跃攻击攻击 return 0; }如果想支持“录制模式”只需要在handleInput里加一点逻辑当处于录制状态时每执行一个命令同时把它addCommand到一个MacroCommand里。这样玩家打一遍连招系统就记录下一套可回放的宏。没有命令模式的话这个需求写起来十分痛苦。5. 命令模式进阶队列、日志、事务与函数式替代5.1 命令队列异步/批量执行因为命令是对象所以可以被放进队列按顺序逐步执行。这对于任务调度非常有用。举个例子一个下载器同时有很多下载任务每个下载任务被封装成一个DownloadCommand放入线程池队列里执行。队列起到了缓冲、限流、批量处理的作用。比较一下如果直接调用下载方法调用者必须等待当前操作完成才能继续而把命令放入队列后调用者立刻返回由后台线程依次消费命令。这是命令模式与生产者-消费者模型的天然结合。5.2 命令日志持久化与回放数据库领域的“重做日志Redo Log”本质上与命令模式有着相同的思想。每条更新操作都记录为一个包含操作类型和参数的日志条目系统崩溃恢复时只要重放这些命令就能恢复到崩溃前的状态。这个思路在业务系统里也常被用来做“审计追踪”和“数据回放”。实现上只需要给命令增加serialize/deserialize能力把命令对象的参数保存成记录需要回放时再按记录创建同类型命令并执行。注意这里有一个前提命令必须足够“纯净”即相同参数的命令在任何时刻执行都会产生相同效果。如果命令依赖外部随机数、时间戳回放时的结果可能不一致。5.3 函数式写法lambda 与命令模式的关系很多用 Java 或现代 C 的朋友会问既然只封装一个行为为什么不用 lambda确实Runnable、std::function、lambda 可以替代最简单的命令模式。比如 Java 里Runnable command () - light.on(); command.run();C 里std::functionvoid() command [light]() { light.on(); }; command();但是完整的命令模式还要考虑撤销、状态保存、宏命令这些能力。lambda 只能解决“把动作封装成数据”这最基础的一点无法优雅地表达undo()、历史栈、命令参数持久化。所以我个人的建议是简单场景直接用 lambda 或者函数指针需要撤销/重做/回放/组合时再完整使用命令模式。两者不是对立关系在很多项目里我用std::function作为命令的底层载体但外部仍然给命令对象加上undo和状态方法。5.4 命令模式与策略模式、模板方法的区别设计模式学多了容易混淆这里单独拎出来对比一下维度命令模式策略模式模板方法模式目的把请求封装为对象支持排队、撤销、回放把算法族封装为独立对象运行时切换算法定义算法骨架子类重写步骤核心接口execute()/undo()algorithm()模板方法调用抽象步骤关注点请求的发起与执行解耦算法的选择流程的重用与定制示例撤销重做、按键绑定排序策略、压缩算法JDBC 模板、做菜流程命令模式重在“动作的传递和记录”策略模式重在“算法的可替换”模板方法重在“流程的固定”。它们经常一起出现在一个大型系统里但要分清楚各自的角色。6. 命令模式在真实项目中的坑与避雷指南6.1 命令类泛滥问题命令模式最容易被诟病的缺点是“类爆炸”。每个操作一个类一个系统几十个命令类很正常。我的经验是用以下几种方式控制嵌套命令类如果命令只服务某个接收者可以把具体命令作为接收者的内部类减少顶层类数量。泛型命令把相似操作抽象成带参数的命令模板比如ChangePropertyCommandT可以封装任何属性的读写。配合工厂模式通过配置文件或枚举来创建命令别在 Client 里手动new一堆类。不过也别过度设计。如果一个项目里只有两三个固定操作并且永远不会变直接方法调用更合适强行套命令模式只会增加阅读成本。6.2 撤销操作的状态保存陷阱撤销最容易出错的地方是“浅拷贝”。看一段代码class SetTextCommand implements Command { private Document doc; private String oldText; private String newText; public void execute() { oldText doc.getText(); doc.setText(newText); } public void undo() { doc.setText(oldText); } }如果oldText只是一个引用而doc.getText()返回的是可变对象那么后续任何修改oldText指向对象的内容都会污染撤销状态。正确的做法是执行前做一次深拷贝比如new String(oldText)或者让接收者只暴露不可变快照。这个问题在 C 里尤其严重直接存指针容易悬空。我见过同学期末作业里撤销了几次后程序崩掉大多数都是因为保存了已释放内存的指针。6.3 内存与性能考量每执行一个命令就创建一个对象如果有大量高频操作比如键盘输入命令对象的创建开销不可忽视。优化思路复用命令对象如果命令内部状态与执行时无关可以预先创建并反复使用。合并小命令对高频的字符输入可以把一段时间内的输入合并成一条命令减少历史栈大小。限制历史栈长度比如只保留最近 1000 条撤销记录超出后丢弃最旧的命令防止内存无限增长。记录命令对象时也要当心如果命令持有接收者的强引用而接收者又持有命令栈就可能形成循环引用导致 GC 无法回收。使用弱引用WeakReference或定期清理可以缓解。6.4 常见问答题面试、期末、大作业整理了一份高频问题的速查表自己复习或做期末复习提纲都很有用问题参考答案要点命令模式解决什么问题把请求封装为对象实现调用者和执行者解耦支持撤销、重做、队列、日志、宏命令命令模式和策略模式的区别命令模式关注“动作的封装与调用解耦”策略模式关注“算法的动态切换”为什么命令模式能实现撤销命令对象保存执行所需的上下文状态并提供undo()执行反操作execute()接口能不能有返回值可以设计execute()返回结果类型但要注意返回值不能破坏命令的隐藏性命令队列和宏命令有什么区别队列是异步/按序批量执行命令宏命令是把一组命令组合成一条命令可整体执行/撤销C 实现命令模式要注意什么内存管理智能指针、虚析构函数、命令对象生命周期命令模式有没有缺点增加类数量可能过度设计需要权衡7. 结语一个老程序员对命令模式的体会说实话命令模式是我最早入门但也最晚真正用好的设计模式之一。刚学的时候觉得不就是包一层execute()嘛没什么了不起直到自己做了一个带撤销功能的画板和一个支持自定义按键的游戏 Demo才明白它真正的力量不在于“调用加一层”而在于“把动作变成数据”后那无穷的玩法。撤销重做、操作历史、批量队列、宏录制回放……这些功能如果没有命令模式写起来会非常痛苦。根据我自己的经验决定用不用命令模式可以看三个信号第一系统里有没有多个调用源按钮、菜单、快捷键、脚本指向同一批操作第二有没有撤销重做的需求第三有没有录制回放、命令队列、日志审计的需求。只要中了任意一条就可以考虑引入命令模式。如果一条都没中那就别硬套保持代码简单比套一个模式更重要。最后再分享一个实际操作中的小技巧如果你的命令参数经常变别把一堆参数塞在构造函数里。可以设置一个CommandContext对象把上下文相关的数据都放进去命令类只依赖这个上下文接口。这样不仅撤销逻辑更好写后续做命令的持久化和网络传输也会省很多事。希望这篇文章能帮你把命令模式真正用起来而不是只停留在“看过”和“背过”的层面。
返回列表