Generics, Deeply — Erasure, Bounds, and PECS¶
Generics look simple until you meet a wildcard signature in a library method. Then you spend an hour on Stack Overflow and still write raw types. This file makes generics stop being scary by grounding every rule in why the compiler does what it does. If you can hold three ideas — erasure, invariance, PECS — the rest follows.
Type Erasure and Its Three Consequences¶
Java generics are a compile-time feature only. After compilation, List<String> becomes List in the bytecode; T becomes Object (or its upper bound). This is called erasure. It was chosen in 2004 for backward compatibility with pre-generic code. The consequences follow directly:
Consequence 1: no runtime type information for T¶
class Box<T> {
// ILLEGAL — T is erased at runtime, so you cannot ask what T is
void whatAmI() {
if (this instanceof Box<String>) { ... } // compile error
}
}
You cannot introspect T at runtime. Frameworks like Jackson and Spring work around this with TypeReference<T> or Class<T> tokens (see below).
Consequence 2: no generic array creation¶
T[] arr = new T[10]; // compile error
List<String>[] lists = new List<String>[10]; // compile error
Arrays are reified (they know their component type at runtime, and throw ArrayStoreException on mismatch). Generics are erased. Mixing them would break array-store checks. The workaround: List<List<String>> lists = new ArrayList<>();.
Consequence 3: no overloading on erased signatures¶
void process(List<String> ls) { ... }
void process(List<Integer> li) { ... } // compile error: same erased signature
Both erase to process(List). The JVM cannot tell them apart.
⚠️ What most people get wrong. They see
ArrayList<String>at runtime in the debugger and conclude “generics are preserved.” What the debugger shows is the class name of the object (ArrayList) and inferred type info from a variable’s declaration. The instance itself has no memory of<String>.new ArrayList<String>()andnew ArrayList<Integer>()produce indistinguishable objects.
Invariance — Why List<String> is Not a List<Object>¶
This is the second confusion:
List<String> strings = List.of("a", "b");
List<Object> objects = strings; // compile error — and rightly so
Why? Because List<Object> accepts add(Object), and if the assignment worked you could do:
objects.add(42); // OK from Object perspective
String s = strings.get(0); // ClassCastException at runtime
So Java makes generic types invariant: List<Sub> is not a subtype of List<Super> even when Sub extends Super. Arrays, by contrast, are covariant (String[] is-a Object[]) — which is unsafe and gives you ArrayStoreException at runtime. Generics chose the safer path.
Bounded Type Parameters¶
Sometimes you want to restrict T:
// Upper-bounded: T must be a Number (or subtype)
<T extends Number> double sum(List<T> nums) {
double s = 0;
for (T n : nums) s += n.doubleValue(); // can call Number's methods
return s;
}
// Multiple bounds: T must be Comparable AND Serializable
<T extends Comparable<T> & Serializable> T minOf(T a, T b) {
return a.compareTo(b) <= 0 ? a : b;
}
Multiple bounds are ANDed. Only the first can be a class; the rest must be interfaces.
Wildcards — ?, ? extends T, ? super T¶
Wildcards let a method signature accept a family of parameterized types without adding a new type parameter.
Unbounded ?¶
void printAll(List<?> list) {
for (Object o : list) System.out.println(o);
}
List<?> means “list of some unknown type.” You can read as Object, but you cannot add(anything) — the compiler does not know what the unknown type is. The one exception: you can add(null) because null is assignable to any type.
Upper-bounded ? extends T — producer¶
double sumNumbers(List<? extends Number> nums) { // accepts List<Integer>, List<Double>, ...
double s = 0;
for (Number n : nums) s += n.doubleValue();
return s;
}
You can read Number from the list but you cannot write into it (except null). Why? The unknown could be Integer — adding a Double would corrupt it.
Lower-bounded ? super T — consumer¶
void addIntegers(List<? super Integer> sink) { // accepts List<Integer>, List<Number>, List<Object>
sink.add(1);
sink.add(2);
}
You can write Integer (or any subtype) into it. Reading gives you Object — the compiler does not know the exact supertype.
PECS — Producer Extends, Consumer Super¶
Bloch’s mnemonic (Effective Java, Item 31):
PECS: If the parameter produces T for you (you read T from it), use
? extends T. If the parameter consumes T from you (you write T into it), use? super T.
The canonical example is Collections.copy:
public static <T> void copy(List<? super T> dest, List<? extends T> src) {
for (int i = 0; i < src.size(); i++) dest.set(i, src.get(i));
}
src produces T (extends). dest consumes T (super). This signature lets you copy List<Integer> to List<Number> — both directions type-check.
A parameter that is both read and written (like a list you mutate in place) uses neither wildcard — use a plain type parameter.
Wildcard ? vs Type Parameter <T> — Which When?¶
Use case |
Prefer |
|---|---|
Method needs to relate two or more parameters or a parameter to the return type |
Type parameter |
Method treats the parameter as an opaque bag |
Wildcard |
API exposed to callers who should not need to name the type |
Wildcard |
Internal helper that manipulates a specific |
Type parameter |
// Related in and out — needs T
<T> T firstOr(List<T> list, T fallback) { return list.isEmpty() ? fallback : list.get(0); }
// Opaque — wildcard
int sizeOf(List<?> list) { return list.size(); }
Rule of thumb (also Bloch, Item 32): if a type parameter appears exactly once in a method signature, replace it with a wildcard.
Generic Methods vs Generic Classes¶
// Generic class — the type is bound to the instance
class Cache<K, V> {
private final Map<K, V> map = new HashMap<>();
V put(K key, V value) { return map.put(key, value); }
}
// Generic method — the type is bound to the call
class Util {
static <T> List<T> repeat(T value, int n) {
var list = new ArrayList<T>(n);
for (int i = 0; i < n; i++) list.add(value);
return list;
}
}
Use a generic method when the type parameter is scoped to a single call. Use a generic class when the type parameter is a property of the instance.
The Class<T> Token Trick — Recovering Type Info¶
Since erasure removes T at runtime, frameworks pass a Class<T> explicitly:
<T> T fromJson(String json, Class<T> type) {
return objectMapper.readValue(json, type);
}
User u = fromJson(payload, User.class);
This works for non-generic T. For generic types (like List<User>), a Class<T> is not enough — you need a super-type token (Jackson’s TypeReference, Guava’s TypeToken):
List<User> users = objectMapper.readValue(payload,
new TypeReference<List<User>>() {}); // anonymous subclass preserves the generic type in class metadata
The trick: subclassing preserves generic supertype information (unlike direct instantiation, which is erased). study partners love this because it forces you to explain erasure precisely.
Common Errors, Named and Explained¶
Raw types¶
List list = new ArrayList(); // raw type — all generic checks disabled
list.add("hello");
list.add(42);
String s = (String) list.get(1); // ClassCastException at runtime
Raw types exist for pre-generics backward compat. Never use them in new code. The compiler warns you for a reason.
Unchecked cast warnings¶
@SuppressWarnings("unchecked")
List<String> ls = (List<String>) someRawList;
Suppress only after proving the cast is safe (usually by controlling the source). Otherwise the warning is telling you a ClassCastException is waiting.
Object[] where you meant T[]¶
class Stack<T> {
private Object[] items = new Object[16]; // can't do new T[16]
@SuppressWarnings("unchecked")
T pop() { return (T) items[--size]; } // safe: we only ever put T in
}
This is the correct, controlled use of unchecked cast: safe because the container guarantees the invariant.
List<Object> ≠ List<String>¶
Covered above under Invariance. If your method genuinely takes any type of list, use List<?> (or List<? extends SomeBound>).
⚠️ What Most People Get Wrong¶
They believe ? extends T “makes the list more flexible” and use it everywhere. This is backwards. ? extends T is more restrictive on writes — the compiler forbids add() (except null). PECS is not about flexibility, it is about direction of data flow. Ask yourself: does the caller give me data (? super T) or take data (? extends T)?
They also over-parameterize. If your Repository<T, ID> has only ever been Repository<User, Long>, you have not built abstraction — you have built ceremony. Speculative generics is the same smell as speculative generality (Phase 03).
What to Practice This Week¶
Write and test
<T extends Comparable<T>> T max(List<T> list). Then rewrite as<T extends Comparable<? super T>>and explain why the second is stricter/safer.Write
Collections.copyfrom scratch. Get the PECS wildcards right. Compile.Read Bloch Effective Java Items 26 (raw types), 28 (lists over arrays), 31 (PECS), 33 (typesafe heterogeneous containers). This last one is the
Class<T>token pattern.Skim Angelika Langer’s Java generics FAQ — focus on “capture conversion” (the compiler internally names your
?so it can reason about it).
Return to README.md · Next: 02_functional_interfaces_lambdas.md