Undo is a feature users expect and programmers dread. It requires a program to capture an object’s state at one moment and restore it later. The Memento pattern does this without breaking encapsulation. The originator (the object with state) produces a memento, an opaque snapshot of itself. A caretaker (the undo machinery) stores mementos and hands one back when asked, without ever looking inside.
The pattern exists because of mutation. An object that changes in place destroys its own past, so you must copy the past and guard it. Python offers the classic form when you need it, and obviates the pattern when state is immutable.
The beginner’s memento is an assignment, and it does not work:
# aliased_snapshot.py
todo = ["eggs", "milk"]
saved = todo
todo.append("bread")
print(saved, saved is todo)
#: ['eggs', 'milk', 'bread'] True
copied = list(todo)
todo.append("jam")
print(copied)
#: ['eggs', 'milk', 'bread']saved = todo binds a second name to the same
list, so the “snapshot” mutates along with the original.
list(todo) makes a real copy, and later changes
leave it alone. A one-level copy is enough here because the
elements are immutable strings. When state nests mutable objects
inside mutable objects, copy.deepcopy() copies all
the way down:
# nested_mutation.py
import copy
todo = [["eggs", "milk"], ["bread"]]
shallow = list(todo)
todo[0].append("cheese")
print(shallow)
#: [['eggs', 'milk', 'cheese'], ['bread']]
deep = copy.deepcopy(todo)
todo[0].append("jam")
print(deep)
#: [['eggs', 'milk', 'cheese'], ['bread']]list(todo) copies the outer list, so
shallow and todo are different
objects. But their elements are the same inner lists, so
todo[0].append("cheese") shows up in
shallow too. copy.deepcopy() walks the
whole structure and rebuilds every nested container from
scratch, so deep’s inner lists share nothing with
todo’s. The later
todo[0].append("jam") never reaches
deep.
Every classic memento is some version of copying the state
before it changes. Here the originator is a Sketch
that accumulates strokes in a list. Its memento converts that
list to a tuple, so the snapshot is immutable even though the
originator is not. restore() copies in the other
direction, rebuilding a fresh list so the sketch and the memento
never share one:
# sketch.py
from dataclasses import dataclass
@dataclass(frozen=True)
class Memento:
strokes: tuple[str, ...]
class Sketch:
def __init__(self) -> None:
self.strokes: list[str] = []
def draw(self, stroke: str) -> None:
self.strokes.append(stroke)
def save(self) -> Memento:
return Memento(tuple(self.strokes))
def restore(self, memento: Memento) -> None:
self.strokes = list(memento.strokes)
def __str__(self) -> str:
return " ".join(self.strokes) or "(blank)"
if __name__ == "__main__":
sketch = Sketch()
sketch.draw("circle")
sketch.draw("beak")
checkpoint = sketch.save()
sketch.draw("scribble")
print(sketch)
sketch.restore(checkpoint)
print(sketch)
#: circle beak scribble
#: circle beakThe caretaker’s side of the contract is restraint. Whoever
holds checkpoint stores it and gives it back. It
does not reach inside and edit the strokes. Languages with
access control enforce this opacity. In Python it is a
convention, though freezing the memento means the honest
mistakes (mutating the snapshot) fail loudly.
A type Memento = tuple[str, ...] alias would
type-check at every call site instead of the class. But an alias
is structural, not nominal. Any
tuple[str, ...] in the program satisfies it,
including one a caretaker builds or unpacks by hand. Wrapping
the tuple in a one-field dataclass gives Memento an
identity of its own. The only way to see the strokes is through
.strokes, so reaching inside becomes visible in the
code, not just a convention to honor. frozen=True
is what makes reassigning checkpoint.strokes fail
instead of silently succeeding. The tuple inside was already
immutable, but the attribute was not.
# test_sketch.py
from sketch import Sketch
def test_restore_rewinds_state() -> None:
sketch = Sketch()
sketch.draw("a")
checkpoint = sketch.save()
sketch.draw("b")
sketch.restore(checkpoint)
assert sketch.strokes == ["a"]
def test_memento_ignores_later_drawing() -> None:
sketch = Sketch()
sketch.draw("a")
checkpoint = sketch.save()
sketch.draw("b")
assert checkpoint.strokes == ("a",)
def test_drawing_after_restore_spares_memento() -> None:
sketch = Sketch()
checkpoint = sketch.save()
sketch.restore(checkpoint)
sketch.draw("x")
assert checkpoint.strokes == ()The third test checks for the subtle bug. If the memento
shared a mutable list with the sketch, as in the variant
exercise 4 explores, drawing after a restore would corrupt the
snapshot. Both save() and restore()
must copy.
All of that copying defends against mutation. Remove the mutation and there is nothing left to prevent. Make the state a frozen data class and every state is a memento:
# frozen_sketch.py
from dataclasses import dataclass, replace
@dataclass(frozen=True)
class Sketch:
title: str
strokes: tuple[str, ...] = ()
def draw(self, stroke: str) -> Sketch:
return replace(
self, strokes=(*self.strokes, stroke))
def __str__(self) -> str:
drawn = " ".join(self.strokes) or "(blank)"
return f"{self.title}: {drawn}"
if __name__ == "__main__":
before = Sketch("Duck").draw("circle").draw("beak")
after = before.draw("scribble")
print(after)
print(before)
#: Duck: circle beak scribble
#: Duck: circle beakdraw() returns a new Sketch instead
of editing this one, using dataclasses.replace() to
change one field and carry the rest along. Since each call
returns a Sketch, the calls can be chained. Saving
means keeping a reference, the move that failed in
aliased_snapshot.py. Now it is safe because no
operation anywhere can change the object bound to
before. No Memento class exists, no
save(), no restore(), and no copying
to protect the past. after shares the two original
stroke strings with before. This is the argument
made by Rethinking
Objects, as Flyweight shares
immutable values across space, and Memento shares them across
time.
# test_frozen_sketch.py
from frozen_sketch import Sketch
def test_draw_returns_new_sketch() -> None:
before = Sketch("Duck").draw("circle")
after = before.draw("beak")
assert before.strokes == ("circle",)
assert after.strokes == ("circle", "beak")
def test_replace_carries_other_fields() -> None:
assert Sketch("Duck").draw("x").title == "Duck"
def test_equal_states_compare_equal() -> None:
first = Sketch("Duck").draw("circle")
second = Sketch("Duck").draw("circle")
assert first == second and first is not secondWith states as plain immutable values, the caretaker no
longer needs to know anything about them. Undo and redo are two
stacks of past and future states, generic over the state type
(the class History[S] syntax is from Static
Typing):
# history.py
class History[S]:
def __init__(self, initial: S) -> None:
self._present = initial
self._past: list[S] = []
self._future: list[S] = []
@property
def present(self) -> S:
return self._present
def do(self, new_state: S) -> None:
self._past.append(self._present)
self._present = new_state
self._future.clear()
def undo(self) -> S:
self._future.append(self._present)
self._present = self._past.pop()
return self._present
def redo(self) -> S:
self._past.append(self._present)
self._present = self._future.pop()
return self._present
def can_undo(self) -> bool:
return bool(self._past)
def can_redo(self) -> bool:
return bool(self._future)
if __name__ == "__main__":
from frozen_sketch import Sketch
history = History(Sketch("Duck"))
history.do(history.present.draw("circle"))
history.do(history.present.draw("beak"))
print(history.present)
print(history.undo())
print(history.redo())
#: Duck: circle beak
#: Duck: circle
#: Duck: circle beakdo() pushes the present into the past and clears
the future, because acting after an undo starts a new timeline.
The states you undid are no longer reachable by redo, which is
how every editor behaves. undo() and
redo() just shuttle the present between the two
stacks. Neither checks its own precondition: undoing with no
past raises IndexError from pop().
can_undo() and can_redo() exist so
callers ask first, which is how an editor knows to gray out the
menu item. History stores whole states, not
descriptions of changes, so it never interprets anything. That
works for any state type, int to full
Sketch, with one condition: states must be
immutable. History cannot protect a list that
someone mutates in place. It would be a stack of aliases, the
bug with which this chapter opened.
# test_history.py
from history import History
def test_undo_and_redo() -> None:
history = History(0)
history.do(1)
history.do(2)
assert history.undo() == 1
assert history.undo() == 0
assert history.redo() == 1
def test_new_action_clears_redo() -> None:
history = History("a")
history.do("ab")
history.undo()
history.do("ax")
assert not history.can_redo()
assert history.present == "ax"
def test_bounds_are_reported() -> None:
history = History(0)
assert not history.can_undo()
history.do(1)
assert history.can_undo() and not history.can_redo()
history.undo()
assert history.can_redo() and not history.can_undo()The alternative design stores commands instead of states.
Each undoable action carries its own inverse, the Command
variation mentioned in Function Objects.
Command-based undo saves memory when states are huge, at the
cost of writing and testing an inverse for every action.
Snapshot-based undo is the one to try first, because immutable
states make snapshots inexpensive. Each Sketch
above shares almost all of its strokes with its neighbors in the
history.
A snapshot in memory disappears when the process ends. The
same frozen value, serialized, becomes a saved game, a session
file, or a crash-recovery point. The standard library’s
pickle turns almost any Python object into bytes
and back:
# round_trip.py
import pickle
from frozen_sketch import Sketch
drawing = Sketch("Duck").draw("circle").draw("beak")
restored = pickle.loads(pickle.dumps(drawing))
print(restored == drawing, restored is drawing)
#: True FalseThe bytes from pickle.dumps() can go to a file
and come back in a different process, days later. The round trip
produces a different object with the same value, which is all a
memento needs, since frozen data classes compare by value.
Only unpickle data you trust, because the format can execute
code. For untrusted storage or other languages, convert the
state with dataclasses.asdict() and write JSON,
which one of the exercises explores.
Pickle’s other limitation is time. The bytes encode a class
by module and name, not by the shape that class had at save
time. If Sketch gains, loses, or renames a field
before the load happens, pickle.loads() still
succeeds. What breaks is everything that touches the missing
piece. Splitting the class into its own module keeps the
simulation honest, since real drift happens between two separate
runs of a program, not inside one script:
# sketch_v1.py
from dataclasses import dataclass
@dataclass(frozen=True)
class SketchV1:
strokes: tuple[str, ...]# pickle_drift.py
import pickle
from dataclasses import dataclass
import sketch_v1
from exceptions import ignore
from sketch_v1 import SketchV1
blob = pickle.dumps(SketchV1(("circle", "beak")))
@dataclass(frozen=True)
class SketchV2:
strokes: tuple[str, ...]
title: str
sketch_v1.SketchV1 = SketchV2 # type: ignore
restored = pickle.loads(blob)
print(restored.strokes)
#: ('circle', 'beak')
with ignore(AttributeError):
print(restored.title)
#: AttributeError("'SketchV2' object has no attribute 'title'")blob is written while
sketch_v1.SketchV1 still means the one-field class.
sketch_v1.SketchV1 = SketchV2 stands in for that
module being edited and reloaded, with a field added between the
save and the load. The type checker flags that reassignment as
unsound so it carries a # type: ignore. There isn’t
a practical way to declare that SketchV1 can become
a different class. pickle.loads() never calls
__init__. It looks up the class by the name pickle
recorded, sketch_v1.SketchV1. That name now points
at SketchV2. pickle.loads() builds a
bare SketchV2 and copies in only the fields the old
bytes had. title is simply absent, since the old
bytes never had one. The same shortcut skips
__post_init__, so a memento saved before a
validated field existed can load a value nothing ever validated.
restored.strokes works because both versions agree
on that field. restored.title fails if anything
asks for it, which is often nowhere near the line that called
pickle.loads(). Pickle is convenient because it
hides this contract. Nothing enforces that the class on load
matches the class on save.
Drift in the other direction is quieter still. Delete or
rename a field, and old bytes load with no error anywhere, ever.
The stale name arrives in the object’s __dict__ as
a ghost attribute, readable but invisible to the class
definition, while the renamed field is simply missing. The
added-field drift above at least fails when something touches
the gap. The removed-field drift never raises an exception. The
data is just quietly wrong.
Databases hit the same problem and gave it a name. A schema migration is the disciplined version of this drift, a versioned, deliberate step that moves the table shape and its data forward together, instead of discovering the mismatch when a query runs.
When either limitation rules out pickle, there
are open-source libraries. msgspec and
pydantic both validate on load. A shape mismatch
raises a clear error at the boundary, instead of the delayed
AttributeError from pickle_drift.py.
Protocol Buffers goes further. Every field gets an explicit,
numbered slot in a schema shared across languages. Old and new
versions can then read each other’s messages on purpose, not by
accident. None of the three execute the bytes they read, so none
carry pickle’s security risk either.
Version control is the memento pattern at industrial scale. A
git commit is an immutable snapshot of your whole tree, checkout
is restore(), and git shares unchanged content
between commits just as History shares unchanged
strokes between states. Databases hand out savepoints, mementos
scoped to a transaction. Multiplayer games snapshot the world so
they can rewind and replay when a late packet arrives. Whenever
you see rewind, rollback, or restore, something is producing
mementos.
erase() to both sketches. It removes the
last stroke. In sketch.py it mutates. In
frozen_sketch.py it returns a new
Sketch. Write tests proving existing mementos and
histories are unaffected in each version.History a maximum depth. When the past
grows beyond n states, discard the oldest. What
should happen to can_undo()?Sketch to JSON using
dataclasses.asdict() and reconstruct it. What did
the round trip change that pickle preserved, and
where must your reconstruction compensate?sketch.py so Memento holds
the list instead of a tuple copy, then write the test that
exposes the corruption. Which of the three tests in
test_sketch.py catches it first?goto(steps_back) to History:
jump the present several states into the past in one call,
keeping redo consistent.