IntermediateJava · Lesson 5 of 9

Generics

Type-safe reusable classes and methods, bounded types and wildcards.

Generics let you write a class or method once and use it with any type while keeping compile-time type checks. List<String> only accepts Strings — no casts, and mistakes are caught by the compiler.

Declare type parameters on a class (class Box<T>) or a method (static <T> T firstOrDefault(List<T> list, T fallback)). Bound them with extends when you need certain capabilities: <T extends Comparable<T>> can be compared.

Wildcards make APIs flexible: List<? extends Number> reads numbers of any subtype; List<? super Integer> accepts Integers being added. A handy rule: producer extends, consumer super (PECS).

GenericsDemo.javaJava
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

public class GenericsDemo {
    static class Repository<T, ID> {
        private final List<T> items = new ArrayList<>();
        private final java.util.function.Function<T, ID> idOf;

        Repository(java.util.function.Function<T, ID> idOf) { this.idOf = idOf; }

        void save(T item) { items.add(item); }

        Optional<T> findById(ID id) {
            return items.stream().filter(i -> idOf.apply(i).equals(id)).findFirst();
        }

        List<T> findAll() { return List.copyOf(items); }
    }

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

    record Student(String id, String name, int score) {}

    static <T extends Comparable<T>> T max(List<T> values) {
        T best = values.get(0);
        for (T v : values) if (v.compareTo(best) > 0) best = v;
        return best;
    }

    static double sum(List<? extends Number> numbers) {     // reads Integers, Doubles, ...
        double total = 0;
        for (Number n : numbers) total += n.doubleValue();
        return total;
    }

    static void addDefaults(List<? super Integer> target) {  // accepts List<Integer>, List<Number>, ...
        target.add(0);
        target.add(100);
    }

    public static void main(String[] args) {
        Repository<Student, String> repo = new Repository<>(Student::id);
        repo.save(new Student("S1", "Amina", 88));
        repo.save(new Student("S2", "Juma", 42));

        System.out.println(repo.findById("S2").map(Student::name).orElse("not found"));
        System.out.println(repo.findById("S9").map(Student::name).orElse("not found"));

        Pair<String, Integer> top = new Pair<>("Amina", 88);
        System.out.println(top.first() + " -> " + top.second());

        System.out.println(max(List.of(3, 9, 4)) + " " + max(List.of("Juma", "Amina", "Neema")));
        System.out.println(sum(List.of(1, 2, 3)) + sum(List.of(0.5, 1.5)));

        List<Number> numbers = new ArrayList<>();
        addDefaults(numbers);
        System.out.println(numbers);
    }
}

Key points

  • Generics give reusable code with compile-time type safety — no casts.
  • Bound type parameters (<T extends Comparable<T>>) to use their capabilities.
  • Wildcards: ? extends T to read (producer), ? super T to write (consumer).

Exercise

Write a generic Stack<T> class with push, pop, peek and isEmpty (throw an exception on empty pop). Then write a generic method <T> Map<T, Integer> frequencies(List<T> items).

Show solution

Try the exercise yourself first — then compare your approach with this one.

Stack<T> wraps an ArrayDeque<T>, so pushes and pops are fast and type-safe. pop on an empty stack throws a clear exception. frequencies works for any element type because it's a generic method.

GenericStack.javaJava
import java.util.*;

public class GenericStack {
    static class Stack<T> {
        private final Deque<T> items = new ArrayDeque<>();

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

        T pop() {
            if (items.isEmpty()) throw new NoSuchElementException("pop from an empty stack");
            return items.pop();
        }

        T peek() { return items.peek(); }

        boolean isEmpty() { return items.isEmpty(); }
    }

    static <T> Map<T, Integer> frequencies(List<T> items) {
        Map<T, Integer> counts = new LinkedHashMap<>();
        for (T item : items) counts.merge(item, 1, Integer::sum);
        return counts;
    }

    public static void main(String[] args) {
        Stack<String> pages = new Stack<>();
        pages.push("Home");
        pages.push("Lessons");
        System.out.println(pages.pop() + " then " + pages.peek());   // Lessons then Home
        pages.pop();
        try {
            pages.pop();
        } catch (NoSuchElementException e) {
            System.out.println("Error: " + e.getMessage());
        }

        System.out.println(frequencies(List.of("A", "B", "A", "C", "A")));   // {A=3, B=1, C=1}
        System.out.println(frequencies(List.of(3, 1, 3)));                   // {3=2, 1=1}
    }
}

Check your understanding

  1. What does List<String> prevent?

  2. What does <T extends Comparable<T>> require of T?

  3. Which parameter accepts a List<Integer> and a List<Double> for reading numbers?

  4. What does PECS stand for?

Ask AI