我先说个结论:命令模式是我个人最常用的行为型设计模式,没有之一。很多人觉得它不就是一个“把方法调用包一层对象”的小技巧嘛,但等你真正遇到需要撤销、排队、做宏、写操作日志的场景时,你会感谢这个“小技巧”的。我在一个模拟项目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::function<void()>作为命令接口,比如:std::function<void()> 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::string:class 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::stack<Command*> 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_ptr<Command>或std::shared_ptr<Command>,别手动管。撤销栈里同理。第三坑是撤销栈的“可重复调用”。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_ptr<Command> 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::vector<std::shared_ptr<Command>> cmds_; };注意撤销必须逆序。如果你执行了 A、B、C,那撤销就要先撤销C、再撤销B、再撤销A,否则状态会错乱。宏命令的威力在于:编辑器里的“格式化操作”可能是几百个子操作的组合,但对外它只是一个命令,用户按一下Ctrl+Z就能全部撤销。产品体验直接拉升一个台阶。4.4 远程调用与持久化:可序列化的命令对象命令对象的另一个优势是——可以序列化。既然“请求”已经是一个独立对象,它就能被保存到文件、通过网络传输、在另一台机器上执行。基于这个思路,很多“操作日志系统”会把每个操作保存成一条命令流,系统崩溃后重放命令流,就能恢复状态。我实现过一个轻量的“编辑操作同步”demo:用JSON序列化命令对象的字段,发给远端的另一个客户端,远端反序列化后调用execute(),两个客户端的文档状态就同步了。这里面唯一要注意的是:序列化不能把Receiver也序列化进去,应该反序列化后从环境中重新绑定Receiver。这意味着命令对象里最好只存“参数”,不存“接收者引用”。工程上常见做法是:命令类里有一个绑定方法,序列化数据恢复后调用绑定方法关联到实际Receiver。如果命令里带着对象指针,序列化直接崩溃。5. 命令模式与“长得像”的模式如何区分5.1 命令模式 vs 策略模式这是我被问得最多的一组对比。两个模式结构上都长这样:一个接口,多个实现,客户端注入。但意图完全不同。策略模式:解决“同一件事,用不同算法”的问题。比如排序,你可以选快排、归并、堆排,算法之间可以替换。命令模式:解决“一个动作,需要在不同时机、不同上下文执行”的问题。关心的是动作的延迟执行、撤销、排队。简单记法:策略模式是“算法的封装”,命令模式是“动作的封装”。如果一个类有多个方法可以互相替换,那是策略;如果它把一个方法调用变成一个可传递的对象,那是命令。二者的代码看起来几乎一样,但语义和设计意图差很远。5.2 命令模式 vs 责任链模式责任链模式是“多个处理者一个接一个地看这个请求”,比如审批流:经理批不了,往上递给总监,总监批不了,递给CTO。请求本身是“数据”,链路决定谁处理。命令模式则是不管谁来处理,命令对象直接绑定了一个Receiver,执行时一步到位。还有一个关键区别:责任链里的请求通常是被传递的“数据”,处理者之间会改这个请求;命令模式里的命令是“已经完整的操作”,Receiver不该修改命令参数,直接执行就行。5.3 命令模式 vs 工厂模式有些场景下,命令对象本身也需要被“创建”。比如一个GUI框架,用户点“打开文件”按钮,框架需要创建“打开文件命令”。这时候可以把OpenFileCommand的创建逻辑丢给工厂。命令模式和工厂模式一点都不冲突,经常是配套使用。记住,设计模式之间不是排他关系,而是可以组合的。我见过最好的架构里,Factory创建Command,Command调用Receiver,Invoker触发执行,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::function<void()>,代表“如何撤销这个命令”,而不是直接把命令对象压栈。这样可以对不同命令做精细控制,还能避免命令对象在栈里被误改。这个方法在复杂项目里比传统做法灵活得多,建议你下次重构时也试试。
我先说个结论:命令模式是我个人最常用的行为型设计模式,没有之一。很多人觉得它不就是一个“把方法调用包一层对象”的小技巧嘛,但等你真正遇到需要撤销、排队、做宏、写操作日志的场景时,你会感谢这个“小技巧”的。我在一个模拟项目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::function<void()>作为命令接口,比如:std::function<void()> 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::string:class 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::stack<Command*> 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_ptr<Command>或std::shared_ptr<Command>,别手动管。撤销栈里同理。第三坑是撤销栈的“可重复调用”。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_ptr<Command> 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::vector<std::shared_ptr<Command>> cmds_; };注意撤销必须逆序。如果你执行了 A、B、C,那撤销就要先撤销C、再撤销B、再撤销A,否则状态会错乱。宏命令的威力在于:编辑器里的“格式化操作”可能是几百个子操作的组合,但对外它只是一个命令,用户按一下Ctrl+Z就能全部撤销。产品体验直接拉升一个台阶。4.4 远程调用与持久化:可序列化的命令对象命令对象的另一个优势是——可以序列化。既然“请求”已经是一个独立对象,它就能被保存到文件、通过网络传输、在另一台机器上执行。基于这个思路,很多“操作日志系统”会把每个操作保存成一条命令流,系统崩溃后重放命令流,就能恢复状态。我实现过一个轻量的“编辑操作同步”demo:用JSON序列化命令对象的字段,发给远端的另一个客户端,远端反序列化后调用execute(),两个客户端的文档状态就同步了。这里面唯一要注意的是:序列化不能把Receiver也序列化进去,应该反序列化后从环境中重新绑定Receiver。这意味着命令对象里最好只存“参数”,不存“接收者引用”。工程上常见做法是:命令类里有一个绑定方法,序列化数据恢复后调用绑定方法关联到实际Receiver。如果命令里带着对象指针,序列化直接崩溃。5. 命令模式与“长得像”的模式如何区分5.1 命令模式 vs 策略模式这是我被问得最多的一组对比。两个模式结构上都长这样:一个接口,多个实现,客户端注入。但意图完全不同。策略模式:解决“同一件事,用不同算法”的问题。比如排序,你可以选快排、归并、堆排,算法之间可以替换。命令模式:解决“一个动作,需要在不同时机、不同上下文执行”的问题。关心的是动作的延迟执行、撤销、排队。简单记法:策略模式是“算法的封装”,命令模式是“动作的封装”。如果一个类有多个方法可以互相替换,那是策略;如果它把一个方法调用变成一个可传递的对象,那是命令。二者的代码看起来几乎一样,但语义和设计意图差很远。5.2 命令模式 vs 责任链模式责任链模式是“多个处理者一个接一个地看这个请求”,比如审批流:经理批不了,往上递给总监,总监批不了,递给CTO。请求本身是“数据”,链路决定谁处理。命令模式则是不管谁来处理,命令对象直接绑定了一个Receiver,执行时一步到位。还有一个关键区别:责任链里的请求通常是被传递的“数据”,处理者之间会改这个请求;命令模式里的命令是“已经完整的操作”,Receiver不该修改命令参数,直接执行就行。5.3 命令模式 vs 工厂模式有些场景下,命令对象本身也需要被“创建”。比如一个GUI框架,用户点“打开文件”按钮,框架需要创建“打开文件命令”。这时候可以把OpenFileCommand的创建逻辑丢给工厂。命令模式和工厂模式一点都不冲突,经常是配套使用。记住,设计模式之间不是排他关系,而是可以组合的。我见过最好的架构里,Factory创建Command,Command调用Receiver,Invoker触发执行,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::function<void()>,代表“如何撤销这个命令”,而不是直接把命令对象压栈。这样可以对不同命令做精细控制,还能避免命令对象在栈里被误改。这个方法在复杂项目里比传统做法灵活得多,建议你下次重构时也试试。