Command¶
Intent¶
Package a request as an object: what to do, to whom, with which arguments, and how to reverse it. Whoever triggers it (a key press, a timer, a worker thread) needs only execute, so the same object can sit on an undo stack, wait in a queue or be written to a log, and the receiver never knows.
When to use and when not to¶
Use it when
- Undo and redo are requirements. A command stores the change and its inverse, O(change) per step, where a snapshot costs O(state): 100 keystrokes on a 1 MB document cost 100 x 1 MB = 100 MB as snapshots, a few bytes each as commands.
- Execution happens elsewhere or later: a scheduler at the due time, a worker pool on another thread.
- You need a record of what happened, not only the result: an audit trail, a macro recorder.
- Several requests must behave as one: a replace is a delete plus an insert, undone together.
Leave it out when
- The request runs immediately and is never stored; a method call is a command with none of the ceremony.
- Only the behaviour varies, with no inverse and no deferral: that is Strategy or a callback.
- Undo has no cheap inverse (a reformat touching every line, a chess move with castling rights): snapshot the state instead, which is Memento, possibly carried inside the command.
Structure¶
Four roles: the Command interface, concrete commands carrying their arguments and their inverse, the Receiver that does the work, and the Invoker that runs and stacks them; the Client is main.
classDiagram
class Command {
<<abstract>>
+execute() None
+undo() None
+coalesce(following: Command) Command
+describe() str
}
class InsertText {
+position: int
+fragment: str
+execute() None
+undo() None
+coalesce(following: Command) Command
}
class DeleteText {
+position: int
+length: int
-_removed: str
+execute() None
+undo() None
}
class MacroCommand {
-_commands: Tuple~Command~
+execute() None
+undo() None
}
class Document {
+text: str
+insert(position: int, fragment: str) None
+delete(position: int, length: int) str
}
class CommandHistory {
-_undo: deque~Command~
-_redo: List~Command~
+execute(command: Command) None
+undo() None
+redo() None
+steps() List~str~
}
Command <|-- InsertText
Command <|-- DeleteText
Command <|-- MacroCommand
MacroCommand o-- "many" Command : runs in order, undoes in reverse
InsertText --> Document : receiver
DeleteText --> Document : receiver
CommandHistory o-- "many" Command : undo and redo stacks
CommandHistory depends on Command alone: it calls execute, undo and coalesce and never reads a position or a fragment. Document has no outgoing arrow; it does not know it is being undone. MacroCommand is a Composite of commands: one user action, one undo step.
Canonical example in Python¶
The receiver is an ordinary class that knows nothing about undo (code/patterns/command.py, tested by code/patterns/tests/test_command.py):
class Document:
"""The Receiver: owns the text and knows how to change it. It knows nothing about undo."""
def __init__(self, text: str = "") -> None:
self._text = text
@property
def text(self) -> str:
return self._text
def insert(self, position: int, fragment: str) -> None:
if not 0 <= position <= len(self._text):
raise ValidationError(f"position {position} is outside the document")
self._text = self._text[:position] + fragment + self._text[position:]
def delete(self, position: int, length: int) -> str:
"""Remove ``length`` characters at ``position`` and return them, so a caller can put them back."""
if length < 0 or not 0 <= position <= len(self._text) - length:
raise ValidationError(f"cannot delete {length} characters at {position}")
removed = self._text[position : position + length]
self._text = self._text[:position] + self._text[position + length :]
return removed
delete returns what it removed; that return value is what lets a delete command reverse itself.
The interface and the three concrete commands:
class Command(ABC):
"""The Command interface: do it, undo it, and say what it was.
An ``ABC`` rather than a ``Protocol`` because two methods have shared defaults:
``coalesce`` (most commands never merge) and ``describe`` (for the log). A
command is bound to its receiver and its arguments at construction, so whoever
executes it later needs neither.
"""
@abstractmethod
def execute(self) -> None: ...
@abstractmethod
def undo(self) -> None:
"""Reverse ``execute``. Only valid after ``execute`` has run."""
def coalesce(self, following: Command) -> Command | None:
"""One command equivalent to ``self`` then ``following``, or ``None`` to keep both."""
return None
def describe(self) -> str:
return type(self).__name__
class InsertText(Command):
"""Insert a fragment at a position; undo deletes exactly that many characters there."""
def __init__(self, document: Document, position: int, fragment: str) -> None:
self._document = document
self.position = position
self.fragment = fragment
def execute(self) -> None:
self._document.insert(self.position, self.fragment)
def undo(self) -> None:
self._document.delete(self.position, len(self.fragment))
def coalesce(self, following: Command) -> Command | None:
"""Consecutive typing is one undo step: 'Hel' then 'lo' at the next position is 'Hello'."""
if (
isinstance(following, InsertText)
and following._document is self._document
and following.position == self.position + len(self.fragment)
):
return InsertText(self._document, self.position, self.fragment + following.fragment)
return None
def describe(self) -> str:
return f"insert {self.fragment!r} at {self.position}"
class DeleteText(Command):
"""Delete a range; the removed text is captured during ``execute`` so ``undo`` can restore it.
The command carries a small memento of its own: what it needs to reverse itself
and nothing more. Before ``execute`` there is nothing to restore, hence the guard.
"""
def __init__(self, document: Document, position: int, length: int) -> None:
self._document = document
self.position = position
self.length = length
self._removed: str | None = None
def execute(self) -> None:
self._removed = self._document.delete(self.position, self.length)
def undo(self) -> None:
if self._removed is None:
raise InvalidStateError("cannot undo a delete that has not run")
self._document.insert(self.position, self._removed)
self._removed = None
def describe(self) -> str:
return f"delete {self.length} chars at {self.position}"
class MacroCommand(Command):
"""Several commands as one: a replace is a delete then an insert, undone together, in reverse.
All or nothing: if a part fails halfway, the parts that ran are undone and the
error propagates, so the history never records a half-applied step.
"""
def __init__(self, name: str, commands: Iterable[Command]) -> None:
self._name = name
self._commands = tuple(commands)
if not self._commands:
raise ValidationError("a macro needs at least one command")
def execute(self) -> None:
done: list[Command] = []
try:
for command in self._commands:
command.execute()
done.append(command)
except Exception:
for command in reversed(done):
command.undo()
raise
def undo(self) -> None:
for command in reversed(self._commands):
command.undo()
def describe(self) -> str:
return f"{self._name}: " + ", ".join(command.describe() for command in self._commands)
Three decisions to say out loud:
- The inverse is captured during
execute.DeleteTextcannot know what it will remove until it runs, so it records the removed text then and refuses toundobefore that. A command carries exactly the memento it needs, never the document. - Coalescing belongs to the command. Only
InsertTextknows that "Hel" then "lo" at the next position is one edit; the history askscoalesceand replaces the top of the stack without looking at text. Otherwise undo retreats one character at a time. - A macro is all or nothing. Parts run in order and undo in reverse; if one fails halfway, the parts that ran are undone before the error propagates.
The invoker:
class CommandHistory:
"""The Invoker: runs commands and keeps the undo and redo stacks. It never reads a command.
A command is recorded only after it succeeded, so the stacks hold nothing that
did not happen. A new command abandons the redo branch. Consecutive commands
that can ``coalesce`` share one undo step, but only when the previous one was
executed directly (``_last``), never when it was reached by undo or redo. The
undo stack is bounded, so a long session costs at most ``limit`` commands.
One history serves one document on one thread; cross-thread work goes through
a queue of commands instead (see ``run_queued``).
"""
def __init__(self, limit: int = DEFAULT_HISTORY_LIMIT) -> None:
if limit < 1:
raise ValidationError("history needs room for at least one command")
self._undo: deque[Command] = deque(maxlen=limit)
self._redo: list[Command] = []
self._last: Command | None = None
def execute(self, command: Command) -> None:
command.execute()
self._redo.clear()
merged = None
if self._undo and self._undo[-1] is self._last:
merged = self._undo[-1].coalesce(command)
if merged is None:
self._undo.append(command)
else:
self._undo[-1] = merged
self._last = self._undo[-1]
def undo(self) -> None:
if not self._undo:
raise InvalidStateError("nothing to undo")
command = self._undo.pop()
command.undo()
self._redo.append(command)
self._last = None
def redo(self) -> None:
if not self._redo:
raise InvalidStateError("nothing to redo")
command = self._redo.pop()
command.execute()
self._undo.append(command)
self._last = None
@property
def undo_depth(self) -> int:
return len(self._undo)
@property
def redo_depth(self) -> int:
return len(self._redo)
def steps(self) -> list[str]:
"""The undo steps as text, oldest first: commands are data, so the history can be shown."""
return [command.describe() for command in self._undo]
A command is pushed only after it succeeded, so a failing edit leaves both stacks as they were. A new edit clears the redo stack: after undo, then type, the old future is gone. deque(maxlen=limit) bounds the session at limit commands, and _last lets typing merge only into the edit made immediately before it, never into one reached by undo or redo.
One keystroke and its undo: the history talks to the command, the command talks to the document.
sequenceDiagram
autonumber
actor U as User
participant H as CommandHistory
participant C as InsertText
participant D as Document
U->>H: execute(insert)
activate H
H->>C: execute()
C->>D: insert(5, fragment)
D-->>C: ok
C-->>H: ok
Note over H: redo cleared, command pushed or coalesced
deactivate H
U->>H: undo()
activate H
H->>C: undo()
C->>D: delete(5, 7)
D-->>C: removed text
C-->>H: ok
Note over H: command moved to the redo stack
deactivate H
Running python -m patterns.command prints:
--- typing, undo, redo; consecutive inserts coalesce into one step ---
insert 'Hello' -> 'Hello' undo depth 1
insert ', world' -> 'Hello, world' undo depth 1 (coalesced)
replace macro -> 'Hello, there' undo depth 2
undo -> 'Hello, world' redo depth 1
undo -> '' redo depth 2
redo -> 'Hello, world' redo depth 1
insert '!' -> 'Hello, world!' redo depth 0 (branch abandoned)
undo steps, oldest first:
insert 'Hello, world' at 0
insert '!' at 12
rejected: nothing to redo
--- a macro that fails halfway leaves no trace ---
rejected: position 99 is outside the document
text 'Hello, world!', undo depth 2
--- functional variant: partials and (do, undo) pairs ---
after two steps: 'cdef'
after two undos: 'abc'
worker ran 2 queued commands -> 'x'
Pythonic variant¶
A command with one method is a callable, and functools.partial is its constructor: it binds the receiver and the arguments now so whoever holds the result can call it later, knowing neither. The undo stack then holds (do, undo) pairs:
# A command with one method is a callable. ``partial`` binds the receiver and the
# arguments now; whoever holds the result calls it later, knowing neither.
type Action = Callable[[], None]
class UndoStack:
"""Undo and redo over ``(do, undo)`` pairs: the invoker when commands are plain callables."""
def __init__(self) -> None:
self._undo: list[tuple[Action, Action]] = []
self._redo: list[tuple[Action, Action]] = []
def run(self, do: Action, undo: Action) -> None:
do()
self._undo.append((do, undo))
self._redo.clear()
def undo(self) -> None:
if not self._undo:
raise InvalidStateError("nothing to undo")
do, undo = self._undo.pop()
undo()
self._redo.append((do, undo))
def redo(self) -> None:
if not self._redo:
raise InvalidStateError("nothing to redo")
do, undo = self._redo.pop()
do()
self._undo.append((do, undo))
def insert_step(document: Document, position: int, fragment: str) -> tuple[Action, Action]:
"""The (do, undo) pair for an insert: two partials over the same receiver."""
return partial(document.insert, position, fragment), partial(document.delete, position, len(fragment))
def delete_step(document: Document, position: int, length: int) -> tuple[Action, Action]:
"""The (do, undo) pair for a delete: the closure keeps the removed text, as ``DeleteText`` kept a field."""
removed: list[str] = []
def do() -> None:
removed.append(document.delete(position, length))
def undo() -> None:
document.insert(position, removed.pop())
return do, undo
def run_queued(queue: deque[Action]) -> int:
"""Queueing: a worker drains commands it never created. Returns how many it ran."""
ran = 0
while queue:
queue.popleft()()
ran += 1
return ran
partial(document.insert, 0, "Hello")isInsertTextwithout the class. A bound method already carries its receiver;partialadds the arguments.- Closures carry the memento.
delete_stepkeeps the removed text where itsundoclosure can see it, asDeleteText._removeddid with a field. - A queue of callables is a command queue.
run_queuedis the whole worker; it never learns what it runs.
When is the callable enough?
| Reach for | When |
|---|---|
partial(method, *args) |
Deferred or queued execution with no undo: timers, executors, callbacks |
(do, undo) pairs on an UndoStack |
Undo over a few operations that need no name and no merging |
A Command subclass |
The command is inspected after creation: logged, merged, grouped, retried |
| A frozen dataclass plus a separate executor | The command crosses a process boundary and must be serialised |
Draw the class diagram, then say "in Python the command is usually a partial on a queue; I promote it to a class when it needs captured state, a log line or merging".
Real-world usage¶
contextlib.ExitStack.callback(fn, *args)andunittest.TestCase.addCleanup: undo commands pushed on a stack and run in reverse on exit, the LIFO discipline ofMacroCommand.undo.threading.Timer(interval, function, args)is a command with a due time and acancel;sched.scheduler.enterkeeps such events in a heap, the core of a task scheduler.concurrent.futures.Executor.submit(fn, *args)wraps the request into a work item, queues it for a worker thread and returns aFuture.- Frameworks: Celery's
task.delayserialises a command onto a broker; Qt'sQUndoCommand.mergeWithiscoalesce; a write-ahead log stores a redo and an undo record per change.
Related patterns and confusions¶
| Looks like Command | How to tell them apart |
|---|---|
| Memento | Command stores what was done and its inverse, O(change); Memento stores what the state was, O(state). Editors use commands for keystrokes and a memento for operations with no cheap inverse. |
| Strategy | A strategy is one way of performing an operation, chosen by the context; a command is a request to perform it, owned by whoever wants it done, possibly later. |
| Chain of Responsibility | The command is the request; the chain is how it finds a receiver. A withdrawal command travels down the denomination chain. |
| Observer | An observer is told that something happened; a command is told to make something happen. Only the second is undoable. |
| Mediator | A mediator routes messages between colleagues and decides who acts; a command is one of the messages. |
| Composite | MacroCommand is a Composite: a command made of commands, which the history treats like any other. |
Where it appears in LLD problems¶
- Design a text editor with undo and redo — the core: insert, delete and paste commands, a capped history, coalesced typing.
- Design a task scheduler (cron, LLD) — a task is a command with a due time in a heap; the worker pool runs it without knowing what it is, and a retry re-enqueues it.
- Design an elevator system — hall and cabin calls are request objects the controller queues per car.
- Design a stock brokerage system — place and cancel as commands with an audit trail.
- Design Splitwise — add, edit and delete expense as commands: undoable edits, and the activity feed for free.
Interview tips¶
Interview tip
Lead with the invariant, not the class list: "the history only holds commands that ran; undo pops, reverses and pushes to redo; a new command clears redo." Volunteer the two follow-ups before they are asked, coalesced typing and the history cap, then the Python shortcut: partial on a queue, a class only when the command must be inspected.
Common mistake
An undo that computes its inverse from the receiver's current state. A delete whose undo trusts the current length works once and breaks as soon as another command runs in between. Capture what you need during execute (the removed text, the previous value) and make undo a pure replay of it. Runner-up: pushing a command before it succeeded.
Related¶
- Memento — the state instead of the change
- Chain of Responsibility — how a request finds its receiver
- Design a text editor with undo and redo — the pattern as the core of a problem
- Design a task scheduler (cron, LLD) — commands with due times
- Design an elevator system — queued requests
- Design Splitwise — undoable edits
- Gamma, Helm, Johnson and Vlissides, Design Patterns (1994), Command
- Python documentation:
contextlib.ExitStack - Qt documentation:
QUndoStack