Command Pattern
Encapsulate operations as objects for deferred execution, undo/redo, queuing, and serialization — virtual hierarchies, std::function, and std::variant approaches.
Command Patternsince C++11A behavioral design pattern that encapsulates an operation — its target object, action, and arguments — as a first-class object, decoupling the point where a call is assembled from the point where it executes.
Overview
The pattern solves a specific timing problem: you need to specify what to do in one place but when to do it somewhere else. Three roles collaborate:
- Command — holds everything needed to perform (and optionally reverse) one operation
- Receiver — the domain object that changes state
- Invoker — decides when to execute; manages queues or history
This structure enables capabilities that are difficult to achieve otherwise: deferred and queued execution, undo/redo, macro recording, and serialization for audit logs or remote replay.
The appropriate implementation depends on which capabilities you need. std::function (C++11) is zero-boilerplate for pure deferred execution. A virtual hierarchy adds open extensibility and undo state. std::variant (C++17) eliminates virtual dispatch while preserving value semantics. These are not mutually exclusive — a real application often uses all three.
Examples
Fire-and-Forget Queue with std::function
When you only need deferred execution — no undo, no serialization — std::function<void()> is sufficient. It erases the type of any callable (lambda, function pointer, bound member function) under a uniform interface, acting as a generalized functor:
// C++11
#include <functional>
#include <vector>
class CommandQueue {
std::vector<std::function<void()>> pending_;
public:
void enqueue(std::function<void()> cmd) {
pending_.push_back(std::move(cmd));
}
void flush() {
for (auto& cmd : pending_) cmd();
pending_.clear();
}
};
// Any callable fits — the invoker knows nothing about the implementation
CommandQueue q;
q.enqueue([&conn] { conn.send(packet); });
q.enqueue([&db] { db.commit(); });
q.flush();This scales naturally: instead of writing CmdSendEmail, CmdCommitDB, and CmdRefreshCache as separate concrete classes, any lambda captures the necessary state inline.
Undo/Redo with a Virtual Hierarchy
When you need reversibility, each command must carry enough state to invert itself. The key invariant: execute() saves whatever undo() will need before mutating the receiver.
// C++11
struct Command {
virtual void execute() = 0;
virtual void undo() = 0;
virtual ~Command() = default;
};
class TextBuffer {
std::string text_;
public:
void insert(std::size_t pos, std::string_view s) { text_.insert(pos, s); }
void erase (std::size_t pos, std::size_t n) { text_.erase(pos, n); }
std::string_view view() const noexcept { return text_; }
};
struct InsertCmd final : Command {
TextBuffer& buf;
std::size_t pos;
std::string text;
InsertCmd(TextBuffer& b, std::size_t p, std::string t)
: buf{b}, pos{p}, text{std::move(t)} {}
void execute() override { buf.insert(pos, text); }
void undo() override { buf.erase(pos, text.size()); }
};
struct EraseCmd final : Command {
TextBuffer& buf;
std::size_t pos, len;
std::string saved; // populated during execute(), used by undo()
EraseCmd(TextBuffer& b, std::size_t p, std::size_t n)
: buf{b}, pos{p}, len{n} {}
void execute() override {
saved = std::string{buf.view().substr(pos, len)};
buf.erase(pos, len);
}
void undo() override { buf.insert(pos, saved); }
};Commands hold references to receivers, not copies. The receiver must outlive the command objects that reference it — typically guaranteed by the invoker's ownership structure.
History Stack
The invoker for undo/redo has one critical rule: any new execute() invalidates the redo stack. Failing to clear undone_ leaves stale commands that reference overwritten state.
// C++11
class History {
std::vector<std::unique_ptr<Command>> done_;
std::vector<std::unique_ptr<Command>> undone_;
public:
void execute(std::unique_ptr<Command> cmd) {
cmd->execute();
done_.push_back(std::move(cmd));
undone_.clear(); // branching edit: redo history is invalidated
}
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));
}
bool can_undo() const noexcept { return !done_.empty(); }
bool can_redo() const noexcept { return !undone_.empty(); }
};
TextBuffer buf;
History hist;
hist.execute(std::make_unique<InsertCmd>(buf, 0, "Hello"));
hist.execute(std::make_unique<InsertCmd>(buf, 5, " World"));
// buf: "Hello World"
hist.undo(); // buf: "Hello"
hist.redo(); // buf: "Hello World"To cap memory use, replace std::vector with std::deque and evict from the front when done_.size() > max_depth. std::list is also appropriate when iteration order is the priority over random access.
Macro Command (Composite)
Group multiple commands into one undoable unit. Undo must reverse steps in reverse execution order — if step B depends on the outcome of step A, undoing A before B corrupts state:
// C++11
struct MacroCmd final : Command {
std::vector<std::unique_ptr<Command>> steps;
void execute() override {
for (auto& cmd : steps) cmd->execute();
}
void undo() override {
for (auto it = steps.rbegin(); it != steps.rend(); ++it)
(*it)->undo();
}
void add(std::unique_ptr<Command> c) { steps.push_back(std::move(c)); }
};
auto macro = std::make_unique<MacroCmd>();
macro->add(std::make_unique<InsertCmd>(buf, 0, "# Title\n"));
macro->add(std::make_unique<InsertCmd>(buf, 8, "Body.\n"));
hist.execute(std::move(macro));
hist.undo(); // both inserts reversed as one atomic stepValue-Type Commands with std::variant (C++17)
When heap allocation and virtual dispatch are unacceptable — real-time audio, embedded systems, hot paths — represent commands as a discriminated union. The trade-off: this is a closed set; adding a command type requires touching every visitor.
// C++17
#include <variant>
struct InsertCmd { std::size_t pos; std::string text; };
struct EraseCmd { std::size_t pos; std::size_t len; std::string saved; };
struct ReplaceCmd { std::size_t pos; std::string old_text; std::string new_text; };
using Cmd = std::variant<InsertCmd, EraseCmd, ReplaceCmd>;
// C++17: explicit deduction guide required; C++20 aggregates deduce without it
template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>; // C++17 only
void do_execute(TextBuffer& buf, Cmd& cmd) {
std::visit(overloaded{
[&](InsertCmd& c) { buf.insert(c.pos, c.text); },
[&](EraseCmd& c) {
c.saved = std::string{buf.view().substr(c.pos, c.len)};
buf.erase(c.pos, c.len);
},
[&](ReplaceCmd& c) {
c.old_text = std::string{buf.view().substr(c.pos, c.new_text.size())};
buf.erase(c.pos, c.old_text.size());
buf.insert(c.pos, c.new_text);
},
}, cmd);
}
void do_undo(TextBuffer& buf, Cmd& cmd) {
std::visit(overloaded{
[&](InsertCmd& c) { buf.erase(c.pos, c.text.size()); },
[&](EraseCmd& c) { buf.insert(c.pos, c.saved); },
[&](ReplaceCmd& c) {
buf.erase(c.pos, c.new_text.size());
buf.insert(c.pos, c.old_text);
},
}, cmd);
}
// std::vector<Cmd> is a single contiguous allocation — no per-command heap
std::vector<Cmd> history;
void execute_cmd(TextBuffer& buf, Cmd cmd) {
do_execute(buf, cmd);
history.push_back(std::move(cmd));
}Thread-Safe Command Queue
Producer threads push; a single consumer serializes execution. The mutex scope must not include the command invocation — holding the lock while the command runs blocks producers for the full duration of the operation:
// C++17 (CTAD on lock_guard/unique_lock)
#include <queue>
#include <mutex>
#include <condition_variable>
class ConcurrentQueue {
std::queue<std::function<void()>> q_;
std::mutex mu_;
std::condition_variable cv_;
bool stop_ = false;
public:
void push(std::function<void()> cmd) {
{ std::lock_guard lock{mu_}; q_.push(std::move(cmd)); }
cv_.notify_one();
}
void run_until_stopped() {
while (true) {
std::unique_lock lock{mu_};
cv_.wait(lock, [&]{ return !q_.empty() || stop_; });
if (stop_ && q_.empty()) return;
auto cmd = std::move(q_.front());
q_.pop();
lock.unlock(); // release before executing
cmd();
}
}
void stop() {
{ std::lock_guard lock{mu_}; stop_ = true; }
cv_.notify_all();
}
};For multiple consumers, commands must be re-entrant or carry their own synchronization.
Best Practices
Match implementation to required capabilities. std::function for fire-and-forget. Virtual hierarchy when the command set is open or undo state is complex. std::variant when the set is closed and allocation matters.
Capture undo state in execute(), not in the constructor. Commands may be queued; the receiver's state at queue time can differ from its state at execution time. EraseCmd above saves the erased bytes inside execute() for exactly this reason.
Clear the redo stack on new execute. This is the most common correctness bug in history implementations. Stale redo commands reference state that has since been overwritten.
Bound history depth with std::deque. std::vector-based history grows without limit. Switch to std::deque and pop from the front at a configured maximum depth.
Release the lock before executing in a command queue. Holding the queue's mutex during command execution serializes producers for no benefit and risks priority inversion.
Common Pitfalls
Dangling receiver references. Commands hold raw references or pointers to receivers. If the receiver is destroyed before undo fires — common with document-close scenarios — the command dereferences a dead object. Use std::shared_ptr/std::weak_ptr when the receiver's lifetime is uncertain.
Non-invertible operations. Sending a network packet, writing to an append-only log, or generating a UUID have no meaningful inverse. Either mark such commands as non-undoable and skip undo, or design the receiver with explicit compensating operations (send a cancellation packet, append a tombstone).
Wrong undo order in macro commands. Iterating steps forward during undo instead of backward produces incorrect results whenever a later step's precondition depends on an earlier step's effect.
std::function overhead for high-frequency trivial callables. std::function type-erases with a potential heap allocation when the stored callable exceeds the small-buffer optimization threshold (typically 16–32 bytes). In tight loops, prefer std::variant of concrete types or bare function pointers.
Shared mutable state between commands. If two enqueued commands both capture [&counter] by reference, executing them concurrently causes a data race. Commands should either own their data or synchronize externally.
See Also
- RAII — command objects that acquire resources in
execute()should release them in destructors, not inundo() - Type Erasure — the technique behind
std::function; understanding it explains its overhead - Policy-Based Design — an alternative to virtual dispatch when command behavior varies at compile time