Skip to content

03 · Generics

Generics let a class, method, or interface operate on a type that's decided when it's used, not when it's written — the same way List<String> and List<Integer> share one List implementation. This module shows how to write your own generic code.

Why generics — the problem they solve

Before generics, a general-purpose container had to store Object and cast back, which is both verbose and unsafe (the compiler can't catch a wrong cast):

// The old, unsafe way
public class RawBox {
    private Object value;
    public void set(Object value) { this.value = value; }
    public Object get() { return value; }
}

RawBox box = new RawBox();
box.set("hello");
Integer n = (Integer) box.get();   // compiles, but throws ClassCastException at runtime!

A generic class

public class Box<T> {
    private T value;

    public void set(T value) {
        this.value = value;
    }

    public T get() {
        return value;
    }
}

Box<String> stringBox = new Box<>();
stringBox.set("hello");
String s = stringBox.get();   // no cast needed -- the compiler knows it's a String
// stringBox.set(42);         // won't compile -- type safety enforced

T is a type parameter — a placeholder that gets filled in with a real type (String, Integer, Employee, ...) at the point of use.

A generic class with two type parameters

public class Pair<A, B> {
    private final A first;
    private final B second;

    public Pair(A first, B second) {
        this.first = first;
        this.second = second;
    }

    public A getFirst() { return first; }
    public B getSecond() { return second; }

    @Override
    public String toString() {
        return "(" + first + ", " + second + ")";
    }
}

Pair<String, Integer> nameAge = new Pair<>("Alice", 30);
System.out.println(nameAge);            // (Alice, 30)
System.out.println(nameAge.getFirst());  // Alice

Generic methods

A single method can introduce its own type parameter, independent of the class it lives in — useful for static utility methods.

public class ArrayUtils {
    // <T> before the return type declares the type parameter for this method
    public static <T> T firstElement(T[] array) {
        if (array.length == 0) {
            throw new IllegalArgumentException("Array is empty");
        }
        return array[0];
    }

    public static <T> void printAll(List<T> items) {
        for (T item : items) {
            System.out.println(item);
        }
    }
}

String[] names = {"Alice", "Bob"};
String first = ArrayUtils.firstElement(names);   // T is inferred as String
System.out.println(first);   // Alice

Bounded type parameters

Sometimes a generic method needs to do something with the type beyond storing and returning it — like compare two values. extends restricts the type parameter to types that provide the needed capability.

// T must be Comparable to itself so we can call compareTo
public static <T extends Comparable<T>> T max(List<T> items) {
    T largest = items.get(0);
    for (T item : items) {
        if (item.compareTo(largest) > 0) {
            largest = item;
        }
    }
    return largest;
}

System.out.println(max(List.of(3, 7, 2, 9, 4)));         // 9
System.out.println(max(List.of("pear", "apple", "kiwi"))); // pear

<T extends Comparable<T>> reads as "T can be any type, as long as it implements Comparable<T>." A bound can also require a class (rather than an interface), or combine one class with multiple interfaces (<T extends Animal & Comparable<T>>).

Wildcards: ?, ? extends, ? super

Wildcards describe an unknown type when you don't need to name it — most often as a parameter type for methods that only read from or only write to a generic collection.

// Unbounded wildcard -- accepts a List of any type, we only care about size
public static void printSize(List<?> list) {
    System.out.println("Size: " + list.size());
}

// Upper-bounded wildcard ("? extends Number") -- read-only access to any
// list of Number or its subclasses (Integer, Double, ...)
public static double sumAll(List<? extends Number> numbers) {
    double total = 0;
    for (Number n : numbers) {
        total += n.doubleValue();
    }
    return total;
}

System.out.println(sumAll(List.of(1, 2, 3)));        // 6.0 -- List<Integer>
System.out.println(sumAll(List.of(1.5, 2.5)));        // 4.0 -- List<Double>

// Lower-bounded wildcard ("? super Integer") -- safe to add Integers into
// any list of Integer or its supertypes (Number, Object)
public static void addNumbers(List<? super Integer> list) {
    list.add(1);
    list.add(2);
}

List<Number> numberList = new ArrayList<>();
addNumbers(numberList);
System.out.println(numberList);   // [1, 2]

The common mnemonic is PECS — Producer extends, Consumer super: use ? extends T when you only read Ts out of the structure, and ? super T when you only put Ts into it.

Wildcard Meaning Typical use
List<?> Unknown type Read-only, type-agnostic operations (size, clear)
List<? extends T> T or any subtype Producer -- safe to read as T
List<? super T> T or any supertype Consumer -- safe to write T into it

Type erasure

Generic type information exists only at compile time — the compiler checks it, then erases it, compiling Box<String> and Box<Integer> down to the same Box bytecode using Object internally (with automatic casts inserted for you). Consequences worth knowing:

Box<String> b1 = new Box<>();
Box<Integer> b2 = new Box<>();
System.out.println(b1.getClass() == b2.getClass());   // true -- both are just Box

// You cannot create a generic array or check a generic type at runtime:
// T[] array = new T[10];         // won't compile
// if (obj instanceof Box<String>) { }   // won't compile ("unchecked" / erased)

Why avoid raw types

Using a generic class without a type argument (a "raw type", like the RawBox example above, or List list = new ArrayList();) disables all compile-time type checking for that variable — you lose the entire benefit of generics and reintroduce the risk of ClassCastException. Always supply a type argument (or `<>, the diamond operator, when it can be inferred).

How It Actually Works

Java generics are implemented by type erasure: List<String> and List<Integer> compile to the exact same bytecode operating on plain List, with the compiler inserting invisible checkcast instructions at extraction points to preserve the illusion of type safety. This is why you can't do new T[10] (the JVM has no runtime record of what T is inside a generic method), why List<String>.class doesn't exist as a distinct Class object, and why two overloads differing only by generic type parameter are a compile error — after erasure they'd have identical signatures.

Bridge methods are the erasure mechanism's most visible artifact: when a generic class is subclassed with a concrete type argument and overrides a method, the compiler silently generates an extra bridge method with the erased signature that just casts arguments and delegates to your typed override — necessary so invokevirtual dispatch still finds the right override when called through the raw/erased type.

Wildcards (? extends T, ? super T) exist purely at the compiler level to make the erasure-based type system sound for covariant/ contravariant use (PECS — Producer Extends, Consumer Super) — there is zero runtime representation of a wildcard; it's entirely a static type-checking device.

Exercise

Write a generic class Stack<T> backed by a List<T> (or an array), with methods push(T item), T pop() (removes and returns the top item, throwing IllegalStateException if empty), and boolean isEmpty(). Then write a generic static method <T extends Comparable<T>> boolean isSorted(List<T> list) that returns true if the list is in non-decreasing order. Test both with a Stack<String> and a List<Integer>.