PP26  ·  Session 2  ·  lab

One Queue, Three Languages

In the lecture we saw a persistent stack, where the LIFO case is straightforward: push shares the whole of the old stack. Today we build the FIFO case, where persistence must be earned. We build it once on paper and three times in code — Python, OCaml, Java — from a single language-independent specification, and place each implementation in the same larger program, a maze solver whose behaviour the data structure decides.

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 Java interface, 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.

The paper part opens §03, and it comes before the keyboard. The pseudocode is projected during the introduction; every implementation that follows goes faster if the reversal has already been traced once by hand.

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

the specificationno language yet
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)
Read the three highlighted lines as three different kinds of statement. The representation says what is stored; the abstraction says which sequence that storage denotes, and is what the tests compare against; the invariant says which stored states are legal. Only 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

 

step 0

██ 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:

the solver, in every languagethe whole mechanism
versions = [frontier]
...
    versions.append(frontier)   # no copy — the old version persists
No replaying, no logging, no snapshots. Retaining an earlier version costs nothing when no operation can modify it.

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.

THE ALGORITHM the search written once · never names a queue or a stack WHAT IT TALKS TO the graph one question: which cells adjoin this one? no frontier, no visited set, no order the frontier empty · put · take · is_empty · to_list five names, no base class required WHAT SUPPLIES THEM persistent queue — FIFO take returns the oldest ⟹ breadth-first persistent stack — LIFO newest ⟹ depth-first one assignment, in argument parsing, chooses between them
The search is written against the middle row and never sees the bottom one. Choosing an algorithm is choosing a data structure — and in all three languages it is a single assignment, made during argument parsing, nowhere near the search and nowhere near the maze.

Two properties of the frontier interface do all the work, and both are visible in the name take.

  • take returns 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

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

3 TODOs · no recursion needed · List.rev is given

Two flags worth trying in every implementation

  • --inspect=40 prints 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.html writes 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.

solver mazes/medium.txt --bfs | --dfs
implementation bfs exploredbfs path dfs exploreddfs path
Python30675240103
OCaml30675240103
Java30675240103
If the numbers differ, the implementation differs — the specification does not leave room for a choice here. The commonest cause is a 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
Rows are drawn from the files actually edited. The margin labels mark ideas introduced here in passing and treated properly in a later session.

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

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 dequeue can 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 --inspect flag 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 checkout of an old commit is cheap is the reason --inspect is 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.