Command Pattern in Python: Undo/Redo Implementation
The Command pattern is a behavioral design pattern that turns requests into standalone objects. Instead of calling methods directly on receivers, you wrap the operation, its parameters, and the...
Key Insights
- The Command pattern transforms operations into objects, enabling undo/redo by storing execution history in stacks and reversing operations on demand.
- A well-designed
CommandManagerhandles the undo/redo stack manipulation, clearing the redo stack when new commands execute to maintain consistency. - Each command must capture enough state at execution time to fully reverse itself—this is the critical detail most implementations get wrong.
Introduction to the Command Pattern
The Command pattern is a behavioral design pattern that turns requests into standalone objects. Instead of calling methods directly on receivers, you wrap the operation, its parameters, and the target into a command object that can be stored, passed around, and executed later.
This encapsulation is what makes undo/redo possible. When every operation is an object with both execute() and undo() methods, you can maintain a history of commands and reverse them in order.
Here’s the foundation:
from abc import ABC, abstractmethod
from typing import Any
class Command(ABC):
"""Base class for all commands."""
@abstractmethod
def execute(self) -> Any:
"""Execute the command."""
pass
@abstractmethod
def undo(self) -> None:
"""Reverse the command's effects."""
pass
@property
def description(self) -> str:
"""Human-readable description for UI/logging."""
return self.__class__.__name__
The undo() method is what separates a command pattern implementation from a simple function wrapper. Every command must know how to reverse itself.
Anatomy of the Command Pattern
Four components make up the pattern:
- Command: The abstract interface defining
execute()andundo() - Concrete Commands: Implementations that perform specific operations
- Receiver: The object being acted upon (e.g., a document, canvas, or database)
- Invoker: Manages command execution and history (our
CommandManager)
# Component relationships:
#
# Client creates ConcreteCommand with Receiver reference
# │
# ▼
# ┌─────────────┐ executes ┌─────────────┐
# │ Invoker │ ───────────────▶│ Command │
# │ (Manager) │ │ (Abstract) │
# └─────────────┘ └─────────────┘
# │ │
# stores in implements
# history │
# │ ▼
# │ ┌─────────────┐
# │ │ Concrete │
# │ │ Command │
# │ └─────────────┘
# │ │
# │ calls │
# │ ▼
# │ ┌─────────────┐
# └───────────────────────▶│ Receiver │
# └─────────────┘
This decoupling means the invoker doesn’t need to know anything about what commands actually do. It just calls execute() and undo(). This makes testing straightforward—you can test commands in isolation with mock receivers.
Building the Undo/Redo Stack
The CommandManager is where the magic happens. It maintains two stacks: one for commands that can be undone, another for commands that can be redone.
The critical rule: executing a new command clears the redo stack. If you undo three actions, then perform a new action, those three undone commands are gone. This matches user expectations from every text editor and design tool.
from typing import Optional
class CommandManager:
"""Manages command execution with undo/redo support."""
def __init__(self, max_history: int = 100):
self._undo_stack: list[Command] = []
self._redo_stack: list[Command] = []
self._max_history = max_history
def execute(self, command: Command) -> Any:
"""Execute a command and add it to history."""
result = command.execute()
self._undo_stack.append(command)
self._redo_stack.clear() # Critical: new action invalidates redo history
# Prevent unbounded memory growth
if len(self._undo_stack) > self._max_history:
self._undo_stack.pop(0)
return result
def undo(self) -> Optional[Command]:
"""Undo the last command. Returns the undone command or None."""
if not self._undo_stack:
return None
command = self._undo_stack.pop()
command.undo()
self._redo_stack.append(command)
return command
def redo(self) -> Optional[Command]:
"""Redo the last undone command. Returns the redone command or None."""
if not self._redo_stack:
return None
command = self._redo_stack.pop()
command.execute()
self._undo_stack.append(command)
return command
@property
def can_undo(self) -> bool:
return len(self._undo_stack) > 0
@property
def can_redo(self) -> bool:
return len(self._redo_stack) > 0
Note the max_history parameter. Without bounds, your undo stack will grow indefinitely. For a text editor handling thousands of keystrokes, this matters.
Practical Implementation: Text Editor
Let’s build a minimal text editor with insert, delete, and replace operations. The receiver is a TextDocument class:
class TextDocument:
"""The receiver: a simple text document."""
def __init__(self, content: str = ""):
self._content = content
@property
def content(self) -> str:
return self._content
def insert(self, position: int, text: str) -> None:
self._content = self._content[:position] + text + self._content[position:]
def delete(self, position: int, length: int) -> str:
"""Delete text and return what was deleted."""
deleted = self._content[position:position + length]
self._content = self._content[:position] + self._content[position + length:]
return deleted
def __len__(self) -> int:
return len(self._content)
Now the concrete commands. Each command captures the state needed to reverse itself:
class InsertTextCommand(Command):
"""Insert text at a specific position."""
def __init__(self, document: TextDocument, position: int, text: str):
self._document = document
self._position = position
self._text = text
def execute(self) -> None:
self._document.insert(self._position, self._text)
def undo(self) -> None:
# To undo an insert, delete the same text
self._document.delete(self._position, len(self._text))
@property
def description(self) -> str:
preview = self._text[:20] + "..." if len(self._text) > 20 else self._text
return f"Insert '{preview}' at position {self._position}"
class DeleteTextCommand(Command):
"""Delete text at a specific position."""
def __init__(self, document: TextDocument, position: int, length: int):
self._document = document
self._position = position
self._length = length
self._deleted_text: str = "" # Captured during execute
def execute(self) -> str:
# Capture the deleted text so we can restore it
self._deleted_text = self._document.delete(self._position, self._length)
return self._deleted_text
def undo(self) -> None:
# To undo a delete, insert the captured text back
self._document.insert(self._position, self._deleted_text)
@property
def description(self) -> str:
preview = self._deleted_text[:20] + "..." if len(self._deleted_text) > 20 else self._deleted_text
return f"Delete '{preview}' at position {self._position}"
class ReplaceTextCommand(Command):
"""Replace text at a specific position."""
def __init__(self, document: TextDocument, position: int, length: int, new_text: str):
self._document = document
self._position = position
self._length = length
self._new_text = new_text
self._old_text: str = "" # Captured during execute
def execute(self) -> None:
self._old_text = self._document.delete(self._position, self._length)
self._document.insert(self._position, self._new_text)
def undo(self) -> None:
self._document.delete(self._position, len(self._new_text))
self._document.insert(self._position, self._old_text)
Notice how DeleteTextCommand captures _deleted_text during execute(). This is essential—you can’t undo a deletion without knowing what was deleted.
Handling Complex Undo Scenarios
Real applications often need to treat multiple commands as a single undoable action. Find-and-replace-all, for example, might make dozens of replacements that should undo together.
class MacroCommand(Command):
"""A composite command that groups multiple commands."""
def __init__(self, commands: list[Command], description: str = "Macro"):
self._commands = commands
self._description = description
def execute(self) -> None:
for command in self._commands:
command.execute()
def undo(self) -> None:
# Undo in reverse order
for command in reversed(self._commands):
command.undo()
@property
def description(self) -> str:
return self._description
# Usage: Find and replace all
def find_replace_all(
document: TextDocument,
find: str,
replace: str
) -> MacroCommand:
"""Create a macro command for find/replace all."""
commands = []
content = document.content
position = 0
while True:
index = content.find(find, position)
if index == -1:
break
# Account for length changes from previous replacements
offset = sum(
len(replace) - len(find)
for cmd in commands
)
commands.append(ReplaceTextCommand(
document,
index + offset,
len(find),
replace
))
position = index + len(find)
return MacroCommand(commands, f"Replace all '{find}' with '{replace}'")
For memory optimization with large histories, consider storing deltas or snapshots at intervals rather than every command:
class CheckpointManager:
"""Manages periodic snapshots for memory efficiency."""
def __init__(self, document: TextDocument, checkpoint_interval: int = 50):
self._document = document
self._interval = checkpoint_interval
self._checkpoints: dict[int, str] = {0: document.content}
self._command_count = 0
def record_command(self) -> None:
self._command_count += 1
if self._command_count % self._interval == 0:
self._checkpoints[self._command_count] = self._document.content
def restore_checkpoint(self, command_index: int) -> Optional[str]:
"""Find nearest checkpoint at or before the given index."""
valid_checkpoints = [k for k in self._checkpoints if k <= command_index]
if not valid_checkpoints:
return None
nearest = max(valid_checkpoints)
return self._checkpoints[nearest]
Testing Command-Based Systems
Commands are highly testable because they’re self-contained. Test each command in isolation, then test sequences:
import pytest
class TestTextCommands:
"""Unit tests for text editor commands."""
def test_insert_execute(self):
doc = TextDocument("Hello World")
cmd = InsertTextCommand(doc, 5, " Beautiful")
cmd.execute()
assert doc.content == "Hello Beautiful World"
def test_insert_undo(self):
doc = TextDocument("Hello World")
cmd = InsertTextCommand(doc, 5, " Beautiful")
cmd.execute()
cmd.undo()
assert doc.content == "Hello World"
def test_delete_captures_text(self):
doc = TextDocument("Hello Beautiful World")
cmd = DeleteTextCommand(doc, 5, 10)
deleted = cmd.execute()
assert deleted == " Beautiful"
assert doc.content == "Hello World"
def test_delete_undo_restores(self):
doc = TextDocument("Hello Beautiful World")
cmd = DeleteTextCommand(doc, 5, 10)
cmd.execute()
cmd.undo()
assert doc.content == "Hello Beautiful World"
class TestCommandManager:
"""Integration tests for undo/redo sequences."""
def test_undo_redo_cycle(self):
doc = TextDocument("")
manager = CommandManager()
manager.execute(InsertTextCommand(doc, 0, "Hello"))
manager.execute(InsertTextCommand(doc, 5, " World"))
assert doc.content == "Hello World"
manager.undo()
assert doc.content == "Hello"
manager.redo()
assert doc.content == "Hello World"
def test_new_command_clears_redo(self):
doc = TextDocument("")
manager = CommandManager()
manager.execute(InsertTextCommand(doc, 0, "A"))
manager.execute(InsertTextCommand(doc, 1, "B"))
manager.undo()
assert manager.can_redo
manager.execute(InsertTextCommand(doc, 1, "C"))
assert not manager.can_redo # Redo stack cleared
assert doc.content == "AC"
When to Use (and Avoid) This Pattern
Use the Command pattern when:
- You need undo/redo functionality
- You want to queue, log, or schedule operations
- You need transaction-like behavior with rollback
- You’re building GUI applications with action history
- You’re implementing game replay systems
Avoid it when:
- Operations are simple and don’t need reversal
- The overhead of command objects isn’t justified
- State restoration is simpler with the Memento pattern (full snapshots)
The Memento pattern is an alternative when commands are hard to reverse. Instead of storing operations, you store complete state snapshots. This uses more memory but handles complex state changes that are difficult to reverse incrementally.
For most undo/redo implementations, the Command pattern is the right choice. It’s memory-efficient, testable, and maps naturally to user actions. Start with the basic structure shown here, then extend with macro commands and memory optimization as your application demands.