Generics in depth: wildcards and erasure

Java · Advanced · 15 min

Writing flexible library-style APIs — and decoding confusing compiler errors — needs a real model of how generics work.

Generic types are invariant: even though Integer is a Number, a List<Integer> is not a List<Number>. If it were, you could add a Double to a list of integers through the List<Number> view. Wildcards restore flexibility safely:

  • List<? extends Number> — a list of some subtype of Number. You can read elements as Number, but can't add (except null), because the exact type is unknown.
  • List<? super Integer> — a list of Integer or a supertype. You can add Integers, but reading only gives you Object.
  • List<?> — any list; read as Object.

The rule of thumb is PECS: Producer Extends, Consumer Super. A parameter you read from is a producer (extends); one you write into is a consumer (super). That's why Collections.copy is declared copy(List<? super T> dest, List<? extends T> src), and why comparator parameters are usually Comparator<? super T>.

Generics are implemented by type erasure: the compiler checks types, then erases type parameters to their bounds (usually Object) and inserts casts. At runtime a List<String> and a List<Integer> are the same class. Consequences:

  • No new T(), no new T[n], no instanceof List<String> — pass a Class<T> or a Supplier<T> when you need to create instances.
  • You can't overload m(List<String>) and m(List<Integer>); they erase to the same signature.
  • Mixing generics with arrays or raw types leads to heap pollution and "unchecked" warnings. Treat those warnings as bugs unless you can prove safety, then suppress narrowly with @SuppressWarnings("unchecked").

Use wildcards in method parameters, not in return types — callers shouldn't have to deal with them.

Example

import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.function.Supplier;

public class Wildcards {
    static double sum(List<? extends Number> nums) {          // producer: extends
        double total = 0;
        for (Number n : nums) total += n.doubleValue();
        return total;
    }
    static void addOnes(List<? super Integer> sink, int count) { // consumer: super
        for (int i = 0; i < count; i++) sink.add(1);
    }
    static <T> T make(Supplier<T> factory) { return factory.get(); } // instead of new T()
    public static void main(String[] args) {
        System.out.println(sum(List.of(1, 2.5, 3L)));   // 6.5
        List<Number> numbers = new ArrayList<>();
        addOnes(numbers, 2);                             // List<Number> accepts Integers
        System.out.println(numbers);                     // [1, 1]
        List<String> words = new ArrayList<>(List.of("b", "a"));
        words.sort(Comparator.comparing(Object::toString)); // Comparator<? super String>
        System.out.println(make(StringBuilder::new).append(words)); // [a, b]
    }
}

Remember

  • Generic types are invariant: List<Integer> is not a List<Number>
  • PECS — read from ? extends T, write into ? super T
  • Type parameters are erased at runtime: no new T(), no instanceof List<String>

Watch out for

  • Returning wildcard types from public methods, forcing every caller to handle them.

Check yourself

  1. Which call compiles for a method `void addAll(List<? super Integer> sink)`?
  2. Why can't you write `new T[10]` in a generic class?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.oracle.com/javase/tutorial/java/generics/wildcards.html

Builds on

Unlocks

Nothing builds on this yet.