命令模式实战:从撤销重做到宏命令的行为型设计模式详解

发布时间:2026/10/19 20:15:30
命令模式实战:从撤销重做到宏命令的行为型设计模式详解
我先说个结论命令模式是我个人最常用的行为型设计模式没有之一。很多人觉得它不就是一个“把方法调用包一层对象”的小技巧嘛但等你真正遇到需要撤销、排队、做宏、写操作日志的场景时你会感谢这个“小技巧”的。我在一个模拟项目X里写过一个编辑器模块最开始所有操作都是直接调编辑器的方法两三天后需求来了操作要支持撤销。那一版代码改得我头皮发麻之后重构成命令模式整个世界清静了。这篇文章不聊PPT术语就用实际代码和踩坑经历把命令模式从头到尾拆一遍。1. 先看懂问题命令模式到底在解决什么1.1 没有命令模式时代码是怎么慢慢烂掉的假设你正在写一个文档编辑器。初期需求很简单提供一个“插入文本”的功能。你会怎么写大概率是这样class Editor { public: void insertText(int pos, const std::string text) { // 在缓冲区指定位置插入文本 } void deleteText(int pos, int len) { // 删除缓冲区指定范围的文本 } };用户点了一个按钮你直接调用editor.insertText(...)完事。这段代码写起来确实爽一天就能跑通。但真实项目不会停在这里。第二周产品经理过来说“操作要支持撤销。”这时候你发现完蛋了撤销需要知道“上一次对编辑器做了什么”也就是说你需要先把“操作”本身记录下来之后才能逆操作。于是你给每个函数加了记录代码插入前存状态、删除前存状态一团乱麻。第三周需求又来了“批量操作要排队执行。”第四周“把几个操作合成一个宏命令。”这时候如果还是分散地写if (op 1) ...这种分支逻辑代码会烂到没法维护。问题出在哪出在你把“发起请求”和“执行请求”焊死在了一起。谁调用方法谁就得立刻知道调用的具体对象、具体方法、具体参数。这种强耦合在日常简单场景无所谓但一旦请求需要被延迟、被排队、被撤销、被记录强耦合就成了拦路虎。这里就是命令模式的切入点把“一个请求”封装成一个独立的对象。请求发起方只管“发出命令”不关心这个命令由谁执行、何时执行、怎么执行。请求执行方只管“收到命令后干活”不关心是谁发来的、命令来自哪里。两者通过命令对象解耦。1.2 命令模式的本质把动词变成名词核心思想说穿了就一句话把“做某件事”变成一个可以传来传去的对象。你平时调用函数动词是函数名参数是数据而命令模式把“函数调用”变成一个真实的类实例这个实例既是动词也是参数。它可以在队列里待着可以被存进历史记录还可以被扔到另一台机器上执行。我习惯用一个生活类比来理解去餐厅吃饭。你客户端叫来服务员调用方/Invoker说“来一份宫保鸡丁”。服务员不是在纸上写“给后厨说做宫保鸡丁”而是在点单系统里生成一个“做宫保鸡丁”的单子命令对象单子上记录了菜名、口味、桌号参数然后把单子传给后厨Receiver。后厨按单子做菜做完菜服务员再按单子上菜。整个过程里你不需要知道后厨是谁、怎么做菜后厨也不需要知道你是谁、你坐在哪桌传递信息的就是那张单子。这张单子能做什么可以排队多个单子先后处理可以退单撤销可以合并一桌点了一堆菜按套餐一起做还可以留作记录今天卖了多少份宫保鸡丁。你看一旦把“做菜请求”变成“单子对象”很多原本做不到的事就变得顺理成章了。1.3 什么时候该用什么时候别硬上命令模式不是银弹我见过很多人为了用设计模式而用设计模式结果代码比不用还复杂。我的判断标准是看这个请求有没有可能被延迟、被反悔、被排队、被传输。如果只是同步调用一次、不需要撤销、不需要排队那直接用函数调用就完了别画蛇添足。反过来只要出现下面这些词命令模式就该登场了撤销/重做必须记录之前执行过的操作才能反向操作。任务队列操作要按顺序执行或者异步执行。宏命令/组合操作多个操作要绑定成一个操作统一触发。操作日志每个操作都要可回放、可追踪。远程调用命令对象需要序列化后传给别人别人执行完再传回来。一句话总结当下不需要但未来可能需要“反悔”或“重放”一个操作时请优先考虑命令模式。2. 命令模式的骨架五个角色各司其职2.1 角色总览Command、ConcreteCommand、Receiver、Invoker、Client命令模式有五个角色很多人一开始被“这五个角色到底谁是谁”绕晕了。我画了一张简表先记住分工角色名字职责抽象命令Command定义执行操作的统一接口通常只有execute()具体命令ConcreteCommand绑定一个接收者对象和一组参数实现真正的操作逻辑接收者Receiver真正干活的对象知道怎么完成业务操作调用者/发起者Invoker持有命令对象在某个时机触发命令的执行客户端Client组装Receiver、ConcreteCommand、Invoker设置它们的关系关键在于Invoker只知道Command接口不知道ConcreteCommand内部细节ConcreteCommand绑定了Receiver和参数但Invoker不关心Reciever是谁。这就是解耦的核心。2.2 接口设计的两个关键点execute与undoexecute()是命令模式最核心的抽象方法它是所有命令的“公共入口”。但只设计一个execute()往往不够因为你还想支持撤销。所以绝大多数实际项目里命令接口长这样class Command { public: virtual ~Command() default; virtual void execute() 0; virtual void undo() 0; };undo()不是“反向执行一遍”而是“恢复成执行之前的状态”。比如插入了三个字符撤销就是删除这三个字符而不是去执行一个“插入三个相反字符”的操作。设计undo()时你必须确保命令对象在执行前保存好“恢复现场所需要的全部信息”。另一个关键点是参数放哪。命令模式要求构造命令时就把参数传进去execute()不带参数。为什么要这样因为命令可能要排队、延迟执行所以它必须是一个“已经打包好的请求”。如果execute()还需要传参数那谁调用它、在什么时机传参数都得外部管命令对象就退化成普通函数了。我见过不少初学设计模式的人把参数继续留在execute()里那其实是拿命令模式当幌子实质还是在直接调用。关于返回值我也多写一句execute()最好不要返回业务结果。为什么因为真正干活的Receiver已经在对象里了如果你想让调用方拿到结果可以在Receiver上记录结果或者用一个不回传的异步机制。命令模式的定位是“发出请求”不是“同步查询数据”。想获取数据就去调Receiver别依赖命令的返回值。2.3 现实变形从命令模式到函数对象近几年写Java、C、Python 的朋友会发现命令模式有了一种“轻量版”实现——直接用函数对象或lambda。道理很简单execute()的本质就是“可调用对象”。功能上lambda完全可以替代一部分ConcreteCommand尤其是那些只执行一次、不需要撤销的命令。举例C里你完全可以直接std::functionvoid()作为命令接口比如std::functionvoid() cmd []() { editor.insertText(3, hello); };这比定义一个完整的类要轻量得多。但有一个限制lambda很难优雅地实现undo。因为撤销需要保留“执行前的状态”以及“反向操作”这些是结构性的状态lambda这种临时闭包很难承载。所以我自己的习惯是只做一次性执行或者内部自己管理复杂闭包时用lambda一旦需要撤销、需要持久化、需要序列化老老实实定义类。3. 手写一个支持撤销的命令模式实现3.1 需求素描模拟一个编辑器缓冲区现在我们写一个能跑的demo。场景是模拟项目X里的精简文本编辑器需求就两条支持插入文本、删除文本并且都能撤销。设计提纲如下Editor作为Receiver内部维护一个字符串提供insertText和deleteText两个真正干活的函数。Command抽象类定义execute()和undo()。InsertTextCommand和DeleteTextCommand是两个具体命令。Invoker维护一个“已执行命令栈”调用命令的execute()后把命令压栈撤销时从栈顶取命令调用undo()并出栈。3.2 从命令接口到具体命令的完整实现先写命令抽象接口class Command { public: virtual ~Command() default; virtual void execute() 0; virtual void undo() 0; };接收者Editor这里简化成操作一个std::stringclass Editor { public: void insertText(int pos, const std::string text) { if (pos 0 || pos buffer_.size()) return; buffer_.insert(pos, text); cursor_ pos text.size(); } void deleteText(int pos, int len) { if (pos 0 || pos buffer_.size()) return; if (pos len buffer_.size()) len buffer_.size() - pos; buffer_.erase(pos, len); cursor_ pos; } const std::string getText() const { return buffer_; } private: std::string buffer_; int cursor_ 0; };然后是插入命令。注意构造时必须把参数插入位置、插入内容和接收者Editor都绑定好class InsertTextCommand : public Command { public: InsertTextCommand(Editor* editor, int pos, std::string text) : editor_(editor), pos_(pos), text_(std::move(text)) {} void execute() override { editor_-insertText(pos_, text_); } void undo() override { // 撤销插入 删除刚插入的内容 editor_-deleteText(pos_, text_.size()); } private: Editor* editor_; int pos_; std::string text_; };删除命令是这个demo里稍微妙的部分因为删除时必须记住“我到底删了什么”否则撤销时无法恢复class DeleteTextCommand : public Command { public: DeleteTextCommand(Editor* editor, int pos, int len) : editor_(editor), pos_(pos), len_(len) {} void execute() override { deleted_text_ editor_-getText().substr(pos_, len_); editor_-deleteText(pos_, len_); } void undo() override { // 撤销删除 把先前删掉的内容插回去 editor_-insertText(pos_, deleted_text_); } private: Editor* editor_; int pos_; int len_; std::string deleted_text_; // execute 时填充 };最后是Invoker它只管取命令、执行命令、记住命令、撤销命令class Invoker { public: void submit(Command* cmd) { cmd-execute(); history_.push(cmd); // 需要手动管理所有权实际工程建议使用智能指针 } void undo() { if (history_.empty()) return; Command* cmd history_.top(); history_.pop(); cmd-undo(); } private: std::stackCommand* history_; };调用方使用int main() { Editor editor; Invoker invoker; InsertTextCommand insertCmd(editor, 0, hello); invoker.submit(insertCmd); // 当前文本: hello DeleteTextCommand deleteCmd(editor, 1, 3); invoker.submit(deleteCmd); // 当前文本: ho invoker.undo(); // 撤销删除回到 hello invoker.undo(); // 撤销插入回到空串 return 0; }3.3 撤销为什么要“记录反操作”而不是“反向执行一遍”这是命令模式最容易踩的坑。新手写完execute()后通常会对undo()一筹莫展。你会想我撤销删除不就重新插入吗撤销插入不就删除吗听起来像“镜像执行”但执行一遍镜像操作和真正用反操作去undo()有本质区别。比如DeleteTextCommand如果我用“反向执行一遍”来撤销你需要执行一个InsertTextCommand(editor, pos, deletedText)。但问题是deletedText是什么只有在你真的删了之后才知道。如果不在deleteText执行时把被删内容保存下来你以后什么都恢复不了。这就是我在execute()里先把deleted_text_保存起来的原因。同理插入命令要撤销得知道“当时插到哪个位置、插了多少字符”。这些数据必须在execute()之前或期间捕获而不是事后靠猜。命令对象的undo()设计原则每个命令必须自足拥有恢复现场所需的全部数据执行时不依赖外部提供额外信息。这样撤销才能被无限次调用才能往undo栈里随便塞。这是命令模式能支撑复杂重做系统的根本原因。3.4 实操现场我在这里踩过的坑第一坑是索引偏移。比如你先在位置3插入了“abc”然后想撤销这个插入如果你记录的还是“位置3”而此期间另一个操作又在位置5插入了一段文本位置就变了。命令模式里的撤销不一定按严格的后进先出顺序执行一旦历史命令混在一起用固定位置就很危险。工程上通常用“稳定标识”而不是“整数位置”来定位比如用元素ID、迭代器、节点指针。我这个简版是为了好演示用了位置真正的编辑器都会用“锚点字符索引”这样的方式自动跟着插入/删除做偏移。第二坑是内存所有权。Command*传给Invoker后谁负责释放如果命令是局部变量submit之后栈一退出指针就悬空如果new出来没人delete必然泄漏。实际工程直接用std::unique_ptrCommand或std::shared_ptrCommand别手动管。撤销栈里同理。第三坑是撤销栈的“可重复调用”。submit里是“执行并压栈”但你有没有想过执行时抛异常怎么办工程上需要保证栈和实际状态的一致性。最稳妥的做法是先让命令执行成功再压栈一旦执行失败不压栈。否则撤销一个根本没生效的命令状态就崩了。4. 命令模式的高价值实战场景4.1 撤销与重做两个栈的经典姿势命令模式最经典的场景就是撤销/重做。做法是在Invoker中维护两个栈一个是“已执行命令栈”一个是“已撤销命令栈”。执行新命令把命令入已执行栈同时清空已撤销栈。撤销从已执行栈顶取命令调用undo()然后将其压入已撤销栈。重做从已撤销栈顶取命令调用execute()然后将其压回已执行栈。这样无限次撤销、重做都能成立。这部分的优雅之处在于Invoker不需要知道每个命令具体是什么它只需要遵守“栈”的规矩。新增一个操作类型时只要新命令类提供了execute()和undo()撤销重做立刻天然支持。这就是为什么编辑器、图像软件、CAD软件几乎都不约而同使用命令模式。4.2 任务队列与异步调度把命令丢进容器命令对象可以被放进队列、列表、优先队列里。我做过一个批量文件处理的工具读进来一大串“处理动作”每个动作是一个命令对象系统按顺序执行。有些动作耗时就放到后台线程池里执行失败的动作还能重新排队重试。这种场景如果还用if分支代码会非常难看把每个动作封装成命令对象后队列逻辑和业务逻辑完全分开。这里有个小知识点命令模式天然适合“生产-消费”模型。生产者创建命令对象丢进队列消费者从队列取出命令并执行。两者之间只需要约定命令接口不需要知道彼此。我在模拟项目X的批处理系统里就是这么用的效果很好。4.3 宏命令把命令组合成命令宏命令是命令模式带给我的惊喜之一。本质上是定义一个MacroCommand它的内部持有一组子命令execute()时依次执行所有子命令undo()时逆序撤销所有子命令。class MacroCommand : public Command { public: void addCommand(std::shared_ptrCommand cmd) { cmds_.push_back(std::move(cmd)); } void execute() override { for (auto cmd : cmds_) cmd-execute(); } void undo() override { for (auto it cmds_.rbegin(); it ! cmds_.rend(); it) { (*it)-undo(); } } private: std::vectorstd::shared_ptrCommand cmds_; };注意撤销必须逆序。如果你执行了 A、B、C那撤销就要先撤销C、再撤销B、再撤销A否则状态会错乱。宏命令的威力在于编辑器里的“格式化操作”可能是几百个子操作的组合但对外它只是一个命令用户按一下CtrlZ就能全部撤销。产品体验直接拉升一个台阶。4.4 远程调用与持久化可序列化的命令对象命令对象的另一个优势是——可以序列化。既然“请求”已经是一个独立对象它就能被保存到文件、通过网络传输、在另一台机器上执行。基于这个思路很多“操作日志系统”会把每个操作保存成一条命令流系统崩溃后重放命令流就能恢复状态。我实现过一个轻量的“编辑操作同步”demo用JSON序列化命令对象的字段发给远端的另一个客户端远端反序列化后调用execute()两个客户端的文档状态就同步了。这里面唯一要注意的是序列化不能把Receiver也序列化进去应该反序列化后从环境中重新绑定Receiver。这意味着命令对象里最好只存“参数”不存“接收者引用”。工程上常见做法是命令类里有一个绑定方法序列化数据恢复后调用绑定方法关联到实际Receiver。如果命令里带着对象指针序列化直接崩溃。5. 命令模式与“长得像”的模式如何区分5.1 命令模式 vs 策略模式这是我被问得最多的一组对比。两个模式结构上都长这样一个接口多个实现客户端注入。但意图完全不同。策略模式解决“同一件事用不同算法”的问题。比如排序你可以选快排、归并、堆排算法之间可以替换。命令模式解决“一个动作需要在不同时机、不同上下文执行”的问题。关心的是动作的延迟执行、撤销、排队。简单记法策略模式是“算法的封装”命令模式是“动作的封装”。如果一个类有多个方法可以互相替换那是策略如果它把一个方法调用变成一个可传递的对象那是命令。二者的代码看起来几乎一样但语义和设计意图差很远。5.2 命令模式 vs 责任链模式责任链模式是“多个处理者一个接一个地看这个请求”比如审批流经理批不了往上递给总监总监批不了递给CTO。请求本身是“数据”链路决定谁处理。命令模式则是不管谁来处理命令对象直接绑定了一个Receiver执行时一步到位。还有一个关键区别责任链里的请求通常是被传递的“数据”处理者之间会改这个请求命令模式里的命令是“已经完整的操作”Receiver不该修改命令参数直接执行就行。5.3 命令模式 vs 工厂模式有些场景下命令对象本身也需要被“创建”。比如一个GUI框架用户点“打开文件”按钮框架需要创建“打开文件命令”。这时候可以把OpenFileCommand的创建逻辑丢给工厂。命令模式和工厂模式一点都不冲突经常是配套使用。记住设计模式之间不是排他关系而是可以组合的。我见过最好的架构里Factory创建CommandCommand调用ReceiverInvoker触发执行Facade对外提供一个统一入口——各有各的活清晰得很。6. 避坑指南命令模式常见问题与解决套路6.1 类爆炸怎么治命令模式最直接的问题是每加一种操作就要新增一个命令类。当操作特别多时会出现几十个类的“类爆炸”。我建议这么处理如果操作只是简单转发没有独立的撤销逻辑直接用lambda或std::function代替类。只有需要撤销、需要持久化的操作才定义类。把命令类压缩到同一个文件里用内部类或者嵌套类组织。在Java/C#里直接把命令类作为业务类的内部类代码聚拢方便阅读。如果很多命令逻辑相似只是参数不同做一个通用的具体命令类用枚举区分类型。但这是偷懒的写法会牺牲命令模式的扩展性慎用。6.2 撤销栈的内存问题与状态一致性如果命令携带大量执行前快照数据撤销栈会占很多内存。我处理过一个图像编辑软件的概念demo每个操作都存整个图层快照撤销几层后内存直接飙升。后来改成只存“变化区域”或其他增量信息内存才降下来。工程上没有一个万能方案但方向都是尽量让命令保存“最小恢复所需数据”而不是“完整快照”。你也可以限制撤销栈深度比如最多保存20条历史超过就把最老的命令释放掉。另一个隐蔽问题是状态一致性。比如execute()执行到一半抛异常此时命令到底算执行了还是没有如果不处理栈的状态就乱了。稳妥做法是让命令负责保证“执行失败时把状态回滚到调用前”让Invoker只在execute()成功返回后才压栈。6.3 多线程执行命令时的共享状态命令对象本身是普通对象多个线程同时执行同一个命令对象如果它内部有可变状态就会有竞态。最典型的就是我在DeleteTextCommand里写的deleted_text_——这个字段在execute()时填充。如果两个线程同时调用execute()就会相互覆盖。解决套路有两种一是让一个命令对象只允许在一个线程执行需要并发执行同一个操作时创建多个彼此独立的命令对象二是把命令设计为不可变所有“执行生成的数据”都存到Receiver里而不是命令对象里。第一种最容易理解第二种更精致但需要重构。在模拟项目X的批处理系统里我的做法是每个任务从命令工厂里生成一个新命令实例绝不共用同一个命令对象。这样既避免了竞态也保证了任务间互不干扰。6.4 命令队列的序列化与重启恢复要做操作日志或远程同步时命令对象必须能序列化和反序列化。这里最大的坑是命令类绑定的是对象引用而序列化只能存数据。所以设计命令类时最好把“参数数据”和“接收者绑定”分开像这样class InsertTextCommand : public Command { public: struct Data { int pos; std::string text; }; InsertTextCommand(Data data) : data_(std::move(data)) {} void bind(Editor* editor) { editor_ editor; } ... };反序列化时先恢复Data然后在运行时调用bind(editor)把接收者绑定上。这样命令对象才能跨进程、跨机器传输不然网络那头反序列化出一堆悬空指针。6.5 命令模式下的事务与补偿企业级应用里经常有“先执行A再执行B若B失败则回滚A”的需求。这个用命令模式实现事务特别顺手把A和B分别做成命令B失败时把已执行的命令从栈顶逐个undo()即可。这就是命令模式在业务系统里的“补偿事务”虽然不是数据库事务但能达到类似的回滚效果。需要注意的一点不是所有命令都能完美撤销。比如“发邮件”“扣钱”这类操作并非简单逆转就能恢复。所以在选择命令模式时一定要想清楚undo()的语义边界。有些操作根本没有撤销语义强行实现undo()只会让代码变得自欺欺人。根据我自己几年的使用体会命令模式设计得好的标志是新增一个操作类型只写一个命令类客户端代码几乎不动撤销、重做、日志、宏这些能力自动获得。如果你发现新增一个命令要连改好几个地方说明你的命令模式设计走形了大概率是把业务逻辑泄漏到Invoker或Command里了。最后再分享一个小技巧在实现命令模式时我会在Invoker的记录栈里存一个函数比如std::functionvoid()代表“如何撤销这个命令”而不是直接把命令对象压栈。这样可以对不同命令做精细控制还能避免命令对象在栈里被误改。这个方法在复杂项目里比传统做法灵活得多建议你下次重构时也试试。