Tree zippers
Read first: nothing — standalone
A tree that is never changed in place cannot give its nodes parent pointers: after an edit, an untouched node hangs under both the old parent and the new one. Without them, climbing up, or finding which ancestors an edit must rebuild, means walking down again from the root.
On this page's six-node tree, the zipper walks Root → A → A1 → X once and keeps the ancestors it passed, [A1, A, Root]. Stepping back up to A1 and giving it a new child Y both read that list instead of walking again; the edit rebuilds the 3 nodes on the path, adds Y, and reuses X, A2 and B.
Terms (current, trail, path copying, structural sharing) are defined where the walks first use them.
The example: one edit, three levels down
A small tree, four levels at its deepest. The goal is to reach X, step back up to its parent A1, and give A1 a second child Y:
flowchart TD
Root --> A
Root --> B
A --> A1
A --> A2
A1 --> X
The answer, with the old tree and the new one drawn together. The edit built the four amber nodes; X, A2 and B each now have two parents, one in each tree:
flowchart TD
Root --> A
Root --> B
A --> A1
A --> A2
A1 --> X
newRoot["new-Root"] --> newA["new-A"]
newRoot --> B
newA --> newA1["new-A1"]
newA --> A2
newA1 --> X
newA1 --> Y
class newRoot,newA,newA1,Y changed
classDef changed fill:#f5b041,stroke:#f5b041,color:#000
Built by the edit (4)
Reused as they are (3)
The zipper behind it is a pair: current, the node in focus, and trail, the ancestors passed to get there, nearest first. This is the shape of NativeZipper, an experimental zipper in cobol-rekt's test sources, in this page's names (full source below):
down(child):
Zipper(trail.prepend(current), child)
up():
Zipper(trail.tail, trail.head)
replaceChildren(kids):
edited = current.clone(kids)
return up().replaceChild(current, edited)
replaceChild(old, new):
copy = current.clone(children,
old → new)
if trail empty: return Zipper([], copy)
return up().replaceChild(current, copy)
And the four lines that produce the answer graph above. The walks below take them one at a time:
z = Zipper([], Root) z = z.down(A).down(A1).down(X) z = z.up() // at A1 z = z.replaceChildren([X, Y]) // current new-Root, trail []
Full listing (cobol-rekt's NativeZipper.java)
@Getter
public class NativeZipper<T extends ZipperNode<T>> {
private final List<T> thread;
private final T current;
public NativeZipper(List<T> thread, T current) {
this.thread = thread;
this.current = current;
}
public T current() {
return current;
}
public NativeZipper<T> down(T child) {
int childIndex = current.children().indexOf(child);
if (childIndex == -1) return this;
return new NativeZipper<>(thread.prepend(current), child);
}
public NativeZipper<T> down(Predicate<T> condition) {
List<T> filtered = current.children().filter(condition);
if (filtered.isEmpty()) return this;
return down(filtered.head());
}
public NativeZipper<T> up() {
if (thread.isEmpty()) return this;
return new NativeZipper<>(thread.tail(), thread.head());
}
public NativeZipper<T> replaceChildren(List<T> newChildren) {
return up().replaceChild(current, current.clone(current, newChildren));
}
private NativeZipper<T> replaceChild(T replacedChild, T replacingChild) {
List<T> currentChildren = current.children();
List<T> updatedChildren =
currentChildren.update(currentChildren.indexOf(replacedChild), replacingChild);
NativeZipper<T> upZipper = up();
T nodeCopy = current.clone(current, updatedChildren);
if (upZipper == this) return new NativeZipper<>(List.of(), nodeCopy);
return upZipper.replaceChild(current, nodeCopy);
}
}
From smojol-core/src/test/java/org/smojol/common/. The source calls the trail thread. Imports and the package line are left out.
Going down: the trail grows
The second fragment's down(A).down(A1).down(X), one call per step. In the diagrams, amber marks the node currently in focus; a bordered node is one remembered on the trail. Each down puts the node just left at the front of the trail.
Walking down to X (3 steps)
Start at the root. No ancestors behind it yet.
flowchart TD Root --> A Root --> B A --> A1 A --> A2 A1 --> X class Root changed classDef changed fill:#f5b041,stroke:#f5b041,color:#000Current
RootTrail, nearest first
emptyDown to
A.Rootis left behind: it joins the trail.flowchart TD Root --> A Root --> B A --> A1 A --> A2 A1 --> X class Root member class A changed classDef member stroke:#4ade80,stroke-width:3px,fill:transparent classDef changed fill:#f5b041,stroke:#f5b041,color:#000Current
ATrail, nearest first
RootDown to
A1.Ajoins the trail, ahead ofRoot.flowchart TD Root --> A Root --> B A --> A1 A --> A2 A1 --> X class Root,A member class A1 changed classDef member stroke:#4ade80,stroke-width:3px,fill:transparent classDef changed fill:#f5b041,stroke:#f5b041,color:#000Current
A1Trail, nearest first
ARootDown to
X.A1joins the trail.flowchart TD Root --> A Root --> B A --> A1 A --> A2 A1 --> X class Root,A,A1 member class X changed classDef member stroke:#4ade80,stroke-width:3px,fill:transparent classDef changed fill:#f5b041,stroke:#f5b041,color:#000Current
XTrail, nearest first
A1ARoot
Steps 1 to 3 made three moves and remembered three ancestors, in the order a rebuild will need them: the trail chips under step 3 read A1, A, Root, nearest first. Nothing has been copied yet; this is pure navigation.
Going up: free, because it was already remembered
The fragment's z.up(). Going up reads the front of the trail from step 3 above; it does not search for a parent.
Walking back up to A1 (1 step)
Up from
X: the front of the trail,A1, becomes current, and drops off the trail.flowchart TD Root --> A Root --> B A --> A1 A --> A2 A1 --> X class Root,A member class A1 changed classDef member stroke:#4ade80,stroke-width:3px,fill:transparent classDef changed fill:#f5b041,stroke:#f5b041,color:#000Current
A1Trail, nearest first
ARoot
Compare the trail chips with step 3 of the walk down: A1 moved from the front of the trail into current, and nothing else changed. No comparison with the original tree, no re-walk from the root.
Editing: rebuild on the way back up
The fragment's z.replaceChildren([X, Y]), from A1. The edit itself is ordinary: give the node in focus a new list of children. What happens next is the part worth watching. The diagrams below mark the newly built node that is current amber, and any node built earlier in the same walk but no longer current with a border.
Editing A1's children, then climbing back to the root (2 steps)
Focus is on
A1, trail is[A, Root]. Ask for its children to become[X, Y]instead of[X].flowchart TD Root --> A Root --> B A --> A1 A --> A2 A1 --> X class Root,A member class A1 changed classDef member stroke:#4ade80,stroke-width:3px,fill:transparent classDef changed fill:#f5b041,stroke:#f5b041,color:#000Build a new
A1with children[X, Y]. The trail's front,A, becomes current — the newA1is substituted into its children. Build a newAholding[new-A1, A2]in place of the old[A1, A2].flowchart TD Root --> newA["new-A"] Root --> B newA --> newA1["new-A1"] newA --> A2 newA1 --> X newA1 --> Y class newA changed class newA1 member classDef member stroke:#4ade80,stroke-width:3px,fill:transparent classDef changed fill:#f5b041,stroke:#f5b041,color:#000Just built
new-A1 = A1(X, Y)Current
new-A = A(new-A1, A2)Trail, nearest first
RootOne ancestor left.
Rootbecomes current, with the newAsubstituted into its children: a newRootholding[new-A, B]in place of[A, B].flowchart TD newRoot["new-Root"] --> newA["new-A"] newRoot --> B newA --> newA1["new-A1"] newA --> A2 newA1 --> X newA1 --> Y class newRoot changed class newA member classDef member stroke:#4ade80,stroke-width:3px,fill:transparent classDef changed fill:#f5b041,stroke:#f5b041,color:#000Just built
new-A = A(new-A1, A2)Current
new-Root = Root(new-A, B)Trail, nearest first
empty
Step 1 built new-A1 and new-A; step 2 built new-Root and emptied the trail:
Path copied, old → new
The original tree has six nodes. The edit built four: the three on the path — A1 and its ancestors A and Root, in the chips above — plus the new leaf Y, rather than copying all six. In step 2's diagram, A2, B and X are untouched originals, reused as children of the new nodes: structural sharing. Copying only the path to the root is path copying. Neither is the zipper's doing; any persistent tree edits this way. What the zipper adds is that the path was already on the trail, so no second walk from the root was needed. The old Root, A, A1 still exist, unchanged: they are the original tree in the answer graph at the top.
Why keep the trail
Look again at the answer graph at the top: X hangs under both A1 and new-A1. A node shared like that cannot carry a single parent pointer, so a tree that is never changed in place has none. The table counts the nodes visited for each of the example's three operations, with and without the trail:
| operation | no trail: nodes visited | with trail |
|---|---|---|
| down to X | Root, A, A1, X | Root, A, A1, X; trail [A1, A, Root] |
| up to A1 | Root, A, A1 again, to find whose child X is | read the trail's front: A1 |
| rebuild after the edit | Root, A, A1 again, to find A1's ancestors | read the trail: [A, Root] |
The two amber cells are the walk down done a second and a third time, only to learn which nodes lie on the path. The zipper is what remembers the first walk, so there is no second one.
How Huet's zipper differs
The data structure in the walks above is a simplified, ancestor-trail variant of Huet's zipper.[1] Huet's version keeps more at each level of its context: the parent path plus the focus's left and right siblings. At the edit's starting point (focus on A1, before step 1 of the edit walk) the two hold:
| level | this page's trail | Huet's context: siblings at that level |
|---|---|---|
| under A | A | left of A1: none; right: [A2] |
| under Root | Root | left of A: none; right: [B] |
Those sibling lists make a step left or right O(1), make an edit at the focus O(1), and defer rebuilding — each up reassembles one level from its left siblings, the focus and its right siblings, and nothing is rebuilt until something moves up. The variant here keeps only the middle column and rebuilds eagerly: the edit walk climbs straight back to the root in its two steps. The idea both share — remember the way back up instead of re-walking it — is the one this page is about.
What goes wrong
Expecting the focus to stay where the edit happened. In this page's variant it doesn't (Huet's does, deferring the rebuild to up) — an edit climbs the whole trail and lands on the new root, trail empty, same as if the zipper had just been created:
| after replaceChildren([X, Y]) at A1 | current | trail |
|---|---|---|
| this page's variant (edit walk, step 2) | new-Root | empty |
| Huet's | A1 with [X, Y] | the same path as before the edit |
Treat the variant's return value as "a new tree," not "my cursor, still at A1."
Assuming a step sideways is as cheap as a step up or down. This variant's trail only remembers ancestors, not the siblings beside them. Moving from A1 to its sibling A2 means going up and back down by name or by condition, scanning that level's children — never a single "move right" step, because none is kept. Huet's zipper keeps exactly those sibling lists, which is what makes its sideways step O(1):
| A1 to A2 | moves | children scanned |
|---|---|---|
| this page's variant | up → A (trail [Root]); down(A2) → A2 (trail [A, Root]) | 2: A1, then A2 |
| Huet's | right → A2 (left [A1], right none) | 0 |
Forgetting the old tree is still there. Nothing was mutated, so a reference taken before the edit still sees the pre-edit shape. Reading A1's children through each root of the answer graph:
| read through | A1's children |
|---|---|
| Root → A → A1 (old) | [X] |
| new-Root → new-A → new-A1 | [X, Y] |
That's structural sharing — the old and new tree share all the nodes that didn't change, only the edited node and its ancestors are new — working as intended, not a bug. But code that edits and then re-reads the original variable expecting to see its own change gets the first row.
Where this shows up in reverse engineering
Any tool that rewrites an AST recovered from legacy or compiled source — rather than one it generated itself — has the example's shape of problem, row for row:
| this page's example | rewriting a recovered AST | |
|---|---|---|
| find | down to X (walk down, steps 1–3) | walk down to a node that needs rewriting |
| change | A1's children [X] → [X, Y] | replace it, e.g. a jump by a conditional block |
| climb | new-A, new-Root (edit walk, steps 1–2) | rebuild its ancestors up to the root |
| reuse | X, A2, B | every subtree off that path |
- A GOTO-elimination pass over a parsed COBOL program: walk down to a jump, turn it into a conditional block, climb back out. The next jump is somewhere else in the same tree, reached the same way, with none of the first edit's work redone.
- A decompiler reconstructing source from a recovered AST meets the same problem at every control-flow fixup: thousands of nodes, a handful to rewrite, and the cost should follow the handful.
- "Just mutate the tree" is the wrong comparison: the zipper's saving is the amber cells under why keep the trail, not avoiding mutation. A tool that already knows its path back up because it just walked there is most of the way to a zipper.
Where this is used
Rewriting an immutable AST without arbitrary jumps, one jump at a time, as in the table above: the trail hands over the path, and path copying keeps the rebuild to the ancestors on it — a few nodes per fix, not thousands. cobol-rekt does not do this:
Its eliminating GOTOs pass mutates its tree in place, so it needs neither a trail nor path copying; a zipper is how the same edits could be made to an immutable one. Its zippers are ancestor-only: NativeZipper, in the full listing under the example, and BridgeZipper, the same design over its AST nodes — the variant this page walks through.
References
- G. Huet. "The Zipper." Journal of Functional Programming, 7(5), September 1997, pp. 549–554. The original zipper, whose context stores sibling lists at every level; this page presents a simplified ancestor-only variant of it.