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.
One buffer for everything
RingBuffer keeps the last few temperatures, and the last few alerts.
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.
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();
}
}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 |
C++ | A |
Python | A |
JavaScript | No error at all: adding a string joins text, and the average quietly becomes |
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.
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;
}
}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 | At compile time |
C++ | Templates: the compiler makes a separate class for each type used | At compile time |
Python | Type hints ( | 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;RingBufferitself needs nothing, so itsTstays 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
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.