Low-level designCore6 min

Generics

Write a class once for any type of item, and let each use say which type it holds. The checker then catches a wrong item where it's added, instead of the program failing later, somewhere else.

Watch it evolve1 / 8
The problem

One buffer for everything

RingBuffer keeps the last few temperatures, and the last few alerts.

80
Start
Stage 1
Final

1 · The situation

One buffer for every kind of item

A building's sensor dashboard keeps the last few of many things: the last three temperatures, the last two alerts. One class does it for all of them: RingBuffer keeps the most recent items and drops the oldest when it's full. Because it must hold temperatures and text alike, it holds anything: Object in Java, std::any in C++, and no type at all in JavaScript and Python.

A buffer of anything, and an average that hopes for numbersJava
class RingBuffer {
    private final Object[] items;
    private int start;
    private int size;

    RingBuffer(int capacity) {
        items = new Object[capacity];
    }

    void push(Object item) {
        if (size < items.length) {
            items[(start + size) % items.length] = item;
            size++;
        } else {
            items[start] = item;
            start = (start + 1) % items.length;
        }
    }

    // 0 is the oldest item kept.
    Object get(int i) {
        return items[(start + i) % items.length];
    }

    Object latest() {
        return get(size - 1);
    }

    int size() {
        return size;
    }
}

class Stats {
    static double average(RingBuffer readings) {
        double sum = 0;
        for (int i = 0; i < readings.size(); i++) sum += (Double) readings.get(i);
        return sum / readings.size();
    }
}
RingBuffer− items: Object[]− start: int− size: int~ push(Object)~ get(int): Object~ latest(): Object~ size(): intStats~ average(RingBuffer): double

Drawn from the code: one RingBuffer whatever it holds, and a Stats helper that uses it.

2 · The change

A word in a buffer of numbers

A new feature records when the sensor goes offline. Its author pushes the word "offline", into the temperature buffer. Nothing complains when it goes in. The next time the dashboard averages the temperatures:

Average: 20.5
Average failed: a reading is not a number
Latest alert: Door open

The average breaks on the next refresh, in Stats, far from the line that caused it.

Language

How the mistake shows up

Java

A ClassCastException at the cast in average()

C++

A std::bad_any_cast exception in average()

Python

A TypeError while adding up the readings

JavaScript

No error at all: adding a string joins text, and the average quietly becomes NaN

Every reader of the buffer also pays a small tax: whoever takes an item out must know what it is and cast it, as in (String) alerts.latest(). The buffer knows nothing about its contents, so every caller has to remember for it.

3 · The fix

Let each buffer say what it holds

RingBuffer<T> is written once, with a placeholder T for the item type. push() takes a T and latest() returns one. Each use fills in T: RingBuffer<Double> for temperatures, RingBuffer<String> for alerts.

Java
class RingBuffer<T> {
    private final Object[] items;
    private int start;
    private int size;

    RingBuffer(int capacity) {
        items = new Object[capacity];
    }

    void push(T item) {
        if (size < items.length) {
            items[(start + size) % items.length] = item;
            size++;
        } else {
            items[start] = item;
            start = (start + 1) % items.length;
        }
    }

    // 0 is the oldest item kept. Only push() stores items, so each one is a T.
    @SuppressWarnings("unchecked")
    T get(int i) {
        return (T) items[(start + i) % items.length];
    }

    T latest() {
        return get(size - 1);
    }

    int size() {
        return size;
    }
}
RingBuffer− items: Object[]− start: int− size: int~ push(T)~ get(int): T~ latest(): T~ size(): intStats~ average(RingBuffer<? extends Number>): double
Average: 20.5
Average: 21.5
Visitors per hour: 12.0
Latest alert: Door open

Temperatures and visitor counts average correctly, and the alert comes out as a string with no cast.

The wrong push is gone from this version because it no longer gets through. In Java the compiler rejects it: incompatible types: String cannot be converted to Double. In C++: cannot convert 'const char [8]' to 'double'. The mistake is reported on the line that makes it.

4 · When it's caught

Each language checks at a different time

Language

Generics are

The wrong push is caught

Java

Checked by the compiler, then erased: at run time every RingBuffer is one class

At compile time

C++

Templates: the compiler makes a separate class for each type used

At compile time

Python

Type hints (class RingBuffer[T], Python 3.12) that the program itself ignores

By a type checker, before running; the program would run anyway

JavaScript

Not in the language; JSDoc types read by TypeScript tools add them

By an editor or build using TypeScript checking, if one is set up

The JavaScript version here is annotated with JSDoc and checked under TypeScript's strict mode; the bad push is reported as Argument of type 'string' is not assignable to parameter of type 'number'. Without such a tool, JavaScript and Python rely on tests to catch it.

5 · Bounds and variance

A buffer of integers is not a buffer of numbers

An Integer is a Number, so it's tempting to think a RingBuffer<Integer> is a RingBuffer<Number>. It isn't, and for a good reason: if it were, code could push a Double into it through the Number view, and the integer buffer would hold a non-integer.

That's why average() takes RingBuffer<? extends Number>: a buffer of some number type that it only reads from. Reading is safe, since whatever comes out is a Number; pushing isn't allowed through that view. The general rule: a view you only read from may accept subtypes, and a view you only write to may accept supertypes (? super Integer). Python's type checkers work out the same thing for each type parameter from how the class uses it.

average() only reads, so a buffer of Integer is acceptable where a buffer of some kind of Number is asked for.

6 · When not to

Generic when it's reused, specific when it isn't

  • One use isn't a reason. A class used for exactly one type gains nothing from a type parameter but harder reading; make it generic when a second type needs it.
  • Containers and algorithms are the sweet spot. Buffers, caches, queues, results and sorting work the same for every type, which is what a type parameter expresses.
  • Bound only what you use. average() needs numbers; RingBuffer itself needs nothing, so its T stays unbounded.

7 · In practice

Typed collections in the practice editor

The practice editor takes Java or TypeScript, both with generics. A level that holds List<ParkingSpot> lets the checks see that it owns its spots, and lets the compiler stop anything else from going in.

Design the parking lot with typed collections.

Check yourself

4 questions

1. In the first version, where does the mistake of pushing "offline" show up?
2. With RingBuffer<Double> temps, what happens to temps.push("offline") in Java?
3. Why isn't a RingBuffer<Integer> usable as a RingBuffer<Number>?
4. In Python, temps: RingBuffer[float] then temps.push("offline"). What happens?

Takeaways

Remember this

  • A type parameter lets one class or function work for many types, with each use naming its type.
  • A container of anything defers mistakes to whoever reads it; a typed one catches them where they're made.
  • Bound a type parameter by what the code needs: average() needs numbers.
  • A writable container of a subtype is not a container of its supertype; read-only views can be.
  • Java and C++ check at compile time; Python and JavaScript rely on type-checking tools.