ARTICLE DETAIL

资讯详情

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

现代C++命令模式:从std::function到undo/redo实战

现代C++命令模式:从std::function到undo/redo实战 命令模式在C里是个老话题了,GoF四大本里写得清清楚楚。但我发现一个挺有意思的现象:很多人对命令模式的印象停留在“用基类加虚函数,派生出一个个ConcreteCommand”,等真正到了现代C工程里,发现自己写的命令类又笨又重,继承体系一拉长就难受,还动不动踩内存管理的坑。其实C11之后,std::function、lambda、智能指针这些东西已经把这个模式重塑了一遍,写法更轻,表达力更强,但相应的,坑也换了新花样。这篇我就从经典写法讲到现代玩法,最后用一个可撤销文本编辑器的完整例子,把命令模式在C里的落地细节和常见坑一次说清楚。适合正在学设计模式的学生,也适合在项目里需要引入undo/redo、任务队列、宏录制这类机制的C开发。1. 命令模式到底在解决什么问题1.1 先从生活里的场景说起假设你去餐厅吃饭,你把“来一份宫保鸡丁”这个需求告诉了服务员。注意,你并没有直接冲进后厨对厨师喊话,厨师也没有因为你的一句话马上颠勺开炒。服务员把这张单子写在纸上,然后根据厨房的忙碌程度、出菜顺序,再决定什么时候把单子递给厨师。这中间多了一层“记录请求、稍后执行”的缓冲。命令模式的思路和这个一模一样。它在代码里抽象出“命令”这个概念:一个命令对象里面封装了一次完整操作的执行方法,以及必要的参数信息。发起操作的人和真正执行操作的人,通过这个命令对象解耦。发起方不需要知道执行方是谁、怎么干,执行方也不关心命令是谁发起的。把这一层加进去,很多原本别扭的架构问题就变顺了。比如要支持撤销,你只需要在执行命令之前把命令对象存进一个栈里,撤销时从栈顶取出命令,调用它的undo方法就行。要支持队列,就把命令对象排进队列,按顺序执行。要支持宏录制,就把一系列命令组合成一个复合命令。这些都是“多加一层”带来的直接红利。1.2 命令模式的四个标准角色经典GoF定义里,命令模式有四个角色:Command(命令接口):声明执行操作的接口,一般就是Execute()方法,扩展场景下会有Undo()。ConcreteCommand(具体命令):实现Command接口,持有接收者对象,在Execute()里调用接收者的具体业务方法。Receiver(接收者):真正干活的类,包含具体的业务逻辑,比如文档类、灯光控制类、游戏角色类。Invoker(调用者):持有命令对象,在合适的时机调用Execute()。它不关心命令内部是怎么实现的。Client(客户端):组装命令,创建具体命令并设置它的接收者,再交给调用者。这个结构在C经典写法里非常直白,基类多态是骨子里的实现方式。但从现代C的角度看,命令接口未必非得是抽象基类,接收者也未必非得是显式传入的类对象,很多场景下std::function加上lambda捕获反而更顺手。1.3 什么时候该用,什么时候不用命令模式不是银弹,用得不好就是平白增加一层类和接口。我个人的判断标准是三个:需要把操作参数化、延迟执行或排队执行。比如按钮点击后不立即执行,而是进入任务队列;比如网络请求要批量发送。需要支持撤销、重做或事务性回滚。这是命令模式最经典的存在理由,没有undo/redo需求就少了一半使用动机。需要把一组操作组合起来形成宏命令,或者需要对操作记日志、做审计。反过来,如果你的场景只是简单的函数回调,一个函数指针或者一个std::function就够了,没必要定义一个抽象基类再写一堆派生类。更极端的例子是只有一个具体命令的代码,那直接调用接收者方法就完了,不用绕圈子。命令模式的本质是解耦和可扩展,如果这两点用不上,它就是个累赘。2. 经典C实现:基于继承的命令模式2.1 接口基类的设计先从教科书写法开始。我们需要一个Command抽象基类,一般长这样:class Command { public: virtual ~Command() default; virtual void execute() 0; };就是这么简单。有的场景需要撤销,那再加一个虚函数:class Command { public: virtual ~Command() default; virtual void execute() 0; virtual void undo() 0; };在C里给多态基类声明虚析构函数是常规操作了,原因不复杂:当通过基类指针delete派生类对象时,如果析构函数不是虚函数,只会调用基类的析构,派生类资源就泄漏了。这是C多态基础中的基础,但真到写代码时候经常有人忘。有意思的是,很多老牌C项目里,Command基类的execute()和undo()还会定义成纯虚函数,强制所有派生类必须实现。这种设计在需求明确的时候挺好,但一旦命令数量多了,两个纯虚函数就成了负担——有些命令根本不需要撤销,还非得写个空的undo()占位。2.2 具体命令与接收者的组合接下来是具体命令类。假设我们有一个文档编辑器,接收者Document类长这样:class Document { public: void insertText(const std::string text, size_t pos) { content_.insert(pos, text); std::cout Inserted \ text \ at pos \n; } void removeText(size_t pos, size_t len) { content_.erase(pos, len); std::cout Removed len chars at pos \n; } void print() const { std::cout content_ \n; } 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_.insertText(text_, pos_); } void undo() override { // 简单起见,直接按长度删除 doc_.removeText(pos_, text_.size()); } private: Document doc_; std::string text_; size_t pos_; }; class RemoveCommand : public Command { public: RemoveCommand(Document doc, size_t pos, size_t len) : doc_(doc), pos_(pos), len_(len) {} void execute() override { // 注意:这里省略了先保存被删除文本的逻辑 doc_.removeText(pos_, len_); } void undo() override { // 省略了恢复文本的逻辑 } private: Document doc_; size_t pos_; size_t len_; };这里有一个特别重要的细节:RemoveCommand要想实现真正的undo,必须在execute()时先把被删掉的文本内容保存下来,否则撤销时拿什么恢复?很多人写命令模式时把注意力都放在结构上,忽略了这一点,结果做出来的undo是假的,一撤销就丢数据。正确的做法是把删除的文本先读出来存到命令对象内部,然后再执行删除。2.3 调用者、客户端与多态优势的体现调用者Invoker在经典写法里就是个持有命令的类,可以在合适时机调用execute():class Editor { public: void setCommand(std::unique_ptrCommand cmd) { pending_ std::move(cmd); } void click() { if (pending_) { pending_-execute(); history_.push_back(std::move(pending_)); } } private: std::unique_ptrCommand pending_; std::vectorstd::unique_ptrCommand history_; };客户端负责把命令组装起来:int main() { Document doc; InsertCommand insertCmd(doc, hello, 0); Editor editor; editor.setCommand(std::make_uniqueInsertCommand(doc, hello, 0)); editor.click(); return 0; }这样的好处在于,Editor类完全不依赖Document,它只知道Command接口。以后如果要换接收者、新增命令类型、加日志、加队列,Editor本身都不用动。这就是开闭原则的一个典型体现:对扩展开放,对修改关闭。但你的编辑器一旦命令多起来,就会发现每个命令都得写成一个类,文件数量爆炸。光是插入、删除、替换、格式化、改颜色、移动光标这种基本操作,就要写十几个甚至几十个类,每个类的代码还高度雷同。这种样板代码写多了,谁都受不了。3. 现代C实现的三个改进方向3.1 用std::function直接包装命令C11引入的std::function是个好东西,它本质上是类型擦除的可调用对象包装器。命令模式的核心本来就是“把操作封装成对象”,而std::function恰好就是“把操作封装成一个变量”的现成方案。class ModernEditor { public: void setExecute(std::functionvoid() fn) { execute_ std::move(fn); } void click() { if (execute_) execute_(); } private: std::functionvoid() execute_; };然后在这个编辑器上注册命令:int main() { Document doc; ModernEditor editor; editor.setExecute([doc]() { doc.insertText(hello, 0); }); editor.click(); return 0; }这个写法的好处不要太明显:不用定义任何Command子类,不用维护继承体系,一个lambda就是一个命令。如果你的命令只是一次性使用、没有撤销需求,这种写法是最简洁的。但代价也随之而来。第一,lambda捕获变量时要非常小心生命周期,按引用捕获的对象如果先于命令销毁,调用时就是悬空引用,程序直接崩。第二,std::function内部有类型擦除的开销,每次调用都有一层间接调用,性能敏感的循环里要注意。第三,撤销怎么办?你可以在捕获里把撤销逻辑也包进去,定义成一个pair或者一个小struct,但那样代码的可读性就开始下降了。3.2 用递归lambda实现undo/redo对一个我比较喜欢的小技巧是用一对lambda,一个执行一个撤销。比如写一个简单struct:struct CommandPair { std::functionvoid() execute; std::functionvoid() undo; };在注册命令时,你可以同时给上执行和撤销逻辑:CommandPair makeInsertCommand(Document doc, std::string text, size_t pos) { return { [doc, text, pos]() { doc.insertText(text, pos); }, [doc, text, pos]() { doc.removeText(pos, text.size()); } }; }这样既保留了lambda的轻量,又把undo逻辑放在一起。对比经典写法,少写两个类,多了一点点灵活性。缺点是这个struct不是多态的,想要把不同命令放进同一个容器,还得再套一层类型擦除,比如再加一个基类或者用variant。3.3 智能指针接管命令的生命周期命令对象本质上就是个值,但多态命令需要指针来访问。这里我建议用std::unique_ptr而不是裸指针。原因很简单:谁持有命令,谁负责销毁,所有权转移清晰,不会出现悬空指针。在实际项目的undo/redo栈中,我们通常会把命令对象push进一个std::vectorstd::unique_ptr。撤销的时候把unique_ptr移出来,调用undo,再把它移到redo栈里。整个过程所有权都是一对一转移,不会有多重指针指向同一个命令的混乱。有同学问,为什么不用std::shared_ptr?我的经验是,命令对象根本不需要共享所有权,基本都是一段一段地被传递。用shared_ptr只会增加原子引用计数的开销,还容易让生命周期变得模糊。除非你有明确的多持有者场景,否则unique_ptr就够了。3.4 模板与类型擦除的取舍现代C还提供了另一条路:模板。你完全可以写一个模板化的Invoker,接受任意可调用对象:template typename Fn class Command { public: explicit Command(Fn fn) : fn_(std::move(fn)) {} void execute() { fn_(); } private: Fn fn_; };这种编译期多态好处是零虚函数开销,性能极好。但是代价也很明显:一旦你需要把不同类型的命令对象放进同一个容器(比如undo/redo栈),模板这条路就走不通了。那时候要么用std::variant把有限几种命令类型包起来,要么回到类型擦除的老路上来。我现在的习惯是:如果命令类型是有限的、数量可控,优先用std::variant配合模板方案,性能和类型安全都有保障。如果命令类型是开放的、未来可能不断增加,用std::function或者经典的多态基类更稳。没有绝对正确的答案,关键看你需要的是性能还是扩展性。4. 实战案例:实现一个带undo/redo的文本编辑器4.1 需求分析与存储结构设计现在来做一个相对完整的项目。我们要实现一个极简的文本编辑器,支持插入文本、删除文本、撤销、重做、打印内容。这个例子里我用经典的多态基类架构,因为它在undo/redo场景下结构最清晰,也最能体现命令模式的设计意图。需要存储两个栈:undo栈和redo栈。执行新命令时,清空redo栈,因为新操作改变了历史分支。撤销时,从undo栈弹出命令执行undo,然后压入redo栈。重做时,从redo栈弹出命令执行execute,再压回undo栈。这里有个容易忽略的点:当执行一个新的命令时,不应该直接执行然后压栈就完事。标准做法是先把命令压入undo栈,再执行execute。这很重要,如果execute抛异常,命令已经被记录,后续状态会乱。更稳的是先在栈外执行成功后再commit进栈,但那样undo立即变的语义就没了。我一般采取“先执行,执行成功后才commit进栈”的策略,配合异常安全代码。4.2 命令类的完整实现接收者还是Document类,但这次为删除命令增加保存被删文本的逻辑:class Document { public: void insertText(const std::string text, size_t pos) { content_.insert(pos, text); } std::string removeText(size_t pos, size_t len) { auto removed content_.substr(pos, len); content_.erase(pos, len); return removed; } const std::string text() const { return content_; } private: std::string content_; };删除命令的execute现在会保存被删内容:class RemoveCommand : public Command { public: RemoveCommand(Document doc, size_t pos, size_t len) : doc_(doc), pos_(pos), len_(len) {} void execute() override { removedText_ doc_.removeText(pos_, len_); } void undo() override { doc_.insertText(removedText_, pos_); } private: Document doc_; size_t pos_; size_t len_; std::string removedText_; };注意removedText_是execute()执行之后才被填充的,所以构造阶段要确保它是空字符串。这样一个细节变化,undo的正确性就有了保障。这给我们的启示是:命令对象不只是“动作的描述”,它还可以是“操作的日志”,记录执行时捕获的现场数据。4.3 编辑器主逻辑与undo/redo栈编辑器的核心逻辑如下:class Editor { public: void executeCommand(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_; };这段代码看起来简单,但做到正确其实有几个要点。第一,undo栈和redo栈中存放的是unique_ptr,所以移动语义必须正确。std::unique_ptr本身就是只可移动不可复制的,所以std::move(cmd)再push_back是标准操作。第二,执行新命令时调用redoStack_.clear()。为什么?因为用户撤销了几步之后,如果执行了一个新操作,那么之前从undo栈移到redo栈的命令已经不应该存在于历史里了,这属于“分支历史被新操作截断”的语义。还有一点我要提醒:直接调用cmd-undo()前,这个命令对象还持有接收者的引用。如果接收者已经被销毁了,这一调用就是访问悬空引用,直接未定义行为。所以命令对象的生命周期一定不能超过接收者的生命周期。在有复杂所有权关系的项目里,我会在命令对象里存接收者的weak_ptr,或者明确约定接收者比所有命令都活得久。4.4 客户端使用示例与验证简单验证一下:int main() { Document doc; Editor editor; editor.executeCommand(std::make_uniqueInsertCommand(doc, hello, 0)); editor.executeCommand(std::make_uniqueInsertCommand(doc, world, 5)); std::cout doc.text() \n; // hello world editor.undo(); std::cout doc.text() \n; // hello editor.undo(); std::cout doc.text() \n; // (empty) editor.redo(); std::cout doc.text() \n; // hello editor.redo(); std::cout doc.text() \n; // hello world return 0; }这个例子跑通后,你会发现命令模式的核心价值体现得淋漓尽致:业务操作本身在Document里,命令决策和执行时机在Editor里,两者完全解耦。以后要加一个“输入一个完整句子”的命令,只需要多写一个具体命令类,谁也不用改。4.5 扩展:宏命令与命令组合编辑器做完了,再加一个需求:支持宏录制。所谓宏,就是把多个步骤录制成一个大的“复合命令”,播放时依次执行,撤销时反向撤销。实现可以用GoF里的Composite模式理念,让宏命令本身也是命令:class MacroCommand : public Command { public: void addCommand(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_; };撤销时为什么要逆序?因为命令执行的顺序是A然后B,撤销就应该先撤销B再撤销A,否则结果不对。想象你先插入hello再插入world,撤销时要先删world再删hello,顺序反了就是灾难。这也是命令模式另一个让我着迷的地方:组合结构的复用能力。一个宏命令可以放进另一个宏命令里,像搭积木一样,扩展性极好。5. 常见问题与排查经验5.1 生命周期管理:悬空引用和悬空指针这是我在实际项目中踩过最深的坑。命令对象往往会被存储、排队、延迟执行,所以它持有的引用或指针必须指向长期存活的对象。如果你把命令对象放到一个任务队列里异步执行,而接收者已经被释放了,程序崩溃的几率是百分之百,而且崩溃位置还不固定,排查起来特别头疼。我的建议有两条:第一,能传引用就别传指针?不不,其实引用比指针更隐蔽,悬空引用不会显式地变成nullptr,调用时直接踩野内存。第二,在异步或可延时的场景里,优先用std::shared_ptr管理接收者,在命令对象里存std::weak_ptr,执行前用lock()提升。虽然有一点点开销,但安全性的提升是值得的。如果是纯同步的编辑器场景,约定接收者生命周期大于所有命令,也可以接受,但要在代码注释里写清楚。5.2 命令对象的拷贝与移动语义很多C教程里的命令模式示例都是值语义,命令对象扔进std::vector。但实际项目中,具体命令类往往持有接收者引用、被删除文本、位置信息等,值拷贝很容易出事。我见过一个比较典型的错误:在execute()里改变了命令对象内部的状态(比如RemoveCommand里的removedText_),然后把这个命令push进undo栈。如果这段代码用了值拷贝,压入栈的是拷贝之后的对象,里面的removedText_已经填充好了,倒是没问题。但如果是在execute()之前拷贝,拷贝出来的命令就没有removedText_,撤销时就是个空文本,数据就丢了。正确做法是明确命令对象的语义:要么把它设计成不可拷贝的,全部用unique_ptr管理;要么把“执行前快照”和“命令本体”分离。我现在倾向于在命令基类里禁用拷贝:class Command { public: Command() default; virtual ~Command() default; Command(const Command) delete; Command operator(const Command) delete; Command(Command) default; Command operator(Command) default; virtual void execute() 0; virtual void undo() 0; };这样至少在编译期能挡住一大类误用。5.3 函数签名对不上:命令接口设计的困境还有一个很现实的困境:并非所有操作都天然适合统一的execute()/undo()接口。比如有的操作需要参数,有的操作返回结果,有的操作根本没有undo语义。硬塞进统一接口里,要么用void*或者any做参数擦除,要么写一堆空实现的undo()占位,代码质量会很难看。这里我的经验是:命令接口不是越统一越好。如果项目里90%的命令都遵循execute()/undo()这个形态,只有10%是特例,那可以用模板方案或者std::function来处理特例,而不是把基类接口设计得过于宽泛。模式是给人用的,不是让人去迁就的。5.4 性能优化与分配开销每个命令都是一个堆对象,undo/redo栈还要存指针,这个开销在编辑器这种用户交互场景完全不是问题。但如果你在游戏引擎里每帧创建大量命令,堆分配的次数累积起来就成瓶颈了。优化方向有两个:第一,用对象池复用命令对象,减少堆分配次数。第二,把std::function换成手写函数指针加void*参数,虽然丑,但性能上可控。第三,在命令接口里加一个execute()的默认实现?这不太合理,会增加虚函数调用次数。我个人的建议是:先用清晰的代码做正确的事情,等profiler告诉你命令模式是瓶颈了再优化。绝大多数场景下,这个模式不会是性能瓶颈,过早优化才是。5.5 调试技巧与日志辅助命令模式的调试比较有特点,因为调用栈里通常要经过Invoker的execute()再到具体Command的execute(),最后才是接收者的业务方法,中间隔了好几层。如果出现逻辑错误,很难直观判断是哪一条命令出了问题。我的做法是在命令的execute()和undo()里加上调试日志,记录命令类型、接收者状态、参数信息。更简单的方法是在Editor的executeCommand/undo/redo函数里打日志,把每一步的操作和栈的状态都打出来。配上一个简单的状态打印函数,排查undo/redo问题会快很多。我试过用gdb给Command基类的execute()下断点,配合条件断点,效果也挺好,适合在IDE里单步追踪。6. 命令模式的适用边界与我的一点心得6.1 C里命令模式的“黄金适用区”在C项目里,我发现命令模式最适合的领域其实非常鲜明:一是编辑器类的undo/redo功能,二是任务队列和命令调度系统,三是游戏里的输入处理和AI决策,四是网络协议里的请求封装。这四类场景有一个共同特征:操作需要被存储、传递、按某种策略延迟执行,并且经常需要反向操作或批量处理。反过来,如果你只是需要一个简单的回调,比如按钮点击后弹个提示,那lambda就够了。如果只是函数指针的升级版,std::function也够。非要上命令模式,只会让代码多出一堆类,维护成本反而上升。这一点我在很多代码评审里反复强调:设计模式是为解决特定问题服务的,不是为了堆砌UML图。6.2 和策略模式、观察者模式的区分命令模式、策略模式、观察者模式在一些场景下看起来很像,但定位不同。策略模式解决的是“算法的可替换性”,重点在算法,执行者固定。观察者模式解决的是“状态变化通知多个订阅者”,重点在通知关系是广播式的。命令模式解决的是“发起和执行解耦”,重点在操作本身的封装与传递。三者在C里都是基类多态或std::function的不同用法,但意图不同,使用场景也不同。很多时候代码写出来长得差不多,真正区分的是设计意图。所以在设计阶段,先明确你的核心问题是什么,再选模式,而不是看着需求像某个模式就硬套。6.3 从实际项目中学到的几件事第一件事,命令模式最怕“工作还没开始就退出”。我在一个项目里被坑过:用户点击了一个按钮,触发了一个命令,但这个命令在execute()里还要申请资源,申请失败抛了异常,异常直接冒泡到最外层,导致整个界面卡死。后来我在Editor的executeCommand里加了异常捕获,把命令的execute()包在try/catch里,失败就不往undo栈里推。这是Make Executions Read-Only的变体,确保一个命令要么完整执行成功,要么不产生任何痕迹。第二件事,宏命令的undo顺序是个细节,但错了就是彻底的bug。我见过有人把宏命令的undo也做成正序遍历,结果撤销时文本恢复到一半就错乱了。逆序撤销这个原则贼简单,但不写注释的话,三个月后自己都会写错。第三件事,别让命令对象变成“万能对象”。有些同事喜欢把数据库连接、配置信息、全局状态全塞进命令对象里,导致命令对象过于臃肿,还容易产生隐式依赖。命令对象最好只依赖必要的外部类,其他东西能通过参数传就尽量参数传。这会让命令对象易于测试,也更容易独立复用。6.4 一个实用的小技巧:把命令和Memento结合最后分享一个我经常用的组合:命令模式和Memento模式一起使用。在很多场景下,让命令逆序撤销不如直接恢复接收者的快照来得稳。比如一个图形编辑器,你很难准确地用一条命令的undo把像素还原回上一个状态,但保存一份整个画布的快照就容易多了。Memento的思路是把接收者的状态封装成一个快照对象,在execute()前保存快照,undo()时恢复快照。代价是快照可能很大、复制成本高,但换来的是绝对的准确性和简单性。二者可以结合:命令对象里保存一个小快照,而不是保存复杂的操作细节。这是我在实际项目里用过最有效的“偷懒方案”,强烈推荐在复杂对象的状态撤销场景里试试。命令模式在C里被讨论了几十年,但从经典继承到现代std::function和智能指针,它一直在演进。工具变了,思想没变:把请求封装成对象,让调用方和执行方解耦,把操作变成可以存储、传递、撤销、组合的一等公民。如果你还在纠结要不要用这个模式,我的建议是先从一个小功能入手,比如给一个现有类加个撤销功能,亲手写完一遍undo/redo,你对这个模式的理解会瞬间提升一个档次。它可能是你在项目里最常用、也最不容易后悔引入的模式之一。
返回列表