资讯动态

C++命令模式实战:从撤销重做到宏命令与日志回放

发布时间:2026/10/14 22:54:12 来源:尧图企业网站定制
开头第一段先说结论C里的按钮点击大多数人的第一版实现都是一行函数调用——回调里直接操作业务对象。项目前期这么写确实爽直到某天产品经理告诉你这个操作要能撤销、能重做、要录制成宏、甚至要在崩溃后重放你才会意识到自己缺了一个关键模块而C中的命令模式就是为这类需求准备的。这篇文章我基于自己在文本编辑类项目里的落地经验把命令模式拆开讲最小骨架、现代C写法、撤销/重做、宏命令与序列化回放以及我从实战里踩过的一些坑。适合想在设计模式里找“能直接抄作业”方案的C开发者也适合刚接触设计模式、被各种UML图绕晕的初学者。1. 从“按钮直接调函数”到“把动作变成对象”命令模式到底解决了什么1.1 传统回调写法到底缺了什么假设你在做一个带图形界面的文本编辑器工具栏上有一个“插入”按钮菜单栏里也有一个“插入”快捷键 CtrlI 同样出发这个操作。大多数人的第一版写法是// 传统做法回调里直接调用业务函数 void onInsertButtonClicked(Document doc, const std::string text, size_t pos) { doc.insert(text, pos); } void onInsertMenuClicked(Document doc, const std::string text, size_t pos) { doc.insert(text, pos); }功能没问题但你很快会发现两个问题第一所有入口都跟 Document 的具体方法耦合了新增一种操作时要把同样的调用逻辑复制到按钮、菜单、快捷键三处第二撤销怎么做最朴素的方案是每次操作前保存整份文档快照向撤销栈里 push 一个 DeepCopy。文档小的时候还能忍文档一旦上 MB用户连续按十次删除你就得存十份快照内存直接爆炸。问题的本质是你在处理“一次调用”而不是在存储“一个用户意图”。函数调用执行完就消失了你没有任何东西能代表“刚才做了什么”自然也没法对它做撤销、排队、重放这类操作。1.2 命令模式把“动作”变成可保存的对象命令模式的思路很直接把“用户想做的操作”封装成一个对象。对象和函数调用最大的区别在于对象可以被存储、传递、排队、序列化。按钮、菜单、快捷键都只是触发者它们只负责调用command-execute()至于这个命令内部调用了谁、修改了什么触发者一概不关心。放到编辑器场景里就是每次点击按钮构造一个具体的命令对象比如InsertCommand把文档引用、插入文本、插入位置塞进去然后调用它的execute()。如果你需要撤销历史把这个命令对象保存起来撤销时调用它的undo()即可。说白了命令对象携带了“执行动作”和“撤销动作”两份关键逻辑历史栈里存的是一堆有行为的数据而不是复制出来的文档快照。我把传统回调和命令模式的差别整理成一张表方便对照维度传统回调写法命令模式动作的表示函数调用执行完即消失命令对象可存储、可传递撤销支持通常依赖快照代价高命令自带 undo 逻辑触发入口入口与业务逻辑强耦合入口只认 execute 接口批量执行需要额外编排代码命令天然可组合、可排队调试与追踪只能看调用栈能看到命令对象的状态和参数1.3 什么时候该用命令模式什么时候不该用我个人的判断标准很简单只要功能需求里出现这几个词——撤销、重做、宏、操作历史、延迟执行、批量回放——你就该认真考虑命令模式。如果只是单纯的按钮点击后弹个窗、写个日志直接写回调反而更清晰硬上命令模式只会徒增类数量是典型的过度设计。命令模式的本质是为“行为的灵活编排”买单而不是为了满足设计模式的 KPI。2. 一份能跑的最小骨架Command接口与Invoker的历史栈2.1 接口设计execute、undo 和虚析构函数命令模式的最小骨架只需要一个抽象基类和几个具体命令。先看接口class Command { public: virtual ~Command() default; virtual void execute() 0; virtual void undo() 0; };三个要点析构函数必须是虚函数否则通过基类指针删除派生类对象时是未定义行为这是命令模式最常见的崩溃来源之一execute()负责执行主操作undo()负责撤销。最小骨架里我用void返回但在真实项目里我更倾向让它们返回bool表示“这次操作是否成功”。返回失败时调用方不该把这个命令推进撤销栈以免后续 undo 操作作用在不完整的状态上后面我会详细说。2.2 具体命令InsertCommand 与 DeleteCommand 怎么保存撤销数据先定义一个最朴素的文本接收者也就是真正干活的对象class Document { public: void insert(const std::string text, size_t pos) { content_.insert(pos, text); } std::string erase(size_t pos, size_t len) { std::string removed content_.substr(pos, len); content_.erase(pos, len); return removed; } private: std::string content_; };接下来是两个具体命令。插入命令的撤销很简单插进去多少就删掉多少class InsertCommand : public Command { public: InsertCommand(Document doc, std::string text, size_t pos) : doc_(doc), text_(std::move(text)), pos_(pos) {} void execute() override { doc_.insert(text_, pos_); } void undo() override { doc_.erase(pos_, text_.size()); } private: Document doc_; std::string text_; size_t pos_; };删除命令就有点讲究了。因为“删除前被删掉的内容”在构造命令时还不存在必须等到execute()真正执行时才拿得到class DeleteCommand : public Command { public: DeleteCommand(Document doc, size_t pos, size_t len) : doc_(doc), pos_(pos), len_(len) {} void execute() override { erased_ doc_.erase(pos_, len_); } void undo() override { doc_.insert(erased_, pos_); } private: Document doc_; size_t pos_; size_t len_; std::string erased_; };这里erased_就是典型的“执行过才有值”的撤销数据。最安全的做法是用std::optionalstd::string来声明它并在undo()里断言它有值否则一旦命令还没有 execute 就被撤回拿空字符串去插入看似不崩实则逻辑全错。这个坑我后面再展开。2.3 调用者先执行再入栈异常时留下干净的状态调用者Invoker负责触发命令并维护历史栈。一个最简的实现长这样class Editor { public: void execute(std::unique_ptrCommand cmd) { cmd-execute(); history_.push_back(std::move(cmd)); } void undo() { if (history_.empty()) return; auto cmd std::move(history_.back()); history_.pop_back(); cmd-undo(); } private: std::vectorstd::unique_ptrCommand history_; };注意execute()的顺序先执行后入栈。如果cmd-execute()抛了异常这个命令就不该出现在历史栈里否则后面执行 undo 时状态是错乱的。undo()里我把命令从栈里移出来后调用它的undo()这个命令在执行完撤销后就被销毁了。如果以后要支持 redo就需要把它压回另一个栈这个场景我在第 4 部分详讲。2.4 角色职责速查表为了方便新手理解我把参与者的角色拆开列一张表角色对应类/对象核心职责Command 接口Command约定 execute / undo 的调用协议具体命令InsertCommand、DeleteCommand持有接收者引用与参数实现具体动作及逆动作接收者Document真正的业务逻辑不感知命令的存在调用者/触发者Editor、按钮、菜单构造命令对象调用 execute维护历史栈3. 现代C实现细节智能指针、std::function与生命周期管理3.1 为什么默认用 unique_ptr 而不是 shared_ptr在命令模式里每个命令对象的归属权是明确且单一的要么在待执行队列里要么在撤销栈里要么在重做栈里。这种所有权模型最适合std::unique_ptr它语义清晰、性能开销为零还能保证命令对象在栈销毁时自动释放。有些场景确实会想到用std::shared_ptr例如同一个命令对象同时被工具栏按钮、右键菜单、快捷键三个入口持有。但我建议你在动手前先想清楚这三个入口真的需要各自持有一份指针吗它们完全可以在触发时才构造一个新的命令对象根本不需要共享所有权。共享能带来便利但也会让“这个命令现在归谁管”变得模糊尤其在 undo/redo 双栈搬运时容易留下意料之外的引用。我通常只在命令需要被多个长期存活的对象持久持有时才用 shared_ptr并仔细检查有没有循环引用。3.2 用 std::function 避免类爆炸收益与取舍C11 之后命令接口不一定非要抽象基类std::function可以让我们用极少的代码实现命令对象class Command { public: Command(std::functionvoid() execute, std::functionvoid() undo) : execute_(std::move(execute)), undo_(std::move(undo)) {} void execute() { if (execute_) execute_(); } void undo() { if (undo_) undo_(); } private: std::functionvoid() execute_; std::functionvoid() undo_; };使用时配合 lambdaauto insertCmd std::make_uniqueCommand( [] { doc.insert(text, pos); }, [] { doc.erase(pos, text.size()); } );这种写法的最大优点是轻——不需要每个操作都新建一个类几个 lambda 一组合就完事了非常合适操作种类多但逻辑简单的场景。但代价也很明显命令类型信息丢失。你很难在历史栈里区分这个命令到底是“插入”还是“删除”没法做序列化也没法给命令附加描述信息用于调试。另外lambda 捕获的引用生命周期一旦没管理好比正式命令类更容易产出悬垂引用。所以我现在的经验是几百行的小工具可以用 std::function 版但正经的项目尤其是需要日志回放的还是老老实实用命令类。3.3 捕获与状态拷贝命令对象里的引用、值和可选值命令对象的成员设计有一条铁律凡是执行时就确定且不再变化的参数用值保存凡是需要操作真实业务对象的用引用保存。插入文本要用std::string text_而不是const std::string因为 lambda 或命令对象被存进历史栈后可能存活很久引用一个栈上字符串或者一个将来被修改的成员变量都会造成数据错乱或悬垂。移动语义在这里也很有用构造函数里用std::move(text)把字符串转移进来避免不必要拷贝。另外命令中的状态成员不要贪图省事随手初始化。像前面 DeleteCommand 里的erased_用std::optional表达“尚未执行”比用空字符串更准确也能在调试时一眼看出逻辑状态class DeleteCommand : public Command { public: void execute() override { erased_ doc_.erase(pos_, len_); } void undo() override { assert(erased_.has_value()); doc_.insert(*erased_, pos_); } private: std::optionalstd::string erased_; };不加这个保护你会在撤销一个未执行的命令时把一段空字符串插入到文档里看起来什么都没发生但偏移量已经错了后面的插入操作全乱套。这种 bug 极难排查因为错误并不在执行点而是在下一次操作里才体现。4. 撤销与重做命令模式最核心的实战价值4.1 双栈撤销/重做的完整实现命令模式在编辑器里最有价值的地方就是撤销/重做。实现思路是准备两个栈undo 栈和 redo 栈。执行新命令时先进 undo 栈同时清空 redo 栈撤销时从 undo 栈弹出命令调用它的 undo然后压入 redo 栈重做时从 redo 栈弹出重新 execute再压回 undo 栈。代码如下class Editor { public: void execute(std::unique_ptrCommand cmd) { cmd-execute(); undoStack_.push_back(std::move(cmd)); redoStack_.clear(); } void undo() { if (undoStack_.empty()) return; auto cmd std::move(undoStack_.back()); undoStack_.pop_back(); cmd-undo(); redoStack_.push_back(std::move(cmd)); } void redo() { if (redoStack_.empty()) return; auto cmd std::move(redoStack_.back()); redoStack_.pop_back(); cmd-execute(); undoStack_.push_back(std::move(cmd)); } private: std::vectorstd::unique_ptrCommand undoStack_; std::vectorstd::unique_ptrCommand redoStack_; };为什么要清空 redo 栈因为一旦你执行了一个全新操作历史就产生了一个新分支原来的“重做路径”已经失效。这个细节常被忽略结果就是用户撤销几步后执行了新操作再点重做编辑器复现出一堆过时状态。4.2 撤销为什么必须逆序执行命令模式的撤销有一条容易被忽略的铁律如果一次要撤销多个命令必须从最新往最旧执行。这跟操作之间的因果依赖有关。举例来说你先插入了一段文本然后在插入的文本里做了替换撤销时必须先撤销替换再撤销插入。如果顺序反了插入都没了替换的撤销逻辑会拿着一个失效的位置去操作文档轻则逻辑错误重则直接越界崩溃。这条规则在宏命令里体现得最明显。当一组操作被打包成一个宏时撤销它必须对内部子命令做逆序遍历我习惯写成这样class MacroCommand : public Command { public: void add(std::unique_ptrCommand cmd) { commands_.push_back(std::move(cmd)); } void execute() override { for (auto cmd : commands_) { cmd-execute(); } } void undo() override { for (auto it commands_.rbegin(); it ! commands_.rend(); it) { (*it)-undo(); } } private: std::vectorstd::unique_ptrCommand commands_; };逆序撤销的本质是“把执行过的一连串状态变换倒着放一遍”。状态变换在数学上不满足交换律的情况太常见了所以不要贪图方便正序遍历哪怕你觉得子命令之间互不影响也一律逆序处理。4.3 撤销粒度控制合并连续操作与性能优化撤销栈里如果一条命令只对应一个字符用户打了三百字就得撤销三百次体验很差。更合理的做法是粒度合并把一段时间内连续的同类输入合并成一个命令对象。你可以给命令设置分组 ID每次插入时先查看栈顶命令的分组 ID如果相同且时间间隔小于某个阈值就把新文本追加进去而不是新建命令。如果是超大型操作比如一次导入几千行文本命令内保存字符串副本的内存开销会很大。我的经验是给这类大命令搭配一个轻量快照机制只保存被影响区域的改动 diff而不是整份文档。如果你的业务对象本身很庞大也可以考虑把统一快照放到磁盘文件里做增量记录内存里只留偏移和校验信息。这和撤销栈本身并不冲突只是把命令对象的状态存储从内存搬到了更合理的位置。5. 宏命令、队列执行与日志回放把命令当数据用的进阶玩法5.1 宏命令把一组操作打包成一个事务上一节已经给出了MacroCommand的代码它的本质是组合多个命令并对外表现为一条命令。这种“组合”能力非常实用用户录制宏时系统不停把新操作包装成命令并塞进宏对象录制结束时整个宏就是一条可以执行、撤销、重放的新命令。宏命令在业务上相当于“事务”。一组操作内部某个子命令执行失败时你可以选择让整个宏失败并把之前已执行的子命令全部逆序撤销。这个回滚行为本质上就是把宏的undo()在失败路径里先手动调用一遍。很多 C 工程里的事务管理器底层就是这么一套逻辑。5.2 队列化与延迟执行命令对象天然适合排队命令对象是数据数据最大的优势就是可以被排队。在实际项目里我遇到过需要把用户操作发送到后台线程执行的需求做法就是把命令对象塞进一个线程安全的任务队列后台线程逐个取出并调用execute()。这时命令内部的Document引用就得多留个心眼——后台线程操作时接收者必须处于安全状态通常需要加锁或者保证文档对象与命令队列同生命周期。游戏里常见的“技能指令序列”也是这样玩家在连击窗口内发出的指令被封装成命令排队每帧只出队执行第一个超时未出手就清空队列。这种以命令作为中间数据的思想让逻辑和表现层解耦得非常干净。5.3 序列化与日志回放让操作历史变成可重放的数据流命令模式最容易被低估的能力是“回放”。如果每个命令都携带了必要的参数你就能把它序列化到磁盘形成操作日志。系统崩溃后不用保存整份业务数据只要从最近一次 checkpoint 开始把日志里记录的命令重新执行一遍就能恢复到崩溃前的状态。C 没有内置反射机制所以命令的序列化需要自己设计。我通常给命令加一个类型 ID 和参数表反序列化时通过工厂函数重建命令对象// 序列化后的命令表示 struct SerializedCommand { int typeId; std::vectorstd::string args; }; // 工厂按 typeId 构造具体命令 class CommandFactory { public: std::unique_ptrCommand create(const SerializedCommand data); };这里有一个关键点回放的命令必须做到幂等或具备完整上下文否则日志重放会产生与原始操作不同的结果。比如“把光标移到下一行”这种依赖当前光标位置的命令回放时状态就不一样不如直接记录“移动后的绝对行号”。记录“用户点了哪个按钮”永远不如记录“这次操作最终改变了什么”来得可靠。我倾向于在命令里保存原始参数和最终效果两组数据前者用于 execute后者用于 undo 和校验。下面是一张针对不同扩展玩法的场景对照扩展方向核心思路典型场景需要注意的问题宏命令组合多个命令为一个命令脚本录制、批量编辑内部子命令必须逆序撤销队列执行命令入队延迟或异步消费后台任务、游戏技能序列线程安全、接收者生命周期日志回放序列化命令并重放崩溃恢复、数据同步幂等性、参数完整性分组合并同类命令合并为一条连续输入文字分组 ID 和状态修正6. 我在实战中踩过的几个坑从悬垂引用到过度设计6.1 命令比接收者长寿悬垂引用的典型修复命令对象持有Document doc_最舒服但也是最容易翻车的地方。我遇过几次情况是文档窗口关闭了Document对象已经被销毁但撤销栈里还存着一堆命令对象。用户重新打开文档后按撤销程序直接段错误。解决方案不复杂要么保证命令栈中的所有命令都跟接收者同生命周期在接收者销毁前先清空命令栈要么把命令持有的裸引用换成std::weak_ptr如果Document是std::shared_ptr管理的话执行 undo 前先lock()失败就跳过这条命令。前者在架构上更干净后者在遗留代码里更容易补救。我的习惯是编辑器主界面会持有一个统一的“编辑会话”对象文档和命令栈都在这个会话内部创建、销毁从源头上杜绝生命周期错配。6.2 DeleteCommand 的 optional那些执行前没有值的撤销数据前面提到过的erased_问题在实际开发里比我预想的更容易出 bug。很多新手会给erased_一个空字符串默认值然后忘掉它是在execute()之后才被赋值的。等用户连续删除几段文字后再撤销插入的全是空字符串文档表面上看起来没变但撤销栈已经乱了后续所有基于位置的操作全部错位。处理这个问题我在工程里立了一条约定所有命令的 undo 逻辑必须能明确表达“这个命令尚未执行”的状态。能用std::optional就用std::optional并且 undo 函数在缺少状态时直接返回 false让调用者感知异常而不是静默吞掉错误。虽然只是多写几行代码但它能把一堆看不见的逻辑错误提前拦下来。6.3 undo栈无限膨胀与调试可读性如果不加限制撤销栈会无限增长。每个命令对象内部又持有字符串副本、快照数据时间一长内存占用相当可观。我的做法是对撤销栈设置容量上限比如最多保留两百条命令超过后逐出最旧的历史同时把连续的同类型命令合并成一条大命令这样既保持了用户可撤销的次数又不至于撑爆内存。另外一个跟可读性相关的坑是命令类多了之后崩溃堆栈里全是InsertCommand::execute这种名字排查问题很难分辨到底是从哪个快捷键进来的。我给每个命令增加了一个description()虚函数返回类似InsertCommand(texthello, pos42)的字符串调试和打日志时直接打印它能省下大量定位时间。6.4 过度设计哪类项目不适合硬上命令模式最后提醒一句命令模式不是银弹。如果你只是给一个小工具写配置面板点一个按钮保存配置不涉及撤销、宏、日志重放那直接用回调函数即可硬套命令模式只会多出一堆类可读性反而变差。我一般用三个问题判断这个操作未来会要求撤销吗会被复用到不同入口吗会被记录成日志吗如果至少两个回答是肯定的再上命令模式。如果不满足直接写业务函数就是最好的设计。在我做的模拟编辑项目里命令模式真正发挥了作用的不是优雅的 UML 结构而是它把所有“操作意图”变成了一份份可保存、可回放、可合并的数据。项目做到后期新增一个操作已经不再需要改动按钮和菜单的代码只需要写一个新的命令类并注册到工厂整个体系就能自动获得撤销、宏录制和日志回放能力。这种扩展带来的收益才是命令模式在 C 里经久不衰的根本原因。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑