all_lessons/SICP JavaScript/05 · Hierarchical datalesson 5 / 22

Hierarchical data and conventional interfaces

Lesson 04 built a single pair behind an abstraction barrier and, at the end, named the property that makes it explosive: the closure property — a pair may contain a pair. This lesson cashes that in. Because pairs nest, the one mechanism from Lesson 04 builds unbounded structure: lists, then trees, then arbitrary hierarchies. But unbounded structure is useless if every program that walks it is a bespoke recursive loop. So the second, bigger idea: process all of it through a small set of conventional interfacesmap, filter, accumulate — viewed as a signal-flow pipeline (enumerate → filter → map → accumulate). That view makes programs snap together like plumbing instead of tangling into nested loops.

Book source
Maps to SICP 2.2 (hierarchical data and closure): sequences as nested pairs, the conventional-interface sequence operations, the signal-processing metaphor, nested mappings, and the picture language as a closure showcase. We follow that arc and section coverage; the JavaScript code, the signal-flow diagram, and the loop-vs-pipeline rewrite are original.
Linear position
Prerequisite: Lesson 04 (data abstraction — constructors/selectors behind a barrier; the closure property: a pair may contain a pair).
New capability: represent sequences and trees as nested pairs, and process them through a fixed vocabulary of conventional interfacesmap, filter, accumulate — designed so independent stages compose into a signal-flow pipeline, making programs modular instead of monolithic.
The plan
Five moves. (1) Build lists from nested pairs and walk one. (2) Define the three conventional interfaces. (3) Reframe them as a signal-flow pipeline and say why composability follows. (4) Rewrite a hand-rolled recursive loop as a pipeline and measure the modular gain. (5) Lift to trees and nested mappings; note the picture language as the closure property at full power.

1 · Lists are nested pairs

Carry over pair/head/tail from Lesson 04. A list is just a right-nested chain of pairs ending in a marker, null (the empty list). The list 1, 2, 3 is pair(1, pair(2, pair(3, null))): each pair's head is an element, its tail is the rest of the list. The closure property is doing all the work — the tail slot holds another pair.

const nil = null;
function list(...xs) {                     // sugar: build a chain of pairs
  let acc = nil;
  for (let i = xs.length - 1; i >= 0; i--) acc = pair(xs[i], acc);
  return acc;                              // list(1,2,3) === pair(1,pair(2,pair(3,nil)))
}
function isEmpty(s) { return s === nil; }

// walking a list is structural recursion: do head, recurse on tail
function length(s) { return isEmpty(s) ? 0 : 1 + length(tail(s)); }
list(1,2,3)  =   [ 1 | *-]--->[ 2 | *-]--->[ 3 | / ]
                   ^   ^                          ^
                 head tail(->rest)             tail = nil

2 · Three conventional interfaces

Almost every list program does one of three things, so we name them once and reuse them everywhere. A conventional interface is a small, fixed vocabulary that many operations are expressed in — instead of each program inventing its own recursion.

// map: transform every element (same length out)
function map(f, s) {
  return isEmpty(s) ? nil : pair(f(head(s)), map(f, tail(s)));
}
// filter: keep elements passing a predicate (shorter out)
function filter(pred, s) {
  if (isEmpty(s)) return nil;
  return pred(head(s)) ? pair(head(s), filter(pred, tail(s)))
                       : filter(pred, tail(s));
}
// accumulate (fold): collapse the whole list to one value with a binary op
function accumulate(op, init, s) {
  return isEmpty(s) ? init : op(head(s), accumulate(op, init, tail(s)));
}
// e.g. accumulate((a,b)=>a+b, 0, list(1,2,3)) === 6

The crucial property: each takes a sequence and returns a sequence (or a summary), so their output type matches the next one's input type. That single uniformity is what lets them chain — and chaining is the whole game.

3 · The signal-flow view — why pipelines compose

SICP's metaphor: think of a list program as a signal-processing pipeline, like wiring electrical components. Data flows left to right through independent stages, each with one job:

  source ──> ENUMERATE ──> FILTER ──> MAP ──> ACCUMULATE ──> answer
            (make signal) (keep some) (transform) (combine to one)
              a tree        a pred      a fn        op + init
enumerate
Turn a source (a range, a tree's leaves) into a flat sequence — the "signal" entering the pipe.
filter
Let through only elements satisfying a predicate. Narrows the signal; type stays "sequence."
map
Apply a transform to each element. Reshapes values; count and order preserved.
accumulate
Collapse the sequence to a single value with a binary combiner and a seed.

Because every stage's output is a sequence (until the final accumulate), you can reorder, insert, or delete stages without rewriting the others — exactly as you re-plumb pipe segments. Each stage is testable in isolation and reusable across programs. A hand-rolled recursive loop fuses enumerate-filter-map-accumulate into one tangled function where the four concerns are interleaved and inseparable; the pipeline keeps them as four independent boxes. That modularity is the payoff, and it is bought by the uniform sequence interface.

4 · Worked example — loop rewritten as a pipeline

Worked example — "sum of squares of the even numbers"
Before — one tangled recursive loop. The four concerns (walk, test even, square, sum) are braided into a single function:
function sumSqEven(s) {
  if (isEmpty(s)) return 0;
  const x = head(s);
  if (x % 2 === 0) return x * x + sumSqEven(tail(s));   // filter+map+accum fused
  return sumSqEven(tail(s));                              // filter (drop) fused in
}
To change the rule (sum of cubes? odds instead?) you must surgically edit this function and risk the recursion. The concerns cannot be tested or reused apart.

After — a signal-flow pipeline. Read it right to left as the diagram:
function sumSqEven(s) {
  return accumulate((a, b) => a + b, 0,           // ACCUMULATE: sum
           map(x => x * x,                          // MAP: square
             filter(x => x % 2 === 0, s)));         // FILTER: keep evens
}
// trace on list(1,2,3,4):
//   filter even  -> list(2,4)
//   map square   -> list(4,16)
//   accumulate + -> 0 + 4 + 16 = 20
Now "sum of cubes of odds" is three independent edits: swap the predicate to x => x % 2 === 1, the map to x => x*x*x — no recursion touched, each stage still tested alone. Modular gain: four reusable boxes versus one bespoke loop. The signal-flow view turned a rewrite into a re-wiring.

5 · Trees, nested mappings, and the picture language

The closure property does not stop at lists — a list element can itself be a list, giving a tree. Operations recur into both head and tail (assume a primitive isPair(x) that reports whether x is a pair rather than a leaf value):

// count leaves of a tree (nested lists), recurring into sublists
function countLeaves(t) {
  if (isEmpty(t)) return 0;
  if (!isPair(head(t))) return 1 + countLeaves(tail(t));   // a leaf
  return countLeaves(head(t)) + countLeaves(tail(t));      // a subtree
}

Nested mappings express "for each i, for each j" as a pipeline of maps whose results are flattened by accumulate — e.g. all pairs (i, j) with i > j up to n become map over i of (map over j), accumulated together, then filtered. The double loop becomes nested signal flow. The capstone of 2.2 is the picture language: a tiny set of operations on painters (beside, above, flip) that are themselves closed — the result of combining two painters is a painter you can combine again. It is the closure property applied to operations, not just data, and it is why a handful of primitives generates infinitely elaborate images. Same idea, one level up.

Common mistakes / failure modes

Reaching into pairs by hand
Re-implementing a recursive walk every time instead of expressing the program in map/filter/accumulate. You lose composability and re-introduce the bugs the conventional interface already solved.
Fusing stages prematurely
Folding the filter and map into one pass "for speed" before it matters. You trade modularity for a micro-optimization and make every later change a surgery — premature fusion is the loop you were escaping.
Wrong accumulate seed / associativity
Using 0 as the seed for a product (always 0) or assuming a left fold when accumulate here folds right. The seed must be the operation's identity and the fold direction must match the combiner.
Forgetting the closure property for trees
Treating a nested list as flat — a list op that does not recur into head when it is itself a pair will miscount leaves or skip subtrees.

Checkpoint exercise

Try it
(a) Express length using only accumulate (no explicit recursion). (b) Write "the product of the squares of the odd numbers in a list" as a three-stage pipeline, and trace it on list(1,2,3). (c) Why can you swap the order of the filter and map stages in (b) without changing the answer, but not in general?

Answers: (a) accumulate((_, n) => 1 + n, 0, s) — ignore each element, add 1, seed 0. (b) accumulate((a,b)=>a*b, 1, map(x=>x*x, filter(x=>x%2===1, s))); on list(1,2,3): filter odds → list(1,3), map square → list(1,9), accumulate × from seed 1 → 1*1*9 = 9. (c) Here the predicate x%2===1 tests the same value squaring preserves the parity of (odd² is odd), so order is harmless — but in general filter-then-map ≠ map-then-filter, because map can change whether the predicate holds; keep the conventional order (enumerate → filter → map → accumulate).

Where this points next

Look at everything we have processed: numbers in, numbers out. Lists of integers, sums of squares, products — the leaves of every structure so far have been values to be evaluated. But a program that does algebra, or compresses text, or manipulates expressions, needs to treat a name as data — to hold the symbol x itself, not the number x stands for. Lesson 06 lifts exactly that hidden rule "data must be evaluated to a value": with quotation we can refer to a symbol instead of its value, and suddenly the same pairs and the same conventional interfaces process representations — differentiating an expression by rewriting its tree, building sets, encoding Huffman trees. Dropping the must-evaluate rule is the pressure that forces symbolic data.

Takeaway
The closure property from Lesson 04 — a pair may contain a pair — turns one gluing mechanism into unbounded hierarchical structure: lists are right-nested pairs ending in nil, and lists of lists are trees. Walking such structure by hand produces tangled bespoke recursion, so we process it through a fixed vocabulary of conventional interfacesmap (transform each), filter (keep some), accumulate (collapse to one) — whose uniform sequence-in/sequence-out shape lets them chain into a signal-flow pipeline (enumerate → filter → map → accumulate). Because each stage's output is the next stage's input type, you re-wire instead of rewrite: "sum of squares of evens" becomes three independent, reusable, separately testable boxes rather than one fused loop, and changing the rule is three local edits. The same idea lifts to trees, nested mappings, and the picture language, where the closure property applies to operations so a few primitives generate infinite combinations. All of it still bottoms out in numbers — the rule that data must be evaluated — and lifting that rule is the door to symbolic data.

Interview prompts