78、STL与命令模式:命令封装、命令队列、撤销操作

命令模式,说白了就是把「请求」包装成一个对象。你想想看,在C++里我们平时调用函数,直接调就完了。但有些场景下,你需要把「做什么」和「谁来做」拆开,甚至要把「做过了什么」记下来,方便反悔。这时候,命令模式就派上用场了。

我个人习惯把命令模式理解为「可撤销的票根」。你每次操作,都留下一张票根。想撤销?把票根拿出来,反向执行一次就行。STL里的容器和算法,恰好能帮我们把这张「票根」管理得井井有条。

命令模式的核心结构

先看一个最简的命令接口。我一般会定义一个抽象基类:

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

就这么简单。execute是执行,undo是撤销。每个具体的命令,只需要实现这两个方法。

举个例子,假设我们有一个文本编辑器,支持插入字符:

class InsertCharCommand : public Command {
    std::string& text;
    char ch;
    size_t pos;
public:
    InsertCharCommand(std::string& t, char c, size_t p)
        : text(t), ch(c), pos(p) {}

    void execute() override {
        text.insert(pos, 1, ch);
    }

    void undo() override {
        text.erase(pos, 1);
    }
};

你看,execute和undo正好是互逆操作。我在项目中遇到过类似的需求——一个图形编辑器,每次拖拽图形都要记录位置变化。撤销时把坐标改回去就行。嗯,这里要注意:undo操作必须精确还原状态,不能有偏差。

用STL管理命令队列

单条命令好办,多条命令串起来就需要队列了。我建议用std::deque来存储命令历史。为什么不用vector?因为撤销操作经常从尾部弹出,deque在两端操作都是常数时间,更合适。

class CommandHistory {
    std::deque<std::unique_ptr<Command>> done;
    std::deque<std::unique_ptr<Command>> undone;
    static constexpr size_t MAX_HISTORY = 100;

public:
    void execute(std::unique_ptr<Command> cmd) {
        cmd->execute();
        done.push_back(std::move(cmd));
        undone.clear();  // 新操作清空重做栈
        if (done.size() > MAX_HISTORY) {
            done.pop_front();  // 限制历史深度
        }
    }

    void undo() {
        if (done.empty()) return;
        auto cmd = std::move(done.back());
        done.pop_back();
        cmd->undo();
        undone.push_back(std::move(cmd));
    }

    void redo() {
        if (undone.empty()) return;
        auto cmd = std::move(undone.back());
        undone.pop_back();
        cmd->execute();
        done.push_back(std::move(cmd));
    }
};

这里用了两个deque:一个存已执行的操作,一个存撤销掉的操作。redo就是把撤销栈里的命令再拿出来执行一遍。我曾经踩过一个坑——忘记在execute新命令时清空undone栈。结果用户撤销几次后又执行新操作,再点redo,把旧操作又恢复出来了,界面直接乱掉。所以,新操作一定要清空重做栈,这是常识。

复合命令与宏命令

有时候一个操作包含多个子操作。比如「粘贴」其实是「插入一段文本」,但这段文本可能包含多个字符。如果每个字符都单独记录一条命令,撤销时得点好多次,用户体验极差。

我的做法是用组合模式:

class MacroCommand : public Command {
    std::vector<std::unique_ptr<Command>> commands;
public:
    void add(std::unique_ptr<Command> 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();
        }
    }
};

注意undo的顺序:先执行的后撤销,后执行的先撤销。这跟栈的LIFO逻辑一致。STL的vector配合rbegin/rend,正好天然支持这种逆序遍历。

用std::function实现轻量命令

如果命令逻辑很简单,没必要每次都写一个新类。C++11之后,std::function配合lambda,可以快速封装命令:

class SimpleCommand {
    std::function<void()> exec_fn;
    std::function<void()> undo_fn;
public:
    SimpleCommand(std::function<void()> e, std::function<void()> u)
        : exec_fn(std::move(e)), undo_fn(std::move(u)) {}

    void execute() { exec_fn(); }
    void undo() { undo_fn(); }
};

使用时:

auto cmd = std::make_unique<SimpleCommand>(
    [&text] { text += "hello"; },
    [&text] { text.erase(text.size() - 5); }
);

我个人习惯在原型阶段用这种轻量方式。等逻辑稳定了,再考虑拆成正式类。你想想看,如果每个小操作都写一个类,文件数量会爆炸。灵活取舍才是工程之道。

撤销操作的陷阱

注意:撤销操作不是万能的。以下场景需要特别小心:

  • 非幂等操作:比如「发送网络请求」,撤销时没法「收回」已经发出的数据包。这种操作要么不允许撤销,要么用补偿操作(比如发送取消请求)。
  • 资源管理:如果命令中分配了资源,撤销时记得释放。否则多次撤销重做会导致内存泄漏。
  • 迭代器失效:如果命令修改了容器,而外部还持有指向容器元素的迭代器,撤销后迭代器可能失效。我建议用索引代替迭代器来记录位置。

我曾经在项目中遇到一个bug:用户撤销了「删除一行文本」的操作,但之前获取的迭代器已经指向了被删除的元素。恢复文本后,迭代器指向的位置完全不对。后来我改用行号+列号来定位,问题才解决。

知识体系结构图

下面这张图展示了命令模式在STL环境下的完整结构:

STL命令模式知识体系 调用者(Invoker) 命令队列(std::deque) 具体命令(ConcreteCommand) 接收者(Receiver) 撤销栈(undone) 历史深度限制(MAX) std::unique_ptr std::function std::vector std::rbegin/rend

实战建议

我的经验总结:

  • 命令对象尽量用std::unique_ptr管理,避免裸指针和拷贝开销。
  • 如果命令需要序列化(比如保存到文件以便恢复),可以考虑用std::variantstd::any存储参数。
  • 撤销操作不要做得太「深」。我一般限制历史记录在50~200条之间,超过就丢弃最早的。否则内存占用会失控。
  • 多线程环境下,命令队列要加锁。或者用无锁队列(比如boost::lockfree::queue),但实现复杂度会上升。

命令模式配合STL,说白了就是「用标准库的数据结构,管理自定义的行为对象」。你不需要自己写链表、自己实现迭代器,STL已经帮你铺好了路。你只需要关注业务逻辑——每个命令做什么,以及怎么撤销。

嗯,最后提醒一句:不要滥用命令模式。如果应用只有「保存」和「加载」两个操作,没必要搞一套完整的命令框架。过度设计比没有设计更可怕。


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