Prototype¶
Intent¶
Create an object by copying an existing, already-configured one instead of running a constructor that would rebuild the same state. The client asks an instance for a copy, so it needs to know neither the concrete class nor how the original was assembled.
When to use and when not to¶
Use it when
- Reaching the configured state is the expensive part — a file, a query, a twenty-call builder. Copying is a memory operation; rebuilding is not: ~500 µs for a datacenter round trip against ~3 µs to read 1 MB from memory, ~170x.
- You explore alternatives from one starting point and discard most of them: game-tree search, what-if pricing, a speculative transaction.
- The variants are data, so a registry of configured instances replaces a subclass or a factory branch per variant.
Leave it out when
- The constructor is cheap and complete;
Board(squares, side)beatsprototype.clone()when nothing was configured. - The state has identity that must not be duplicated — an open connection, a lock, a primary key — so two clones each believe they own one resource.
- You want the same object moved back in time rather than a second object: that is Memento.
- The change is one field of a value object, which
dataclasses.replacespells better, or the fork is cheaper to undo than to copy — a search that can unmake a move should not clone a position.
Structure¶
Four roles: the Prototype interface, a Concrete Prototype that copies itself, a registry of configured instances, and a client that clones instead of constructing.
classDiagram
class Prototype {
<<interface>>
+clone() Self
}
class Board {
+squares: Dict~Square,Piece~
+side_to_move: Side
+move_log: List~str~
+clone() Board
+apply(move: Move) Board
+push(move: Move) Piece
+pop(move: Move, captured: Piece) None
}
class Piece {
+kind: PieceKind
+side: Side
+symbol: str
}
class PrototypeRegistry {
+register(name: str, prototype: Prototype) None
+create(name: str) Prototype
+names: Tuple~str~
}
class MoveSearch {
+clones: int
+count_by_cloning(board: Board, depth: int) int
+count_in_place(board: Board, depth: int) int
}
Prototype <|.. Board
Board o-- Piece : shares frozen values
PrototypeRegistry o-- Prototype : stores and clones
MoveSearch ..> Board : one clone per branch
MoveSearch never names a class or a constructor; it calls clone. Board decides per field what is copied and what is shared; PrototypeRegistry holds instances, so a new position is data.
Canonical example in Python¶
The interface and the prototype come first (code/patterns/prototype.py, tested by code/patterns/tests/test_prototype.py):
@runtime_checkable
class Prototype(Protocol):
"""The interface: an object that can hand back an independent copy of itself.
``Self`` ties the return type to the receiver, so a clone of a ``Board`` is a
``Board``. A ``Protocol`` rather than an ``ABC``: nothing inherits from this,
a class qualifies by having a matching ``clone`` method.
"""
def clone(self) -> Self: ...
class Side(StrEnum):
WHITE = "white"
BLACK = "black"
def other(self) -> Side:
return Side.BLACK if self is Side.WHITE else Side.WHITE
class PieceKind(StrEnum):
KING = "k"
ROOK = "r"
type Square = str
type Move = tuple[Square, Square]
@dataclass(frozen=True, slots=True)
class Piece:
"""Immutable, therefore shared by every clone instead of copied into each one.
This is the decision that makes cloning cheap: a board copies its *mapping*
of squares to pieces, never the pieces, because no holder can change one.
"""
kind: PieceKind
side: Side
@property
def symbol(self) -> str:
return self.kind.upper() if self.side is Side.WHITE else str(self.kind)
@dataclass(slots=True)
class Board:
"""The Concrete Prototype: mutable state that a search wants to fork cheaply.
``clone`` copies field by field and that is the whole pattern: a new ``dict``
and a new ``list`` because both are mutable, the same ``Piece`` objects
because they are frozen, and ``side_to_move`` by value. The cost is one
sentence you can defend: O(occupied squares), no constructor logic re-run.
"""
squares: dict[Square, Piece] = field(default_factory=dict)
side_to_move: Side = Side.WHITE
move_log: list[str] = field(default_factory=list)
def clone(self) -> Board:
"""A new board that shares nothing mutable with this one."""
return Board(dict(self.squares), self.side_to_move, list(self.move_log))
def moves(self) -> list[Move]:
"""Every one-square orthogonal step for the side to move, in a fixed order."""
found: list[Move] = []
for source in sorted(self.squares):
piece = self.squares[source]
if piece.side is not self.side_to_move:
continue
for target in neighbours(source):
occupant = self.squares.get(target)
if occupant is None or occupant.side is not piece.side:
found.append((source, target))
return found
def apply(self, move: Move) -> Board:
"""Return a *new* board with the move made; the receiver is untouched."""
child = self.clone()
child.push(move)
return child
def push(self, move: Move) -> Piece | None:
"""Make the move in place and return the captured piece, if any.
The return value is the undo record. Handing it to the caller keeps it on
the recursion stack, which is why make/unmake needs no memory of its own.
"""
source, target = move
piece = self.squares.get(source)
if piece is None:
raise ValidationError(f"no piece on {source}")
if piece.side is not self.side_to_move:
raise InvalidStateError(f"{self.side_to_move} to move, not {piece.side}")
captured = self.squares.pop(target, None)
del self.squares[source]
self.squares[target] = piece
self.move_log.append(f"{piece.symbol}{source}{target}")
self.side_to_move = self.side_to_move.other()
return captured
def pop(self, move: Move, captured: Piece | None) -> None:
"""Unmake the move: O(1), the alternative to a clone per branch."""
source, target = move
if target not in self.squares:
raise InvalidStateError(f"nothing on {target} to unmake")
self.squares[source] = self.squares.pop(target)
if captured is not None:
self.squares[target] = captured
self.move_log.pop()
self.side_to_move = self.side_to_move.other()
def render(self) -> str:
"""One group of characters per rank, highest rank first."""
ranks: list[str] = []
for rank in reversed(RANKS):
cells = []
for file in FILES:
piece = self.squares.get(f"{file}{rank}")
cells.append(piece.symbol if piece is not None else ".")
ranks.append("".join(cells))
return " ".join(ranks)
def neighbours(square: Square) -> list[Square]:
"""The squares one orthogonal step away that are still on the board."""
file_index, rank_index = FILES.index(square[0]), RANKS.index(square[1])
targets = []
for file_step, rank_step in ORTHOGONAL_STEPS:
file, rank = file_index + file_step, rank_index + rank_step
if 0 <= file < len(FILES) and 0 <= rank < len(RANKS):
targets.append(f"{FILES[file]}{RANKS[rank]}")
return sorted(targets)
Three decisions to say out loud:
Self, notBoard, in the Protocol ties the return type to the receiver, so a clone of aBoardtype-checks as one.- The clone is a per-field decision, and that is the whole pattern.
dict(self.squares)andlist(self.move_log)are new containers because both are mutable;side_to_movecopies by value; thePieceobjects are shared, because a frozen dataclass cannot change under either holder. Deep or shallow is a property of each field, not of the copy, and the cost follows: O(occupied squares). pushreturns the undo record instead of storing it, so make/unmake keeps its bookkeeping on the recursion stack and the board carries no history a clone would copy.
The registry and the client cover the two reasons the pattern exists:
class PrototypeRegistry[P: Prototype]:
"""The Prototype Manager: named, already-configured instances that ``create`` clones.
It never calls a constructor and never learns a class name, so adding a
position is registering an object rather than writing a subclass or a
builder. Handing out ``clone()`` and not the stored object is the invariant:
a caller that mutated the prototype would poison every later ``create``.
"""
def __init__(self) -> None:
self._prototypes: dict[str, P] = {}
def register(self, name: str, prototype: P) -> None:
if name in self._prototypes:
raise ConflictError(f"prototype {name!r} is already registered")
self._prototypes[name] = prototype
def create(self, name: str) -> P:
prototype = self._prototypes.get(name)
if prototype is None:
raise NotFoundError(f"no prototype named {name!r}")
return prototype.clone()
@property
def names(self) -> tuple[str, ...]:
return tuple(sorted(self._prototypes))
class MoveSearch:
"""The Client: it explores a tree of positions and must not disturb the caller's board.
``count_by_cloning`` forks a board per branch, which is correct by
construction and costs one clone per edge. ``count_in_place`` walks the same
tree with make/unmake and allocates nothing. Both return the same number;
which one you reach for is a cost decision, not a correctness one.
"""
def __init__(self) -> None:
self.clones = 0
def count_by_cloning(self, board: Board, depth: int) -> int:
if depth < 0:
raise ValidationError("depth cannot be negative")
if depth == 0:
return 1
total = 0
for move in board.moves():
self.clones += 1
total += self.count_by_cloning(board.apply(move), depth - 1)
return total
def count_in_place(self, board: Board, depth: int) -> int:
if depth < 0:
raise ValidationError("depth cannot be negative")
if depth == 0:
return 1
total = 0
for move in board.moves():
captured = board.push(move)
total += self.count_in_place(board, depth - 1)
board.pop(move, captured)
return total
create returns prototype.clone(), never the stored object; one caller that mutated the prototype would poison every later create. MoveSearch walks the same tree twice on purpose: cloning per branch cannot corrupt the parent, while make/unmake allocates nothing and relies on pop being an exact inverse. Both find 51 positions at depth 3, and the forking walk pays 63 clones for it — a cost decision, not a correctness one.
Running python -m patterns.prototype prints:
--- a registry of configured objects, not of constructors ---
registered: ('bare_kings', 'duel')
two creates, two objects: True, equal state: True
mutated the first: ..kr .... R... .K..
the second is cold: ..kr .... .... RK..
the stored prototype is untouched: True
--- clone copies the mutable containers and shares the frozen pieces ---
same piece object: True
different dict: True
copy.copy shares the dict, so the original moved too: ..kr .... .K.. R...
and the halves disagree: white vs black to move
--- fork per branch, or make and unmake: same tree, different cost ---
depth 3: 51 positions, 63 clones
depth 3: 51 positions, 0 clones
the caller's board survived both: True
--- the Pythonic forms: dataclasses.replace and a deepcopy hook ---
replace: 90+30 -> 3+2, rated still True
deepcopy: shared engine True, notes ['rook lift looks strong']
the branch's evaluation is visible on the session: 1
rejected: no prototype named 'sicilian'
The two copy.copy lines show the failure mode: a shallow copy shares containers but rebinds scalars.
Pythonic variant¶
Python ships the pattern, so a hand-written clone must justify itself. copy.deepcopy is generic and never rots when a field is added, dataclasses.replace is clone-with-changes on a frozen value, and __deepcopy__ declares what to share:
@dataclass(frozen=True, slots=True)
class GameSettings:
"""A frozen value object: ``dataclasses.replace`` is its clone-with-changes."""
variant: str
minutes: int
increment_seconds: int
rated: bool = True
def as_blitz(settings: GameSettings) -> GameSettings:
"""No ``clone`` method and no builder: one call returns a new value, two fields changed.
``replace`` re-runs ``__init__``, so ``__post_init__`` validation still fires
and a misspelled field name is a ``TypeError`` at the call, not a silent
attribute on the copy.
"""
return replace(settings, minutes=3, increment_seconds=2)
class Engine:
"""An expensive shared resource. Copying one would be a bug, not a clone."""
def __init__(self, name: str) -> None:
self.name = name
self.evaluations = 0
def evaluate(self, board: Board) -> int:
self.evaluations += 1
return sum(1 if piece.side is Side.WHITE else -1 for piece in board.squares.values())
@dataclass(slots=True)
class AnalysisSession:
"""``copy.deepcopy`` copies the entire reachable graph; ``__deepcopy__`` says what not to.
Without the hook, deep-copying a session would duplicate the engine (and,
in real code, the connection pool, the lock and the logger behind it).
Registering the clone in ``memo`` before copying anything that could point
back here is what stops ``deepcopy`` looping on a cycle in the graph.
"""
board: Board
engine: Engine
notes: list[str] = field(default_factory=list)
def __deepcopy__(self, memo: dict[int, Any]) -> AnalysisSession:
clone = AnalysisSession(self.board.clone(), self.engine, list(self.notes))
memo[id(self)] = clone
return clone
replacere-runs__init__, so validation fires again and a misspelled field is aTypeErrorat the call, not a stray attribute on the copy. Python 3.13 generalises it beyond dataclasses ascopy.replace().deepcopycopies too much by default. It follows every reference it can reach, duplicating the engine, pool or lock hanging off your object. Write thememoentry before copying anything that can point back, or a cycle never ends.
| Reach for | When |
|---|---|
| Nothing | The constructor is cheap, or the fork is better as make/unmake |
dataclasses.replace |
A frozen value with one or two fields changed |
copy.copy |
Every field is immutable or shared on purpose |
copy.deepcopy |
You want the whole graph and accept O(graph) |
__deepcopy__ or __copy__ |
Something reachable must be shared, or a cached field dropped |
A hand-written clone |
Selective copying on a hot path |
Say the last row out loud: clone is an optimisation over deepcopy, bought with a maintenance hazard — add a field, forget the clone, and the bug is a silently shared list.
Real-world usage¶
- The
copymodule is the pattern.copy.copyandcopy.deepcopydispatch to__copy__and__deepcopy__, thread amemodict so cycles terminate and shared subobjects stay shared, and otherwise fall back to__reduce_ex__— which is why anything picklable is already copyable. - Clone-with-changes is everywhere on immutable types —
datetime.replace,namedtuple._replace,Path.with_suffix— and every container ships a shallow clone:dict.copy,list.copy,collections.ChainMap.new_child. - Frameworks: a Django
QuerySetclones itself on everyfilter(), so chaining never mutates the queryset you started from; duplicating a row is thepk = None; save()idiom. JavaScript's prototypes delegate to an exemplar rather than copying one.
Related patterns and confusions¶
The creational patterns all produce objects; classify by what the creator knows.
| Looks like Prototype | How to tell them apart |
|---|---|
| Memento | Both may call deepcopy. A prototype is copied to get a new object that lives its own life; a memento, to bring the same object back. A memento is also opaque — only its originator reads it — while a clone is a usable peer. |
| Factory Method / Abstract Factory | A factory calls a constructor and knows the classes; PrototypeRegistry stores instances and knows none, so it is a factory whose products are copies. |
| Builder | A builder assembles step by step, and it is what you run once to produce the object worth registering. The two compose. |
| Flyweight | Opposite intent: a flyweight is the same shared object you must not change, a clone is a distinct copy you are expected to change. Piece is a flyweight inside a prototype. An Object Pool is a third relative — it lends objects and takes them back, while nobody returns a clone. |
| Copy-on-write | An optimisation of cloning, not a rival: share, copy on first write. A clone whose fields are all immutable is already copy-on-write. |
Where it appears in LLD problems¶
- Design chess — clone the board, play the candidate move, ask whether your own king is attacked, drop the clone. Volunteer the follow-up: a real engine uses make/unmake, this page's
push/pop. - Design a text editor with undo and redo — cloning the buffer gives a checkpoint you can branch from; the same copy kept for restore is a memento.
- Design an in-memory key-value store with transactions —
BEGINcopies the map so the transaction scribbles on its own version; a write-set of the changed keys only is the same clone-versus-delta trade.
Interview tips¶
Interview tip
Answer the copy question before naming the pattern: "the clone gets a new dict and a new move log because both are mutable, and the same Piece objects because they are frozen." That proves you know deep versus shallow is decided per field. Then be honest about Python: copy.deepcopy is the default, and clone is an optimisation you justify with a cost.
Common mistake
A shallow clone that shares a mutable container. Board(self.squares, ...) without the dict(...) gives two objects that move together, and the first symptom is a search result that depends on the order the branches ran in. Runner-up: cloning something with identity — a connection, a lock, a primary key — leaving two objects that each believe they own it.
Related¶
- Memento — a copy kept to restore, not to fork
- Flyweight — shared immutable state, what makes a clone cheap
- Design chess — cloning a position versus make/unmake
- Design a text editor with undo and redo — buffer checkpoints
- Design an in-memory key-value store with transactions — a copied map per savepoint
- Gamma, Helm, Johnson and Vlissides, Design Patterns (1994), Prototype
- Python documentation:
copy— Shallow and deep copy operations - Python documentation:
dataclasses.replace