资讯动态

C++命令模式实战:从概念到带撤销重做的文本编辑器

发布时间:2026/10/9 14:51:48 来源:尧图企业网站定制
聊到 C 设计模式命令模式Command Pattern是我少数几个在真实项目里反复用、而且用出感情的模式。原因很简单只要程序里存在操作这个概念——编辑器里的撤销重做、游戏里的技能释放、任务队列、宏录制、甚至是数据库事务的回滚日志——命令模式都能把这些操作封装成对象让代码从到处写 if-else 调函数变成组装命令、执行命令、回滚命令。这篇文章我打算从零开始把 C 里命令模式拆开讲透。不只讲概念还会带大家手写一个带撤销重做的文本编辑器把接口设计、历史栈管理、异常安全、现代 C 的写法全部过一遍。适合刚学完 C 基础、准备开始看设计模式的同学也适合那些已经写过不少业务代码、但觉得代码越来越难维护的工程党。1. 命令模式到底解决了什么问题1.1 一个最直观的场景编辑器的撤销重做先想一个问题怎么给编辑器输入一段文字这个功能加上撤销最朴素的做法是这样的每次用户输入时把当前文本的快照保存到一个 vector 里撤销就是把内容恢复成上一个快照。这个方案在文本很短的时候确实能用但一旦文本变大或者操作变多问题就来了快照内存占用爆炸、每个操作都复制整段文本、而且你没法对某一步做精细处理。更关键的是快照方案根本没回答一个问题用户上一次到底做了什么命令模式换了个思路不保存结果保存操作本身。用户输入abc不是记下插入前文本是什么而是记下在第 3 个位置插入了一段 abc。撤销的时候就把这段 abc 删掉重做的时候再插回去。这样内存占用只跟操作数据量有关跟文本总长度基本无关而且逻辑天然对称。这个场景基本就是命令模式的教科书案例也是理解这个模式最好的入口。1.2 解耦的本质把操作变成对象命令模式的核心思想一句话就能概括把一次请求、一次操作封装成一个独立的对象。这个对象里保存了执行这个操作所需的全部信息——对哪个对象操作、传什么参数、以及如何撤销。为什么要绕这么大一圈因为操作一旦变成对象就获得了对象的一切能力可以被存进容器里放进栈、队列实现撤销栈或任务队列可以在不同时间点执行比如延迟执行、批量执行可以被记录下来比如宏录制、操作日志可以在执行前后去做统一的处理比如事务、权限校验、日志记录。换句话说命令模式把函数调用这种紧耦合的请求方式变成了命令对象流转这种松耦合的方式。调用方Invoker不需要知道接收方Receiver是谁不需要知道操作内部细节它只负责持有命令、触发命令。接收方也不需要知道什么时候被调用、被谁调用它只负责把自己的能力暴露出来。我用一个生活化的类比帮助理解你去餐厅吃饭不会直接冲进后厨告诉厨师给我切这三颗土豆、炒两份饭。你面对的是服务员你说点一份炒饭。服务员把你的需求记成一张小票命令对象然后小票被送到后厨接收者后厨照着执行。服务员不关心后厨怎么做饭后厨也不用管客人是谁。中间加了一层两边反而都轻松了。1.3 什么时候该用什么时候别硬上命令模式不是万能的它引入的额外类、额外间接层在小项目里可能反而显得啰嗦。我自己的判断标准有三条需要撤销/重做这是最强的信号。没有撤销需求命令模式的收益会少一大半。需要把操作排队、异步执行或批量执行比如把所有用户操作塞进队列由工作线程逐个消费。需要把一组操作统一处理比如统一记日志、统一做权限判断、统一做事务提交。反过来如果只是简单的一对一调用调用方和接收方就摆在同一个模块里生命周期清清楚楚那直接调函数效率最高。硬上命令模式只会制造一堆只有 execute() 方法的空壳类属于过度设计。2. 核心部件设计四个角色一个都不能少2.1 Command 接口长什么样命令模式标准结构里Command 是抽象基类定义了操作的统一入口。我见过不少新手把接口设计成只有一个 execute()这在需要撤销的场合是不够的。一个面向实战的 Command 接口至少要有execute() 和 undo()。析构函数必须声明为虚函数并且用 default明示否则通过基类指针 delete 派生类对象时行为未定义。class Command { public: virtual ~Command() default; virtual void execute() 0; virtual void undo() 0; };注意 execute() 和 undo() 我都声明为非 const 成员函数。这个细节经常被忽略。原因是在真实场景里命令对象可能在执行过程中需要更新内部状态——比如一次插入命令执行时记录下实际插入后的位置方便撤销时精准删除又比如合并命令时命令对象内部要缓存累计数据。所以命令往往是有状态的不能一概 const。2.2 Receiver 接收者命令的下手对象接收者就是真正干活的类它提供最底层的操作能力。命令对象本身不应该写死业务逻辑它只负责在合适的时机调用接收者的某个方法并携带正确参数。以文本编辑器为例接收者就是一个 TextBuffer它提供 insert()、erase() 这些原子操作。这里有个关键设计原则接收者的每个操作要足够底层让命令可以自由组合。如果接收者直接提供插入整个段落和删除整个段落这种高层方法那命令的抽象层次就乱了以后要复用底层能力的时候会发现接口不够用。接收者还有一个容易被忽略的点undo 所需要的逆操作。insert 的逆操作是 eraseerase 的逆操作是 insert。每个可撤销的命令背后接收者都得有一套与之对称的逆操作否则 undo 无从谈起。这是设计接收者接口时要提前规划好的。2.3 Invoker 调用者只管触发和记账调用者在编辑器例子里是命令历史 CommandHistory负责持有命令对象、触发执行、管理撤销栈和重做栈。它对外暴露执行、撤销、重做三个能力但完全不关心具体命令是谁、命令内部做了什么。这里有一个恒心要守住Invoker 不应该知道任何具体命令类。它只依赖 Command 抽象接口。如果你发现 Invoker 里出现了dynamic_castInsertCommand或者根据命令类型做特殊处理的代码说明设计已经变形了。正确做法是任何需要特殊处理的逻辑都下沉到命令类内部去或者通过扩展接口的方式解决。2.4 Client 客户端组装一切的导演客户端负责创建具体的命令对象把接收者、参数、上下文绑定到命令里然后交给调用者。这个角色通常由 UI 事件处理器、网络消息分发层或者脚本解释器充当。客户端组装命令的时机很讲究。以编辑器为例每次用户按下键盘UI 层就创建一个 InsertCommand绑定当前 TextBuffer 和输入字符然后交给 CommandHistory 去执行。命令对象从创建到执行之间的时间窗口接收者和参数必须保持有效。这也是很多 bug 的源头后面第 4 节我会专门展开讲。3. 实战一个带撤销重做的文本编辑器3.1 场景设定与整体拆解我们现在来做一个完整的例子一个极简文本编辑器支持插入、删除单个字符支持撤销和重做。我故意选择单字符操作而不是整段操作因为单字符是最小的原子操作代码清楚不影响理解模式本身。实际工程里你可以把粒度放大比如按单词、按选区操作只要命令设计的原则一致就行。整体结构分四层TextBuffer接收者维护文本内容提供 insert/erase 原子操作Command抽象命令基类InsertCommand、DeleteCommand两个具体命令CommandHistory调用者管理 undo/redo 栈对外提供 execute、undo、redo。用户按键生成命令、把命令交给 CommandHistory 的就是客户端这里我在 main 函数里模拟一下。3.2 代码实现接收者与具体命令先看接收者。为了让命令的逆操作对应起来insert 和 erase 都要返回或记录操作位置信息class TextBuffer { public: void insert(size_t pos, const std::string text) { m_content.insert(pos, text); } void erase(size_t pos, size_t len) { m_content.erase(pos, len); } size_t size() const { return m_content.size(); } const std::string content() const { return m_content; } private: std::string m_content; };然后是两个具体命令。InsertCommand 执行时调用 insert撤销时调用 erase并且撤销时需要记住插入的文本长度DeleteCommand 反过来执行时删除撤销时要把删掉的内容重新插回去。所以 DeleteCommand 里必须保存被删除的文本这个信息只能在实际执行时获取不能提前编造。class InsertCommand : public Command { public: InsertCommand(TextBuffer buffer, size_t pos, std::string text) : m_buffer(buffer), m_pos(pos), m_text(std::move(text)) {} void execute() override { m_buffer.insert(m_pos, m_text); } void undo() override { m_buffer.erase(m_pos, m_text.size()); } private: TextBuffer m_buffer; size_t m_pos; std::string m_text; }; class DeleteCommand : public Command { public: DeleteCommand(TextBuffer buffer, size_t pos, size_t len) : m_buffer(buffer), m_pos(pos), m_len(len) {} void execute() override { m_deleted m_buffer.content().substr(m_pos, m_len); m_buffer.erase(m_pos, m_len); } void undo() override { m_buffer.insert(m_pos, m_deleted); } private: TextBuffer m_buffer; size_t m_pos; size_t m_len; std::string m_deleted; };DeleteCommand 里 m_deleted 是关键。它在 execute() 之前是空的只有当 execute() 真正执行了才拿到被删除的内容。这意味着一个 DeleteCommand不能在被执行之前就调用 undo()否则你撤销的是空气。这种命令对象内部状态随执行推进而变化的设计是命令模式和纯函数式思想的最大区别也是新手最容易踩坑的地方之一。3.3 历史栈管理撤销、重做与新操作清空重做栈调用者 CommandHistory 用两个栈来管理命令undo 栈保存已执行但未撤销的命令redo 栈保存已撤销、可以重新执行的命令。这里我用std::stackstd::unique_ptrCommand让每个命令对象的所有权清晰可见。class CommandHistory { public: void executeCommand(std::unique_ptrCommand cmd) { cmd-execute(); m_undoStack.push(std::move(cmd)); // 新操作会清空重做栈 std::stackstd::unique_ptrCommand empty; m_redoStack.swap(empty); } bool canUndo() const { return !m_undoStack.empty(); } bool canRedo() const { return !m_redoStack.empty(); } void undo() { if (!canUndo()) return; auto cmd std::move(m_undoStack.top()); m_undoStack.pop(); cmd-undo(); m_redoStack.push(std::move(cmd)); } void redo() { if (!canRedo()) return; auto cmd std::move(m_redoStack.top()); m_redoStack.pop(); cmd-execute(); m_undoStack.push(std::move(cmd)); } private: std::stackstd::unique_ptrCommand m_undoStack; std::stackstd::unique_ptrCommand m_redoStack; };执行新命令时清空重做栈这是各种编辑器撤销/重做行为的基本约定一旦用户撤销几步、然后又做了新操作重做路径就废了老的重做命令应该全部丢弃。如果不清空用户会在撤销后重做和撤销后输入新内容这两个分支里得到完全错乱的状态。用unique_ptr而不是裸指针还有个实际好处CommandHistory 析构时栈里所有命令对象会被自动释放不会泄漏。这是现代 C 里最直接的收益——少写一半析构逻辑。客户端的使用方式是这样的int main() { TextBuffer buffer; CommandHistory history; // 模拟输入三个字符h e l history.executeCommand( std::make_uniqueInsertCommand(buffer, 0, h)); history.executeCommand( std::make_uniqueInsertCommand(buffer, 1, e)); history.executeCommand( std::make_uniqueInsertCommand(buffer, 2, l)); // 当前文本: hel history.undo(); // 删掉 l, 文本: he history.undo(); // 删掉 e, 文本: h history.redo(); // 重新插入 e, 文本: he // 此时如果执行新命令, redo 栈会被清空 }这个例子的代码量不大但已经覆盖了命令模式的所有核心机制命令封装操作、撤销栈重做栈、新操作清空重做栈。你完全可以直接把这个骨架搬到自己的项目里。3.4 增强命令合并与宏命令单字符命令写起来容易但在真实编辑器里你不可能让用户输入 200 个字符就生成 200 个撤销记录字体里一退退 200 步显然不合理。解决办法是命令合并。思路有两种。第一种是在 Command 接口里加一个mergeWith(const Command)方法让相同类型的命令尝试合并。InsertCommand 的合并规则是新命令的位置等于旧命令的位置加旧文本长度、且文本连续时把新文本拼接到旧文本后面。第二种更简单粗暴在 CommandHistory 里维护一个当前正在累积的操作块由客户端决定什么时候开新块、什么时候追加到旧块。实际工程里我大多数时候用第二种因为合并规则往往和业务强相关放客户端更灵活。比如编辑器里连续输入字符的间隙如果超过 500 毫秒就认为用户想开启一个新撤销单元如果没有超过就合并进前一个输入命令。这种时间窗口合并的策略比在命令类内部硬编码文本相邻才合并要实用得多。宏命令就是一组命令的组合。你可以在 Command 之上再定义一个 MacroCommand内部持有一个std::vectorstd::unique_ptrCommand执行时依次执行每个子命令撤销时逆序撤销每个子命令。这个模式可以用在很多地方批量格式化、录制一段操作序列再回放、数据库事务的提交/回滚。第 5 节我会再展开讲。4. 常见问题与排查技巧实录4.1 生命周期问题命令里保存了失效的引用命令对象里保存了接收者的引用或指针这是命令模式最常见的坑。最常见的翻车现场是这样的编辑器窗口关闭了TextBuffer 对象被销毁但 CommandHistory 里还压着一堆引用着这个 TextBuffer 的命令这时候用户点了撤销程序直接访问了一块已释放的内存崩溃得莫名其妙。解决办法从源头设计上来讲有三个明确所有权的层次TextBuffer 的生命周期必须长于引用它的所有命令。通常做法是让 CommandHistory 和 TextBuffer 由同一个外层对象比如编辑器主窗口持有并且保证析构顺序是 CommandHistory 先析构。避免在命令里持有悬空引用如果接收者可能被销毁命令里不要存裸引用改存std::shared_ptrTextBuffer让命令自己持有接收者的一个引用计数。状态检查在 undo() 执行前检查接收者是否仍处于有效状态。我个人经验是小项目用第 1 种靠生命周期规划就够了多线程或插件化架构里老老实实用shared_ptr。4.2 execute() 抛异常怎么办命令执行过程中可能抛异常最典型的情况插入文本导致内存分配失败或者删除位置越界。如果 execute() 抛出异常但命令已经被塞进撤销栈那么栈里的命令和目标状态是不匹配的——它还没真正执行你却允许用户撤销它。更糟的情况是execute() 执行到一半异常退出接收者状态已经部分改变但命令内部状态没有正确记录。我的建议是执行两阶段提交的思路void executeCommand(std::unique_ptrCommand cmd) { try { cmd-execute(); } catch (...) { // 记录日志后返回命令不进撤销栈 return; } m_undoStack.push(std::move(cmd)); }这个处理看起来简单但保证了只把成功执行的命令记账。至于 execute() 内部部分修改的状态那就是接收者那一层要保证的原子性了——insert 和 erase 这种标准库方法基本都能保证强异常安全比较安全。自定义的接收者操作一定要写成先计算、后修改或者用 copy-and-swap 惯例。4.3 拷贝与切片vectorCommand 是个大坑有人图省事想用std::vectorCommand直接存命令对象然后往里面塞 InsertCommand。这是经典的切片问题派生类对象被拷贝成基类对象时派生类特有的成员全丢光InsertCommand 变成了一具 Command 的壳execute() 纯虚函数根本没法调。正确做法是存指针现代 C 里优先unique_ptr。还有一个相关的小坑std::stackstd::unique_ptrCommand不支持直接拷贝这其实是好事强制你用 move 语义。少走弯路的方法就是凡是容器里要保存多态对象要么存指针要么存std::reference_wrapper永远不要直接存对象。4.4 撤销栈内存过大给历史加上限如果每个命令都保存一份完整的文本数据撤销栈很快会吃掉几百 MB 内存。即使命令只保存增量信息长期运行的程序——比如 IDE、CAD 软件历史栈仍然可能膨胀。工程上通用的解法是设置撤销深度上限。CommandHistory 里维护一个 maxUndoCount每次 push 之前判断栈大小超过上限就先 pop 掉最早的命令。std::stack 不支持遍历栈底所以工程实现里我更推荐用std::deque或者boost::circular_buffer来当历史容器。如果不想引入额外依赖用std::deque配合 push_back/pop_back/pop_front 就行性能和 stack 差不多。另外可以给命令加一个sizeInBytes()虚方法历史栈根据命令占用内存动态回收最占空间的老命令。这个在游戏引擎的状态记录系统里很常见。4.5 面试和八股文里命令模式的高频考点关于命令模式技术面试和八股文里翻来覆去问的就是那几件事命令模式是什么、解决什么问题、适用场景、和策略模式的区别、标准结构里的四个角色。答得好不好关键看能否举出真实例子。这里建议直接拿本文的编辑器例子说事然后补一句我实际还用它做过任务队列把网络请求封装成命令统一走重试和日志这就比背诵概念高出一个档次。另外注意一个区分命令模式和策略模式的区别是很多人混淆的地方。策略模式是同一件事的不同实现方式重点在怎么算命令模式是把一件事的请求和执行解耦重点在什么时候调、怎么撤销、怎么排队。一个是算法替换一个是行为封装方向完全不同。5. 现代 C 的玩法从虚函数到更强的抽象5.1 用 std::function 消灭样板代码虚函数 派生类是传统命令模式的标准形态但 C11 之后std::function提供了一种轻量替代不需要为每个操作定义一个类直接用 lambda 构造命令。最简单的封装甚至就两个 functionclass LambdaCommand : public Command { public: using Action std::functionvoid(); LambdaCommand(Action exec, Action und) : m_exec(std::move(exec)), m_undo(std::move(und)) {} void execute() override { m_exec(); } void undo() override { m_undo(); } private: Action m_exec; Action m_undo; };之后用起来就是history.executeCommand( std::make_uniqueLambdaCommand( [] { buffer.insert(pos, text); }, [] { buffer.erase(pos, text.size()); }));这样做的好处是减少类数量特别适合命令类型多、但每个命令逻辑很短的场景。缺点也很明显lambda 捕获的上下文容易悬空、命令类型信息丢失、没法优雅地做命令合并。我的建议是脚本化、原型开发、命令结构简单的项目用 std::function需要 undo/redo、命令合并、性能敏感的模块用传统虚函数类。两者可以共存我在实际项目里就是一边用传统命令类做核心编辑操作一边用 LambdaCommand 做外围的辅助操作。5.2 noexcept 与移动语义的细节Command 基类最好声明virtual ~Command() default;具体命令类则要关注移动构造。因为std::unique_ptrCommand本身不要求 Command 可移动但如果哪天你想用std::vectorstd::unique_ptrCommand做历史栈vector 扩容时只需要移动 unique_ptr命令类本身可以不可移动这个设计很干净。不过有一种情况需要命令类支持移动如果你想把一个命令对象从待执行队列搬到执行线程用std::move转移 std::function 或内部持有的 unique_ptr 成员。只要命令类内部没有裸指针、没有需要深拷贝的复杂资源默认的移动语义基本够用。5.3 宏命令与事务组合命令的正确姿势组合多个命令为一个命令是命令模式最容易扩展的方向。我写过的一个资源管理器里批量重命名就是典型的宏命令用户一次勾选 10 个文件点重命名生成一个 MacroCommand里面包含 10 个 RenameCommand。执行时依次执行撤销时逆序撤销——撤销顺序必须注意因为第 1 个文件的改名可能影响第 2 个文件的路径。事务场景也一样。比如游戏里的升级建筑操作可能包含扣金钱、改属性、刷新 UI 三个子命令。把它们包成一个宏命令只要有一个子命令执行失败就把前面成功执行的子命令全部逆序撤销保证整个操作要么全成功、要么全失败。这个思路和数据库事务的 rollback 完全一致代码里实现起来也不复杂就是为 MacroCommand 增加一个内部失败标记和中断逻辑。5.4 拓展思路把命令模式用在事件驱动架构里命令模式还能玩出不少花活任务队列把命令塞进队列由一个工作线程逐个 execute。配合无锁队列或者线程安全的 std::deque就能做出一个简易的任务调度器。注意命令对象内部不能有共享可变状态或者要加锁。操作日志与回放每个命令执行时记录二进制日志重启后可以通过重放全部命令恢复现场。这本质上就是事件溯源Event Sourcing的雏形很多游戏回放系统、金融交易系统这么干。统一拦截在 CommandHistory 的 executeCommand() 里统一做权限校验、耗时统计、审计日志不用改任何具体命令类。这一点在业务系统里价值极高。我最后想说一个实际体会命令模式写起来不难难的是想清楚边界。到底哪些操作值得封装成命令哪些直接调函数更划算这个判断需要靠项目体量和需求来定。如果你发现自己的命令类里 execute() 只有一行、又没有撤销需求、又不进队列那删掉这个类直接调函数代码反而更健康。命令模式是工具不是教条——把它用在该用的地方它会让你的 C 项目结构清晰一大截用错了地方只会平添一层又一层空壳。如果你准备在自己的项目里上手试试我建议从最小的场景开始给现有代码里的某个操作加上撤销功能先写出两个具体命令和一个 CommandHistory跑通了再往宏命令、命令合并方向迭代。这样一步一个脚印比一开始就设计一个庞大的命令框架要稳妥得多。

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

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

免费获取报价 →
↑