ARTICLE DETAIL

资讯详情

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

C++命令模式实战:打造支持撤销重做的文本编辑器

C++命令模式实战:打造支持撤销重做的文本编辑器 刚开始接手一个老项目时我印象最深的一件事界面上一个“保存”按钮代码里硬生生写了三种不同模块的回调每次迭代需求都要在按钮点击事件里加一个 if 分支。时间一长这个按钮的 onClick 函数长达几百行改一处崩三处。后来我把这套逻辑重构为命令模式才彻底体会到“把请求封装成对象”这句话的分量。这篇文章就结合一个“支持撤销/重做的迷你文本编辑器”项目聊透 C 命令模式从设计到落地的完整过程。命令模式Command Pattern的核心价值在于把一次操作所需的全部信息封装成一个独立对象让调用者、执行者、触发者三方彻底解耦。你可以把它理解成餐厅里的点菜单——顾客Client点菜服务员Invoker接单后厨Receiver做菜而那张单子Command本身记录了你点了什么、要什么口味。这套思维用在按钮事件、撤销重做、批量任务、宏录制场景里效果立竿见影。本文适合正在学设计模式的 C 开发者也适合写业务代码写到想重构的老手。我会从最朴素的代码讲起逐步改造成完整的命令模式工程最后再分享几个我踩过的坑和排查思路。1. 项目概述与核心需求解析1.1 没有命令模式时业务代码是怎么腐化的先看一个非常典型的反面教材。假设你在做一个文本编辑器界面上有“插入文本”和“删除文本”两个功能按钮。第一版代码通常长这样class Button { public: void onClick(int actionId, const std::string param) { if (actionId 0) { // 在当前光标位置插入文本 text.insert(cursorPos, param); } else if (actionId 1) { // 删除光标后的 N 个字符 if (cursorPos param.size() text.size()) { text.erase(cursorPos, param.size()); } } // 后续每加一个功能就多一层 else if } private: std::string text; size_t cursorPos 0; };这个版本看着简单但加需求时问题就来了。比如要加“撤销”功能传统写法需要额外记录一份操作日志把“我在哪插入了什么文本”这种信息硬塞进 Button 类里。如果再要实现“重做”、把操作录制成宏、或者从菜单和快捷键两个入口触发同一操作这个类的复杂度会呈指数增长。根本原因在于请求的动作、请求的参数、请求的处理逻辑全部耦合在一个函数里。按钮不需要知道“怎么插入文本”它只需要知道“用户点了插入按钮”。插入的具体行为、插入什么内容、插入到哪里这些信息本应可以被独立传递、存储、回放但在硬编码的分支写法中它们被焊死在调用点。1.2 命令模式的核心价值解耦、可回溯、可组合命令模式把一次请求拆成四个角色Command命令接口声明 execute 和 undo 方法的抽象接口。ConcreteCommand具体命令持有 Receiver 和执行参数在 execute 中调用业务方法。Receiver接收者真正执行业务逻辑的对象比如文本编辑器本身。Invoker调用者持有命令并触发执行通常是按钮、快捷键、菜单项。这个模式的精髓在于调用者只认命令接口不关心命令背后是谁在处理。这样带来的直接收益有三点解耦按钮、菜单、快捷键随便加都通过同一个 Command 接口与业务层打交道。可回溯命令对象可以入栈保存天然支持撤销和重做。撤销不是靠“猜”的而是每个命令知道自己怎么回滚。可组合多条命令可以被组合成一条宏命令像一个普通命令一样被调用、撤销、存储。我用一个很直白的类比解释给你听你把命令模式想象成“在便利贴上写下要做的事再贴到冰箱上”。写完之后这张便利贴可以被撕下来换位置换调用者可以拍张照留着以后照着做记忆也可以把好几张钉在一起一次性执行宏命令。而传统的直接调用就像你每次都要亲口对着家里喊一句“把地拖了”一次只能喊一个指令想“撤销拖地”更是无从谈起。2. 核心设计命令模式的四个角色到底怎么分工2.1 Command、Receiver、Invoker、Client 的边界先看整套架构的类图关系我直接用代码来表示每个角色// 1. 命令接口所有命令的地基 class Command { public: virtual ~Command() default; virtual void execute() 0; // 执行命令 virtual void undo() 0; // 撤销命令 }; // 2. 接收者真正干活的类 class TextEditor { public: void insertText(const std::string text, size_t pos) { content.insert(pos, text); } void eraseText(size_t pos, size_t len) { content.erase(pos, len); } const std::string getContent() const { return content; } private: std::string content; }; // 3. 具体命令插入命令 class InsertCommand : public Command { public: InsertCommand(TextEditor editor, std::string text, size_t pos) : editor_(editor), text_(std::move(text)), pos_(pos) {} void execute() override { editor_.insertText(text_, pos_); } void undo() override { editor_.eraseText(pos_, text_.size()); } private: TextEditor editor_; // 接收者引用 std::string text_; // 执行参数 size_t pos_; // 执行参数 }; // 4. 调用者触发命令的按钮 class Button { public: explicit Button(std::shared_ptrCommand cmd) : command_(std::move(cmd)) {} void onClick() { command_-execute(); } private: std::shared_ptrCommand command_; };在实际写代码的时候最容易混淆的是 Receiver 和 Command 的边界。很多初学者喜欢把业务逻辑直接写进 Command 的 execute 里比如让 InsertCommand 自己去操作 std::string 的 content 成员。这样做的坏处是如果你有多个编辑器实例或者需要切换不同类型的编辑器命令类就得跟着改。Receiver 应当是业务逻辑的唯一持有者Command 只是“调用 Receiver 的某个方法并带上参数”的翻译官。另一个常见错误是让 Invoker 持有具体命令类型。如果你在 Button 里面写了一个InsertCommand insertCmd_成员那么这个按钮就只能绑定插入操作了。Invoker 应该只依赖 Command 抽象接口不是某个具体命令。这也是为什么 Button 构造函数接收的是std::shared_ptrCommand而不是具体的 InsertCommand。依赖抽象而不是实现这是命令模式能灵活扩展的根本保证。2.2 为什么不用函数指针或回调状态与组合的代价你可能会想C 不是有函数指针吗有std::function和回调函数我用一个std::functionvoid()传给按钮照样能实现“延迟调用”为什么非要绕一圈定义一个 Command 类这个质疑非常合理实际上在很多简单场景下std::function确实够用。但命令模式相比纯函数回调多了两个杀手级能力撤销能力函数指针只描述了“做什么”无法描述“怎么退回去”。撤销需要命令保存“反向操作所需的状态”。比如DeleteCommand在执行时得把被删掉的文本存到自己的成员变量里这样undo()才能恢复现场。函数指针做不到这一点除非你用闭包捕获一堆状态而这些状态本质上就是一个小型命令对象。组合能力宏命令需要把多个命令塞进一个容器里统一调度这就要求命令是一个可存储、可遍历的具体对象。函数指针也能放到容器里但你想过一个问题吗命令的参数怎么办同一个“删除”操作删除位置不同、长度不同就需要不同的参数而函数指针无法优雅地携带这些参数。我画一张表格对比一下三种方案你就看得更明白了方案参数携带撤销支持组合支持可持久化适用场景硬编码 if/else局部变量困难困难无法一次性脚本std::function回调闭包捕获需要额外设计需要额外设计困难轻量事件回调命令模式对象成员变量天然支持天然支持容易编辑器、游戏、事务系统所以在做真正复杂的业务系统时不要图省事直接用 lambda 糊一个回调就完事。命令模式多写几个类的成本在后期扩展和调试的时候会十倍地还回来。当然现代 C 里命令模式也可以用 lambda 来简化这个我在后面第 4 节专门讲。3. 实战从零实现一个支持撤销/重做的迷你文本编辑器3.1 核心架构设计三个类怎么搭这个项目我定的需求很明确做一个支持插入、删除、撤销、重做四个操作的文本框。设计上分成三块TextEditor接收者维护一个std::string提供 insertText 和 eraseText 两个操作。Command基类 InsertCommand DeleteCommand具体命令每个命令记录自己的执行参数并实现 undo 逻辑。CommandHistory撤销/重做管理器内部维护两个栈一个叫 undoStack一个叫 redoStack。这是整个项目里最容易出 bug 的地方我先把设计说清楚。CommandHistory 的行为逻辑是执行新命令把这个命令压入 undoStack同时清空 redoStack。因为新操作意味着旧的“重做”路径已经失效。撤销从 undoStack 弹栈顶命令调用它的 undo()然后把这个命令压入 redoStack。重做从 redoStack 弹栈顶命令调用它的 execute()然后把这个命令压入 undoStack。注意这里的栈里存的是命令的指针不是命令的拷贝。因为有些命令比如 DeleteCommand在执行过程中会缓存被删除的内容如果存拷贝那么“执行前”和“执行后”的状态就分断了。正确的做法是栈中持有命令对象的唯一所有权move 进栈move 出栈。这也是我为什么用std::unique_ptrCommand而不是裸指针或std::shared_ptr的原因——每个命令在任意时刻只属于一个栈移动语义天然匹配这个场景。3.2 完整代码实现插入、删除、撤销、重做一条龙下面是这个项目的完整实现我放在一个文件里方便你直接编译测试。关键注释我都写在代码里了#include iostream #include memory #include stack #include string #include vector // ---------- 1. 接收者文本编辑器 ---------- class TextEditor { public: void insertText(const std::string text, size_t pos) { if (pos content_.size()) pos content_.size(); content_.insert(pos, text); } void eraseText(size_t pos, size_t len) { if (pos content_.size()) return; if (pos len content_.size()) len content_.size() - pos; content_.erase(pos, len); } const std::string getContent() const { return content_; } size_t size() const { return content_.size(); } private: std::string content_; }; // ---------- 2. 命令抽象接口 ---------- class Command { public: virtual ~Command() default; virtual void execute() 0; virtual void undo() 0; }; // ---------- 3. 具体命令插入文本 ---------- class InsertCommand : public Command { public: InsertCommand(TextEditor editor, std::string text, size_t pos) : editor_(editor), text_(std::move(text)), pos_(pos) {} void execute() override { editor_.insertText(text_, pos_); } void undo() override { editor_.eraseText(pos_, text_.size()); } private: TextEditor editor_; std::string text_; size_t pos_; }; // ---------- 4. 具体命令删除文本 ---------- class DeleteCommand : public Command { public: DeleteCommand(TextEditor editor, size_t pos, size_t len) : editor_(editor), pos_(pos), len_(len) {} void execute() override { // 执行删除前先记录被删除的内容以便撤销时恢复 deletedText_ editor_.getContent().substr(pos_, len_); editor_.eraseText(pos_, len_); } void undo() override { editor_.insertText(deletedText_, pos_); } private: TextEditor editor_; size_t pos_; size_t len_; std::string deletedText_; // 保存执行时的现场数据 }; // ---------- 5. 调用者撤销/重做管理器 ---------- class CommandHistory { public: void execute(std::unique_ptrCommand cmd) { cmd-execute(); // 新命令执行后redo 栈必须清空 while (!redoStack_.empty()) redoStack_.pop(); undoStack_.push(std::move(cmd)); } bool canUndo() const { return !undoStack_.empty(); } bool canRedo() const { return !redoStack_.empty(); } void undo() { if (!canUndo()) return; auto cmd std::move(undoStack_.top()); undoStack_.pop(); cmd-undo(); redoStack_.push(std::move(cmd)); } void redo() { if (!canRedo()) return; auto cmd std::move(redoStack_.top()); redoStack_.pop(); cmd-execute(); undoStack_.push(std::move(cmd)); } private: std::stackstd::unique_ptrCommand undoStack_; std::stackstd::unique_ptrCommand redoStack_; }; // ---------- 6. 测试入口 ---------- int main() { TextEditor editor; CommandHistory history; // 模拟输入 AB history.execute(std::make_uniqueInsertCommand(editor, A, 0)); history.execute(std::make_uniqueInsertCommand(editor, B, 1)); std::cout 当前文本: editor.getContent() std::endl; // AB // 撤销一次删掉 B history.undo(); std::cout 撤销后: editor.getContent() std::endl; // A // 重做重新插入 B history.redo(); std::cout 重做后: editor.getContent() std::endl; // AB // 新命令出现redo 被清空 history.execute(std::make_uniqueInsertCommand(editor, C, 1)); std::cout 执行 C 后: editor.getContent() std::endl; // ACB std::cout canRedo: history.canRedo() std::endl; // 0 // 连续撤销到空 while (history.canUndo()) history.undo(); std::cout 全部撤销后: [ editor.getContent() ] std::endl; // [] return 0; }编译运行上面的代码输出结果是当前文本: AB 撤销后: A 重做后: AB 执行 C 后: ACB canRedo: 0 全部撤销后: []这套代码麻雀虽小五脏俱全。执行命令、撤销、重做、新操作清空重做栈这四件事全部覆盖到了而且没有用一个分支判断来区分操作类型——插入和删除各自封装自己的 undo 逻辑CommandHistory 只需要无脑调 undo() / execute() 就行。3.3 实操复盘为什么 undo 要保存旧状态而不是反向操作这个项目里最值得讲的一个设计决策在 DeleteCommand 里执行删除命令时先把被删除的文本存到 deletedText_ 里撤销时再把它插回去。你可能觉得这太麻烦了为什么不直接在 eraseText 之前记一个bool wasDeleted或者干脆做一个完全相反的操作呢这里有两个关键原因反向操作不一定能精确恢复现场。比如用户在第 3 个位置删了 2 个字符撤销时你把 2 个字符插回第 3 个位置表面上看没问题。但如果“插入”操作的底层实现带有格式化、自动补全、纠错之类的副作用直接反向操作是补不回来的。保存旧状态快照比反向操作更可靠这是命令模式和 Memento备忘录模式经常配合使用的原因。命令对象本身要承担“记忆”功能。如果你把“被删的文本”存在 ReceiverTextEditor里面撤销时从 Receiver 里取状态那么一旦执行了第二条删除命令第一条命令的现场数据就被覆盖了。状态必须跟命令绑定而不是跟接收者绑定。我补充一个实际碰到的例子。以前我写过一段代码删除命令里图省事直接调用了editor_.getContent()去“猜”要恢复什么。结果因为这个文本编辑器支持自动格式化用户删除一段文本后撤销时恢复的内容和原内容不一样了。调试了整整一个下午最后才意识到问题出在“反向操作不能精确还原”上。从那以后我的原则就是凡是撤销时需要的数据一律在 execute 执行时快照下来保存成成员变量。内存多花几个字节换来的是行为的确定性这笔账非常划算。4. 现代 C 下的命令模式lambda、std::function 与复合命令4.1 用 std::function 简化命令定义两行搞定一条命令传统的命令模式要求你为每一个操作都写一个类代码量确实偏重。现代 C 提供了std::function和 lambda 表达式可以大幅简化具体命令的编写。我不建议完全抛弃类版本去写纯 lambda因为可读性会下降但可以把“命令对象”抽象成一对std::function在轻量场景下用起来非常舒服。#include functional #include string class TextEditor; class LightweightCommand { public: std::functionvoid() execute; std::functionvoid() undo; }; // 创建一条命令的工厂函数 LightweightCommand makeInsertCommand(TextEditor editor, std::string text, size_t pos) { LightweightCommand cmd; cmd.execute [editor, text, pos]() { editor.insertText(text, pos); }; cmd.undo [editor, text, pos]() { editor.eraseText(pos, text.size()); }; return cmd; }这种方式天然适合“一次性命令”或原型阶段。它的缺点也很明显lambda 捕获列表里如果捕获了 this 指针很容易产生悬垂引用。如果你捕获了一个局部对象或临时对象命令的生存期可能比被引用对象更长那就不可避免要踩到 use-after-free 的坑。我的建议是轻量命令只在对象生命周期明确的情况下用生产环境的核心命令还是老老实实写类。两条路都走按场景取舍这才是一个务实工程师的做法。4.2 宏命令/复合命令把 N 个操作打包成一条命令模式最漂亮的特性之一就是可以组合。假设用户操作了“输入 A、输入 B、删除末尾”这三步现在你要提供一个“一键做三件事”的按钮并且希望点击一次就能撤销这三件事。用命令模式实现这个需求只需写一个宏命令类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_; };这里有一个特别容易出错的点我在代码里已经标出来了撤销时要逆序执行。原因是命令之间往往有依赖关系。比如“在位置 1 插入 X”和“在位置 2 插入 Y”正序执行后位置 2 实际上是原始位置 2 的后面撤销时必须先撤销后插入的 Y再撤销先插入的 X否则位置就错乱了。先入后出、后入先出这是命令回滚的铁律。使用宏命令也非常自然三个子命令被组合成一条命令对外看起来和一个普通命令没有任何区别auto macro std::make_uniqueMacroCommand(); macro-add(std::make_uniqueInsertCommand(editor, Hello, 0)); macro-add(std::make_uniqueInsertCommand(editor, , , 5)); macro-add(std::make_uniqueInsertCommand(editor, World, 7)); history.execute(std::move(macro));4.3 命令生命周期与内存管理用智能指针怎么省心C 项目里用命令模式内存管理是每个人都绕不过去的话题。我的建议非常简单直接命令对象一律用std::unique_ptr来传递除非你需要在多个地方共享同一个命令实例这种情况极少。为什么不用裸指针裸指针没法表达所有权语义。谁负责释放栈弹出后要不要 delete这些问题在代码规模小的时候还能靠纪律约束规模一大必然出错。为什么不用 shared_ptr命令对象的核心生命周期是“入栈、出栈、执行、销毁”这是明确的独占所有权。用 shared_ptr 不仅增加引用计数的开销还会掩盖所有权模型。只有当你确定一个命令被多个调用者同时持有时才值得引入 shared_ptr。Receiver 呢Receiver如 TextEditor通常是被多个命令共享的它的生命周期应该独立于命令之外。如果你是在 GUI 程序里用Receiver 一般是一个长命对象命令里的引用是安全的。如果是在服务端程序里可能出现命令执行晚于接收者析构的情况把裸引用TextEditor换成std::weak_ptrTextEditor或std::shared_ptrTextEditor会更保险。还有一个 trap 值得提醒CommandHistory 在析构时会释放 undoStack 和 redoStack 里所有命令对象的资源。如果命令对象里保存了很大的数据比如整个文本快照一次性析构会偶发卡顿。我处理过量大的做法是在程序退出前主动清空栈并且把“清空”操作拆到空闲时间分批执行或者在快照策略上做限制不让单条命令持有整个文档副本。5. 常见问题与排查技巧实录5.1 撤销栈失控内存占用居高不下的排查项目跑了一段时间内存占用不断上升用监测工具一看发现 undoStack 里的命令对象个个都是“大胖子”。这个问题我排查过两次根因都出在快照范围太大上。拿 DeleteCommand 举例如果你在 execute 时把整篇文档的std::string拷贝进 deletedText_而不是只保存被删除的那一小段那每删除一个字符就要复制一份整个文档。用户在长文档里删 1000 次内存里就有 1000 份文档副本不崩才怪。正确的做法是只保存增量信息被删除的片段、插入的位置、长度这些才是真正需要恢复现场的数据。如果文档很大、操作很频繁还可以引入命令合并机制连续在同一位置的输入操作合并成一条编辑命令而不是每敲一个字符就生成一条命令。我在做文本编辑器项目时把“连续输入”合并成一次命令后撤销栈大小直接降了一个数量级。另外限制撤销深度也是一个实用策略。很多商业软件根本不支持无限撤销比如限制最近 500 次操作。实现方式很简单push 新命令前如果栈大小超过限制就丢弃栈底最老的命令。std::stack不能直接操作栈底你可以换成std::deque或std::vector来做同样的栈逻辑。5.2 撤销后执行新操作重做栈为什么不生效这是命令模式里一个设计细节很多初学者踩坑后很困惑“我撤销了三次又执行了一次新操作为什么点重做回不到撤销前的样子了”这个行为其实是刻意的设计绝大多数编辑器都是这么做的新操作会清空重做栈。这里的业务逻辑是用户已经明确改变了操作历史原来的重做路径已经失去意义。如果你让旧的 redo 命令继续生效就会出现“新操作和旧操作混在一起”的混乱状态。我在 CommandHistory 的 execute 方法中特意写了这句while (!redoStack_.empty()) redoStack_.pop();这个细节很多教程会忽略但在实战里非常重要。如果你希望支持“操作树分支”那样更高级的撤销历史用户可以在任意撤销节点开始新的编辑那就需要设计更复杂的图结构而不是简单栈但那是另一个项目了普通业务场景用栈就够。5.3 命令引用悬垂捕获 this 之后的三连问异步或延迟执行命令时最容易出现引用悬垂问题。特别是用 lambda 实现命令时不小心捕获了 this 指针而 this 指向的对象在命令执行前已经析构。排查这类问题我喜欢用一个简单的“三连问”检查法这个命令对象会被存储多久如果它进了 undoStack理论上可以存活到用户下一次撤销为止。这段时间内接收者对象一定还活着吗命令在哪个线程执行如果是多线程环境接收者和命令的生存期如何同步接收者是被谁持有的如果 Receiver 是局部栈对象命令对象一定不能存储它的引用或指针。我在一个网络库项目里就吃过这个亏一个连接对象的“发送命令”被做成异步任务连接断开后连接对象被销毁但命令队列里还留着引用该连接的命令执行时就崩溃了。解决方案是让命令持有std::shared_ptrConnection而不是裸引用或裸指针。这个教训很深刻从此我给自己定了一条规矩命令对象纳入栈管理后它的成员持有关系必须重新审查一遍每一个引用都要问一遍“它一定活得比命令久吗”5.4 问题速查表现象根因解决方案撤销恢复的内容不对反向操作不能精确还原现场execute 时快照必要状态undo 时基于快照恢复内存占用持续增长命令保存了整份文档快照只保存增量数据限制撤销深度合并连续操作撤销多个命令后位置错乱宏命令撤销时未逆序执行undo 时按逆序遍历子命令程序崩溃use-after-free命令持有了已析构对象的引用改用 shared_ptr/weak_ptr 管理接收者生命周期新操作后 redo 无效新命令未清空 redo 栈execute 新命令时显式清空 redoStack大量小命令撑爆栈每次按键都生成命令合并同类连续操作限制栈深度6. 实战总结与个人经验回到最初那个几百行代码的按钮。重构完成之后Button 类只剩一个成员变量std::shared_ptrCommand onClickCommand点击事件就是一行onClickCommand-execute()。添加新功能的方式从“改几百行代码”变成了“写一个新的具体命令类然后注入给按钮”。代码体积没有膨胀反而更清爽了。我个人的体会是命令模式是最适合先用起来的设计模式之一因为它解决的是“业务逻辑和触发逻辑纠缠不清”这个几乎每个项目都会遇到的问题。你不需要把整个系统都改成命令模式只需要从“多做一步、多一个分支”的一类代码开始逐步重构。每当你遇到“这个按钮要做三件事”“这个操作需要撤销”“多个入口要触发同一个业务动作”这三个信号中的任何一个就说明可以上命令模式了。最后再分享一个小技巧命令对象的调试输出很重要。我给所有命令基类加了一个纯虚函数toString()返回类似InsertCommand(pos5, len3)这样的描述字符串。CommandHistory 每次 push、undo、redo 时打印一条日志。这个习惯在排查撤销顺序、宏命令嵌套问题时帮了我大忙你也不妨试一试。
返回列表