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());
}
}
}