Skip to content
academia.sh

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.

Start typing to search.

↑↓ Esc navigate · open · close