命令模式:把请求装进盒子里

命令模式,说白了就是把「你要做什么」封装成一个对象。我刚开始学设计模式时,觉得这玩意儿有点多余——直接调用函数不就行了?直到我在一个项目中遇到了撤销操作的需求,才明白命令模式有多香。

为什么需要命令模式?

想象一下这个场景:你写了一个文本编辑器,用户点击「保存」按钮,直接调用了 saveFile() 方法。一切看起来都很美好。但突然有一天,产品经理说:「我们要支持撤销操作!」

嗯,问题来了。你没法撤销一个已经执行完的函数调用。你想想看,函数调用就像泼出去的水,收不回来的。

命令模式解决的就是这个问题。它把「执行一个操作」这件事,变成了一个对象。这个对象里存着所有需要的信息——谁调用、调什么、参数是什么。这样一来,你就可以把这个对象存起来,需要的时候再执行,甚至还能反着执行一遍来实现撤销。

核心思想: 将请求封装为对象,从而让你可以用不同的请求对客户进行参数化、对请求排队或记录请求日志,以及支持可撤销的操作。

命令模式的三个角色

命令模式的结构其实很简单,就三个核心角色:

  • Command(命令接口):声明执行操作的接口,通常就一个 execute() 方法
  • ConcreteCommand(具体命令):实现命令接口,持有接收者的引用,在 execute() 中调用接收者的具体方法
  • Invoker(调用者):持有命令对象,在某个时间点调用命令的 execute() 方法
  • Receiver(接收者):真正干活的对象,知道如何执行具体的业务逻辑

我习惯把调用者和接收者分开。调用者只管「什么时候执行」,接收者只管「怎么执行」,命令对象负责把两者桥接起来。

Java 实现:从 Runnable 说起

其实 Java 里早就内置了命令模式的影子——Runnable 接口。你看:

// 这就是命令接口
public interface Runnable {
    public void run();
}

// 具体命令
public class SaveCommand implements Runnable {
    private Document document;
    
    public SaveCommand(Document document) {
        this.document = document;
    }
    
    @Override
    public void run() {
        document.save();
    }
}

// 调用者
public class MenuItem {
    private Runnable command;
    
    public void setCommand(Runnable command) {
        this.command = command;
    }
    
    public void click() {
        command.run();
    }
}

你看,Runnable 就是命令接口,run() 就是 execute()。线程池、按钮点击、定时任务,本质上都是命令模式的应用。

我在项目中遇到过这样一个坑:有人把业务逻辑直接写在 Runnablerun() 方法里,而不是委托给接收者。这样做的问题在于,你没法复用接收者的逻辑,也没法轻松地做撤销。记住,命令对象只负责「调用」,不负责「实现」。

C++ 实现:函数对象与 lambda

C++ 里实现命令模式就更灵活了。函数对象(functor)和 lambda 表达式都是天然的命令封装:

#include <iostream>
#include <vector>
#include <functional>
#include <stack>

// 接收者
class Document {
public:
    void save() {
        std::cout << "文档已保存" << std::endl;
    }
    
    void undo() {
        std::cout << "撤销保存" << std::endl;
    }
};

// 命令接口(用 std::function 替代)
using Command = std::function<void()>;

// 调用者
class MenuItem {
private:
    Command command;
    std::stack<Command> history;
    
public:
    void setCommand(Command cmd) {
        command = cmd;
    }
    
    void click() {
        if (command) {
            command();
            history.push(command);
        }
    }
    
    void undo() {
        if (!history.empty()) {
            history.pop();
            std::cout << "已撤销上一步操作" << std::endl;
        }
    }
};

int main() {
    Document doc;
    MenuItem saveButton;
    
    // 用 lambda 创建命令
    saveButton.setCommand([&doc]() {
        doc.save();
    });
    
    saveButton.click();  // 输出:文档已保存
    saveButton.undo();   // 输出:已撤销上一步操作
    
    return 0;
}

C++ 的 std::function 加上 lambda,写命令模式简直不要太爽。你想想看,连具体的命令类都不用定义,一个 lambda 就搞定了。

我的建议: 在 C++ 中,优先使用 lambda 表达式实现简单命令。只有当命令逻辑非常复杂,或者需要多个方法(比如 execute、undo、redo)时,才考虑定义专门的命令类。

撤销操作的实现技巧

撤销操作是命令模式最经典的应用。实现思路其实很简单:

  1. 每个命令除了 execute(),再加一个 undo() 方法
  2. 执行命令时,把命令对象压入历史栈
  3. 撤销时,从历史栈弹出命令,调用它的 undo() 方法

我曾经在一个图形编辑器中实现过撤销功能。当时踩了一个坑:有些操作是不可逆的,比如「删除文件」。对于这类操作,要么不允许撤销,要么在 undo() 里做补偿操作(比如把文件从回收站恢复)。

注意: 不是所有操作都能完美撤销。对于不可逆操作,建议在命令对象中保存足够的状态信息,以便在撤销时能够恢复到之前的状态。如果实在无法撤销,干脆就不要把该命令加入历史栈。

命令模式的更多应用场景

场景 说明 我的经验
事务处理 将一系列操作封装为命令,要么全部成功,要么全部回滚 在数据库迁移工具中用过,每个迁移脚本就是一个命令
宏录制 记录一系列命令,然后可以回放 游戏回放系统就是典型的命令模式
任务队列 将命令放入队列,由工作线程依次执行 线程池的 Runnable 就是命令
菜单系统 每个菜单项对应一个命令,点击时执行 几乎所有 GUI 框架都在用这个模式

命令模式的优缺点

任何模式都不是银弹。我用了这么多年命令模式,总结了几点:

优点:

  • 解耦调用者和接收者,调用者不需要知道接收者的具体实现
  • 容易扩展,新增命令不需要修改现有代码
  • 支持撤销、重做、事务等高级功能
  • 可以将多个命令组合成复合命令(宏命令)

缺点:

  • 每个具体操作都要创建一个命令类,类数量会膨胀
  • 命令对象需要保存状态信息,可能占用较多内存
  • 对于简单操作,引入命令模式反而增加了复杂度
什么时候用命令模式? 当你需要支持撤销/重做、需要将请求排队或记录日志、需要将一组操作组合执行时,命令模式就是你的最佳选择。如果只是简单的函数调用,别为了用模式而用模式。

SVG 结构图:命令模式的核心流程

命令模式核心结构 Invoker 调用者 持有 Command 引用 <<接口>> Command + execute() ConcreteCommand + execute() 持有 Receiver 引用 Receiver 接收者 + action() 调用 实现 委托 调用者(Invoker)→ 命令(Command)→ 接收者(Receiver) 三者完全解耦,新增命令无需修改调用者和接收者

这张图展示了命令模式的核心流程。调用者只认识命令接口,具体命令知道该找谁干活,接收者只管埋头做事。三者各司其职,互不干扰。

总结

命令模式把「请求」变成了「对象」,这个转变带来了巨大的灵活性。你可以把命令存起来、排队、记录日志、甚至撤销。Java 的 Runnable、C++ 的 std::function 都是命令模式的体现。

我个人习惯在项目初期就引入命令模式,哪怕当时只需要一个简单的操作。因为我知道,产品经理迟早会过来说:「我们要加撤销功能。」到那时候再重构,成本就高了。

嗯,记住一点:命令模式不是为了炫技,而是为了应对变化。当你的系统需要支持撤销、重做、事务回滚、任务队列这些功能时,命令模式就是你的不二之选。

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