Lesson 03 / 14
Abstraction
Separating exposed name count from known detail as abstraction's measure: counting the detail known by clients across a representation-exposed and a representation-hidden presentation of the same tariff, comparing how many clients break when the representation changes, and measuring the cost of hiding.
Contents
The previous lesson made the consignment’s fields unwritable from outside and counted how this protected the invariants. That measurement left one question open: how many names a module exposes outward, and how much internal detail those names carry. Encapsulation blocks writing; it does not block reading or dependency.
Abstraction is a unit showing outward only what is necessary for use and hiding the rest. This is the definition introduced in the Programming Fundamentals course. The question asked in this lesson is not the definition but the measure: what number determines “what is necessary,” and what does a wrongly drawn boundary cost.
The Measure: Exposed Names and Known Detail Are Not the Same Thing
The common measure is the number of exposed names — a module exposing fewer names is considered better abstracted. This measure is misleading on its own, because a single name can carry a data structure’s entire internal shape. A module that exposes a tariff table as a single object exposes one name, but every client using that object is forced to know how the tiers are stored, whether they are ordered, and the fields’ names.
The measure that works is twofold: the number of exposed names and the number of representation details a client is forced to know. Together, the two predict a third number — how many clients must be touched when a detail changes. This lesson counts all three.
Two Presentations of the Same Tariff
The fee-calculation library’s tariff table is presented in two forms. In the first, the representation is outward.
// open/tariff.mjs — representation open outward: tier array, factor table, and thresholds are readable export const TARIFF = { tiers: [[1000, 4500], [5000, 7000], [10000, 11000]], ratePerKg: 1800, zoneFactor: { 1: 1, 2: 1.25, 3: 1.6 }, minimumFee: 5000, };
Three clients use this table. The first works out an order’s total and builds the fee itself.
// open/order.mjs — the client knows the tier array's shape and builds the fee itself import { TARIFF } from "./tariff.mjs"; const chargeableWeight = (g) => Math.max(g.weightGrams, g.volumeCm3 / 3); export function fee(shipment, zone) { const w = chargeableWeight(shipment); const tier = TARIFF.tiers.find(([max]) => w <= max); const [lastMax, lastFee] = TARIFF.tiers[TARIFF.tiers.length - 1]; const base = tier !== undefined ? tier[1] : lastFee + Math.ceil((w - lastMax) / 1000) * TARIFF.ratePerKg; return Math.max(Math.round(base * TARIFF.zoneFactor[zone]), TARIFF.minimumFee); } export const total = (shipments, zone) => shipments.reduce((t, g) => t + fee(g, zone), 0);
The second gives a shipment’s lowest and highest fee across zones; it derives the zone list from the factor table’s keys.
// open/quote.mjs — the client derives the zone list from the factor table's keys import { TARIFF } from "./tariff.mjs"; import { fee } from "./order.mjs"; export function range(shipment) { const fees = Object.keys(TARIFF.zoneFactor).map((z) => fee(shipment, Number(z))); return { min: Math.min(...fees), max: Math.max(...fees) }; }
The third totals the surcharge for weight exceeding the top tier.
// open/capacity.mjs — the client reads the top tier's threshold and per-kg rate directly import { TARIFF } from "./tariff.mjs"; export function overflowFee(shipments) { const [lastMax] = TARIFF.tiers[TARIFF.tiers.length - 1]; return shipments.reduce((t, g) => { const w = Math.max(g.weightGrams, g.volumeCm3 / 3); return t + (w <= lastMax ? 0 : Math.ceil((w - lastMax) / 1000) * TARIFF.ratePerKg); }, 0); }
In the second presentation, the representation stays inside the module; three names are exposed outward. The exposed names are not data, but the questions clients actually ask.
// closed/tariff.mjs — representation private; three names exposed outward const TIERS = [[1000, 4500], [5000, 7000], [10000, 11000]]; const RATE_PER_KG = 1800; const ZONE_FACTOR = { 1: 1, 2: 1.25, 3: 1.6 }; const MINIMUM_FEE = 5000; export function overflowFee(weight) { const lastMax = TIERS.at(-1)[0]; return weight <= lastMax ? 0 : Math.ceil((weight - lastMax) / 1000) * RATE_PER_KG; } export function fee(weight, zone) { const tier = TIERS.find(([max]) => weight <= max); const base = tier !== undefined ? tier[1] : TIERS.at(-1)[1] + overflowFee(weight); return Math.max(Math.round(base * ZONE_FACTOR[zone]), MINIMUM_FEE); } export const zones = () => Object.keys(ZONE_FACTOR).map(Number);
The same three clients only make calls in this presentation.
// closed/order.mjs — the client uses only the fee name; it does not know the representation import { fee as tariffFee } from "./tariff.mjs"; const chargeableWeight = (g) => Math.max(g.weightGrams, g.volumeCm3 / 3); export const fee = (shipment, zone) => tariffFee(chargeableWeight(shipment), zone); export const total = (shipments, zone) => shipments.reduce((t, g) => t + fee(g, zone), 0);
// closed/quote.mjs — the zone list is asked of the tariff import { zones } from "./tariff.mjs"; import { fee } from "./order.mjs"; export function range(shipment) { const fees = zones().map((z) => fee(shipment, z)); return { min: Math.min(...fees), max: Math.max(...fees) }; }
// closed/capacity.mjs — the overflow fee is asked of the tariff import { overflowFee as tariffOverflow } from "./tariff.mjs"; export function overflowFee(shipments) { return shipments.reduce( (t, g) => t + tariffOverflow(Math.max(g.weightGrams, g.volumeCm3 / 3)), 0); }
Two Numbers: Exposed Names and Known Detail
The measurement names seven tariff details in advance and searches each client source for which ones appear. The seven details are written into the list itself; that is what gets counted.
// measurement.mjs — counts the number of exported names and the representation detail known by clients import { readFileSync } from "node:fs"; const DETAILS = [ ["tier list", /\.tiers\b/], ["tier is a pair", /\(\[max\]\)|\[lastMax(, lastFee)?\]|tier\[1\]/], ["tier ordering", /tiers\[TARIFF\.tiers\.length - 1\]/], ["per-kg rate name", /\.ratePerKg\b/], ["factor table", /\.zoneFactor\b/], ["zones as object keys", /Object\.keys\(TARIFF\./], ["minimum fee name", /\.minimumFee\b/], ]; const CLIENTS = ["order.mjs", "quote.mjs", "capacity.mjs"]; for (const dir of ["open", "closed"]) { const tariff = readFileSync(`${dir}/tariff.mjs`, "utf8"); console.log(`${dir.padEnd(6)} exported names = ${(tariff.match(/^export /gm) ?? []).length}`); const all = new Set(); for (const file of CLIENTS) { const source = readFileSync(`${dir}/${file}`, "utf8"); const known = DETAILS.filter(([, k]) => k.test(source)).map(([name]) => name); known.forEach((d) => all.add(d)); console.log(` ${file.padEnd(13)} known detail = ${known.length} ${known.join(", ")}`); } console.log(` distinct detail total = ${all.size}`); }
node measurement.mjs
open exported names = 1 order.mjs known detail = 6 tier list, tier is a pair, tier ordering, per-kg rate name, factor table, minimum fee name quote.mjs known detail = 2 factor table, zones as object keys capacity.mjs known detail = 4 tier list, tier is a pair, tier ordering, per-kg rate name distinct detail total = 7 closed exported names = 3 order.mjs known detail = 0 quote.mjs known detail = 0 capacity.mjs known detail = 0 distinct detail total = 0
The two numbers run in opposite directions. The open presentation exposes one name, the closed presentation three. Judged by name count, the open presentation looks like it has a narrower surface. Judged by detail count, the table reverses: the open presentation’s clients know seven separate representation details, the closed presentation’s clients know none. The number of exposed names is not an abstraction measure; the measure is what is left behind the names.
For the comparison to be meaningful, the two presentations must be shown to produce the same result. The checker below runs the three clients in both directories and compares them against known values.
// check.mjs — runs the three clients in both directories and compares them against known values const SHIPMENTS = [ { weightGrams: 800, volumeCm3: 900 }, { weightGrams: 4000, volumeCm3: 30000 }, { weightGrams: 12000, volumeCm3: 900 }, ]; const EXPECTED = { "order.mjs": 37625, "quote.mjs": "5000/7200", "capacity.mjs": 3600 }; async function attempt(dir, file) { if (file === "order.mjs") { const { total } = await import(`./${dir}/order.mjs`); return total(SHIPMENTS, 2); } if (file === "quote.mjs") { const { range } = await import(`./${dir}/quote.mjs`); const r = range(SHIPMENTS[0]); return `${r.min}/${r.max}`; } const { overflowFee } = await import(`./${dir}/capacity.mjs`); return overflowFee(SHIPMENTS); } for (const dir of ["open", "closed"]) { const broken = []; for (const file of Object.keys(EXPECTED)) { let result; try { result = await attempt(dir, file); } catch (e) { result = e.constructor.name; } if (result !== EXPECTED[file]) broken.push(`${file}->${result}`); } console.log(`${dir.padEnd(6)} broken client = ${broken.length} ${broken.join(" ")}`); }
node check.mjs
open broken client = 0 closed broken client = 0
If the same results are produced, the cost of hiding in terms of code volume can also be asked. Contrary to what might be assumed, the cost in this example is negative.
for d in open closed; do echo "$d non-empty lines = $(cat $d/tariff.mjs $d/order.mjs $d/quote.mjs $d/capacity.mjs | grep -c .)" done
open non-empty lines = 37 closed non-empty lines = 34
The closed presentation is three lines shorter. The three function bodies added to the module took up less room than the repeated representation information that disappeared from the clients. Code volume turns in abstraction’s favor once the detail count is spread across enough clients.
When a Detail Changes
Two of the seven details change: the tiers move to named fields, and the zone factors turn into a list. The tariff’s meaning stays the same — the same tiers, the same factors.
// open/tariff-v2.mjs — same tariff: tiers as named fields, zones as a list export const TARIFF = { tiers: [ { maxGrams: 1000, fee: 4500 }, { maxGrams: 5000, fee: 7000 }, { maxGrams: 10000, fee: 11000 }, ], ratePerKg: 1800, zoneFactor: [{ zone: 1, factor: 1 }, { zone: 2, factor: 1.25 }, { zone: 3, factor: 1.6 }], minimumFee: 5000, };
// closed/tariff-v2.mjs — same representation change; the three exported names stay exactly the same const TIERS = [ { maxGrams: 1000, fee: 4500 }, { maxGrams: 5000, fee: 7000 }, { maxGrams: 10000, fee: 11000 }, ]; const RATE_PER_KG = 1800; const ZONE_FACTOR = [{ zone: 1, factor: 1 }, { zone: 2, factor: 1.25 }, { zone: 3, factor: 1.6 }]; const MINIMUM_FEE = 5000; export function overflowFee(weight) { const lastMax = TIERS.at(-1).maxGrams; return weight <= lastMax ? 0 : Math.ceil((weight - lastMax) / 1000) * RATE_PER_KG; } export function fee(weight, zone) { const tier = TIERS.find((t) => weight <= t.maxGrams); const base = tier !== undefined ? tier.fee : TIERS.at(-1).fee + overflowFee(weight); return Math.max(Math.round(base * ZONE_FACTOR.find((z) => z.zone === zone).factor), MINIMUM_FEE); } export const zones = () => ZONE_FACTOR.map((z) => z.zone);
In both directories, the number of files changed is one. The same checker is run again.
cp open/tariff-v2.mjs open/tariff.mjs cp closed/tariff-v2.mjs closed/tariff.mjs node check.mjs
open broken client = 3 order.mjs->TypeError quote.mjs->TypeError capacity.mjs->TypeError closed broken client = 0
A change made in a single file broke all three of the open presentation’s three clients, and none of the closed presentation’s. The break count is a direct result of the seven measured details: if a client knows a detail, it breaks when that detail changes.
All the breaks here produced a runtime error; none stayed silent. This is because the representation change was large enough to break the type. Smaller changes — the tiers’ ordering getting disturbed, a unit change — produce a wrong number, not an error, in the same sources; the number of broken clients is still three, but it requires a validation rather than a counter.
The Cost of Hiding
Abstraction is not free. Code volume was not the cost — the measurement gave 37 versus 34 lines. The real cost is the directly measured number three: the closed module answers three questions. A fourth question — printing the tariff table as a document, listing the tier boundaries in an interface — is answered in the open presentation without touching the module at all, and requires modifying the module in the closed presentation. Hiding moves the cost of a new need from the client to the module’s owner.
This cost is paid without a return when abstraction is drawn in the wrong place. If the module exposes a fourth name that returns the tier list as-is, the measured zero climbs back toward seven: the hidden representation re-emerges outward under a changed name. This is called a leaky abstraction. The fourth question’s correct answer is not handing over the internal list, but returning a view that answers the question asked in the module’s own words — a summary whose fields the module defines, with no tie to the internal representation.
Summary
- The number of exposed names is not by itself an abstraction measure: the open presentation exposed one name, the closed presentation three, yet the relationship in detail count ran the opposite way.
- The representation detail clients know was measured at seven for the open presentation and zero for the closed; this number predicts in advance how many clients will break on a change.
- A representation change made in a single file broke all three of the open presentation’s clients, and none of the closed presentation’s.
- Abstraction’s code cost came out negative in this example (37 versus 34 lines); hiding shortens code once the detail is spread across enough clients.
- The real cost is measured by the number of questions the module answers: the closed module answers three questions, and the fourth requires modifying the module.
- A name that hands the internal representation back out under a changed name is a leaky abstraction; it climbs the detail count back from zero.
Next Step
The closed tariff module hides a single tariff form. In a real fee-calculation library, there is more than one tariff type: a domestic tier tariff, a transfer tariff, a contracted-customer tariff. One way to meet the same names with different implementations is to derive one type from another. The next lesson takes up this relationship and measures when derivation actually produces a subtype: a client working through the same supertype is run with a subclass that follows the contract and one that does not, and the number of wrong results the noncompliant version produces is counted.
To keep your progress and take notes, Log in
My notes
Log in to take notes.