☰
C++命令模式实战:从撤销重做到宏命令与性能优化
2026/10/15 7:42:30 网站建设 项目流程

1. 引子:一次撤销功能把我写崩溃之后

前年做某个文本编辑器模块的时候,我接了一个看起来特别简单的需求:给一组批量编辑操作(替换、删除、插入、格式化)加上撤销和重做。当时我心想,撤销不就是一个“反向操作”吗?把每次动作记录一下,撤销时反过来执行不就行了。结果等我真正动手,代码写到一个星期以后,整个人都是崩溃的——不是功能跑不通,而是每加一个新操作,撤销逻辑就要跟着上一次“打补丁”,代码里全是状态标志位、临时快照、各种 if 分支。最要命的是撤销和重做的对称性经常被打破:删了三个字然后重做,结果光标位置不对,内容也对不上。

后来把这块彻底重构,核心思路换成了 C++ 里非常经典的行为型模式——命令模式(Command Pattern)。一句话说透它的价值:把“用户干了什么”这件事本身封装成一个对象。有了这个对象,撤销、重做、批量操作、操作日志、延迟执行,全都变成了对这个对象的“排列组合”,而不是对业务逻辑的反复侵入。

这篇文章不是教科书式地讲四个参与者谁是谁,而是把我实际踩过的坑、最终落地的实现、以及现代 C++(C++11 及以上)环境下命令模式要注意的细节全部摊开来讲。适合正在做编辑器、图形工具、任务调度这类需要操作记录场景的 C++ 开发者,也适合想真正理解命令模式而不是只看 UML 图的人。

2. 命令模式的本质:把“操作意图”变成对象

在动手写代码之前,先要搞清楚一个问题:为什么把操作“包一层对象”就能解决这么多麻烦?

2.1 从函数调用到命令对象的思维转换

假设一开始你写的是普通函数:

void insertText(TextEditor& editor, size_t pos, const std::string& text) { editor.text().insert(pos, text); }

调用的时候直接insertText(editor, 5, "hello")。函数本身没问题,问题在于调用方一旦发出调用,这件事情就“过去”了。你没法把它存起来,没法在事后说“撤销刚才那次”,除非再把参数复制一份放到某个全局列表里。函数是一维的,只有“执行”,而命令对象是三维的,它同时携带“执行”“撤销”“状态记录”三个维度的信息。

生活里最直观的类比是餐厅点单:你不会直接把“我要一盘宫保鸡丁”这句话喊进厨房,而是写在点菜单上,服务员拿着单子去后厨。这张单子同样的一份,可以用于结账、用于记录、用于顾客反悔时退菜。命令对象就是那张“点菜单”,它把请求固化成了数据。

2.2 它到底解决了什么问题:从“呼叫方驱动”到“被记录驱动”

理解了“固化”之后,再来看一个更深层的点:操作发出之后,控制权发生了转移。

传统函数调用下,控制权在调用者手里,调用者说“插入”,插入就执行了。命令模式中,调用者把命令对象交给一个“调度中心”(通常叫 Invoker,或者具体业务里的 History/Queue),由调度中心决定:立刻执行、延迟执行、排队执行、还是记录后执行。

这个“控制权转移”带来四个非常实在的能力:

  • 可回溯:因为命令对象还保留着参数和状态,可以执行撤销逻辑。
  • 可组合:多条命令放进一个容器,构成宏命令或事务。
  • 可延迟:命令对象可以先构造好,不立刻执行,后面再触发。
  • 可记录:命令对象本身能序列化,存日志、做回放。

我在重构文本编辑器时,最重要的收获还不是“撤销容易写了”,而是业务代码不再需要关心“我这个操作是不是要被记录”。编辑器核心的插入、删除函数就只是纯粹的文本操作,所有的记录和撤销逻辑全部收拢到命令对象里面。

2.3 什么时候不该用命令模式

这里必须先泼一盆冷水:命令模式不是银弹。如果只是两层调用,只有一个操作,而且永远不需要撤销、重放、排队,那直接写普通函数反而更简单。命令模式的价值随着“操作种类”和“回溯需求”增加才会愈发明显,操作少于三四个的时候,它的价值体现不出来,反而徒增类数量。

我自己的判断标准很简单:如果你发现自己在代码里维护了一个“最近操作记录”的数组,里面装的是结构体 {类型,参数...} 然后到处 switch 分支处理,那就到了该用命令模式的时候。

3. C++实现的核心骨架:接口、执行者与会话历史

进入正题。下面这套实现是一个简化但完整的文本编辑器例子,覆盖了 C++ 实现命令模式最主要的几个决策点。

3.1 Command 接口的设计:析构函数一定要 virtual

命令模式的首要工作就是定义基类接口。我见过不少初学者在这里栽跟头——把virtual写漏了,后面用基类指针删除派生类对象时直接未定义行为。

class Command { public: virtual ~Command() = default; virtual void execute() = 0; virtual void undo() = 0; };

这里有两个设计值得注意:

  • 析构函数声明为 virtual:因为命令对象一定会通过基类指针(或者unique_ptr<Command>)来管理,非 virtual 析构会导致派生类资源无法正确释放。
  • execute() 和 undo() 都是纯虚函数:每个具体命令都必须实现这两个操作。这一点从命令模式的设计动机上就是注定的——不作为虚函数,调用方就没法统一地调用“执行/撤销”。

值得思考的是:要不要加clone()虚函数?在 C++98/03 时代,复制命令对象时需要借助 clone 模式。但在 C++11 以后,我建议默认不要加,原因我放在后面生命周期管理那一段细讲。

3.2 接收者(TextEditor):业务代码保持纯粹

接收者就是真正干活的对象。命令模式要求接收者只提供“原子操作”,自己不关心撤销逻辑。

class TextEditor { public: void insertText(size_t pos, std::string_view text) { if (pos > m_text.size()) { throw std::out_of_range("insertText: pos out of range"); } m_text.insert(pos, text); } std::string deleteText(size_t pos, size_t len) { if (pos + len > m_text.size()) { throw std::out_of_range("deleteText: pos + len out of range"); } std::string removed = m_text.substr(pos, len); m_text.erase(pos, len); return removed; } const std::string& getText() const { return m_text; } private: std::string m_text; };

请注意删除操作很关键的一点:deleteText 要把被删除的内容作为返回值返回出来。因为以后的撤销动作需要这些文本才能“插回去”。这就是接收者纯粹但信息不丢失的体现——它的返回值携带了撤销所需的数据。

3.3 两个具体命令:InsertCommand 与 DeleteCommand 的完整实现

真正有意思的部分来了。插入命令和删除命令,两者正好互为镜像。

class InsertCommand : public Command { public: InsertCommand(TextEditor& editor, size_t pos, std::string text) : m_editor(editor), m_pos(pos), m_text(std::move(text)) {} void execute() override { m_editor.insertText(m_pos, m_text); } void undo() override { m_editor.deleteText(m_pos, m_text.size()); } private: TextEditor& m_editor; size_t m_pos; std::string m_text; };
class DeleteCommand : public Command { public: DeleteCommand(TextEditor& editor, size_t pos, size_t len) : m_editor(editor), m_pos(pos), m_len(len), m_removedDone(false) {} void execute() override { m_removed = m_editor.deleteText(m_pos, m_len); m_removedDone = true; } void undo() override { if (!m_removedDone) { throw std::logic_error("DeleteCommand::undo called before execute"); } m_editor.insertText(m_pos, m_removed); } private: TextEditor& m_editor; size_t m_pos; size_t m_len; std::string m_removed; bool m_removedDone; };

DeleteCommand 这个类里有几个很容易忽略但又极其重要的细节:

  • m_removed不是构造函数里填的,而是在execute() 时取得的。因为删除操作在命令构造时还没执行,谁也不知道此时文档里对应位置是什么内容。这就意味着命令对象在执行前后状态发生了改变。它不是一个“纯配置对象”,而是“会积累状态的对象”。

  • m_removedDone标志位是有必要的。它不是多余的防御代码,而是为了防止undo()在execute()之前被调用而导致的逻辑错误。实际工程中很多“撤销把程序搞崩了”的 bug 都源于这种状态顺序被破坏。

  • 我特意让undo()抛异常而不是静默返回。在调试阶段,这个异常能立刻暴露问题。生产环境可以根据策略捕获或忽略,但开发期把它吞掉,只会让 bug 藏得更深。

这组命令类揭示了一个重要的对称关系:InsertCommand 的 undo 就是一次 delete,DeleteCommand 的 undo 就是一次 insert。正是这种“反向操作”的存在,才让撤销逻辑不再是业务逻辑的复制品,而是每个命令都自带“镜像”。

3.4 调用者(History):用两个栈维护撤销和重做

命令模式里的调用者,在本例中就是会话历史的维护者。它的职责很简单:执行命令并入栈、撤销时出栈、重做出栈后重新执行,并且关键一点是——任何新操作执行后,清空重做栈。

class History { public: void execute(std::unique_ptr<Command> cmd) { cmd->execute(); m_undoStack.push_back(std::move(cmd)); m_redoStack.clear(); // 新操作使重做历史失效 } void undo() { if (m_undoStack.empty()) return; auto cmd = std::move(m_undoStack.back()); m_undoStack.pop_back(); cmd->undo(); m_redoStack.push_back(std::move(cmd)); } void redo() { if (m_redoStack.empty()) return; auto cmd = std::move(m_redoStack.back()); m_redoStack.pop_back(); cmd->execute(); m_undoStack.push_back(std::move(cmd)); } bool canUndo() const { return !m_undoStack.empty(); } bool canRedo() const { return !m_redoStack.empty(); } private: std::vector<std::unique_ptr<Command>> m_undoStack; std::vector<std::unique_ptr<Command>> m_redoStack; };

这里有几个你会在网上看教程时看不到的实战决策:

  • 为什么用std::vector而不是std::stack?虽然逻辑上是栈没错,但std::stack默认基于 deque,而且不方便遍历,调试时想打印历史记录很别扭。vector的back()和pop_back()完全够了,还能reserve预分配容量。在编辑器这种连续操作场景下,vector 的缓存局部性也更好。

  • 为什么用unique_ptr而不是裸指针?命令对象的生命周期完全由 History 掌握。undo()时从 undo 栈移入 redo 栈,redo()时又移回 undo 栈,整个过程只有一个 owner,用 unique_ptr 最干净。用 shared_ptr 当然也能跑,但等于放弃了“该命令只属于一条历史链”这一语义保证,反而容易导致命令对象生命周期混乱。

  • redo 栈的 clear 时机:在execute()里,每次执行新命令后立刻清空 redo 栈。这是符合直觉的——你在撤销了几步之后又做了新的修改,原本那些“重做”历史就失去意义了,必须清掉。这个逻辑虽简单,但忘掉它造成的 bug(重做跳到一个不存在的时间线)非常隐蔽。

  • undo/redo 时命令对象是“移动”而不是“复制”。我强调移动,是因为命令对象内部可能持有大量数据(DeleteCommand 里的m_removed可能是一大段文本),如果复制,内存开销和性能损耗都是不可接受的。unique_ptr 的std::move保证对象在栈间转移时底层数据零拷贝。

到这里,一个可用的命令模式骨架已经成型。你可以在main()里模拟一串操作:先插一段文字,再删几个字符,然后撤销两次,再重做,观察文本变化是否符合预期。

但说实话,骨架好写,真正把命令模式用好,还需要解决几个实战中的大头问题。下面一章我集中讲状态与快照的取舍。

4. 撤销/重做这里的坑:快照、状态与一次性命令

很多人以为命令模式学会了,撤销就是“调用 undo 而已”,真正做项目才发现这里坑极多。我把自己踩过的三个大坑梳理一遍。

4.1 快照 vs 增量:DeleteCommand 为什么必须保存被删内容

撤销操作本质上只有两种实现策略:

  • 快照式:执行操作前把整个对象完整拷贝一份,撤销时整体还原。
  • 增量式:命令对象只保存“影响的那一部分信息”,撤销时用这部分信息反向操作。

文本编辑器几乎必须用增量式——因为快照式需要把整个文本内容反复拷贝,一个几 MB 的文档,每敲一个字符都拷贝一次,谁都扛不住。你自己分析一下,插入命令保存了位置和插入的文本,删除命令保存了位置和删除的文本。它们都没有保存“整个文档”,只保存了“这次操作涉及的局部”,代价最小。

但增量式也引出一个新的问题:命令对象不是构造出来就能 undo 的,有些命令必须等 execute 后才持有撤销所需的数据。这在 DeleteCommand 里体现得很明显。所以设计命令类时,你得在脑海里画一条“命令状态线”:

构造(只有参数) -> execute()(获得状态) -> undo()(利用状态) -> 可以再次被redo吗?

这条状态线的存在,意味着你不能在命令入栈之前就指望它拥有完整的撤销信息。这也是为什么 History::execute 里必须“先执行、后入栈”的原因——如果先入栈再执行,undo 栈里会存在一批状态未初始化的命令对象。

4.2 命令的“一次生效”语义:不要在 undo 之后再次调用 execute

这是我最想强调的一个实战细节。请看下面这个误用场景:

History h; auto cmd = std::make_unique<InsertCommand>(editor, 0, "abc"); h.execute(std::move(cmd)); // 执行一次 h.undo(); // 撤销,移入redo栈 // 假设你此刻还拿着 cmd 的裸指针,想“再执行一次” // 你会发现 cmd 已经被 move 走了,不能再调 execute()

命令模式在 C++ 中的使用惯例是:命令对象是一次性消费的。一条命令绑定一次操作上下文。如果你想重复执行同一个操作,正确做法是重新构造一个命令对象,而不是复用旧的。

这个惯例背后有充分理由:InsertCommand 一旦执行过,它的状态已经和某次具体历史绑定,如果再执行一次,等于在同一把椅子上插了同样的文本两次——通常不是想要的结果。而且 C++ 的 unique_ptr 语义本身就强制了这个约束,反向提醒了设计者不要试图跨历史复用命令对象。

4.3 一个反直觉的坑:redo 的是“新的执行”,不是“重新入栈”

我重构早期的撤销功能时,犯过一个特别隐蔽的错误。我当时的 redo 实现是这样的:

void redo() { auto cmd = std::move(m_redoStack.back()); m_redoStack.pop_back(); m_undoStack.push_back(std::move(cmd)); // 只放回,没执行! }

看着好像没毛病——undo 的时候我们不是把命令从 undo 栈移到了 redo 栈吗?那 redo 直接再移回来不就“恢复原状”了?实际上这是错误的。因为撤销后,文档状态已经改变了,redo 需要重新执行命令,才能把文档改动再次应用。只移回 undo 栈而不执行,文档根本没变,用户点了重做却发现内容没变化。

所以 redo 必须是:

cmd->execute(); // 先重新应用 m_undoStack.push_back(std::move(cmd)); // 再放回 undo 栈

这个顺序一步都不能换。先执行、后入栈,和前面 History::execute 的逻辑保持一致。如果你觉得这两段逻辑重复了,可以抽一个私有函数,不过实战中我建议保持显式写法——重做的执行语义和常规执行的语义完全相同,这点清晰暴露出来更有利于维护。

4.4 大型操作的内存开销估算

提一个性能相关但常被忽略的点:命令栈里积压的不只是命令对象的“结构”,还有字符串数据。假设用户对一段 10MB 的文档做了 1000 次替换操作,每条 DeleteCommand 都可能保存了被替换掉的几百字节文本,累计可能几 MB 到几十 MB。在桌面应用里这不是大问题,但在内存受限的环境下,你必须考虑给命令栈设上限。

我在项目中采用的做法是:当 undo 栈超过阈值(比如 500 条)时,丢弃最底部的命令对象,并输出日志。显然这会让“撤销到最早”的能力受限,但换来了稳定的内存边界。你可以在 History 里加一个容量限制:

void History::setMaxUndoDepth(size_t depth) { m_maxDepth = depth; if (m_undoStack.size() > m_maxDepth) { size_t excess = m_undoStack.size() - m_maxDepth; m_undoStack.erase(m_undoStack.begin(), m_undoStack.begin() + excess); } }

注意 erase 的是底部的历史。vector 的 erase 头部元素会移动剩余元素,但因为只是移动 unique_ptr 而不是深拷贝底层命令数据,这个操作的开销可控。

5. 用 std::function 和 lambda 做轻量化的命令封装

前面那套经典的“继承 + 两个虚函数”的写法是命令模式的教科书实现。但 C++11 之后,很多场景下你根本不需要派生那么多个具体命令类——直接用std::function+ lambda 就能把命令对象封装起来。

5.1 通用命令类:把 execute 和 undo 变成两个可调用对象

思路很简单:定义一个命令类,内部存两个std::function<void()>,一个代表执行,一个代表撤销。

class FunctionalCommand : public Command { public: FunctionalCommand(std::function<void()> execute, std::function<void()> undo) : m_execute(std::move(execute)), m_undo(std::move(undo)) {} void execute() override { m_execute(); } void undo() override { m_undo(); } private: std::function<void()> m_execute; std::function<void()> m_undo; };

有了这个类,创建命令变得非常轻量:

void makeItalic(TextEditor& editor, size_t pos, size_t len) { // 假设某处记录了原来的格式信息 auto oldState = saveFormatState(editor); editor.applyFormat(pos, len, Format::Italic); auto cmd = std::make_unique<FunctionalCommand>( [] { /* 执行时的代码 */ }, [oldState] { // 撤销时恢复状态 restoreFormatState(oldState); } ); history.execute(std::move(cmd)); }

5.2 捕获捕获:lambda 的生命周期是这条路线最大的坑

FunctionalCommand 看起来简单,但它把生命周期的复杂度从“命令类的成员变量”转移到了“lambda 的捕获列表”。这里有一个非常容易踩坑的场景:

auto editorPtr = &editor; // 裸指针 auto cmd = std::make_unique<FunctionalCommand>( [editorPtr] { editorPtr->insertText(0, "hello"); }, [editorPtr] { editorPtr->deleteText(0, 5); } );

如果editor在命令对象还存在于 undo 栈时就被销毁了,这两个 lambda 就成为了悬垂引用,undo 时直接未定义行为。这个问题在传统命令类的写法中同样存在(我们之前也用TextEditor& m_editor),但在 lambda 写法下更隐蔽,因为 lambda 捕获列表看起来像是在“复制一份使用”,实际上对于裸指针和引用来说,它复制的只是地址,并没有延长被引用对象或指针所指对象的生命周期。

安全对策有三个层次:

  1. 如果接收者的生命周期严格大于命令对象的生命周期——比如编辑器主窗口持有 History,且 History 销毁时编辑器一定活着——那么裸指针或引用都没问题。这是最常用的假设,但你必须明确写出这个前提。
  2. 如果接收者的生命周期不可控,考虑改用std::shared_ptr<TextEditor>作为接收者的管理方式,命令里持有 shared_ptr。代价是接收者变成共享所有权,可能在多个历史命令之间引入循环引用(下面会讲)。
  3. 忌惮悬垂问题时,用std::weak_ptr获取接收者,execute 时先 lock,失败则静默忽略执行。这个方案最安全,但代码会繁琐一些。

5.3 std::function 的开销:小对象优化与堆分配

std::function不是零成本的。它内部的实现通常有小对象优化(Small Buffer Optimization),小 lambda 直接存在对象内部,但较大的捕获对象会触发堆分配。一个 FunctionalCommand 里有两个std::function,如果 lambda 捕获的toBeKept很大,堆分配的次数会翻倍。

在性能敏感场景,我的建议是:

  • 大多数编辑器、工具类场景:FunctionalCommand 完全够快,除非你每秒创建上万个命令。
  • 需要高频创建命令时,用经典继承线路,把撤销要保存的数据直接作为成员,省掉 std::function 内部的类型擦除开销。
  • 折中方案:FunctionalCommand 只适合“一次性、低频、需要临时绑定大量上下文”的命令;核心高频命令比如字符输入,还是建议专门写一个类。

我见过一个印象很深的例子,某插件系统用了一堆 std::function 包装命令,结果产生的堆分配把一个启动流程拖慢了将近一倍。当然那是一个做了几千次的批量场景,不是常规交互频率。但能说明一条经验:别把 std::function 用在循环内部。

6. 宏命令与命令队列:组合、事务和延迟执行

命令模式真正的爆发力,体现在“组合”上。一条命令能记录操作,那一组命令呢?它可以成为一个更大的命令,这就是宏命令(MacroCommand)。

6.1 宏命令:把多条命令封装成一条可整体撤销的操作

用户录制一个宏:先输入“abc”,再删除“d”,再插入“efg”。这些在 UI 层是三次独立操作,但我们希望它们作为一个整体被记录:用户撤销一次,三个操作全部回退。

实现非常简单,我把关键点放在 undo 的逆序执行上:

class MacroCommand : public Command { public: void add(std::unique_ptr<Command> cmd) { m_commands.push_back(std::move(cmd)); } void execute() override { for (auto& cmd : m_commands) { cmd->execute(); } } void undo() override { for (auto it = m_commands.rbegin(); it != m_commands.rend(); ++it) { (*it)->undo(); } } private: std::vector<std::unique_ptr<Command>> m_commands; };

为什么 undo 要逆序?考虑一个很简单的例子:先插入字符 A 到位置 0,再插入字符 B 到位置 0。结果是文档开头为“BA”。如果按顺序 undo,先把 A 的 undo——也就是删除位置 0 的文本——执行了,删掉的是 B,不是 A,文档就乱了。你必须先从“最近的动作”开始回退:先删 B,再删 A。这就是撤销栈的逆序逻辑。

因此宏命令的 undo 必须是rbegin()到rend(),从最后一条命令往前逐条撤。

6.2 批处理与事务语义:要么全成,要么全撤

宏命令稍加改造,就能成为一个简易的事务单元。比如一个“加载模板”功能要同时执行建目录、写配置、初始化资源三步,如果第二步失败怎么办?

传统做法是业务代码抛异常然后希望上层把前面做的清理掉。命令模式下,可以把三步封装成宏命令,并提供执行中失败的处理:

bool MacroCommand::tryExecute() { // 记录已执行的命令数量 size_t executedCount = 0; try { for (auto& cmd : m_commands) { cmd->execute(); ++executedCount; } return true; } catch (...) { // 回滚已执行的命令 for (size_t i = executedCount; i > 0; --i) { m_commands[i - 1]->undo(); } throw; } }

这是典型的“事务性执行”思想:失败时把所有已执行步骤全部回滚。配合 undo 栈,这个宏命令要么以整体身份进栈,要么不存在于历史中,不会留下“半执行”的脏状态。

实际工程中这个模式非常有用。比如一个“导出图片”流程,既要创建输出目录又要生成临时文件,中间失败时,用户的撤销栈不会残留半截状态。你会发现命令模式让事务控制从业务代码里剥了出来,这是很多人没想到的用法。

6.3 命令队列与延迟执行:把操作交给另一个线程

命令对象天然适合排队。将命令按序放入一个队列,由单独的工作线程逐个取出执行,可以实现任务调度与并发控制。在 C++ 中通常是这样:

class CommandQueue { public: void push(std::unique_ptr<Command> cmd) { std::lock_guard<std::mutex> lock(m_mutex); m_queue.push(std::move(cmd)); m_cv.notify_one(); } std::unique_ptr<Command> pop() { std::unique_lock<std::mutex> lock(m_mutex); m_cv.wait(lock, [this] { return !m_queue.empty(); }); auto cmd = std::move(m_queue.front()); m_queue.pop(); return cmd; } // 工作线程循环 void runLoop() { while (true) { auto cmd = pop(); cmd->execute(); } } private: std::mutex m_mutex; std::condition_variable m_cv; std::queue<std::unique_ptr<Command>> m_queue; };

当然这里省略了线程停止机制,重点是命令对象不需要知道它将运行在哪个线程,这一点让代码的解耦性更上一层楼。唯一要注意的是:命令内部访问的接收者对象必须线程安全,或者保证单线程独享。如果一条命令被设计为跨线程执行,它捕获的上下文就必须是值或 shared_ptr,而不是对栈变量的引用。

6.4 日志记录与回放

命令模式还有一个很有意思的副产品:如果命令对象可以被序列化,那它天然就是操作日志。把所有命令按顺序序列化到磁盘,恢复现场时从头到尾重新执行一遍,就能精确重现用户的操作轨迹。

这个能力对许多业务场景价值巨大:自动化测试、用户操作审计、崩溃后状态恢复。我在这类实现里常用的做法是:给 Command 基类增加一个virtual std::string serialize() const接口,具体命令序列化自己的参数,反序列化时重建命令对象。注意 undo() 需要捕获的运行时状态(比如删除的内容)不需要序列化,因为回放时再执行一次 execute 自然能捕获。

7. 一些性能和生命周期上的观察

最后,汇总我在实践中积攒的几条心得。它们不是理论推演,而是真实跑过项目之后总结出来的。

7.1 虚函数开销:在真实场景中通常可忽略

很多人一听说命令模式用虚函数,第一反应是性能差。实际上,一条命令从执行到撤销,触发的虚函数调用只有两次:execute()和undo()。每次调用是一次 vtable 跳转,现代 CPU 的分支预测几乎可以把这开销吸收掉。除非你的热路径在循环体内每帧创建上万个命令,否则完全不用纠结。

真正的性能大头在于命令对象里保存的数据(尤其是删除的文本内容)和vector 的重新分配。针对前者,建议大块文本操作时考虑合并策略——比如连续输入字符时,把多个小命令合并成一个大的 InsertCommand;针对后者,可以给 History 的 vector 预留容量,减少 realloc 次数。

7.2 命令对象持有裸指针/引用的生命周期条件

这一点我在 lambda 章节已经提醒过,这里再从整体上讲一次。命令对象持有接收者的引用或裸指针,意味着接收者的生命周期必须是命令栈的超集。换句话说,接收者必须比任何一条命令活得更久,或者至少保证在 History 清空之前接收者不能被销毁。

实际项目中我见过最典型的 bug 类型是:某个配置对象被回收,但 undo 栈里还挂着引用它的命令,用户点撤销直接崩溃。修复方式要么是确保销毁顺序(先清空 History,再销毁配置对象),要么在接收者析构时主动通知 History 清空相关命令(用观察者模式或 weak_ptr)。

7.3 shared_ptr 与循环引用:命令模式里容易忽略的隐患

如果接收者用std::shared_ptr管理,而命令对象里保存的是接收者的 shared_ptr,那么只要命令对象还在 undo 栈里,接收者就不可能被析构。这通常不是问题——你当然希望命令引用的对象活着。但隐患在于:如果接收者内部也持有了 History 的 shared_ptr(比如编辑器组件自己管理历史),就可能出现引用环:接收者 -> History -> 命令 -> 接收者。这个环会导致两个对象都无法释放,形成内存泄漏。

解决方法很简单:History 不要被接收者以 shared_ptr 持有;或命令中改用std::weak_ptr。如果项目里出现了“以为能释放,内存却不降”的诡异现象,优先检查命令对象的捕获列表和成员变量。

7.4 一个实测过的合并策略:连续输入不要一条命令一入栈

再分享一个性能优化层面的实测经验。在编辑器里最频繁的操作是打字,每敲一个字符就生成一个 InsertCommand 入栈。这不仅让撤销栈很快膨胀到几百条,还会让 undo 一次只消除一个字符——用户往往期望“按一次撤销删掉整个单词”。

我的处理方式是:当检测到连续多次 InsertCommand 作用于同一位置且时间间隔小于阈值(比如 300ms)时,把它们合并成一条命令。可以在 History::execute 里检查栈顶命令是否也是 InsertCommand、位置是否相邻,如果是,则把新文本追加到旧命令的文本中,替换掉旧命令。

这个合并优化的收益是双重的:撤销体验更符合直觉,内存和性能开销大幅下降。类似地,DeleteCommand 也可以合并。这个细节在很多讲设计模式的书里完全找不到,但在实际产品中往往是决定手感的关键。

7.5 用户自定义资源释放的边界

还有一个容易被忽视的设计点。有些命令在撤销后其功能已经不成立了,比如一个命令持有某种文件句柄或动态分配的资源。仅仅调用undo()不代表资源被正确释放。这种情况下,我建议 Command 接口可以增加一个virtual void release()(或使用析构函数)来处理“历史被清除”时资源的回收。

区分一下三者的时机:

  • undo():对象被移入 redo 栈,不能释放底层数据,因为 redo 还需要它。
  • release():历史被清空时调用,此时命令对象不会再次执行,可以安全回收所有资源。
  • 析构函数:命令对象被销毁时,默认释放成员。

如果你的命令类持有非平凡资源,务必确保“命令对象被摧毁时不泄漏数据”。通常成员变量的 RAII 已足够,但如果持有的是裸指针或句柄,建议显式释放。

到这里,关于 C++ 命令模式的核心实战难点都过了一遍。从最初的硬编码撤销,到最终的命令对象化、状态管理、生命周期掌控,整个重构过程最大的收益并不是代码变少了,而是每加一个新操作,我只需要写一个命令类,然后告诉 History “执行它”,剩下的撤销、重做、宏命令、队列等基础设施全部复用。最后再分享一个习惯:拿到复杂需求先画一下“这个操作会如何被撤销”的草图,如果答案不清楚,那大概率是需要继续拆解或者换个模式了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询