Case Study: The Numerical Tower
Students are taught that int and double are the defaults — and rarely see how those abstractions leak until a critical bug arrives. This case study builds a numerical tower: a hierarchy of number types, inspired by Scheme and Lisp but built cleanly in modern Java with records and interfaces, in which values promote themselves upward when a computation demands more than their representation can honestly deliver. It ties together interface contracts, polymorphism, immutability and performance trade-offs — the whole OOP track in one worked example. (The complete source, NumericalTower.java, accompanies this page.)
1. The Broken Abstractions of Hardware Primitives
The integer overflow illusion
long x = Long.MAX_VALUE;
System.out.println(x + 1); // -9223372036854775808 — silently negative
Hardware arithmetic does not fail loudly; it wraps. No exception, no warning — it simply pollutes your data space with quiet garbage. The tower's ground floor refuses that bargain: PrimitiveInt wraps a long but performs arithmetic with Math.addExact(), which throws on overflow — and the tower catches that signal and promotes both operands to the next level instead of corrupting the result:
record PrimitiveInt(long value) implements TowerNumber {
@Override
public TowerNumber add(TowerNumber other) {
if (other.getLevel() > this.getLevel()) return other.add(this);
long otherVal = ((PrimitiveInt) other).value();
try {
return new PrimitiveInt(Math.addExact(this.value, otherVal));
} catch (ArithmeticException e) {
return this.promoteTo(2, null).add(other.promoteTo(2, null)); // escalate, don't corrupt
}
}
}
The floating-point catastrophe
System.out.println(0.1 + 0.2 == 0.3); // false
System.out.println(0.1 + 0.2); // 0.30000000000000004
IEEE 754 cannot represent 0.1 or 0.2 exactly in binary, and the tiny errors compound. For graphics it rarely matters; for currency, compliance or anything cumulative it is a correctness bug wearing a performance costume — a direct failure of the verifiability pillar in Trustworthy Software. The tower's answer is a level of exact rationals: BigFraction keeps numerator and denominator as BigIntegers, normalised by GCD in the record's compact constructor, so \(\tfrac{1}{10} + \tfrac{2}{10}\) is exactly \(\tfrac{3}{10}\).
2. Anatomy of the Tower
(exact-checked long)"] --> L2["Level 2: BigFraction
(exact rationals)"] L2 --> L3["Level 3: LazySymbolic
(π, e — evaluated on demand)"] L3 --> L4["Level 4: ArbitraryDecimal
(BigDecimal, chosen precision)"] L4 --> L5["Level 5: ComplexNumber
(real + imaginary)"]
The power of the interface contract
Everything rests on one small contract:
interface TowerNumber {
int getLevel();
TowerNumber add(TowerNumber other);
TowerNumber multiply(TowerNumber other);
TowerNumber promoteTo(int level, MathContext mc);
}
Client code never asks how a number is implemented — only that it can add, multiply, and promote. Any mix of levels can flow through the same arithmetic; this is Liskov substitution doing real work, a far better illustration than "Cat extends Animal" (see SOLID).
Symmetric dispatch via promotion
Binary operations are OOP's awkward corner: add dispatches on one receiver, but addition has two operands of possibly different types. How do you add a PrimitiveInt to a ComplexNumber? The tower's elegant answer is the level check — one line at the top of every operation:
if (other.getLevel() > this.getLevel()) return other.add(this);
A lower type meeting a higher type yields control; the higher type then promotes the lower operand up to its own level and proceeds. Two records and a comparison achieve what double-dispatch visitor machinery usually does with ceremony — and adding a Level 6 touches no existing class.
Lazy evaluation: a number that hasn't happened yet
record LazySymbolic(String symbol,
Function<MathContext, BigDecimal> evaluator) implements TowerNumber {
// π is not 3.14159...; π is a RECIPE that yields 3.14159... to any requested precision
}
LazySymbolic demonstrates that a "number" need not be bits in memory — it can be a closure that delays a costly high-precision computation (the accompanying PiCalculator is exactly such a recipe) until someone actually asks, with a required precision. Symbolic exactness upstream, numeric commitment only at the last moment.
3. The Performance Matrix
Absolute precision is not free; engineering is the art of priced trade-offs:
| Type | Representation | Operation cost | Memory | Best for |
|---|---|---|---|---|
Hardware primitives (long) | 64-bit raw registers, unboxed | \(O(1)\) — single CPU cycle | 8 bytes | Game loops, graphics, array indexing |
Exact fractions (BigFraction) | Two heap BigInteger references | GCD-dominated (logarithmic) | Medium, heap | Analytical engines, geometry, anywhere decimals must not drift |
Arbitrary decimals (BigDecimal) | Unscaled digit array + scale | Varies with MathContext precision | Grows with precision | Financial transactions, compliance, physics at chosen precision |
| The full tower | Dynamic polymorphic dispatch | Depends on promotion path | Highest (boxing + dispatch) | Domains demanding absolute safety; mathematical modelling; notebooks |
4. Engineering a Performant Tower (Capstone Challenges)
- Minimise allocations. Records are immutable, so every
addallocates a fresh object — in a tight loop, that is Garbage Collector pressure (see the JVM section of Advanced Java). Where could operations stay unboxed? - Primitive fast paths. Design the tower to remain inside raw
longarithmetic as long as possible, promoting only on the first exception — measure how much of a realistic workload never leaves Level 1. - Watch Project Valhalla. JVM value classes aim to give objects like these the memory layout of primitives — potentially the tower's safety at close to hardware speed. Reading the JEPs and estimating the impact on your benchmark is a genuinely current engineering exercise.
A note on academic integrity and AI co-piloting: if you extend this tower with an AI assistant, remember the learning feedback loop. Do not ask the model to "write a Matrix level". Write the behavioural unit tests for matrix operations first, watch them fail on your own terminal build (CLI First), and use the model to analyse your type-dispatch design against them. You must remain the system architect.