Multiple representations of data
Lesson 06 ended on a warning: one symbolic object — a set, a polynomial, a number — can be represented in several incompatible ways, each with its own cost profile, and a tower of ifs asking "which representation is this?" does not scale. The constructor/selector barrier from Lesson 04 hid one representation behind a contract. This lesson breaks the assumption that there is only one. We attach a type tag to each datum, and replace the tower of conditionals with a table of operations indexed by type — data-directed programming. The payoff is additivity: you can install an entirely new representation by adding rows to a table, editing not one line of code that already exists.
apply-generic, and message passing. We use complex numbers in rectangular and polar form as the running example, exactly as the book does in spirit, but every trace, table, and the third-representation worked example are original and written in JavaScript.New capability: support several coexisting representations of one abstract object behind a single set of selectors, by tagging data with its type and dispatching operations through a table — and add a new representation without touching existing code.
apply_generic. (4) Demonstrate additivity by installing a third representation and changing zero existing lines. (5) Show message passing as the dual: instead of a table indexed by (op, type), let each datum be a function that answers operation names.1 · One abstract object, two honest representations
A complex number z = a + b·i can be described two equally valid ways. Rectangular form stores the real part a and imaginary part b. Polar form stores the magnitude r and angle θ, where a = r·cos θ and b = r·sin θ. Neither is "the truth"; each makes some operation cheap. Addition is trivial in rectangular form (add components); multiplication is trivial in polar form (multiply magnitudes, add angles). A serious system wants both available at once.
Whatever the storage, the four selectors are the same abstract interface, exactly the contract idea from Lesson 04:
// the abstract object's contract — clients only ever call these four
real_part(z) // the real component a
imag_part(z) // the imaginary component b
magnitude(z) // the radius r
angle(z) // the angle θ
So that we can tell the two representations apart at runtime, we tag each datum: glue a type symbol onto the front. A tagged datum is just a pair (tag, contents).
const attach_tag = (tag, contents) => ({ tag, contents });
const type_tag = d => d.tag;
const contents = d => d.contents;
// rectangular: store [a, b]
const make_from_real_imag_rect = (a, b) => attach_tag("rect", [a, b]);
// polar: store [r, theta]
const make_from_mag_ang_polar = (r, t) => attach_tag("polar", [r, t]);
2 · The obvious fix and why it is not additive
The first instinct is one generic selector per operation that asks the tag and branches. Call this explicit dispatch:
const real_part = z => {
if (type_tag(z) === "rect") { const [a, b] = contents(z); return a; }
if (type_tag(z) === "polar") { const [r, t] = contents(z); return r * Math.cos(t); }
throw new Error("unknown type: real_part");
};
const magnitude = z => {
if (type_tag(z) === "rect") { const [a, b] = contents(z); return Math.sqrt(a*a + b*b); }
if (type_tag(z) === "polar") { const [r, t] = contents(z); return r; }
throw new Error("unknown type: magnitude");
};
// ... and the same shape for imag_part and angle
This works, and for two representations it even looks tidy. But measure what it costs to grow. The structure is a matrix of operations × types, and explicit dispatch slices it the wrong way: each operation contains a branch for every type. Add one type and you must reopen and edit every operation.
"unit" (complex numbers known to lie on the unit circle, stored as just an angle). To support it you must reopen real_part, imag_part, magnitude, and angle and add a "unit" branch to each. Four functions edited for one new type. With m operations and a new type, that is m edits to working, tested, shipped code — and every edit is a chance to break the other types. Worse, two teams adding two representations both edit the same four functions and collide in source control. The design is not additive: new capability is not added, it is spliced into existing code. There is also a hidden global hazard — every author must use distinct tags, or one team's "polar" silently shadows another's.3 · Inverting the structure — the operation/type table
The fix is to stop hard-coding the matrix into the operations and instead store it in a table indexed by (operation name, type tag). Each cell holds the implementation. A representation registers its own column of the table by calling put; one universal dispatcher, apply_generic, reads the right cell by looking up the datum's tag.
// a 2-D table keyed first by operation name, then by type tag
const _table = new Map();
const put = (op, type, fn) => { _table.set(op + "|" + type, fn); };
const get = (op, type) => _table.get(op + "|" + type);
// the ONE dispatcher every generic selector funnels through
const apply_generic = (op, ...args) => {
const tag = type_tag(args[0]);
const fn = get(op, tag);
if (!fn) throw new Error(`no method ${op} for type ${tag}`);
// strip the tag, call the bare implementation on raw contents
return fn(...args.map(contents));
};
// the generic selectors are now one line each — no branching, ever
const real_part = z => apply_generic("real_part", z);
const imag_part = z => apply_generic("imag_part", z);
const magnitude = z => apply_generic("magnitude", z);
const angle = z => apply_generic("angle", z);
Each representation lives in its own self-contained install function that registers its column and never mentions any other type:
const install_rectangular = () => {
put("real_part", "rect", ([a, b]) => a);
put("imag_part", "rect", ([a, b]) => b);
put("magnitude", "rect", ([a, b]) => Math.sqrt(a*a + b*b));
put("angle", "rect", ([a, b]) => Math.atan2(b, a));
};
const install_polar = () => {
put("real_part", "polar", ([r, t]) => r * Math.cos(t));
put("imag_part", "polar", ([r, t]) => r * Math.sin(t));
put("magnitude", "polar", ([r, t]) => r);
put("angle", "polar", ([r, t]) => t);
};
install_rectangular();
install_polar();
The matrix is the same; we only changed which way we slice it. Explicit dispatch sliced by operation (each op knows every type). Data-directed slices by type (each type's install knows every op, and ops know nothing).
magnitude of a polar numbermagnitude(make_from_mag_ang_polar(5, 0.3)):
make_from_mag_ang_polar(5, 0.3)
-> { tag: "polar", contents: [5, 0.3] }
magnitude(z)
= apply_generic("magnitude", z)
tag = type_tag(z) -> "polar"
fn = get("magnitude","polar") -> ([r,t]) => r
fn(contents(z)) -> fn([5, 0.3]) -> 5
= 5
No if ran. The dispatch was a single table lookup keyed by the tag the datum carried with it. The selector magnitude did not know polar numbers exist — it asked the table.4 · Additivity — install a third representation, change nothing
Now redo the experiment that broke explicit dispatch: add the "unit" representation (a complex number on the unit circle, stored as a lone angle). With the table, this is one new self-contained block — and crucially, no existing function or install changes.
const make_from_angle_unit = t => attach_tag("unit", [t]);
const install_unit = () => {
put("real_part", "unit", ([t]) => Math.cos(t)); // r = 1
put("imag_part", "unit", ([t]) => Math.sin(t));
put("magnitude", "unit", ([t]) => 1);
put("angle", "unit", ([t]) => t);
};
install_unit(); // the ONLY new top-level call
// works immediately through the same selectors — no edits upstream
magnitude(make_from_angle_unit(0.3)); // -> 1
real_part(make_from_angle_unit(0)); // -> 1
real_part/magnitude were written before "unit" existed and still serve it correctly. New capability was added, not spliced. That is additivity, and it is the property that lets large systems with many representations be built — and extended by independent teams — without a combinatorial edit storm.5 · The dual view — message passing
The table is indexed by (operation, type). There are two ways to decompose a table into code: fix the column and vary the row (that is the install functions above — group by type), or make each datum itself a procedure that, given an operation name, returns the answer. The latter is message passing: the datum is its own dispatch.
// a polar number that responds to operation-name "messages"
const make_from_mag_ang_msg = (r, t) => (op) => {
if (op === "real_part") return r * Math.cos(t);
if (op === "imag_part") return r * Math.sin(t);
if (op === "magnitude") return r;
if (op === "angle") return t;
throw new Error(`unknown message ${op}`);
};
// the generic selector now just sends the message
const apply_generic_msg = (op, z) => z(op);
const magnitude2 = z => apply_generic_msg("magnitude", z);
magnitude2(make_from_mag_ang_msg(5, 0.3)); // -> 5
This is the same matrix decomposed the other way — and it is the seed of object orientation: a "message-passing object" bundles state with the operations that respond to it. The table view makes adding an operation easy (add a row); the message-passing view makes adding a type easy (write one new closure). Hold that asymmetry — it returns, sharpened, in Lesson 08.
Common mistakes / failure modes
install_polar references "rect", the modules are coupled again and additivity is lost. Each install must mention only its own tag. Cross-type work belongs above the barrier (the topic of Lesson 08's coercion).apply_generic hands the implementation the contents, not the tagged datum. A method written to expect the tagged form re-tags and the abstraction barrier leaks. Keep implementations operating on raw contents only."polar" overwrite each other's table cells silently. Tags are a shared global namespace; treat them like one (namespacing, registration discipline).if (type_tag(z) === ...) surviving in a selector quietly reintroduces the non-additive coupling for that one operation. Data-directed dispatch only pays off if every selector funnels through the table.Checkpoint exercise
// (a) Add a generic operation, not a type: a constructor-agnostic
// `add_complex(z1, z2)` built ONLY from the four selectors.
// (b) Then install a brand-new type "imag_only" (purely imaginary,
// stored as just the imaginary part b) WITHOUT editing add_complex.
Answers. (a) Because the selectors are generic, add_complex needs no dispatch of its own and works for every type, present and future:
const add_complex = (z1, z2) => make_from_real_imag_rect(
real_part(z1) + real_part(z2),
imag_part(z1) + imag_part(z2));
(b) One new self-contained block; add_complex is never touched, and it immediately accepts "imag_only" values:
const make_imag_only = b => attach_tag("imag_only", [b]);
const install_imag_only = () => {
put("real_part", "imag_only", ([b]) => 0);
put("imag_part", "imag_only", ([b]) => b);
put("magnitude", "imag_only", ([b]) => Math.abs(b));
put("angle", "imag_only", ([b]) => b >= 0 ? Math.PI/2 : -Math.PI/2);
};
install_imag_only();
Note the asymmetry the exercise reveals: adding the operation add_complex was free across all types; adding the type imag_only was free across all operations — but only because each axis was made data-directed. Lose either and one of these tasks becomes an edit storm.Where this points next
We taught a single family — complex numbers — to wear several representations, dispatched through one table. But a real arithmetic system has whole kinds of number — ordinary, rational, complex — and you want add to mean the right thing for each, and you want to stack them: a complex number whose parts are themselves rational. The dispatch mechanism we just built does not stop at one family; it generalizes into a whole layered system of generic operations across many types, with one twist this lesson dodged — what happens when you add a rational to a complex, two different tags? Lesson 08 builds the generic-arithmetic tower on top of apply_generic, introduces coercion between types, and confronts the tension this lesson only hinted at in §5: making it easy to add a new type versus a new operation — the expression problem, in embryo.
apply_generic that looks up the cell by the datum's tag. Now a new representation is one self-contained install block that registers its column and touches nothing else — change zero existing lines, as the third ("unit") representation demonstrated. Message passing is the dual decomposition: make each datum a closure that answers operation-name messages — the seed of objects. The two views trade off which axis is cheap to extend: a row (operation) versus a closure (type).Interview prompts
- Why does explicit conditional dispatch fail to scale? (§2 — it slices the operation×type matrix by operation, so each new type forces a new branch in every operation; m edits to working code per type, plus merge collisions and tag-collision hazards — it is not additive.)
- What is tagged data and why is it needed? (§1 — a (type, contents) pair so the dispatcher can tell representations apart at runtime; the tag is the key into the operation/type table.)
- Describe the operation/type table and
apply_generic. (§3 — a 2-D table keyed by (op name, type tag) holding implementations;apply_genericreads the first arg's tag, looks up the cell, strips tags, and applies the bare implementation to raw contents.) - Define additivity and show it concretely. (§4 — new capability is added without editing existing code; installing the
"unit"representation was one new block and zero edits, vs m edits under explicit dispatch.) - What is message passing and how does it relate to the table? (§5 — the dual decomposition: each datum is a closure that responds to operation-name messages, so the datum carries its own dispatch; it is the seed of objects and makes adding a type cheap, where the table makes adding an operation cheap.)
- Where must cross-type logic NOT go, and why? (§ common mistakes — never inside a single type's install (that recouples modules and kills additivity); cross-type handling belongs above the barrier — foreshadowing Lesson 08's coercion.)
- Sketch a generic
add_complexthat survives new types. (§ checkpoint — build it purely from the four generic selectors so it does no dispatch of its own; any future type that registers those selectors works for free.)