Saltar a contenido

09 - Generics

What this session is

About an hour. You'll learn how Java's generics work - the <T> syntax you've already used with List<String> and Map<String, Integer>. Generics let you write code that works on any type, with the compiler checking types at compile time.

The problem generics solve

Before Java 5 (which added generics), a list was just a List - it held Object, and you had to cast on every access:

List items = new ArrayList();
items.add("hello");
items.add(42);                 // legal - list takes Object
String s = (String) items.get(0);
String wrong = (String) items.get(1);    // ClassCastException at runtime

Mistakes weren't caught until you actually ran the bad cast. Generics fix that:

List<String> items = new ArrayList<>();
items.add("hello");
items.add(42);                 // COMPILE ERROR - int is not String
String s = items.get(0);       // no cast needed; compiler knows it's a String

The compiler tracks the type parameter through every operation.

Type parameters: the basics

A type parameter is a placeholder for a type, written in angle brackets:

public class Box<T> {
    private T value;

    public Box(T value) {
        this.value = value;
    }

    public T get() {
        return value;
    }

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

<T> is the type parameter. Inside the class, T stands in for whatever type the caller specifies.

Use it:

Box<String> nameBox = new Box<>("Alice");
String name = nameBox.get();          // T is String here

Box<Integer> ageBox = new Box<>(30);
int age = ageBox.get();               // T is Integer here

// nameBox.set(42);                   // COMPILE ERROR - wrong type

The compiler enforces type consistency per instance.

By convention, type parameters are single uppercase letters: - T - type (generic). - E - element (used in collections). - K, V - key, value (used in maps). - R - return type.

Generic methods

Just classes - methods can have type parameters too:

public static <T> T first(List<T> list) {
    return list.get(0);
}

The <T> before the return type declares the type parameter. The compiler infers T from the argument:

List<String> names = List.of("Alice", "Bob");
String firstName = first(names);    // T inferred as String

List<Integer> nums = List.of(1, 2, 3);
int firstNum = first(nums);          // T inferred as Integer

Useful for utility methods that work uniformly across types.

Bounded type parameters: extends

Sometimes you want T to be something specific (or one of its subtypes). Use extends:

public static <T extends Number> double sumAll(List<T> list) {
    double total = 0;
    for (T n : list) {
        total += n.doubleValue();      // OK - Number has doubleValue
    }
    return total;
}

<T extends Number> says "T is some subtype of Number." Inside the method, you can call any Number method on T. The caller is free to pass List<Integer>, List<Double>, List<Long> - anything that's-a Number.

Try to call sumAll(List.of("hello", "world")) and the compiler refuses: String is not a Number.

Wildcards: ?, ? extends T, ? super T

You'll see wildcards in real Java code. Briefly:

  • List<?> - "a list of unknown type." Read-only (you can't add to it; the compiler doesn't know what type would be safe). Useful for "I just want to iterate or count."
  • List<? extends Number> - "a list of some subtype of Number." Read-only-ish: you can read items as Number; you can't add (you don't know if Integer would be accepted by a List<Double>).
  • List<? super Integer> - "a list of some supertype of Integer." Write-only-ish: you can add Integers; reading gives you Object.

The mnemonic: PECS - "Producer Extends, Consumer Super." A list you read from (producer) uses extends; a list you write to (consumer) uses super.

You'll mostly use simple <T> and <T extends X>. Wildcards appear in library APIs (Collections.sort etc.); recognize them when you see them.

Generics and runtime: type erasure

Java's generics are a compile-time feature. At runtime, all generic types are erased to their bounds (or Object). A List<String> and a List<Integer> are both just List at runtime.

Practical consequence: you cannot do this:

public <T> T create() {
    return new T();         // COMPILE ERROR - can't instantiate T
}

Or this:

if (x instanceof List<String>) { ... }   // COMPILE ERROR - can't check generic type

You can do instanceof List<?> (the wildcard is fine; the specific type isn't checkable).

Workarounds (you'll see them in real code): - Pass Class<T> as a parameter: <T> T create(Class<T> cls) { return cls.getDeclaredConstructor().newInstance(); }. - Use specialized libraries (Gson, Jackson) that handle generic type info via TypeToken/TypeReference.

For now, just know: generics disappear at runtime; if you need type info at runtime, pass Class<T> explicitly.

A practical example: a generic stack

import java.util.ArrayList;
import java.util.List;

public class Stack<T> {
    private final List<T> items = new ArrayList<>();

    public void push(T item) {
        items.add(item);
    }

    public T pop() {
        if (items.isEmpty()) {
            throw new IllegalStateException("stack is empty");
        }
        return items.remove(items.size() - 1);
    }

    public T peek() {
        if (items.isEmpty()) {
            throw new IllegalStateException("stack is empty");
        }
        return items.get(items.size() - 1);
    }

    public int size() { return items.size(); }
    public boolean isEmpty() { return items.isEmpty(); }
}

Use it for any type:

Stack<String> messages = new Stack<>();
messages.push("first");
messages.push("second");
System.out.println(messages.pop());    // second

Stack<Integer> nums = new Stack<>();
nums.push(1);
nums.push(2);
System.out.println(nums.peek());       // 2

One class, type-safe usage for any T.

(In real code, prefer Deque<T> via ArrayDeque over rolling your own stack - but this is a nice generics exercise.)

Records with generics

Records support type parameters too:

public record Pair<A, B>(A first, B second) {}

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

Useful for "two related things" when you don't want to define a class for it. (For more named structure, define a real record with named components.)

Exercise

In a new file Generics.java:

  1. Write a generic class Box<T> with set(T), get(), and toString() that prints Box{value=...}.

  2. Write a generic method static <T> List<T> repeat(T value, int times) that returns a list with value repeated times times. (Use a for loop and List.add.)

  3. In main:

    Box<String> b = new Box<>("hi");
    System.out.println(b);
    
    List<Integer> ones = repeat(1, 5);
    System.out.println(ones);     // [1, 1, 1, 1, 1]
    
    List<String> hellos = repeat("hello", 3);
    System.out.println(hellos);   // [hello, hello, hello]
    

  4. Stretch: write a generic record Result<T> with two cases. Use a sealed interface and two implementing records:

    public sealed interface Result<T> permits Success, Failure {}
    public record Success<T>(T value) implements Result<T> {}
    public record Failure<T>(String error) implements Result<T> {}
    
    Write a method that processes a Result<String> with a switch expression and pattern matching, printing either the value or the error.

What you might wonder

"Why all the angle brackets?" Generics are a powerful feature for type safety. The cost is verbosity at declaration sites - but most use sites benefit from inference (new ArrayList<>()).

"Can primitives be type arguments?" No - only reference types. List<int> is illegal; you write List<Integer> and rely on autoboxing. Project Valhalla (in development) aims to add value types that would let primitives work as type arguments, but it's not in 25 yet.

"What's the difference between List<Object> and List<?>?" - List<Object> is "a list whose element type is Object" - strict; not assignable from List<String>. - List<?> is "a list of unknown type" - permissive; assignable from any List<T>. Read-only.

"What about Kotlin's generics being 'better'?" Kotlin keeps generic type info at runtime for inline functions (reified type parameters). Java doesn't. It's a real ergonomic win in Kotlin; in Java you carry Class<T> around when you need it. Live with it.

"Why are wildcards confusing?" Because they encode subtype variance, which is genuinely subtle. Read PECS, write code that uses <T> without wildcards where you can, accept that wildcards exist in real APIs.

Done

You can now: - Read and write generic classes and methods. - Use bounded type parameters (<T extends X>). - Recognize wildcards (?, ? extends, ? super). - Understand the basics of type erasure and its workarounds. - Use generic records.

Generics are one of Java's foundational type-system features. You now have the vocabulary to read any real Java code.

Next page: testing your own code with JUnit 5.

Next: Tests →

Comments