Graph algorithms · a data structure, not an analysis

Tree zippers

A cursor into an immutable tree that remembers the way back up, so editing one node deep inside does not mean redoing the walk that found it.

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 this exact shape of problem: find one node deep inside a large tree, change it, climb back out, then do it again for the next one.

The problem

The data structure this page walks through is Huet's zipper.[1] The tree never changes in place — a new node is built, never overwritten. So changing one node three levels down means building a new copy of it, then a new copy of its parent pointing at the new copy, then a new copy of its parent, up to the root. Nothing above the edit can be reused; the whole path has to be rebuilt.

Walking down to find the node in the first place already visits every statement on that path. Thrown away once the node is found, that walk has to happen a second time, backward, just to know which nodes need rebuilding. The zipper is what remembers the first walk, so there is no second one.

The tree

A small tree, four levels at its deepest:

flowchart TD
    Root --> A
    Root --> B
    A --> A1
    A --> A2
    A1 --> X

Say the goal is to reach X and give it a sibling. In the diagrams below, amber marks the node currently in focus; a bordered node marks one remembered on the trail.

Going down: the trail grows

A zipper is two things: current, the node in focus, and trail, the ancestors passed to get there, nearest first. Moving down to a named child adds the node just left to 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

Three moves, three ancestors remembered — in the order a rebuild will need them, nearest first. Nothing has been copied yet; this is pure navigation.

Going up: free, because it was already remembered

Going up is reading the front of the trail, not searching for a parent — the whole point of having written it down on the way in.

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

No comparison with the original tree, no re-walk from the root — the parent was sitting at the front of the trail the whole time.

Editing: rebuild on the way back up

The edit itself is ordinary: give the node in focus a new list of children. What happens next is the part worth watching. From A1, giving it a second child Y alongside X. The diagrams below mark the newly built node that is current amber, and any node built earlier in the same step but no longer current with a border.

Editing A1's children, then climbing back to the root (3 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

Three new nodes — A1, A, Root — not five. A2 and B are untouched originals, reused as children of the new nodes around them rather than copied. And the old Root, A, A1 still exist, unchanged, pointed to by anything that was holding them before the edit — the edit built a new path to a new root; it did not touch the old one.

What goes wrong

Where this is used

Rewriting an AST without arbitrary jumps: walk down into the tree to a jump that needs restructuring, turn it into a conditional block, climb back out — each climb rebuilding only the handful of ancestors on that one path, every sibling subtree reused untouched. An AST with thousands of nodes and one jump somewhere inside it rebuilds a few nodes per fix, not thousands.

References

  1. G. Huet. "The Zipper." Journal of Functional Programming, 7(5), September 1997, pp. 549–554. Names and popularizes the data structure this page walks through.