Generics & Wildcards: PECS Without Tears
The Bug That Generics Killed
Before Java 5, every collection was a bag of Object. The compiler didn't care what you put in, and it was your job to cast everything coming out. It worked — until it didn't:
// The pre-generics world (Java 1.4 style). Do not write this.
List scores = new ArrayList();
scores.add(95); // int → Integer, no problem
scores.add("absent"); // also no problem — the compiler shrugs
scores.add(88);
int total = 0;
for (Object o : scores) {
total += (Integer) o; // blind cast: "trust me, compiler"
}
// Runs for a while… then: ClassCastException: String cannot be cast to Integer
// …at 3 a.m., in production.
The failure mode was the problem, not the cast. Nothing was wrong at compile time. The program ran fine for weeks until one record with unexpected data hit the loop, and then it exploded. Generics exist to move that failure from runtime to compile time. That's the whole pitch: if the wrong thing goes in, your editor tells you immediately instead of your users telling you later.
Parameterized Types: Saying What a Collection Holds
List<String> names = new ArrayList<>();
names.add("Ada");
// names.add(42); // COMPILE ERROR — int is not a String
String first = names.get(0); // no cast needed: get() returns String
List<String> is a parameterized type: List parameterized by String. Two things happen. First, add() only accepts String. Second, get() returns String, so the cast disappears — and with it, the entire class of ClassCastException bugs from the example above.
Notice the diamond operator <> on the right side: the compiler infers the type argument from the left. Writing new ArrayList<String>() is legal but redundant — modern style always uses the diamond. One catch: the diamond needs a target type to infer from, so a bare new ArrayList<>() with no assignment or with var is an error:
var names = new ArrayList<String>(); // OK: var infers ArrayList<String>
// var names2 = new ArrayList<>(); // ERROR: nothing to infer from
You can write your own generic classes the same way. A Box that holds anything, typed once:
class Box<T> {
private T value;
void set(T value) { this.value = value; }
T get() { return value; }
}
Box<Integer> intBox = new Box<>();
intBox.set(42);
// intBox.set("nope"); // COMPILE ERROR
Generic methods
Type parameters can also live on methods, before the return type:
static <T> T first(List<T> list) {
return list.get(0);
}
String s = first(List.of("a", "b")); // T inferred as String
Integer n = first(List.of(1, 2)); // T inferred as Integer
The inference usually just works. Explicit type arguments (Util.<String>first(...)) exist for the rare case where it doesn't — you'll almost never need them.
Bounded Type Parameters: T Must Be Able to Do Something
Sometimes "anything" is too loose. If you want to sum a list, you need elements you can turn into numbers:
static <T extends Number> double sum(List<T> numbers) {
double total = 0;
for (T n : numbers) total += n.doubleValue();
return total;
}
System.out.println(sum(List.of(1, 2, 3))); // 6.0
System.out.println(sum(List.of(1.5, 2.5))); // 4.0
// sum(List.of("a", "b")); // COMPILE ERROR: String isn't a Number
The bound T extends Number means T is Number or any subtype. Inside the method, the compiler knows T at least is a Number, so doubleValue() is legal. (Yes, the keyword is extends even for interfaces — a small wart worth memorizing.) You can also stack bounds: <T extends Number & Comparable<T>> means "a Number that can compare itself."
Type Erasure: The Runtime Doesn't Know
Here is the single fact that explains most generics confusion: type parameters exist only at compile time. At runtime, List<String> and List<Integer> are the same class — just List. The compiler erases the type argument and inserts the casts for you. This design decision (made so generics could interoperate with pre-2004 Java code) has real consequences you must memorize, because each one is a compile error waiting for you:
- No
new T[]. You can't create an array of a type parameter — the runtime wouldn't know the array's component type. (new T[size]is a compile error.) - No
instanceofon parameterized types.if (x instanceof List<String>)is illegal, because at runtime there's noList<String>to test against. TestList<?>(or rawList) instead, then check the elements. - No overloads differing only in type arguments.
void print(List<String>)andvoid print(List<Integer>)collide — after erasure both areprint(List). Pick different names or merge them.
One more one-liner, for completeness: when you override a generic method, the compiler silently generates a bridge method (e.g. a synthetic compareTo(Object) delegating to your compareTo(String)) so the override keeps working after erasure. You'll never write one by hand; just don't be alarmed if a debugger or reflection shows an extra method.
The Invariance Trap (Where Wildcards Begin)
Given everything so far, this should be legal — and it isn't:
List<Dog> dogs = new ArrayList<>();
List<Animal> animals = dogs; // COMPILE ERROR
Why? Think about what could happen if it were allowed:
// If the line above compiled, then:
animals.add(new Cat()); // perfectly legal for a List<Animal>…
Dog d = dogs.get(0); // …but dogs now contains a Cat. Disaster.
Generics are invariant: List<Dog> is neither a subtype nor a supertype of List<Animal>, even though Dog is a subtype of Animal. The rule exists precisely to keep the snippet above impossible. (Arrays, by contrast, are covariant — Dog[] is a Animal[] — but that design is unsound, which is why arrays throw ArrayStoreException at runtime to catch the same mistake. Generics refuse to need a runtime net because the compile-time one is stronger.)
But invariance creates a real problem. A method that reads every animal in a list should accept List<Dog>, List<Cat>, and List<Animal> — otherwise you'd write one copy per species. Wildcards fix exactly this.
Wildcards: ? With a Direction
A wildcard ? means "some type, I don't care which." Bounded wildcards add a direction, and the direction determines whether you can read out or write in:
? extends T — the producer
static void printNames(List<? extends Animal> animals) {
for (Animal a : animals) {
System.out.println(a.name());
}
}
printNames(dogs); // List<Dog> — OK
printNames(cats); // List<Cat> — OK
printNames(mixedAnimals); // List<Animal> — OK
Because the list holds some subtype of Animal, everything coming out is at least an Animal. Safe to read. But you cannot add to it — not even a Dog, because the actual list might be a List<Cat>. The compiler forbids all writes (except null, which fits everywhere).
? super T — the consumer
static void addDogs(List<? super Dog> shelter) {
shelter.add(new Dog("Rex")); // OK: a Dog fits in any supertype of Dog
shelter.add(new Puppy("Max")); // OK: Puppy is a Dog
}
List<Animal> animals = new ArrayList<>();
addDogs(animals); // List<Animal> — OK
addDogs(new ArrayList<Dog>()); // List<Dog> — OK
// addDogs(new ArrayList<Puppy>()); // ERROR: Puppy is not a supertype of Dog
Because the list holds some supertype of Dog, any Dog (or subtype) fits — safe to write. Reading, though, only guarantees Object, because the elements might be Animals of any kind.
PECS: Producer Extends, Consumer Super
This is the whole decision rule in four words, coined by Josh Bloch in Effective Java:
- Producer
extends: if a parameter produces values you read, use? extends T. - Consumer
super: if a parameter consumes values you write into it, use? super T.
The canonical example is a copy method. The source list only produces values; the destination only consumes them:
public static <T> void copy(List<? extends T> src, List<? super T> dst) {
for (T item : src) {
dst.add(item);
}
}
List<Dog> dogs = new ArrayList<>(List.of(new Dog("Rex"), new Dog("Luna")));
List<Animal> animals = new ArrayList<>();
copy(dogs, animals); // T inferred as Dog — dogs produce, animals consume
// copy(dogs, new ArrayList<Cat>()); // ERROR: Cat is not a supertype of Dog
Notice how the type parameter T is inferred from both sides. The method accepts a List<Dog> and a List<Animal> in one call — maximum flexibility, zero casts, no way to smuggle a Cat in.
The trap: reaching for extends on something you write to
The most common wildcards mistake looks like this:
// What a developer writes, intending "any list of numbers":
static void addOne(List<? extends Number> numbers) {
numbers.add(1); // COMPILE ERROR
}
The compiler rejects numbers.add(1) — and it is saving you. The list could be a List<Double>, and inserting an Integer would corrupt it. The error message ("no suitable method found" / capture-of-? extends Number) reads like arcane noise, but the meaning is simple: you declared a producer, then tried to use it as a consumer. Fix it by asking PECS: am I reading out of this parameter or writing into it? Writing → super:
static void addOne(List<? super Integer> numbers) {
numbers.add(1); // OK: Integer fits in any supertype of Integer
}
List<Number> ns = new ArrayList<>();
addOne(ns); // fine — List<Number> consumes Integers
Rule of thumb when you're stuck: start with the unbounded or exact type (List<Animal>), and only add a wildcard when a caller hands you the wrong-but-legitimate specialization. Wildcards are a flexibility tool, not a default.
Raw Types: Never
A raw type is a generic type used without its parameter: List instead of List<String>. It exists only for compatibility with pre-generics code, and using it silently disables every check generics give you — including the casts the compiler normally inserts:
List<String> strings = new ArrayList<>(List.of("a", "b"));
List raw = strings; // raw alias to the SAME list — compiles with a warning
raw.add(42); // heap pollution: an Integer now lives in a List<String>
String s = strings.get(1); // ClassCastException at runtime — the bug is BACK
Heap pollution is exactly this: a variable of type List<String> whose actual contents aren't all Strings, made possible because the raw reference bypassed the gate. The compiler warns you about raw types — heed the warning, fix the type, and you get the compile-time guarantee back. If you genuinely don't know the element type, List<?> says so without dropping the guardrails: you can read from it, but you can't corrupt it.
Cheat Sheet
List<T>exact: use when the method both reads and writes, or the element type is fixed.List<? extends T>: use when you only read (the parameter is a producer).List<? super T>: use when you only write (the parameter is a consumer).<T extends Number>: use whenTmust behave like something — call its methods.- Raw types: never. Use
List<?>for "unknown element type." - Compiler error on
addto? extends: that's the type system doing its job — you needsuper.
Generics are a contract between you and your future self: the casts you write once in the type parameter are the ClassCastExceptions you never debug at 3 a.m. Next up, those type parameters start paying compound interest — functional interfaces use generics everywhere, which is why lambdas feel so seamless in the next post.
Continue: Java Learning Roadmap 2026
Comments
Post a Comment