Course contents

Command — actions as first-class objects

Sign in to run this lab and save your progress

Learn

Before you start

Command is what turns "action" from a verb into a noun. Once actions are objects, you can do things you can't do with plain method calls: queue them, log them, undo them, replay them, serialize them, ship them across a network.

The one-line version

Command wraps each action (execute + undo) in an object so the surrounding system can treat actions like data.

The signal to use Command

Ask: "do I want to treat this action as data?" If yes to any:

  • Undo / redo — the classic use case
  • Queue for later — job scheduler, dry-run mode
  • Log / audit trail — record what happened
  • Macro / replay — save a sequence, play it back
  • Cross-process action — serialize and send
  • Transaction rollback — same shape as undo

Command is the pattern behind ALL of these. Whichever requirement fires first, the shape is the same.

Where Command shows up in real code

  • Every editor's undo/redo (Word, Photoshop, VS Code, every IDE ever)
  • Database transactions — SQL statements as commands with implicit inverses
  • Message queues — each queued message is a command
  • Redux / Vuex — dispatched "actions" ARE Commands
  • Bash history + Ctrl+R — command replay
  • CI systems — pipeline steps as commands
  • Automation frameworks — Selenium, Cypress record commands
  • Kubernetes rollbacks — deployments as commands

The undo challenge

Commands are easy to execute(). The hard part is undo(). For InsertCommand, you know how many characters you added, so undo removes them — simple.

For DeleteCommand, you need to remember what you deleted so you can put it back. That "remember what I removed" step must happen at execute() time, because by undo() time the text that was there is gone.

This lesson tests that specifically. It's a subtle bug — a naive DeleteCommand that captures state at __init__ instead of execute() breaks when the command is applied later than you expected.

The redo chain semantics

Standard editor behavior: after undo(), the undone action moves to a redo stack. redo() runs it again. Fine so far.

But if the user does something NEW after undoing, the redo stack must clear. Otherwise you could:

  1. Type "hello"
  2. Undo
  3. Type "world"
  4. Redo — restore "hello"?? No — that history was invalidated.

Every real editor works this way. Our Editor implements this via _undone.clear() inside apply(). Missing this is a common bug.

Command vs Strategy vs Observer

Three behavioral patterns that get mixed up:

  • Strategy — one operation, many algorithms. The Strategy object provides the "how".
  • Command — one action wrapped as an object so it can be handled like data. The Command object provides the "what" + "how to reverse".
  • Observer — an event, notified to N reactions. The Observer callbacks provide the "who cares".

Different problems. Different patterns.

The macro extension

Once you have Command, macros are trivial. A MacroCommand holds a list of commands, .execute() runs them all, .undo() runs them all in REVERSE order.

class MacroCommand(Command):
    def __init__(self, commands):
        self._cmds = commands
    def execute(self, editor):
        for c in self._cmds:
            c.execute(editor)
    def undo(self, editor):
        for c in reversed(self._cmds):
            c.undo(editor)

That's the composability payoff Command gives you.

The decision cheat-sheet

Situation Pattern
Actions need undo Command
Actions need to be queued for later Command
Actions need to be logged / replayed Command
Actions need to be sent over the network Command (+ serialization)
One operation, multiple algorithms Strategy
One event, multiple reactions Observer
Object behavior changes based on its state State (next lesson)

Your task

Command turns each action into an OBJECT with execute() and undo(). Once actions are objects, you can queue them, log them, undo them, replay them, or send them across a network.

The problem

You're building a text editor. Users type, delete, replace. They also press Ctrl+Z (undo) and Ctrl+Y (redo). And they can record macros ("do this sequence of actions again").

Naive first attempt — the Editor exposes methods:

class Editor:
    def insert(self, pos, text): ...
    def delete(self, pos, length): ...

How do you undo an insert? You need to know WHERE it happened and HOW MANY characters were inserted, so you can delete them back out. That state has to live somewhere. The usual way: an "undo history" list of tuples like ("insert", pos, text). Every action type needs a matching undo case. If/elif on action type in the undo handler.

Command turns each action into a self-contained object that knows how to execute AND how to undo itself.

Questions to ask about the requirements

  1. Do you need UNDO? Yes → Command is the natural fit. No → probably don't need Command.
  2. Do you need REDO (re-do an undone action)? Command supports this naturally — it keeps the executed commands.
  3. Do you need QUEUING (schedule an action for later, batch actions)? Command works — an action is just an object you can hold onto.
  4. Do you need LOGGING ("what actions happened in this session")? Command's execute() history is your log.
  5. Do you need MACROS (replay a sequence of actions)? A macro is just a list of Commands you store and replay.

If yes to any 2-3 of these, Command fits.

For our editor: yes undo, yes redo, macros are on the roadmap. Command fits.

Different designs, compared

Design A — inline actions + a separate undo history

class Editor:
    def __init__(self):
        self.text = ""
        self.undo_history = []

    def insert(self, pos, text):
        self.text = self.text[:pos] + text + self.text[pos:]
        self.undo_history.append(("insert", pos, len(text)))

    def undo(self):
        action, *args = self.undo_history.pop()
        if action == "insert":
            pos, length = args
            self.text = self.text[:pos] + self.text[pos + length:]
        elif action == "delete":
            ...
  • Good: Simple.
  • Bad: Undo logic lives far from execute logic. If/elif on action type in undo. Every new action needs edits in two places. Macros would be a whole new mechanism.
  • When it wins: 2 action types, no macros, no redo.

Design B — one big class with a state stack

Everything on the Editor: execute, undo, snapshots. This is the Memento pattern — save the whole editor state every time.

  • Good: Undo is trivial (restore the snapshot).
  • Bad: Uses a lot of memory for big documents. No individual "what was this action" object; you can only think in terms of "whole-document state".
  • When it wins: Small documents. Some graphics editors.

Design C — Command with execute/undo

class Command(ABC):
    @abstractmethod
    def execute(self, editor): ...
    @abstractmethod
    def undo(self, editor): ...

class InsertCommand(Command):
    def __init__(self, pos, text):
        self.pos = pos
        self.text = text
    def execute(self, editor):
        editor.text = editor.text[:self.pos] + self.text + editor.text[self.pos:]
    def undo(self, editor):
        start, end = self.pos, self.pos + len(self.text)
        editor.text = editor.text[:start] + editor.text[end:]

class Editor:
    def __init__(self):
        self.text = ""
        self._done = []      # commands successfully executed
        self._undone = []    # commands undone but re-doable

    def apply(self, cmd):
        cmd.execute(self)
        self._done.append(cmd)
        self._undone.clear()   # new action invalidates redo

    def undo(self):
        if not self._done:
            return
        cmd = self._done.pop()
        cmd.undo(self)
        self._undone.append(cmd)

    def redo(self):
        if not self._undone:
            return
        cmd = self._undone.pop()
        cmd.execute(self)
        self._done.append(cmd)
  • Good: Each command is self-contained. Adding a new action is one class. Undo, redo, and macros all fall out for free. Actions are serializable (you can save an edit session to disk).
  • Bad: More classes. Each command has to store the state it needs to undo — sometimes that's a lot.
  • When it wins: All the requirements we gathered.

The locked-in choice: Design C

Reviewing the requirements:

  • Undo, redo, future macros. → Design C is the natural fit.
  • Simple text editor. → Not memory-tight enough for Memento to win.

Design C wins.

Where Command shows up in real code

  • Every text editor's undo/redo system.
  • Every graphical editor (Photoshop, Figma, Sketch).
  • Database transactions — each SQL statement is a command with an implicit inverse.
  • Message queues — each queued message is a command (execute-later semantics).
  • Redux / Vuex actions — each dispatched action is a command applied to a store.
  • CLI's --dry-run mode — build commands but don't execute them yet.
  • Test harnesses that record + replay user interactions.

Any time you see "execute + undo + queue + replay" in the same system — Command.

Your task

In main.py build:

  1. Abstract Command(ABC) with execute(editor) and undo(editor).
  2. InsertCommand(Command):
    • __init__(self, pos, text) stores both
    • execute(editor) — insert self.text at self.pos
    • undo(editor) — remove the same chars back out
  3. DeleteCommand(Command):
    • __init__(self, pos, length) stores both
    • execute(editor) — REMEMBER the deleted text (store on self as self._removed), then remove it
    • undo(editor) — re-insert self._removed at self.pos
  4. Editor:
    • __init__self.text = "", self._done = [], self._undone = []
    • apply(cmd) — execute, push to _done, clear _undone
    • undo() — pop _done, undo, push to _undone. No-op if _done is empty.
    • redo() — pop _undone, execute (again), push to _done. No-op if _undone is empty.

Structural tests enforce:

  • Redo works after undo, up until a NEW apply clears the undo stack.
  • DeleteCommand's undo restores the exact deleted text (proving it captured state on execute).
  • Undo/redo stacks track correctly across many operations.

Click Submit to run the tests.

Starter files

Preview only — open the lab to edit and run.

files_dir
13-command-starter