V
Vel·ToolKit
Simple · Fast · Ready to use
EN
Chapter 12 of 20

Generics

Class/method generics, wildcards, PECS, type erasure

Why Generics

Without generics, collections store Object — you can put any type in, but you must cast on the way out, and a mistake only fails at runtime with ClassCastException. Generics declare the type as a parameter so the compiler checks types while you write, avoiding casts and runtime crashes.

  • Use cases: all collections (List<T>, Map<K,V>), Optional<T>, CompletableFuture<T>, Comparator<T>
  • Custom cases: generic containers (Pair<A,B>, Result<T>), utility methods (find max/min/convert), domain objects that "hold any type" (Cache<K,V>, Repository<T>)
  • Core benefit: compile-time type safety + no casts for the writer
// WhyGenerics.java
import java.util.ArrayList;
import java.util.List;

public class WhyGenerics {
    public static void main(String[] args) {
        // no generics: cast on the way out, crashes at runtime on a mistake
        List rawList = new ArrayList();
        rawList.add("hello");
        rawList.add(42);                  // the compiler doesn't stop it
        String s = (String) rawList.get(1); // ClassCastException at runtime

        // with generics: the compiler intercepts
        List<String> typed = new ArrayList<>();
        typed.add("hello");
        // typed.add(42);                 // compile error
        String s2 = typed.get(0);          // no cast needed
        System.out.println(s2);
    }
}

Class Generics

Declare type parameters with <T> after the class name. Common case: writing a container that "holds a value of any type". Multiple parameters use commas: <K, V>.

// Pair.java
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 first()  { return first; }
    public B second() { return second; }

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

    public static void main(String[] args) {
        Pair<String, Integer> p = new Pair<>("age", 30);
        System.out.println(p);                    // (age, 30)
        System.out.println(p.first().length());   // String methods are available
        System.out.println(p.second() + 1);        // Integer auto-unboxing
    }
}

Method Generics

Generics at the method level — independent of the enclosing class's generics. Declaration syntax: add <T> before the return type. Common case: a generic utility method whose parameter and return involve the same type.

// GenericMethod.java
import java.util.List;

public class GenericMethod {
    // <T> is this method's own type parameter
    static <T> T firstOrDefault(List<T> list, T fallback) {
        return list.isEmpty() ? fallback : list.get(0);
    }

    public static void main(String[] args) {
        List<String> names = List.of();
        List<Integer> nums  = List.of(1, 2, 3);

        System.out.println(firstOrDefault(names, "-"));    // -
        System.out.println(firstOrDefault(nums,  0));      // 1
    }
}

Wildcards ? extends T and ? super T

List<Number> cannot accept List<Integer> — the key pitfall of Java generics invariance. To pass a collection parameter of a "variable range", use wildcards: ? extends T (upper bound, read-only) and ? super T (lower bound, write-only).

// Wildcards.java
import java.util.ArrayList;
import java.util.List;

public class Wildcards {
    // read-only: accepts a List of any Number or its subclasses
    static double sumAll(List<? extends Number> list) {
        double s = 0;
        for (Number n : list) s += n.doubleValue();
        return s;
    }

    // write-only: can push Integer in
    static void addInts(List<? super Integer> list, int n) {
        for (int i = 0; i < n; i++) list.add(i);
    }

    public static void main(String[] args) {
        List<Integer> ints = List.of(1, 2, 3);
        List<Double>  dbs  = List.of(1.5, 2.5);
        System.out.println(sumAll(ints));   // 6.0
        System.out.println(sumAll(dbs));    // 4.0

        List<Number> bucket = new ArrayList<>();
        addInts(bucket, 3);
        System.out.println(bucket);          // [0, 1, 2]
    }
}

The PECS Principle

Remember the mnemonic: **Producer Extends, Consumer Super**. A collection used as a "data source" (producer, you read from it) uses extends; as a "data target" (consumer, you write to it) uses super. The standard library's Collections.copy(dst, src) is the classic example.

// Pecs.java
import java.util.ArrayList;
import java.util.List;

public class Pecs {
    // src is the producer (read) -> extends
    // dst is the consumer (written) -> super
    static <T> void copy(List<? super T> dst, List<? extends T> src) {
        for (T item : src) {
            dst.add(item);
        }
    }

    public static void main(String[] args) {
        List<Integer> src = List.of(1, 2, 3);
        List<Number> dst = new ArrayList<>();
        copy(dst, src);
        System.out.println(dst);  // [1, 2, 3]
    }
}

Type Erasure and Runtime Impact

Java generics are "compile-time type checking + runtime erasure" — there is no generic info in the bytecode, and List<String> and List<Integer> are the same List class at runtime. This causes some limitations:

  • Can't new T() (the actual type is unknown)
  • Can't create a generic array new T[10]
  • Can't obj instanceof List<String> (the String layer isn't available at runtime)
  • A class's static field can't use the type parameter T
  • Reflection's Class object for List carries no generics; to get generic type info use Type / ParameterizedType
// Erasure.java
import java.util.ArrayList;
import java.util.List;

public class Erasure {
    public static void main(String[] args) {
        List<String> a = new ArrayList<>();
        List<Integer> b = new ArrayList<>();
        System.out.println(a.getClass() == b.getClass()); // true! the same ArrayList class

        // if (a instanceof List<String>) {}  // compile error
        if (a instanceof List<?>) {            // use the <?> wildcard instead
            System.out.println("is a list");
        }
    }
}

Using Class<T> to Solve "No Runtime Type"

When you need the actual type at runtime (e.g. JSON deserialization, reflective instantiation), the idiom is to have the caller explicitly pass Class<T> as a parameter. Jackson's readValue(json, User.class) is this pattern.

// ClassToken.java
public class ClassToken {
    static <T> T newInstance(Class<T> cls) throws Exception {
        return cls.getDeclaredConstructor().newInstance();
    }

    public static void main(String[] args) throws Exception {
        StringBuilder sb = newInstance(StringBuilder.class);
        sb.append("hello");
        System.out.println(sb);
    }
}

In Practice: a Generic Result<T> Container

Use case: a business method may either succeed (with data) or fail (with an error code / message); Result<T> expresses both uniformly, friendlier than throwing exceptions (especially at the API layer).

// Result.java
public class Result<T> {
    private final boolean ok;
    private final T data;
    private final String error;

    private Result(boolean ok, T data, String error) {
        this.ok = ok;
        this.data = data;
        this.error = error;
    }

    public static <T> Result<T> ok(T data)      { return new Result<>(true,  data, null); }
    public static <T> Result<T> fail(String msg) { return new Result<>(false, null, msg); }

    public boolean isOk()    { return ok; }
    public T       getData() { return data; }
    public String  getError(){ return error; }

    public static void main(String[] args) {
        Result<Integer> r1 = Result.ok(42);
        Result<Integer> r2 = Result.fail("divide by zero");

        for (Result<Integer> r : new Result[]{r1, r2}) {
            if (r.isOk()) System.out.println("data=" + r.getData());
            else          System.out.println("err=" + r.getError());
        }
    }
}