all_lessons/SICP JavaScript/08 · Generic operationslesson 8 / 22

Systems with generic operations

Lesson 07 took one family — complex numbers — and let it wear many representations, dispatched through an operation/type table by apply_generic. It deliberately dodged one question: what does add mean when its two arguments carry different tags? This lesson builds the answer into a whole layered arithmetic system: ordinary numbers, rational numbers, and complex numbers stacked on the same dispatcher, with one generic add/mul that works across them. Mixing types forces coercion and a type tower, and raises the question of where that cross-type logic should live. Underneath it all sits the structural tension that will haunt every large system you build: adding a new type versus adding a new operation — the expression problem, in embryo.

Book source
Maps to SICP 2.5 (systems with generic operations): the layered generic-arithmetic package, cross-type operations via coercion, the type tower of raising/lowering, and polynomials over generic coefficients. The package code, the coercion trace, the tower diagram, and the expression-problem framing here are original JavaScript, following that section's conceptual arc without quoting it.
Linear position
Prerequisite: Lesson 07 (tagged data, the operation/type table, and one universal apply_generic dispatcher).
New capability: build a multi-layer generic-arithmetic system on top of apply_generic; combine operands of different types through coercion organized by a type tower; locate where coercion belongs; and name the central design tension (new type vs new operation) that recurs for the rest of the course.
The plan
Five moves. (1) Stack three number packages — ordinary, rational, complex — on the same generic add/mul, all routed through Lesson 07's table. (2) Hit the wall: add of a rational and a complex finds no table cell. (3) Fix it with coercion, first the naive pairwise way, and watch that fail to scale. (4) Replace pairwise coercion with a type towerraise the lower operand until the tags match — and decide where this logic lives. (5) Push coefficients up a level with polynomials over generic terms, then name the expression problem and the pressure it sets up for Part III.

1 · A layered arithmetic on one dispatcher

We reuse Lesson 07's machinery verbatim — attach_tag/type_tag/contents, the put/get table, and apply_generic. Now the operations are arithmetic, and the types are kinds of number. The user-facing generic operations are one line each:

const add = (x, y) => apply_generic("add", x, y);
const mul = (x, y) => apply_generic("mul", x, y);
const equ = (x, y) => apply_generic("equ", x, y);

Each number kind is an install package that registers its column, mentioning no other type — exactly the discipline from Lesson 07. Note apply_generic dispatches on the tags of all arguments here (the key includes every operand's tag), because arithmetic is binary.

// --- integers: the tower's bottom rung (a "real" package mirrors this one rung up) ---
const install_integer = () => {
  const tag = x => attach_tag("integer", x);
  put("add", ["integer","integer"], (a, b) => tag(a + b));
  put("mul", ["integer","integer"], (a, b) => tag(a * b));
  put("equ", ["integer","integer"], (a, b) => a === b);
};
// --- rational numbers: contents is [n, d] ---
const install_rational = () => {
  const tag = (n, d) => attach_tag("rational", [n, d]);
  put("add", ["rational","rational"], ([n1,d1],[n2,d2]) => tag(n1*d2 + n2*d1, d1*d2));
  put("mul", ["rational","rational"], ([n1,d1],[n2,d2]) => tag(n1*n2, d1*d2));
};
// --- complex numbers: reuses Lesson 07's selectors internally ---
const install_complex = () => {
  const tag = z => attach_tag("complex", z);
  put("add", ["complex","complex"], (z1, z2) => tag(/* add components */ z1));
  put("mul", ["complex","complex"], (z1, z2) => tag(/* mul mags, add angles */ z1));
};

Here apply_generic("add", ...) keys on the tuple of argument tags. We must generalize the dispatcher slightly so the key reflects every operand:

const apply_generic = (op, ...args) => {
  const tags = args.map(type_tag);
  const fn   = get(op, tags);              // key = op + tuple of tags
  if (fn) return fn(...args.map(contents));
  // ...the interesting case (different tags) handled in §3
  throw new Error(`no method ${op} for [${tags}]`);
};

So far this is just Lesson 07 with more types. add(rational, rational) and add(complex, complex) each find their cell. The system is layered: complex numbers can even contain rational parts, because their parts are themselves manipulated by the generic add/mul — the recursion the dispatcher enables.

2 · The wall — mixed types have no cell

The failure — add(rational, complex)
Ask for add(make_rational(1,2), make_complex(3,4)). The dispatcher computes tags = ["rational","complex"] and looks up get("add", ["rational","complex"]). No such cell exists — every package only registered same-tag entries. The naive answer is to add the missing cells: ["rational","complex"] and ["complex","rational"]. But that path is a trap. With t types and m operations, filling in every cross-type cell is m·t² implementations, and adding one new type means writing 2·m·t new mixed cells against all existing types. The cross-type matrix explodes quadratically. We need a way to reduce mixed-type calls to the same-type cells we already have, instead of enumerating them.

3 · Coercion — and why pairwise coercion is not enough

Coercion is converting a value of one type into an equivalent value of another, so that a mixed call collapses to a same-type call. The first idea is a coercion table keyed by (from-type, to-type):

const _coerce = new Map();
const put_coercion = (from, to, fn) => _coerce.set(from + "->" + to, fn);
const get_coercion = (from, to)     => _coerce.get(from + "->" + to);

put_coercion("integer", "complex", x => make_complex(x, 0));     // 5 -> 5+0i
put_coercion("rational", "real", ([n,d]) => make_real(n/d));      // 1/2 -> 0.5

Then apply_generic, on a two-argument miss, tries to coerce one operand to the other's type and retry:

// inside apply_generic, when get(op, [t1, t2]) misses and t1 !== t2:
const t1c = get_coercion(t1, t2);
if (t1c) return apply_generic(op, t1c(contents(a1)), a2);  // lift a1 to t2, retry
const t2c = get_coercion(t2, t1);
if (t2c) return apply_generic(op, a1, t2c(contents(a2)));  // lift a2 to t1, retry
Why pairwise coercion still does not scale
A coercion table between every ordered pair of types is again a object — to add one new type you must write a coercion to-and-from every existing type, up to 2·(t−1) functions, many of them mechanical. It is the §2 explosion moved one level over. Worse, the entries are not independent: if number→rational and rational→complex both exist, then number→complex is forced to agree with their composition, but nothing in a flat table enforces that. We have replaced a quadratic mess of operations with a quadratic mess of coercions. The structure is begging for an ordering.

4 · The type tower — and where coercion belongs

The number types are not an unstructured set; they form a chain by inclusion. Every integer is a rational; every rational is a real; every real is a complex number:

  complex          (most general — sits at the top)
     ^  raise
  real
     ^
  rational
     ^
  integer          (most specific — sits at the bottom)

Instead of pairwise coercions we define just one step per level: raise, which lifts a value to the type one rung above it. Any coercion is then a composition of single steps along the tower — to add operands of different levels, repeatedly raise the lower one until the two tags match, then dispatch to the same-type cell that already exists.

const install_raise = () => {
  put("raise", ["integer"],  n => make_rational(n, 1));
  put("raise", ["rational"], ([n,d]) => make_real(n / d));
  put("raise", ["real"],     x => make_complex(x, 0));
  // complex has no raise — it is the top
};
const raise = x => apply_generic("raise", x);

// level maps a tag to its rung index in the tower (0 = bottom, integer):
const level = t => ["integer","rational","real","complex"].indexOf(t);

// the new mixed-type rule, replacing the pairwise block in apply_generic:
const apply_generic_with_tower = (op, a, b) => {
  const [ta, tb] = [type_tag(a), type_tag(b)];
  if (ta === tb) return get(op, [ta,tb])(contents(a), contents(b));
  if (level(ta) < level(tb)) return apply_generic_with_tower(op, raise(a), b);
  else                       return apply_generic_with_tower(op, a, raise(b));
};
Worked trace — add of an integer and a complex
Evaluate add(3, 2+1i) where 3 is tagged "integer" and the other is "complex":
add(int 3, complex 2+1i)
  tags differ; level(integer)=0 < level(complex)=3  -> raise the integer
  raise(int 3)      -> rational 3/1
  add(rational 3/1, complex 2+1i)
    raise(rational 3/1) -> real 3.0
    add(real 3.0, complex 2+1i)
      raise(real 3.0)   -> complex 3+0i
      add(complex 3+0i, complex 2+1i)
        tags match -> get("add",["complex","complex"]) -> complex 5+1i
  = complex 5+1i
The integer climbed the tower one rung at a time — integer → rational → real → complex — until both operands shared a tag, then the ordinary same-type add cell ran. We wrote one raise per level (O(t) functions), not a pairwise table (O(t²)), and the tower guarantees the coercions compose consistently.

Where does coercion live? Not inside any arithmetic package — a package must mention only its own type, or additivity from Lesson 07 dies. Not hard-wired pairwise into each operation. It lives in one place: the dispatcher's mixed-type rule, parameterized by the raise table. That keeps each type's package self-contained and makes "how types relate" a single, separately-maintained concern. (Symmetrically, drop / lowering simplifies results back down — 5+0i can become the integer 5 — but only the raising direction is needed to dispatch.)

5 · Generic coefficients, and the expression problem

Because the operations are generic, the layer can be stacked again. A polynomial is a list of terms, and its arithmetic uses generic add/mul on the coefficients — which can therefore be ordinary numbers, rationals, complex numbers, or polynomials themselves. Polynomials become just another type registered in the table:

const install_polynomial = () => {
  // term-by-term, using GENERIC add/mul on coefficients — they may be any type
  put("add", ["poly","poly"], (p1, p2) => tag(add_terms(p1, p2)));  // add_terms calls add(...)
  put("mul", ["poly","poly"], (p1, p2) => tag(mul_terms(p1, p2)));
};
// a polynomial whose coefficients are complex numbers "just works" —
// each coefficient add bottoms out in the complex "add" cell via apply_generic.

Now stand back at the whole table — operations down the side, types across the top. There are exactly two ways to grow it, and they pull against each other:

Add a new TYPE (a column)
e.g. add "quaternion". Write one install package implementing every existing operation for it, plus its raise rung. Touch no existing operation. Data-directed / message-passing makes this cheap.
Add a new OPERATION (a row)
e.g. add generic negate. You must add a negate cell to every existing type's package. Touch every existing type. The explicit-dispatch style of Lesson 07 made this cheap.

This is the expression problem: no single decomposition makes both "add a type" and "add an operation" free without editing existing code. The operation/type table makes types cheap (a new column slots in) but operations costly (a new row touches every column); the message-passing dual flips it. Every architecture you meet later — interpreters, the visitor pattern, type classes — is one more answer to this same tension. SICP does not "solve" it; it shows you the two poles and that the table at least makes the costs explicit and additive in one direction.

Common mistakes / failure modes

Hard-wiring coercion pairwise
Coercion entries between every pair of types are O(t²) and don't compose consistently. A tower of single raise steps is O(t) and forces transitive agreement. Don't enumerate pairs.
Putting cross-type logic in a package
If install_complex knows how to convert rationals, it now depends on the rational package and additivity is gone. Coercion belongs in the dispatcher, parameterized by the raise table — one place, separately owned.
Raising past the top
Calling raise on the most general type (complex) has no cell and loops or throws. The tower must terminate; the top type has no raise rung, and the dispatcher stops when tags match.
Forgetting which extension is cheap
Deciding the table layout commits you: it makes new types cheap and new operations costly (or vice-versa for message passing). Choosing wrong for your real change-pattern means fighting the expression problem on the expensive axis forever.

Checkpoint exercise

Try it
The tower is integer → rational → real → complex with one raise per rung. Tasks:
// (a) Add a generic operation `negate(x)` to the WHOLE system.
//     How many existing packages must you edit? Which axis of the
//     expression problem is this?
// (b) Add a new TYPE "gaussian" (integers a+bi) that sits between
//     real and complex. How many cells/rungs, and what existing code
//     changes?
Answers. (a) negate is a new row. You must add a negate cell to every type's package — integer, rational, real, complex (and poly): all of them. This is the costly axis of the expression problem under a type/operation table — adding an operation touches every existing type. (Message passing would have made this free and the next task costly instead.) (b) gaussian is a new column. You write one install package implementing every operation for it, plus a raise rung real→gaussian (or insert it in the chain), and you re-point real's raise to target it. Existing operations and other types are not edited — this is the cheap axis. The asymmetry between (a) and (b) is the expression problem made concrete.

Where this points next

We now have a richly layered system — generic operations, coercion, a tower, recursive coefficients — and it is built entirely from the tools of Parts I and II: functions, pairs, tags, and tables. Crucially, everything so far is purely functional. Every value means one fixed thing; make_rational(1,2) denotes the same rational forever; the substitution model from Lesson 01 still explains every program by replacing expressions with their values. But real systems model objects — a bank account, a counter, a circuit — whose identity persists while their state changes over time. You cannot express "the same account, now with less money" with values that never change. To model identity-through-change you must add assignment — and assignment detonates the substitution model, because a name no longer denotes a single value. Lesson 09 introduces local state and assignment, watches substitution break on a specific step, and thereby creates the pressure for an entirely new model of evaluation.

Takeaway
A generic-arithmetic system stacks many number kinds — ordinary, rational, complex, polynomial — on Lesson 07's one apply_generic dispatcher, with user-facing add/mul that key on the tuple of operand tags. Same-type calls hit a registered cell directly; mixed-type calls have no cell, and enumerating cross-type cells (or pairwise coercions) is an O(t²) explosion. The fix is a type tower — a single raise step per rung (integer → rational → real → complex) — so any coercion is a composition of O(t) steps that compose consistently; you raise the lower operand until tags match, then dispatch normally. Coercion must live in one place (the dispatcher, parameterized by the raise table), never inside a type's package, or additivity dies. Because operations are generic, coefficients can themselves be any number type, including polynomials — the layering recurses. Underneath sits the expression problem: the table makes adding a type (a column) cheap but adding an operation (a row) costly; message passing flips it; no decomposition makes both free. This whole system is purely functional — which is exactly why modeling identity-through-change next forces assignment, and assignment breaks substitution.

Interview prompts