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环境下的完整结构:
实战建议
我的经验总结:
- 命令对象尽量用
std::unique_ptr管理,避免裸指针和拷贝开销。 - 如果命令需要序列化(比如保存到文件以便恢复),可以考虑用
std::variant或std::any存储参数。 - 撤销操作不要做得太「深」。我一般限制历史记录在50~200条之间,超过就丢弃最早的。否则内存占用会失控。
- 多线程环境下,命令队列要加锁。或者用无锁队列(比如
boost::lockfree::queue),但实现复杂度会上升。
命令模式配合STL,说白了就是「用标准库的数据结构,管理自定义的行为对象」。你不需要自己写链表、自己实现迭代器,STL已经帮你铺好了路。你只需要关注业务逻辑——每个命令做什么,以及怎么撤销。
嗯,最后提醒一句:不要滥用命令模式。如果应用只有「保存」和「加载」两个操作,没必要搞一套完整的命令框架。过度设计比没有设计更可怕。
公众号:蓝海资料掘金营,微信deep3321