21、命令模式:模式动机与定义、角色与结构、C++代码实现、撤销与重做

模式动机:为什么我们需要命令模式?

先聊个实际场景。我在几年前接手过一个编辑器项目,里面有个“操作历史”功能。一开始代码很简单:每个按钮点一下,直接调用对应的函数。比如“保存”按钮就调 SaveFile(),“撤销”按钮就调 UndoLastAction()

但问题很快就来了。产品经理说:“我们要支持宏录制。” 嗯,就是把用户一连串操作录下来,以后一键回放。接着又说:“我们要支持操作队列,比如批量处理时按顺序执行。” 再后来:“撤销要支持多步,而且每个操作都要能redo。”

你想想看,如果每个操作都是直接调用函数,那这些需求怎么实现?根本没法做。因为函数调用是“一次性”的,你没法把“保存”这个动作存下来,也没法把它排进队列里。

说白了,命令模式要解决的核心问题就是:把“请求”封装成对象。这样一来,你就可以把请求存起来、传参、排队、记录日志,甚至支持撤销和重做。

核心思想:将“调用操作的对象”与“执行操作的对象”解耦,中间加一层“命令对象”作为媒介。

定义与角色结构

命令模式的定义其实很简洁:将一个请求封装为一个对象,从而使你可用不同的请求对客户进行参数化,对请求排队或记录请求日志,以及支持可撤销的操作。

嗯,这里有几个关键角色,我列个表给你看:

角色 职责 类比
Command(抽象命令) 声明执行操作的接口,通常包含 Execute()Undo() 遥控器上的“按钮接口”
ConcreteCommand(具体命令) 实现命令接口,绑定一个接收者对象,调用接收者的对应操作 “开灯”按钮的具体逻辑
Invoker(调用者/请求者) 持有命令对象,并在某个时机调用命令的 Execute() 遥控器本身
Receiver(接收者) 真正执行业务逻辑的对象 电灯
Client(客户端) 创建具体命令对象,并设置其接收者 你——把遥控器和灯关联起来的人

结构图我画了一张SVG,你看一眼就明白了:

Client Invoker 持有 Command 对象 Command (抽象) +Execute() +Undo() ConcreteCommand 持有 Receiver 引用 Receiver +Action() 创建 调用

C++ 代码实现:一个文本编辑器示例

光说不练假把式。我们直接写一个文本编辑器的“插入字符”和“删除字符”命令,并支持撤销重做。

先定义接收者——文本缓冲区:

// Receiver:文本缓冲区
class TextBuffer {
public:
    void InsertChar(char ch, size_t pos) {
        if (pos > text_.size()) pos = text_.size();
        text_.insert(text_.begin() + pos, ch);
    }
    
    void DeleteChar(size_t pos) {
        if (pos < text_.size()) {
            text_.erase(text_.begin() + pos);
        }
    }
    
    std::string GetText() const { return text_; }
    
private:
    std::string text_;
};

然后定义抽象命令接口:

// Command 抽象基类
class Command {
public:
    virtual ~Command() = default;
    virtual void Execute() = 0;
    virtual void Undo() = 0;
};

接下来是具体命令。这里有个细节:撤销操作需要保存“反向操作”所需的状态。比如插入字符,撤销时就要删除那个字符,所以得记住插入的位置和字符本身。

// ConcreteCommand:插入字符
class InsertCharCommand : public Command {
public:
    InsertCharCommand(TextBuffer& buffer, char ch, size_t pos)
        : buffer_(buffer), ch_(ch), pos_(pos) {}
    
    void Execute() override {
        buffer_.InsertChar(ch_, pos_);
    }
    
    void Undo() override {
        buffer_.DeleteChar(pos_);
    }
    
private:
    TextBuffer& buffer_;
    char ch_;
    size_t pos_;
};

// ConcreteCommand:删除字符
class DeleteCharCommand : public Command {
public:
    DeleteCharCommand(TextBuffer& buffer, size_t pos)
        : buffer_(buffer), pos_(pos) {
        // 执行前先保存被删除的字符,用于撤销
        // 注意:这里需要在 Execute 之前获取字符
    }
    
    void Execute() override {
        // 先保存被删字符(实际项目中可能在构造时获取)
        // 这里简化处理,假设外部已经设置 deletedChar_
        buffer_.DeleteChar(pos_);
    }
    
    void SetDeletedChar(char ch) { deletedChar_ = ch; }
    
    void Undo() override {
        buffer_.InsertChar(deletedChar_, pos_);
    }
    
private:
    TextBuffer& buffer_;
    size_t pos_;
    char deletedChar_ = '\0';
};

个人经验:我在做编辑器时,撤销操作最容易被忽略的一点是——命令执行前和执行后的状态都要保存。比如删除操作,你得在真正删除之前就把字符读出来存好。否则删完了你上哪找去?

撤销与重做的实现

撤销和重做,说白了就是维护两个栈:撤销栈重做栈

  • 执行一个命令时:压入撤销栈,清空重做栈(因为新操作会打断重做历史)。
  • 撤销时:从撤销栈弹出,调用 Undo(),然后压入重做栈。
  • 重做时:从重做栈弹出,调用 Execute(),然后压入撤销栈。

代码实现如下:

class CommandHistory {
public:
    void ExecuteCommand(std::unique_ptr<Command> cmd) {
        cmd->Execute();
        undoStack_.push(std::move(cmd));
        // 新操作清空重做栈
        while (!redoStack_.empty()) redoStack_.pop();
    }
    
    void Undo() {
        if (undoStack_.empty()) return;
        auto cmd = std::move(undoStack_.top());
        undoStack_.pop();
        cmd->Undo();
        redoStack_.push(std::move(cmd));
    }
    
    void Redo() {
        if (redoStack_.empty()) return;
        auto cmd = std::move(redoStack_.top());
        redoStack_.pop();
        cmd->Execute();
        undoStack_.push(std::move(cmd));
    }
    
private:
    std::stack<std::unique_ptr<Command>> undoStack_;
    std::stack<std::unique_ptr<Command>> redoStack_;
};

我曾经踩过一个坑:如果命令对象里持有的是指针或引用,撤销时一定要确保接收者对象还活着。否则 Undo() 调用时接收者已经被销毁了,程序直接崩溃。我的建议是:用 shared_ptr 管理接收者生命周期,或者在命令执行前做有效性检查

使用示例

最后,看看客户端怎么用:

int main() {
    TextBuffer buffer;
    CommandHistory history;
    
    // 插入字符 'A' 在位置 0
    history.ExecuteCommand(
        std::make_unique<InsertCharCommand>(buffer, 'A', 0));
    
    // 插入字符 'B' 在位置 1
    history.ExecuteCommand(
        std::make_unique<InsertCharCommand>(buffer, 'B', 1));
    
    std::cout << buffer.GetText() << std::endl; // 输出 "AB"
    
    history.Undo(); // 撤销插入 'B'
    std::cout << buffer.GetText() << std::endl; // 输出 "A"
    
    history.Redo(); // 重做插入 'B'
    std::cout << buffer.GetText() << std::endl; // 输出 "AB"
    
    return 0;
}

输出结果:

AB
A
AB

总结一下

命令模式说白了就是一层“中间人”。它把操作请求封装成对象,让你能存、能传、能排队、能撤销。我个人觉得,这是设计模式里“性价比”最高的几个之一——学起来不难,用起来效果立竿见影。

你想想看,如果没有命令模式,你要实现撤销重做,得在每个业务函数里写一堆历史记录代码,耦合度极高。有了命令模式,每个命令自己管自己的撤销逻辑,调用者只管按顺序执行或回退,清爽多了。

一句话记住:命令模式 = 把“做事”变成“对象”,然后你就可以对这个对象为所欲为——存它、传它、排队它、撤销它。


公众号:蓝海资料掘金营,微信deep3321