命令模式:让请求与执行优雅解耦

说实话,命令模式是我在项目中用得最频繁的设计模式之一。为什么?因为它解决了一个很实际的问题——如何把「发起请求的对象」和「执行请求的对象」彻底分开

我记得有一次接手一个老项目,里面有个菜单系统,每个按钮点击后直接调用业务逻辑。后来需求变了,要加撤销功能、要记录操作日志、还要支持宏录制……改得我头皮发麻。那时候我就想,要是当初用了命令模式,这些需求加进来简直不要太轻松。

模式动机与定义

先说说动机。你想想看,在传统设计中,调用者直接持有接收者的引用,调用者说「你干这个」,接收者就执行。这看起来没问题,但一旦出现下面这些场景,问题就来了:

  • 需要记录操作历史,支持撤销/重做
  • 需要把操作排队,异步执行
  • 需要把多个操作组合成一个宏命令
  • 需要在不同时间、不同地点执行同一个请求

说白了,调用者和接收者之间耦合太紧,导致扩展困难。

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

核心思想:请求本身变成一个对象,这个对象包含了执行操作所需的所有信息。调用者只需要发出命令对象,至于谁来执行、怎么执行,调用者不关心。

角色与结构

命令模式涉及四个角色,我习惯把它们分成两组来理解:

角色 英文名 职责
命令接口 Command 声明执行操作的接口,通常包含 execute() 和 undo()
具体命令 ConcreteCommand 实现命令接口,持有接收者引用,在 execute 中调用接收者的业务方法
调用者 Invoker 持有命令对象,在需要时调用命令的 execute 方法
接收者 Receiver 真正执行业务逻辑的对象

嗯,这里要注意:调用者只认识命令接口,不认识具体命令,更不认识接收者。这就是解耦的关键所在。

下面这张图能帮你理清它们之间的关系:

Invoker 调用者 持有 Command 引用 Command 命令接口 +execute() +undo() ConcreteCommand 具体命令 持有 Receiver 引用 Receiver 接收者 业务方法 action() 调用 实现 调用业务方法 命令模式结构图

C++ 代码实现

光说不练假把式。我们直接看代码。假设我们要做一个文本编辑器,支持「插入文本」和「删除文本」两个操作。

先定义命令接口:

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

然后是接收者——文本编辑器:

// TextEditor.h
#include <string>
#include <iostream>

class TextEditor {
private:
    std::string text_;
public:
    void insert(const std::string& str, size_t pos) {
        text_.insert(pos, str);
        std::cout << "插入后: " << text_ << std::endl;
    }
    
    void remove(size_t pos, size_t len) {
        text_.erase(pos, len);
        std::cout << "删除后: " << text_ << std::endl;
    }
    
    const std::string& getText() const { return text_; }
};

接下来是具体命令。这里有个细节——命令对象需要保存执行时的上下文信息,这样才能支持撤销:

// InsertCommand.h
class InsertCommand : public Command {
private:
    TextEditor* editor_;
    std::string text_;
    size_t position_;
public:
    InsertCommand(TextEditor* editor, const std::string& text, size_t pos)
        : editor_(editor), text_(text), position_(pos) {}
    
    void execute() override {
        editor_->insert(text_, position_);
    }
    
    void undo() override {
        // 撤销插入 = 删除刚插入的文本
        editor_->remove(position_, text_.length());
    }
};

// DeleteCommand.h
class DeleteCommand : public Command {
private:
    TextEditor* editor_;
    std::string deletedText_;
    size_t position_;
public:
    DeleteCommand(TextEditor* editor, size_t pos, size_t len)
        : editor_(editor), position_(pos) {
        // 执行前先保存被删除的内容
        deletedText_ = editor_->getText().substr(pos, len);
    }
    
    void execute() override {
        editor_->remove(position_, deletedText_.length());
    }
    
    void undo() override {
        // 撤销删除 = 重新插入被删的文本
        editor_->insert(deletedText_, position_);
    }
};

个人经验:DeleteCommand 的构造函数里就保存了被删除的内容。为什么不在 execute 里保存?因为如果 execute 被调用多次,每次保存的内容可能不一样。我习惯在构造时就把「快照」拍好,这样 undo 时能精确恢复。

最后是调用者——按钮或者菜单项:

// Button.h
#include <memory>

class Button {
private:
    std::unique_ptr<Command> command_;
public:
    void setCommand(std::unique_ptr<Command> cmd) {
        command_ = std::move(cmd);
    }
    
    void click() {
        if (command_) {
            command_->execute();
        }
    }
};

使用起来是这样的:

int main() {
    TextEditor editor;
    Button btn;
    
    // 插入 "Hello"
    btn.setCommand(std::make_unique<InsertCommand>(&editor, "Hello", 0));
    btn.click();  // 输出: 插入后: Hello
    
    // 插入 " World"
    btn.setCommand(std::make_unique<InsertCommand>(&editor, " World", 5));
    btn.click();  // 输出: 插入后: Hello World
    
    return 0;
}

撤销操作实现

撤销是命令模式的经典应用场景。实现思路其实很简单:维护一个命令历史栈,每次执行命令时压栈,撤销时从栈顶弹出并调用 undo。

// CommandHistory.h
#include <vector>
#include <memory>

class CommandHistory {
private:
    std::vector<std::unique_ptr<Command>> history_;
public:
    void executeCommand(std::unique_ptr<Command> cmd) {
        cmd->execute();
        history_.push_back(std::move(cmd));
    }
    
    void undo() {
        if (history_.empty()) {
            std::cout << "没有可撤销的操作" << std::endl;
            return;
        }
        auto& lastCmd = history_.back();
        lastCmd->undo();
        history_.pop_back();
    }
    
    bool canUndo() const {
        return !history_.empty();
    }
};

我曾经踩过的坑:撤销操作一定要考虑「幂等性」。什么意思?就是同一个命令的 undo 被多次调用时,不能把系统搞崩。我见过一个项目,undo 里直接 delete 了资源,结果第二次 undo 时又 delete 了一次……嗯,double free 了。

使用 CommandHistory 改造后的调用方式:

int main() {
    TextEditor editor;
    CommandHistory history;
    
    // 插入 "Hello"
    history.executeCommand(
        std::make_unique<InsertCommand>(&editor, "Hello", 0));
    // 输出: 插入后: Hello
    
    // 插入 " World"
    history.executeCommand(
        std::make_unique<InsertCommand>(&editor, " World", 5));
    // 输出: 插入后: Hello World
    
    // 撤销一次
    history.undo();  // 输出: 删除后: Hello
    
    // 再撤销一次
    history.undo();  // 输出: 删除后: (空字符串)
    
    return 0;
}

你看,有了命令模式,撤销功能加进来几乎不需要改动现有代码。这就是设计模式的价值——不是让你写更少的代码,而是让你写更容易扩展的代码

总结一下:命令模式把请求封装成对象,让调用者和接收者彻底解耦。它的核心价值在于:支持撤销/重做、支持操作队列、支持宏命令、支持日志记录。我个人觉得,凡是涉及到「操作历史」的场景,命令模式都是首选。

最后说一句,命令模式虽然好,但也不要滥用。如果你的系统只有两三个操作,而且永远不需要撤销,那直接用函数回调反而更简单。设计模式是工具,不是目的。