00 — description
What this session is for
A persistent data structure is one in which every operation returns a new version and no existing version is ever modified. The lab asks what that costs, what it buys, and how much of the answer depends on the language in which it is written.
The vehicle is the classic two-list queue: a FIFO queue built from two singly-linked lists, in which the expensive step — reversing one list into the other — is rare enough to be paid for in advance. We derive its cost with an amortised argument, implement it three times, and then deliberately construct the case in which that argument fails. The failure is not a defect in the implementation; it is a genuine and well-known tension between amortisation and persistence, and meeting it here is the point of the session.
Three things generalise well beyond queues, and are the reason the lab is built this way. First, a specification can be language-independent while its implementation is not: one page of pseudocode yields three programs that agree on every observable number and disagree on almost everything else. Second, immutability turns version history from an expensive feature into a free consequence — the same property behind undo stacks, git's object model, and time-travelling debuggers. Third, an amortised bound is a claim about a sequence of operations, and therefore a claim that persistence is uniquely able to invalidate.
Concepts
- Persistent vs. ephemeral structures, and why the distinction is about what an operation returns, not about how it is spelled.
- Structural sharing: why a new version can be O(1) in space even when it represents a whole new collection.
- Representation, abstraction, invariant as three separate statements about one type.
- Amortised analysis via the banker's argument, and the assumption of linear use hiding inside it.
- Programming to an interface: an algorithm written against operations rather than against a representation.
Skills
- Implement a smart constructor that restores an invariant so that no other operation has to check it.
- Read an interface file as a specification — an OCaml
.mli, a Javainterface, a Python docstring — and implement against it. - Distinguish tests of correctness from tests of persistence, and recognise that an ephemeral implementation passes the first set.
- Read a micro-benchmark as evidence: run an experiment that exhibits an asymptotic claim, and identify which assumption of the proof it violates.
- Compare the same design across a dynamically typed, a functional, and an object-oriented language.
In class
The queue by hand on paper, then Parts A–D at the keyboard: the queue in Python, the maze solver it drives, the same queue in OCaml, and the benchmark that breaks the amortised bound. Each part is guarded by tests; make them pass in order.
Homework
Part E: the queue a third time in Java, plus a set of OCaml exercises. Start it only once the Python and OCaml work of the lab is running — the details are in §05.
01 — before any language
The structure, what it drives, and why it is persistent
A queue is represented by two lists. Three lines and four equations fix everything else in the lab: the search we are about to watch run, and the reason the containers had to be persistent in the first place.
The structure, specified once
representation: (front, back) abstraction: contents = front ++ reverse(back) invariant: front is empty ⟹ back is empty enqueue(x, (front, back)) = make(front, x·back) -- cons onto back, O(1) dequeue((x·front', back)) = (x, make(front', back)) -- take head of front dequeue((∅, _)) = nothing make(∅, back) = (reverse(back), ∅) -- restore the invariant make(front, back) = (front, back)
make is allowed to know about the invariant, because every other operation builds its result through it.Every operation returns a new queue and no version is ever modified. The invariant guarantees that the oldest element is always the head of front, so peek and dequeue are one pointer away — except when front is exhausted and make performs a single O(n) reversal.
Why that is acceptable: a banker's argument. Charge every enqueue two coins: one for the cons cell, one deposited on the element itself. When the reversal eventually happens, each element in back pays for its own move with the coin it is carrying. No element is ever reversed twice, so n operations cost O(n) in total — amortised O(1) per operation.
Note the word eventually, and note that the argument counts coins across a whole sequence of operations. The third point below returns to what that assumes.
The demo — same code, same maze, different search
Both panels below run identical search code over an identical maze. The only difference is which structure supplied the frontier. Drag the slider, or press play.
queue frontier · BFS
stack frontier · DFS
██ wall ██ / ██ explored ▣ / ▣ the frontier, right now S start E exit ██ / ██ path, drawn at the end
The outlined cells are the frontier — the contents of the queue or the stack at that instant — and they are the only mechanical difference between the two runs. What that difference produces is the shape of the pale region behind them. BFS spreads outwards evenly, so by the time it first reaches the exit it has necessarily arrived by a shortest route. DFS follows a single thread, commits to a corridor, and returns the first path it finds rather than the best one.
Run the slider to the end and compare the two. DFS leaves whole regions of the maze white — never visited at all — and still comes back with a path 28 steps longer than it needed to be. BFS looks at 66 more cells and is repaid in the guarantee. Neither behaviour is better; they are different guarantees, and we select between them by choosing a data structure.
Why the containers had to be persistent
A mutable queue would drive this solver perfectly well, and faster. The honest question is therefore not whether persistence works here, but what it is for. There are three answers, in increasing order of how much they matter.
1. For the search itself, it was not necessary
This is worth stating plainly, because it is the honest starting point. An ordinary mutable queue would run the solver correctly and with less allocation. Persistence is not a performance argument and it is not required by breadth-first search. If finding a path were the only goal, the whole design would be unjustified.
2. It makes the version history free
Because take returns a value and a new frontier rather than modifying one in place, every intermediate frontier remains valid indefinitely. The solver therefore keeps all of them, in a list, at the cost of one pointer per step:
versions = [frontier]
...
versions.append(frontier) # no copy — the old version persists
With a mutable queue, answering "show me the frontier as it was at step 40" costs either a deep copy at every step — O(n) space per step, so O(n²) overall — or a replay of the search from the beginning. With a persistent structure it costs a single append, because each new version shares almost all of its structure with the previous one and nothing in the language can reach in and alter what is shared.
The list itself is the same list either way — an array of
references, and any language can build one.
Persistence is what makes versions[40] a different
value from versions[41], rather than another alias for one
mutating object. Keep the same list against a mutable queue and
every entry in it reads the same, because there is only ever one queue to
read.
That list is what --inspect=40 reads: it prints versions[40], a structure that is still there to be asked. The slider is a different case, and instructive for being so — a browser cannot hold references into the solver's heap, so --html walks each frontier with to_list and exports the contents. The slider therefore moves between several hundred recordings; --inspect moves between several hundred live structures. Only the second is free, and it is the one that matters: immutability converts "keep the history" from an expensive feature into a free consequence — for any consumer that can hold a reference, which a web page cannot and the rest of our program can.
Could the page not share too? It could. The obstacle
is a process boundary, not the browser: pointers do not cross it, only
bytes do. But sharing survives serialisation if it is encoded —
emit each cell once with an id, and let each version be a root pointing
into that pool. That is how Git ships history: objects once, commits as
references into them. On large.txt it would be 2404 cells
and 1201 roots instead of 12 972 flattened entries. We export the
flat version because it is 20 KB and ten lines of code. The page
could share; we declined.
3. It is what breaks the amortised bound
This is the reason the containers had to be persistent, rather than merely could be.
Recall the banker's argument from the start of this section: each element is pushed onto back once, reversed into front once, and taken once, so the O(n) reversal is paid for by the n cheap operations that preceded it. That argument contains a silent premise — each version is used at most once, so operations form a single line and the deposited coins are spent exactly once.
Persistence is precisely the power to violate that premise. Retain one version whose front holds a single element and whose back holds a thousand, then call dequeue on that same version a thousand times. Every call triggers the full reversal, because no call can see the work done by the others, and each has an equal right to the coins. The amortised bound collapses to O(n) per operation.
Why a mutable queue would have hidden this. One cannot re-take from an old version of a mutable structure, because there are no old versions — the premise holds by construction, and the tension is invisible. Persistence is what exposes it: amortisation reasons about a sequence of operations, and persistence is the freedom to branch out of any sequence. The two are genuinely in conflict.
Part D measures the collapse and stops there. The repair — rebuilding the reversal as a lazy computation that is forced once and shared by every version that observes it — needs machinery introduced later.session 5 The omission is deliberate, not an oversight.
So the containers had to be persistent because the lab is not really about mazes. The maze is a vehicle: first for making the version history observable, then for making the amortised bound visibly fail. Neither is possible with a mutable queue.
02 — before any language
The architecture, also specified once
The queue is not an exercise in isolation. It is dropped into a maze solver that was written without knowing it would exist, and the seam between them is five names.
Two properties of the frontier interface do all the work, and both are visible in the name take.
takereturns a value and a new frontier. Nothing is modified in place. This is what makes the whole history of the search available for free — see §01 — and it is why the interface cannot be satisfied by an ordinary mutable queue without wrapping one.- Nothing else about the container is assumed. Not its representation, not its cost, not its ordering. Substituting FIFO for LIFO therefore cannot break the search: the search never had an opinion to break.
- The graph is a separate layer again. It reports adjacency and lets the caller sequence the results, which is why the same maze file drives both searches and why neither search can tell which maze generator produced it.session 7
From here the lab is that one seam, three times over. We write a single file, and a program we did not write changes its behaviour.
03 — in class
What we build, and in what orderassignment
One structure, built once by hand and then twice at the keyboard. The code parts are guarded by tests; make them pass in order, because the test classes are named so that they run in teaching order and the ones about persistence come last — a correct ephemeral queue passes everything before them.
First, away from the keyboard
Fifteen minutes with a pen, before an editor is opened. This is the difference between implementing make from understanding and implementing it from guesswork.
The handout queue-on-paper.pdf walks the structure by hand: enqueue five elements, dequeue until the reversal fires, and draw the cons cells that two versions share. Bring it along — the pseudocode is projected during the introduction, and every implementation that follows goes faster if the reversal has already been traced once by hand.
Then, at the keyboard
Python first, because it has the fewest obstacles between us and the structure; then the same queue in OCaml, against an interface file that states the specification in the type system. The third implementation, in Java, is homework — §05.
Python
parts A · B · D
Build the queue, watch it drive the solver, swap it for the stack, then break the amortised bound on purpose.
cd python python3 -m unittest test_pqueue -v M=../mazes/medium.txt python3 solver.py $M --bfs python3 solver.py $M --dfs python3 bench.py
pqueue.html — the four holes architecture.html — the wiring python/pqueue.py — the file to edit
4 TODOs · --dfs works before a line is written
OCaml
part C
The same queue against a given interface file. Read lib/pqueue.mli first — it is the lecture's point turned into syntax.
cd ocaml dune runtest M=../mazes/medium.txt dune exec bin/solver.exe -- $M --bfs dune exec bin/solver.exe -- $M --dfs
ocaml-architecture.html — the wiring ocaml/lib/pqueue.mli — the spec ocaml/lib/pqueue.ml — the file to edit
3 TODOs · no recursion needed · List.rev is given
Two flags worth trying in every implementation
--inspect=40prints the frontier as it was at step 40. Nothing is replayed and nothing is logged; the solver retained every version. If this seems unremarkable, work out what it would have cost with a mutable queue.--html=bfs.htmlwrites the page dragged in §01. All three languages emit a byte-identical file, because the page is HTML and JavaScript in every case — a small reminder that the interesting differences between these three programs are not in what they can produce.
The check — three implementations, one set of numbers
One specification should produce one behaviour. On mazes/medium.txt all three solvers must agree exactly — including the Java row, which can only be filled in after the homework in §05.
| implementation | bfs explored | bfs path | dfs explored | dfs path |
|---|---|---|---|---|
| Python | 306 | 75 | 240 | 103 |
| OCaml | 306 | 75 | 240 | 103 |
| Java | 306 | 75 | 240 | 103 |
make that reverses eagerly on enqueue instead of lazily on exhaustion: that is a different queue, it passes many of the tests, and it changes what the frontier holds at each step.The mazes themselves are generated by randomised Kruskal over a union–find structure — an imperative one, deliberately. We implement it persistently, and regenerate these same mazes from the same seeds, in a later session.session 7
04 — three languages
What survives the translation
The specification in §01 is identical in all three languages, and so is every number in the check above. Almost nothing else survives the translation.
Each language embodies the frontier interface of §02 differently — a bag of functions, a record of functions, or an interface with implementing classes. The table below lays the three side by side; the per-language architecture pages take one apart in detail.
| Python | OCaml | Java | |
|---|---|---|---|
| a frontier is | a namespace holding five functions | a record of five functionssession 5 | an interface with implementing record types |
| dispatch | duck typing, resolved at each call | parametric polymorphism, resolved when compiling | nominal subtyping, dynamic dispatchsession 9 |
| who checks the five names exist | nobody — a typo surfaces at run time | the compiler | the compiler |
| immutability is | a discipline (tuples chosen because they enforce it) | the default | a keyword: final fields, record nodes, final class |
| the abstraction barrier is | a naming convention — _rev, _make |
pqueue.mli: type 'a t is genuinely opaque |
private fields on a final class |
| taking from an empty queue | raises IndexError |
returns None — the type forces the caller to handle it |
throws NoSuchElementException |
| the DFS side needs | a whole module, pstack.py |
nothing — a built-in list already is a persistent stack | a StackFrontier record implementing the interface |
| the queue must be | used bare | used bare | wrapped in a QueueFrontier: an interface needs an object, not a function |
| the shared empty value | EMPTY = (None, None) |
val empty : 'a t |
a static final instance plus an unchecked cast, because generics are erased |
| holes to fill | 4 — _rev, _make, enqueue, dequeue |
3 — make, enqueue, dequeue |
4 — rev, make, enqueue, dequeue |
Read the last row first, because the hole count is the language difference. OCaml supplies List.rev, so there is no reversal to write and the lab is three holes; Python and Java require it to be built out of cons cells. The two rows above it are the same observation running in the opposite direction: OCaml needs no stack module at all, because a list already is one, while Java needs a wrapper class around a queue that the other two use bare. Same architecture, three different amounts of boilerplate — and the boilerplate is not distributed evenly.
One row worth pausing on. Java's single shared empty queue is safe only because the structure is persistent — every holder of it is safe precisely because none of them can change it. The type system cannot express that reason, so the code signs for it by hand with @SuppressWarnings("unchecked"). That is a compact example of the general situation: what a language checks, what it merely permits, and what is left to us to guarantee.
05 — at home
What to finish at homeassignment
One more implementation, in the language that checks the most and provides the least. Start it once the Python and OCaml queues are passing their tests: the third pass is instructive only once the first two are complete.
Java
part E · homework
The same queue a third time, in a language where immutability is a keyword rather than a default or a discipline.
cd java javac *.java && java Tests M=../mazes/medium.txt java MazeSolver $M java MazeSolver $M --dfs
java-architecture.html — the wiring java/PQueue.java — the file to edit java/MazeSolver.java — the interface
4 TODOs · what final checks that a convention only asks
A set of OCaml exercises extends Part C beyond the queue itself: nine short problems on lists, recursion, match and option, each starting as a hole, with a self-check that reports what is still to do. They are homework too — and tour.ml is the worked tour they follow.
06 — reference
Related work and further reading
The two-list queue is not folklore; it has a literature, a known repair, and industrial descendants. None of the following is required reading for the lab.
- Chris Okasaki, Purely Functional Data Structures (CUP, 1998; thesis 1996). The standard reference. Chapter 5 is the queue we are building; chapter 6 is the repair for the problem met in Part D, using lazy evaluation and memoisation. His short retrospective ten years on, Ten Years of Purely Functional Data Structures, is worth the five minutes it takes. the amortisation/persistence tension is §5.6 and §6.1
- Eric Lippert, “Immutability in C#” (2007–08). Eleven posts building immutable stacks, queues and trees. Part Four is this very queue, in a fourth language and twenty years earlier — including the observation that a stack is a backwards queue, and the warning that one
dequeuecan cost O(n). Part One separates the several different things the word immutable is used to mean, which is the distinction the Java column of §04 turns on. read Part One, then jump to Part Four - Hood & Melville (1981); Kaplan & Tarjan (1995). Queues with worst-case O(1) bounds rather than amortised ones, obtained by incremental rebuilding rather than laziness — the other way out of the same problem.
- Driscoll, Sarnak, Sleator & Tarjan, "Making data structures persistent" (1986). Where the vocabulary comes from: partial persistence (old versions readable) versus full persistence (old versions writable). The
--inspectflag in this lab uses the partial kind. - Git's object model. A production persistent data structure: a commit is a new version of a whole tree that shares every unchanged subtree with its parent. The reason
git checkoutof an old commit is cheap is the reason--inspectis cheap here. - Clojure's persistent vectors; Scala's immutable collections; React's state discipline. The same idea at industrial scale, and the reason "don't mutate, return a new one" is now ordinary advice rather than an academic position.
- “Understanding Clojure's Persistent Vectors” (hyPiRion, 2013). Three heavily illustrated posts on the bit-partitioned trie behind the vectors named above: how a persistent structure gets effectively constant-time indexing and update once cons cells are no longer enough. The companion post on transients covers the escape hatch — mutation that no one is able to observe — which is the other half of the amortisation story. the natural sequel to this lab
A caveat on the BFS/DFS comparison above. The numbers on this page make DFS look simply better, and on exploration it mostly is: measured over 120 generated mazes (21×31, 40 per setting), DFS settled fewer cells than BFS in 36/40 perfect mazes and 39/40 braided ones, and on mazes/large.txt it settles 444 cells against BFS's 1201. What DFS never does is find a shorter path — 0/40, in every setting.
In a perfect maze it does not need to. A maze with no loops is a tree: there is exactly one simple path between start and exit, so both searches return the same path (40/40, identical length) and BFS's guarantee costs about 1.4× the exploration while buying nothing. Loops are what give the guarantee value — at the braid of 0.06 used for these mazes DFS averages 71.8 steps against BFS's 57.0, worst case +52. So the question is never “which search is better”, but what a worst-case bound is worth on the graph actually at hand; the braid parameter is the dial that sets its price.