Generics: erasure, invariance, and wildcards
Why a List<Integer> is not a List<Number>, what erasure removes at runtime, and how ? extends and ? super make an API accept more.
After this lesson you can
- Explain type erasure and what it prevents you from writing
- Say why generics are invariant while arrays are covariant
- Choose between ? extends T and ? super T for a parameter (PECS)
- Recognise the runtime failure a raw type can cause far from its cause
Generics let the compiler check element types: a List<String> refuses
an Integer at compile time, and reading from it needs no cast. That
checking happens only at compile time. The compiler then erases
the type arguments: at runtime there is one ArrayList class, and a
List<String> and a List<Integer> are indistinguishable.
Erasure is why you cannot write new T() or new T[n], why
obj instanceof List<String> on a plain Object does not compile (the
runtime has no type argument to check), and why a class cannot overload
void f(List<String>) with void f(List<Integer>) — after erasure both
are f(List).
Invariance, and the array that is not
Integer is a subtype of Number, but List<Integer> is not a
subtype of List<Number>. If it were, this would compile:
List<Integer> ints = new ArrayList<>();
List<Number> nums = ints; // does not compile — and this is why:
nums.add(3.14); // a Double inside a List<Integer>
Arrays made the opposite choice long before generics existed: an
Integer[] is a Number[]. The hole is then caught at runtime
instead, with an ArrayStoreException.
import java.util.*; public class Solution { public static List<String> describe() { List<String> strings = new ArrayList<>(); List<Integer> ints = new ArrayList<>(); String sameClass = "same runtime class: " + (strings.getClass() == ints.getClass()) + " (" + strings.getClass().getName() + ")"; Integer[] boxes = new Integer[2]; Number[] view = boxes; // compiles: arrays are covariant String store; try { view[0] = 3.14; // a Double into an Integer[] store = "stored"; } catch (ArrayStoreException e) { store = "ArrayStoreException: " + e.getMessage(); } return List.of(sameClass, store); }}Wildcards: accepting more than one type
Invariance makes naive signatures too strict. double max(List<Number> xs) cannot be called with a List<Integer>. Wildcards fix that:
List<? extends Number>— "a list of some subtype of Number". You can readNumbers out of it, but you cannot add anything (exceptnull), because the compiler does not know which subtype it holds.List<? super Integer>— "a list of Integer or some supertype". You can addIntegers to it, but what you read back is only known to be anObject.
The rule of thumb is PECS: producer extends, consumer super. A
parameter you only take values from is a producer; one you only put
values into is a consumer. Collections.copy(List<? super T> dest, List<? extends T> src) is the textbook signature.
import java.util.*; public class Solution { // Reads Numbers from any list of a Number subtype. static double max(List<? extends Number> xs) { double best = Double.NEGATIVE_INFINITY; for (Number n : xs) best = Math.max(best, n.doubleValue()); return best; } // Writes Integers into any list that can hold them. static void addRange(List<? super Integer> sink, int from, int to) { for (int i = from; i <= to; i++) sink.add(i); } public static List<String> describe() { List<Number> numbers = new ArrayList<>(List.of(0.5)); List<Object> anything = new ArrayList<>(List.of("start")); addRange(numbers, 1, 3); addRange(anything, 7, 8); return List.of( "numbers=" + numbers + " max=" + max(numbers), "anything=" + anything, "max of List<Integer>=" + max(List.of(4, 9, 5))); }}Bounded type parameters use the same idea on a method's own type
variable: <T extends Comparable<? super T>> T max(List<? extends T> xs) accepts any type that can compare itself — including a class that
inherits compareTo from its parent.
Common mistakes
- Using a raw type (
Listwith no type argument). The compiler only warns, and an element of the wrong type travels through the program until a cast the compiler inserted fails somewhere else — heap pollution. - Declaring a parameter
List<Object>to mean "any list". It accepts only aList<Object>;List<?>is the "any list" type. - Writing
List<? extends T>for a parameter you need to add to — it will not compile; that parameter is a consumer and wants? super T.
import java.util.*; public class Solution { @SuppressWarnings({"rawtypes", "unchecked"}) static void addLegacy(List list) { list.add(42); // no check: raw type } public static List<String> describe() { List<String> names = new ArrayList<>(List.of("ana")); addLegacy(names); List<String> out = new ArrayList<>(); out.add("size=" + names.size()); try { String second = names.get(1); // the compiler inserted a cast here out.add("read " + second); } catch (ClassCastException e) { out.add("ClassCastException when reading index 1"); } return out; }}What interviewers ask
- What is type erasure, and what can't you do because of it? Type
arguments exist only at compile time; you cannot create
TorT[], test anObjectwithinstanceof List<String>, or overload on type arguments. - Why is
List<Integer>not aList<Number>? Because the latter lets you add aDouble; arrays allow it and pay withArrayStoreExceptionat runtime. - Explain PECS with an example.
Collections.max(Collection<? extends T> coll, Comparator<? super T> comp): the collection only producesTs, soextends; the comparator only consumes them, sosuper— aComparator<Object>can then pick the max of aList<String>.
Exercise · Pro
Write the signature that PECS asks for
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade