04 - Generics in depth¶
What this session is¶
About ninety minutes. In From Scratch you used generics - List<String>, Map<String, Integer> - as type-safe containers. This session is about writing generic code: generic methods and classes, bounded type parameters, wildcards (the ? you've seen and maybe feared), the PECS rule that makes wildcards make sense, and the truth about type erasure - what generics actually compile to, and the surprising limits that follow. By the end you'll write generic APIs with confidence and read the gnarliest generic signatures in the JDK.
Why generics exist: the problem they solve¶
Before generics (Java 1.4 and earlier), containers held Object, and you cast on the way out:
List names = new ArrayList(); // a list of... something
names.add("Alice");
names.add(42); // oops, nobody stopped me
String first = (String) names.get(0); // cast required
String second = (String) names.get(1); // ClassCastException at runtime!
Two problems: no compiler help (you could put anything in), and casts everywhere (verbose and error-prone). Generics fix both:
List<String> names = new ArrayList<>();
names.add("Alice");
names.add(42); // COMPILE ERROR - caught immediately
String first = names.get(0); // no cast - the compiler knows it's a String
The core idea: generics move type errors from runtime to compile time, and remove casts. A List<String> is a promise, checked by the compiler, that only strings go in and only strings come out.
Generic methods¶
You can make a single method generic by declaring a type parameter before the return type:
// <T> declares a type parameter. The method works for any T.
static <T> T firstOrNull(List<T> list) {
return list.isEmpty() ? null : list.get(0);
}
String s = firstOrNull(List.of("a", "b")); // T inferred as String
Integer n = firstOrNull(List.of(1, 2, 3)); // T inferred as Integer
The <T> before the return type says "this method introduces a type variable T." The caller never specifies it - the compiler infers T from the arguments. One method, type-safe for every type.
A two-parameter example:
// Swap returns a new pair with elements swapped.
static <A, B> Map.Entry<B, A> swap(Map.Entry<A, B> entry) {
return Map.entry(entry.getValue(), entry.getKey());
}
Convention: type parameters are single uppercase letters - T (type), E (element), K/V (key/value), R (result), N (number). Multiple are comma-separated: <A, B>.
Generic classes¶
A class can be parameterized too - every instance fixes the type:
// A simple immutable box holding one value of type T.
final class Box<T> {
private final T value;
Box(T value) { this.value = value; }
T get() { return value; }
<R> Box<R> map(java.util.function.Function<T, R> f) {
return new Box<>(f.apply(value)); // transform T into R
}
}
Box<String> b = new Box<>("hello");
Box<Integer> len = b.map(String::length); // Box<String> -> Box<Integer>
System.out.println(len.get()); // 5
Box<T> is type-safe: a Box<String> only ever holds a string. The map method even introduces its own type parameter R so it can transform the box's type. This is exactly how Optional<T> and Stream<T> are built.
Bounded type parameters: extends¶
Sometimes a generic method needs to do something with T, not just store it. To call methods on T, you must constrain it. <T extends SomeType> says "T can be any type that is a SomeType."
// To find the max, T must be Comparable - we need compareTo.
static <T extends Comparable<T>> T max(List<T> list) {
T best = list.get(0);
for (T item : list) {
if (item.compareTo(best) > 0) best = item; // compareTo available now
}
return best;
}
max(List.of(3, 1, 2)); // works - Integer is Comparable
max(List.of("c", "a", "b")); // works - String is Comparable
// max(List.of(new Object())); // COMPILE ERROR - Object isn't Comparable
Without the bound, item.compareTo(best) wouldn't compile - the compiler only knows the methods guaranteed by the bound. <T extends Comparable<T>> unlocks compareTo. Note extends here means "is a subtype of," and it works for both classes and interfaces (you always write extends, never implements, in a bound).
You can have multiple bounds with &:
// T must be both Comparable AND Serializable.
static <T extends Comparable<T> & java.io.Serializable> T pick(T a, T b) {
return a.compareTo(b) >= 0 ? a : b;
}
Wildcards: ?¶
Here's where people get lost. Consider:
static double sumOfList(List<Number> list) {
double sum = 0;
for (Number n : list) sum += n.doubleValue();
return sum;
}
List<Integer> ints = List.of(1, 2, 3);
sumOfList(ints); // COMPILE ERROR (!)
Surprise: a List<Integer> is not a List<Number>, even though Integer is a Number. Generics are invariant - List<Integer> and List<Number> are unrelated types. (If they were related, you could add a Double to a List<Number> that's really a List<Integer>, breaking type safety. The invariance protects you.)
To accept "a list of Number or any subtype," use a wildcard:
static double sumOfList(List<? extends Number> list) { // ? extends Number
double sum = 0;
for (Number n : list) sum += n.doubleValue();
return sum;
}
sumOfList(List.of(1, 2, 3)); // List<Integer> - works
sumOfList(List.of(1.5, 2.5)); // List<Double> - works
sumOfList(List.of(1, 2.5)); // List<Number> - works
? extends Number means "some specific subtype of Number, I don't know which." That's enough to read Numbers out. But there's a catch that leads to the most important rule in generics.
PECS: Producer Extends, Consumer Super¶
There are two wildcard forms, and choosing between them confuses everyone until they learn the mnemonic.
? extends T - an upper-bounded wildcard. "Some subtype of T." You can read T from it (everything is at least a T), but you cannot write to it (you don't know the exact subtype, so no value is safe to add).
List<? extends Number> nums = List.of(1, 2, 3);
Number n = nums.get(0); // OK to read - it's at least a Number
// nums.add(4); // COMPILE ERROR - could be a List<Double>, can't add an Integer
? super T - a lower-bounded wildcard. "Some supertype of T." You can write a T to it (a T fits in any supertype container), but reading gives you only Object (you don't know which supertype).
List<? super Integer> sink = new ArrayList<Number>();
sink.add(42); // OK to write - an Integer fits in a List of any Integer-supertype
// Integer x = sink.get(0); // COMPILE ERROR - could be List<Object>, read gives Object
Object o = sink.get(0); // only this works
The mnemonic, from Joshua Bloch's Effective Java:
PECS: Producer Extends, Consumer Super.
- If a parameter produces values for you (you read from it), use
? extends T. - If a parameter consumes values you give it (you write to it), use
? super T.
The classic example is Collections.copy, which reads from a source (producer) and writes to a destination (consumer):
static <T> void copy(List<? super T> dest, List<? extends T> src) {
// consumer (super) producer (extends)
for (int i = 0; i < src.size(); i++) {
dest.set(i, src.get(i)); // read from src (T), write to dest (super T)
}
}
src is a producer - we read Ts out, so extends. dest is a consumer - we write Ts in, so super. This signature lets you copy a List<Integer> into a List<Number>, which the rigid List<T>, List<T> version couldn't.
When do you use a plain ? (unbounded wildcard)? When you don't care about the type at all - you only use methods that don't depend on it:
static void printSize(List<?> list) { // any list, of anything
System.out.println("size: " + list.size()); // size() doesn't care about type
}
Type erasure: the truth about generics¶
Now the part that explains every weird limit of Java generics. Generics are a compile-time feature only. After the compiler checks your types and inserts casts, it erases the type parameters. At runtime, a List<String> and a List<Integer> are both just List. The <String> is gone.
List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
System.out.println(a.getClass() == b.getClass()); // true - both are just ArrayList
Why did Java do this? Backward compatibility. Generics arrived in Java 5 (2004), and erasure let generic code interoperate with the mountain of pre-generics code. The cost is a set of limitations you have to know:
1. You can't check a generic type at runtime.
// if (list instanceof List<String>) // COMPILE ERROR - type info is erased
if (list instanceof List<?>) // OK - can only check the raw type
2. You can't create an array of a generic type.
// T[] arr = new T[10]; // COMPILE ERROR
// List<String>[] arr = ... // COMPILE ERROR
T[] arr = (T[]) new Object[10]; // workaround: create Object[], cast (unchecked warning)
3. You can't use primitives as type arguments.
// List<int> nums; // COMPILE ERROR
List<Integer> nums; // must use the wrapper - and pay autoboxing (chapter 12)
4. Overloading on erased types collides.
// These two methods erase to the same signature - won't compile together:
// void process(List<String> s) {}
// void process(List<Integer> i) {} // COMPILE ERROR - both erase to process(List)
5. A class can't have static state per type parameter - because there's only one class at runtime, shared across all T.
The mental model: the compiler uses the type parameters to check your code and insert casts, then throws them away. Everything generics can and can't do follows from this. When a generic limitation surprises you, ask "what's left after erasure?" and the answer usually explains it.
Reading scary JDK signatures¶
Armed with all this, you can decode signatures that look terrifying. Here's Stream.collect:
Decoded: it introduces two type variables R (the result) and A (an internal accumulator). It takes a Collector that consumes the stream's elements (? super T - consumer, super) and produces an R. You don't need to memorize it - you can read it now.
And Comparator.comparing:
static <T, U extends Comparable<? super U>> Comparator<T> comparing(
Function<? super T, ? extends U> keyExtractor)
Decoded: for a type T to compare and a key type U that is comparable (U extends Comparable<? super U> - U can be compared against itself or a supertype), take a function that consumes a T (? super T) and produces a comparable key (? extends U). PECS everywhere. Intimidating until you have the vocabulary; routine once you do.
Try it¶
-
Write a generic method. Implement
static <T> List<T> repeat(T item, int n)that returns a list withitemrepeatedntimes. Call it with a String and an Integer; note the type is inferred. -
Feel invariance. Write
void addNumbers(List<Number> list). Try to pass aList<Integer>. Watch it fail to compile. Change the parameter toList<? super Integer>and add integers to it. Now it works - that's the consumer (super) case. -
PECS in practice. Write
static <T> void moveAll(List<? extends T> from, List<? super T> to)that adds every element offromtoto. Test it copying aList<Integer>into aList<Number>. Try swappingextendsandsuperand watch the compile errors - they teach you which side reads and which writes. -
Hit erasure. Try to write
static <T> T[] makeArray(int n) { return new T[n]; }. Read the compile error. Then try(T[]) new Object[n]and note the unchecked warning. Reason about why the JVM can't make a realT[]. -
Bounded type. Write
static <T extends Comparable<T>> T min(T a, T b). Use it on Strings and Integers. Remove the bound and watchcompareTostop compiling - the bound is what unlocks the method. -
Decode a signature. Open the JDK docs for
Map.computeIfAbsent. Read its signature out loud, naming each type variable and wildcard. You should be able to explain every symbol now.
What you might wonder¶
"When do I write <T extends Comparable<T>> vs <T extends Comparable<? super T>>?" The ? super T version is more flexible - it allows T to be comparable against a supertype (e.g., a Timestamp that's Comparable<Date>). For your own code, <T extends Comparable<T>> is usually fine and simpler. The JDK uses the ? super T form for maximum flexibility in public APIs. Start simple; widen to ? super T if a real type forces you.
"Do I need wildcards in my own code, or just to read the JDK?" Both, but reading first. You'll use wildcard-typed APIs constantly (every stream collector). You'll write wildcards when you make utility methods that work across a type family - which is less often, but PECS is the rule when you do. If a method only reads a collection, take ? extends; if it only writes, take ? super; if both, take an exact <T>.
"Is erasure why I get 'unchecked cast' warnings?" Yes. When you cast to a generic type the runtime can't verify (like (T[]) new Object[n] or (List<String>) someRawList), the compiler warns it can't guarantee safety - because the type info is erased and it can't insert a real check. Suppress with @SuppressWarnings("unchecked") only when you've reasoned that it's actually safe.
"Why can other languages (C#, Rust) do things Java generics can't?" C# reifies generics - the type info survives to runtime, so new T[n] and typeof(List<int>) work. Rust monomorphizes - it generates a specialized copy per concrete type. Java chose erasure for backward compatibility in 2004. Each approach trades off differently; erasure's cost is the limitations above, its benefit was seamless interop with a decade of existing code. (This exact comparison is a cross-topic page on the site if you want the three-language view.)
"What's the diamond operator <> actually doing?" new ArrayList<>() lets the compiler infer the type argument from the left-hand side (List<String> x = new ArrayList<>() infers String). Before Java 7 you had to write new ArrayList<String>() redundantly. It's pure inference convenience - no runtime effect.
Done¶
- You know why generics exist: compile-time type safety, no casts.
- You can write generic methods and generic classes, including ones that transform their type.
- You can bound type parameters with
extends(and&) to unlock methods onT. - You understand invariance, both wildcard forms, and PECS - producer extends, consumer super.
- You understand type erasure and the five limitations that follow from it.
- You can read intimidating JDK generic signatures.
Next: collections deep - which one to reach for, the performance characteristics, and the families that matter.