Generics

Difficulty

Before generics (pre-Java 5), collections held plain Object references:

List list = new ArrayList();
list.add("hello");
list.add(42); // no compile-time complaint — mixed types allowed
String s = (String) list.get(1); // compiles, but throws ClassCastException at runtime

Generics let you parameterize a type with the element type it holds, moving that error from a runtime surprise to a compile-time error:

List<String> list = new ArrayList<>();
list.add("hello");
list.add(42); // compile error — caught immediately
String s = list.get(0); // no cast needed

Benefits:

  • Compile-time type checking — catches type mismatches before the program ever runs.
  • Eliminates explicit casts — the compiler inserts them safely where needed.
  • Enables generic algorithms — a single sort method can work correctly on List<String>, List<Integer>, etc., without duplication.

Generics are implemented via type erasure (the compiler enforces the constraints, then strips the type parameter information for the compiled bytecode) — which explains several of their more advanced quirks and limitations.

Java generics are a compile-time only feature — the JVM itself has no concept of List<String> vs List<Integer>; both compile down to plain List (using Object, or the bound type, internally). This is type erasure: the compiler checks your generic type usage for correctness, inserts the necessary casts, and then discards the type parameter information.

Consequences / limitations:

  1. Can't create an instance of a type parameter: new T() doesn't compile — the compiler has no idea what T actually is at runtime.
  2. Can't check generic types with instanceof: if (obj instanceof List<String>) doesn't compile — only obj instanceof List (the raw type) is allowed, since the <String> information is erased.
  3. Can't create a generic array directly: new T[10] or new List<String>[10] doesn't compile, due to interactions with array covariance and erasure that could otherwise let you store the wrong type undetected.
  4. All parameterized instances share one class: List<String>.class and List<Integer>.class are the same Class object at runtime — list1.getClass() == list2.getClass() is true even for different element types.
  5. Overloading on erased types conflicts: you can't overload void foo(List<String> s) and void foo(List<Integer> i) in the same class — after erasure, both become void foo(List).

Erasure exists mainly for backward compatibility: it let pre-generics bytecode (Java 1.4 and earlier) keep working alongside generic code without a parallel, incompatible runtime type system.

Without a bound, a type parameter <T> can be anything, and the compiler only lets you treat values as Object — you can't call any type-specific method on them. A bounded type parameter restricts T to a subtype of a given type, unlocking that type's methods:

static <T extends Number> double sumAll(List<T> list) {
    double sum = 0;
    for (T t : list) sum += t.doubleValue(); // legal — Number guarantees doubleValue()
    return sum;
}

sumAll(List.of(1, 2, 3));         // T = Integer, extends Number — OK
sumAll(List.of(1.0, 2.0));        // T = Double, extends Number — OK
sumAll(List.of("a", "b"));        // compile error — String isn't a Number

Multiple bounds are allowed with &, useful for requiring both a base class and an interface — but only one class bound is allowed, and it must be listed first:

static <T extends Number & Comparable<T>> T max(List<T> list) {
    T best = list.get(0);
    for (T t : list) if (t.compareTo(best) > 0) best = t;
    return best;
}

Despite the keyword extends being reused, it means "is a subtype of" in this context — it works for both class inheritance and interface implementation (<T extends Comparable<T>> is valid even though Comparable is an interface).

Wildcards express "some unknown type" with varying degrees of constraint, used mainly for method parameters that need flexibility beyond an exact generic type match:

  • List<?> (unbounded wildcard): a list of some type, but the compiler doesn't know which. You can call type-agnostic methods (size(), clear()), and read elements only as Object; you cannot add() anything except null (the compiler can't verify any specific type is safe to insert).
void printAll(List<?> list) {
    for (Object o : list) System.out.println(o); // fine — read as Object
}
  • List<? extends T> (upper-bounded / "producer"): a list of T or any subtype of T. Safe to read T (or a supertype) out of it, since every element is guaranteed to be at least a T. Cannot add (except null), because the compiler doesn't know the exact subtype — adding a plain T could violate an actual List<Dog> masquerading as List<? extends Animal>.
double sum(List<? extends Number> nums) {
    double s = 0;
    for (Number n : nums) s += n.doubleValue(); // read OK
    // nums.add(5); // compile error
    return s;
}
  • List<? super T> (lower-bounded / "consumer"): a list of T or any supertype of T. Safe to add a T (or subtype) into it, since any supertype list can hold a T. Reading only guarantees Object, since the actual list could be any supertype.
void addNumbers(List<? super Integer> list) {
    list.add(1); list.add(2); // write OK
    Object o = list.get(0);   // only guaranteed to be Object
}

This read/write asymmetry is exactly the PECS principle: "Producer extends, Consumer super."

PECS — "Producer extends, Consumer super" — is Joshua Bloch's mnemonic (from Effective Java) for picking the right wildcard on a generic method parameter:

  • If the parameter is a producer — your method only reads values out of it — use ? extends T.
  • If the parameter is a consumer — your method only writes values into it — use ? super T.
  • If it does both, don't use a wildcard at all — use the exact type T.

The textbook 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 is a producer of T (the method only reads from it) → ? extends T.
  • dest is a consumer of T (the method only writes into it) → ? super T.

This lets you call copy(List<Object>, List<Integer>) even though List<Object> isn't a List<Integer> and vice versa — without PECS-style wildcards, generics' lack of subtyping between List<Integer> and List<Object> would make such a utility method far less reusable.

PECS is the practical reasoning behind the read/write restrictions on ? extends/? super covered by wildcard generics — it's less something to memorize abstractly and more a direct consequence of what's actually type-safe to do with each.