Graph algorithms · a data structure, not an analysis

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)

new-Rootnew-Anew-A1Y

Reused as they are (3)

XA2B

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)
  1. 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:#000
    

    Current

    Root

    Trail, nearest first

    empty
  2. Down to A. Root is 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:#000
    

    Current

    A

    Trail, nearest first

    Root
  3. Down to A1. A joins the trail, ahead of Root.

    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:#000
    

    Current

    A1

    Trail, nearest first

    ARoot
  4. Down to X. A1 joins 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:#000
    

    Current

    X

    Trail, 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)
  1. 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:#000
    

    Current

    A1

    Trail, 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)
  1. 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:#000
    
  2. Build a new A1 with children [X, Y]. The trail's front, A, becomes current — the new A1 is substituted into its children. Build a new A holding [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:#000
    

    Just built

    new-A1 = A1(X, Y)

    Current

    new-A = A(new-A1, A2)

    Trail, nearest first

    Root
  3. One ancestor left. Root becomes current, with the new A substituted into its children: a new Root holding [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:#000
    

    Just 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

A1 → new-A1A → new-ARoot → new-Root

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:

operationno trail: nodes visitedwith trail
down to XRoot, A, A1, XRoot, A, A1, X; trail [A1, A, Root]
up to A1Root, A, A1 again, to find whose child X isread the trail's front: A1
rebuild after the editRoot, A, A1 again, to find A1's ancestorsread 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:

levelthis page's trailHuet's context: siblings at that level
under AAleft of A1: none; right: [A2]
under RootRootleft 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 A1currenttrail
this page's variant (edit walk, step 2)new-Rootempty
Huet'sA1 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 A2moveschildren scanned
this page's variantup → A (trail [Root]); down(A2) → A2 (trail [A, Root])2: A1, then A2
Huet'sright → 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 throughA1'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 examplerewriting a recovered AST
finddown to X (walk down, steps 1–3)walk down to a node that needs rewriting
changeA1's children [X] → [X, Y]replace it, e.g. a jump by a conditional block
climbnew-A, new-Root (edit walk, steps 1–2)rebuild its ancestors up to the root
reuseX, A2, Bevery subtree off that path

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:

cobol-rekt GOTO pass: mutates in placecobol-rekt zippers: experimental, test sources only

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

  1. 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.