Lesson 05 / 14
Polymorphism
Implementing subtype, parametric, and ad hoc polymorphism separately on the same tariff problem: counting the files touched on each path when a new tariff type and a new operation are added to the system, and showing the contrast between the two directions of extension.
Contents
In the previous lesson, two subclasses implemented the same name — fee — differently, and
the client did not know which one it was working with. This is a single name corresponding
to more than one behavior. The subtype relationship is only one way to do this, and the path
chosen determines which direction the system can extend in.
Polymorphism is the ability of a single interface to work with more than one type. This is the definition introduced in the Programming Fundamentals course. This lesson builds three separate forms on the same problem and counts the cost of choosing among them.
Three Separate Forms
Subtype polymorphism routes a call according to the object’s type at runtime. The name is declared on a shared supertype; the implementation lives in the subtypes.
Parametric polymorphism is the same code running independent of type. The code does not look at any type; the work it does is the same for every type. The type-specific part is supplied from outside.
Ad hoc polymorphism is writing a separate implementation for each type and calling it through a shared name. In languages that support overloading, this means defining the same name with different signatures; here it is implemented with a dispatch table that looks at a type tag.
The problem is the same in all three: two tariff types (tiered, flat) and two operations
(fee, deliveryDay).
Same Problem, Three Implementations
On the subtype path, the supertype declares the operations, and each type implements them in its own file.
// v1/subtype/tariff.mjs — supertype: every tariff type implements these two methods export class Tariff { fee(shipment) { throw new Error(`fee not implemented: ${shipment}`); } deliveryDay(shipment) { throw new Error(`deliveryDay not implemented: ${shipment}`); } }
// v1/subtype/tiered.mjs — tiered tariff type import { Tariff } from "./tariff.mjs"; export class Tiered extends Tariff { fee(s) { return s.weightGrams <= 1000 ? 4500 : 4500 + Math.ceil((s.weightGrams - 1000) / 1000) * 1800; } deliveryDay(s) { return s.zone === 1 ? 1 : 3; } }
// v1/subtype/flat.mjs — flat-rate tariff type import { Tariff } from "./tariff.mjs"; export class Flat extends Tariff { fee() { return 9000; } deliveryDay() { return 2; } }
On the ad hoc path, the type is a tag, and each operation collects all the types into a single table in its own file. The direction of the split is the exact opposite of the subtype path.
// v1/ad-hoc/fee.mjs — fee operation: one table for every type const TABLE = { tiered: (s) => (s.weightGrams <= 1000 ? 4500 : 4500 + Math.ceil((s.weightGrams - 1000) / 1000) * 1800), flat: () => 9000, }; export const fee = (tariff, shipment) => TABLE[tariff.type](shipment);
// v1/ad-hoc/delivery-day.mjs — delivery day operation: one table for every type const TABLE = { tiered: (s) => (s.zone === 1 ? 1 : 3), flat: () => 2 }; export const deliveryDay = (tariff, shipment) => TABLE[tariff.type](shipment);
On the parametric path, type never appears. The following two functions work with every tariff representation, because they take the criterion from outside: with class instances as well as with tagged plain objects.
// v1/parametric/select.mjs — uses no type information; takes the criterion from outside export function cheapest(options, criterion) { let best = options[0]; for (const o of options) if (criterion(o) < criterion(best)) best = o; return best; } export const sorted = (options, criterion) => [...options].sort((a, b) => criterion(a) - criterion(b));
The three paths give the same results on the same shipment.
// v1/run.mjs — shows that the three paths give the same results on the same shipment import { Tiered } from "./subtype/tiered.mjs"; import { Flat } from "./subtype/flat.mjs"; import { fee } from "./ad-hoc/fee.mjs"; import { deliveryDay } from "./ad-hoc/delivery-day.mjs"; import { cheapest } from "./parametric/select.mjs"; const S = { weightGrams: 4000, zone: 2 }; const objects = [new Tiered(), new Flat()]; const records = [{ type: "tiered" }, { type: "flat" }]; console.log("subtype ", objects.map((t) => `${t.fee(S)}/${t.deliveryDay(S)}`).join(" ")); console.log("ad hoc ", records.map((t) => `${fee(t, S)}/${deliveryDay(t, S)}`).join(" ")); console.log("parametric ", cheapest(objects, (t) => t.fee(S)).constructor.name, cheapest(records, (t) => fee(t, S)).type);
node v1/run.mjs
subtype 9900/3 9000/2 ad hoc 9900/3 9000/2 parametric Flat flat
The parametric line shows that path’s character: cheapest worked on both class instances
and tagged records, because it looked at neither — it only called the criterion it was given.
Two Extensions
Two changes arrive for the system. The first is a new type: a tariff for shipments routed through a transit point. The following block sets up the second version by copying the first and adds this type.
cp -r v1 v2 cat > v2/subtype/transit.mjs <<'FILE' // v2/subtype/transit.mjs — new type: shipment routed through a transit point import { Tariff } from "./tariff.mjs"; export class Transit extends Tariff { fee(s) { return 6000 + Math.ceil(s.weightGrams / 1000) * 1200; } deliveryDay() { return 5; } } FILE cat > v2/ad-hoc/fee.mjs <<'FILE' // v2/ad-hoc/fee.mjs — fee operation: a line added for the new type const TABLE = { tiered: (s) => (s.weightGrams <= 1000 ? 4500 : 4500 + Math.ceil((s.weightGrams - 1000) / 1000) * 1800), flat: () => 9000, transit: (s) => 6000 + Math.ceil(s.weightGrams / 1000) * 1200, }; export const fee = (tariff, shipment) => TABLE[tariff.type](shipment); FILE cat > v2/ad-hoc/delivery-day.mjs <<'FILE' // v2/ad-hoc/delivery-day.mjs — delivery day operation: a line added for the new type const TABLE = { tiered: (s) => (s.zone === 1 ? 1 : 3), flat: () => 2, transit: () => 5 }; export const deliveryDay = (tariff, shipment) => TABLE[tariff.type](shipment); FILE echo "v2 set up"
v2 set up
The second change is a new operation: an insurance fee based on the shipment’s declared value. The third version is set up by copying the second.
cp -r v2 v3 cat > v3/subtype/tariff.mjs <<'FILE' // v3/subtype/tariff.mjs — supertype: third method added export class Tariff { fee(shipment) { throw new Error(`fee not implemented: ${shipment}`); } deliveryDay(shipment) { throw new Error(`deliveryDay not implemented: ${shipment}`); } insuranceFee(shipment) { throw new Error(`insuranceFee not implemented: ${shipment}`); } } FILE cat > v3/subtype/tiered.mjs <<'FILE' // v3/subtype/tiered.mjs — third method added import { Tariff } from "./tariff.mjs"; export class Tiered extends Tariff { fee(s) { return s.weightGrams <= 1000 ? 4500 : 4500 + Math.ceil((s.weightGrams - 1000) / 1000) * 1800; } deliveryDay(s) { return s.zone === 1 ? 1 : 3; } insuranceFee(s) { return Math.round(s.value * 0.01); } } FILE cat > v3/subtype/flat.mjs <<'FILE' // v3/subtype/flat.mjs — third method added import { Tariff } from "./tariff.mjs"; export class Flat extends Tariff { fee() { return 9000; } deliveryDay() { return 2; } insuranceFee() { return 0; } } FILE cat > v3/subtype/transit.mjs <<'FILE' // v3/subtype/transit.mjs — third method added import { Tariff } from "./tariff.mjs"; export class Transit extends Tariff { fee(s) { return 6000 + Math.ceil(s.weightGrams / 1000) * 1200; } deliveryDay() { return 5; } insuranceFee(s) { return Math.round(s.value * 0.02); } } FILE cat > v3/ad-hoc/insurance-fee.mjs <<'FILE' // v3/ad-hoc/insurance-fee.mjs — new operation: one file, every type const TABLE = { tiered: (s) => Math.round(s.value * 0.01), flat: () => 0, transit: (s) => Math.round(s.value * 0.02), }; export const insuranceFee = (tariff, shipment) => TABLE[tariff.type](shipment); FILE echo "v3 set up"
v3 set up
Number of Files Touched
The measurement counts the difference between two versions. diff -rq produces one line for
both a changed file and a newly added file; these are the lines being counted. The comparison
covers only the three paths’ own directories — the client side is affected the same way on
all three paths, so it does not enter the measurement.
for s in subtype ad-hoc parametric; do echo "$s" echo " new type -> files touched = $(diff -rq v1/$s v2/$s | wc -l | tr -d ' ')" echo " new operation -> files touched = $(diff -rq v2/$s v3/$s | wc -l | tr -d ' ')" done
subtype new type -> files touched = 1 new operation -> files touched = 4 ad-hoc new type -> files touched = 2 new operation -> files touched = 1 parametric new type -> files touched = 0 new operation -> files touched = 0
The numbers are opposites. On the subtype path, a new type cost one file and a new operation cost four files. On the ad hoc path, the relationship reversed: a new type cost two files, a new operation cost one file. On the parametric path, both extensions cost zero files.
The reason for the contrast is the direction of the split. The subtype path splits the code by type; all the operations of a single type sit side by side, so adding a type is cheap and adding an operation is expensive. The ad hoc path splits the code by operation; all the types of a single operation sit side by side, so the relationship reverses. A design cannot be cheap in both directions at once; the choice is made according to which direction extends more often.
The zeros on the parametric path do not mean that path is free. cheapest was unaffected by
both extensions because it does no type-specific work; the caller supplies the criterion that
computes the fee. Parametric polymorphism does not eliminate type-specific work, it pushes
that work outside itself. This is why it does not appear alone in a system, but alongside the
other two.
The extended system’s three types and three operations produce the same results.
// v3/run.mjs — extended system: three types, three operations, same results import { Tiered } from "./subtype/tiered.mjs"; import { Flat } from "./subtype/flat.mjs"; import { Transit } from "./subtype/transit.mjs"; import { fee } from "./ad-hoc/fee.mjs"; import { deliveryDay } from "./ad-hoc/delivery-day.mjs"; import { insuranceFee } from "./ad-hoc/insurance-fee.mjs"; import { cheapest } from "./parametric/select.mjs"; const S = { weightGrams: 4000, zone: 2, value: 150000 }; const objects = [new Tiered(), new Flat(), new Transit()]; const records = [{ type: "tiered" }, { type: "flat" }, { type: "transit" }]; const n = (t) => `${t.fee(S)}/${t.deliveryDay(S)}/${t.insuranceFee(S)}`; const k = (t) => `${fee(t, S)}/${deliveryDay(t, S)}/${insuranceFee(t, S)}`; console.log("subtype ", objects.map(n).join(" ")); console.log("ad hoc ", records.map(k).join(" ")); console.log("parametric ", cheapest(objects, (t) => t.fee(S)).constructor.name, cheapest(records, (t) => fee(t, S)).type);
node v3/run.mjs
subtype 9900/3/1500 9000/2/0 10800/5/3000 ad hoc 9900/3/1500 9000/2/0 10800/5/3000 parametric Flat flat
Selection Criterion
The criterion is which axis the system extends along, and this is answered not by prediction but by history: it looks at how many types and how many operations were added recently.
Where the type axis is active — carrier options, payment methods, notification channels — the set of operations usually settles early, and the subtype path comes out cheap. Where the operation axis is active — a fixed set of types with a steady stream of new reports, new validations, new exports — the ad hoc path comes out cheap; if the type set is closed, adding a new operation comes down to a single file.
There is also a signal for a wrong choice. If every new operation on the subtype path touches
four files, and this repeats every month, that means the design has measurably split along
the wrong axis. In the same way, if the ad hoc path keeps adding new type rows to its tables
and updating one table is forgotten when a type is added — in that case the error shows up at
runtime as TABLE[tariff.type] coming out undefined — that means the axis has reversed.
Summary
- The three forms of polymorphism can solve the same problem: subtype routes by runtime type, ad hoc selects a table by type tag, parametric looks at no type at all.
- On the same shipment, all three paths gave the same result; the comparison was made not on behavior but on extension cost.
- Files touched when a new type is added: subtype 1, ad hoc 2, parametric 0.
- Files touched when a new operation is added: subtype 4, ad hoc 1, parametric 0.
- The reason for the contrast is the direction of the split: the subtype path splits by type, the ad hoc path splits by operation; a design cannot be cheap in both directions at once.
- The zeros on the parametric path show that type-specific work is not eliminated but pushed outside the code; it is used alongside the other two, not alone.
Next Step
The Tariff class on the subtype path did two jobs: declaring which methods must exist and
throwing an error when one is not implemented. These two jobs can be separated. One
abstraction can declare only the contract; another can carry the contract together with the
shared implementation. The next lesson compares these two tools — the interface and the
abstract class — and shows, by running code, the point where the choice breaks down: when a
tariff type needs two abstractions at once, it measures what each arrangement does and how
many lines get repeated.
To keep your progress and take notes, Log in
My notes
Log in to take notes.