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

graph BT L1["Level 1: PrimitiveInt
(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:

TypeRepresentationOperation costMemoryBest for
Hardware primitives (long)64-bit raw registers, unboxed\(O(1)\) — single CPU cycle8 bytesGame loops, graphics, array indexing
Exact fractions (BigFraction)Two heap BigInteger referencesGCD-dominated (logarithmic)Medium, heapAnalytical engines, geometry, anywhere decimals must not drift
Arbitrary decimals (BigDecimal)Unscaled digit array + scaleVaries with MathContext precisionGrows with precisionFinancial transactions, compliance, physics at chosen precision
The full towerDynamic polymorphic dispatchDepends on promotion pathHighest (boxing + dispatch)Domains demanding absolute safety; mathematical modelling; notebooks

4. Engineering a Performant Tower (Capstone Challenges)

  • Minimise allocations. Records are immutable, so every add allocates 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 long arithmetic 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.