Generics in Java
Deep dive into Java generics — generic methods, bounded type parameters, wildcards (? extends, ? super), type erasure, and the PECS principle.
Why Generics?
Without generics, collections stored Object and required explicit casts — with no compile-time type safety:
// Pre-generics (Java 1.4) — error-prone
List names = new ArrayList();
names.add("Alice");
names.add(42); // compiles — bug hiding here
String name = (String) names.get(0); // OK
String bad = (String) names.get(1); // ClassCastException at runtime!
// With generics — type checked at compile time
List<String> safeNames = new ArrayList<>();
safeNames.add("Alice");
// safeNames.add(42); // compile error — caught immediately
String first = safeNames.get(0); // no cast needed
Generic Classes
// A generic pair that holds two values of potentially different types
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; }
public Pair<B, A> swap() { return new Pair<>(second, first); }
@Override public String toString() {
return "(" + first + ", " + second + ")";
}
}
// Usage
Pair<String, Integer> nameAge = new Pair<>("Alice", 30);
System.out.println(nameAge.first()); // Alice
System.out.println(nameAge.second()); // 30
System.out.println(nameAge.swap()); // (30, Alice)
Generic Methods
Generic methods declare their type parameter before the return type:
public class GenericMethods {
// Type parameter <T> is scoped to this method
public static <T> T firstNonNull(T a, T b) {
return a != null ? a : b;
}
// Multiple type parameters
public static <K, V> Map<V, K> invertMap(Map<K, V> map) {
Map<V, K> inverted = new HashMap<>();
map.forEach((k, v) -> inverted.put(v, k));
return inverted;
}
// Generic swap in an array
public static <T> void swap(T[] arr, int i, int j) {
T temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
public static void main(String[] args) {
System.out.println(firstNonNull(null, "default")); // default
System.out.println(firstNonNull("value", "default")); // value
Map<String, Integer> codes = Map.of("HTTP", 200, "NOT_FOUND", 404);
Map<Integer, String> inverted = invertMap(codes);
System.out.println(inverted.get(200)); // HTTP
Integer[] arr = {1, 2, 3, 4, 5};
swap(arr, 0, 4);
System.out.println(Arrays.toString(arr)); // [5, 2, 3, 4, 1]
}
}
Bounded Type Parameters
Upper Bound — <T extends SomeType>
// T must be a Number or subclass (Integer, Double, Long, etc.)
public static <T extends Number> double sum(List<T> list) {
return list.stream().mapToDouble(Number::doubleValue).sum();
}
System.out.println(sum(List.of(1, 2, 3))); // 6.0
System.out.println(sum(List.of(1.5, 2.5, 3.0))); // 7.0
// sum(List.of("a", "b")); // compile error — String is not a Number
// Multiple bounds — T must extend Comparable AND Serializable
public static <T extends Comparable<T> & java.io.Serializable> T clamp(T val, T min, T max) {
if (val.compareTo(min) < 0) return min;
if (val.compareTo(max) > 0) return max;
return val;
}
System.out.println(clamp(15, 1, 10)); // 10
System.out.println(clamp(5, 1, 10)); // 5
System.out.println(clamp(-3, 1, 10)); // 1
Wildcards
Unbounded Wildcard — <?>
// Accept any kind of List — for code that only uses Object methods
public static void printAll(List<?> list) {
for (Object item : list) {
System.out.println(item);
}
}
printAll(List.of("a", "b", "c")); // works
printAll(List.of(1, 2, 3)); // works
Upper-Bounded Wildcard — <? extends T> (Producer)
Use when you read from the structure (it produces values of type T):
// Works with List<Integer>, List<Double>, List<Long>, etc.
public static double sumList(List<? extends Number> list) {
return list.stream().mapToDouble(Number::doubleValue).sum();
}
List<Integer> ints = List.of(1, 2, 3);
List<Double> doubles = List.of(1.5, 2.5);
System.out.println(sumList(ints)); // 6.0
System.out.println(sumList(doubles)); // 4.0
// You CANNOT add to a ? extends list (type is unknown)
// list.add(1.0); // compile error — could be List<Integer>!
Lower-Bounded Wildcard — <? super T> (Consumer)
Use when you write to the structure (it consumes values of type T):
// Works with List<Integer>, List<Number>, List<Object>
public static void addNumbers(List<? super Integer> list, int count) {
for (int i = 1; i <= count; i++) {
list.add(i); // safe — Integer is a subtype of whatever super type it is
}
}
List<Integer> intList = new ArrayList<>();
List<Number> numList = new ArrayList<>();
List<Object> objList = new ArrayList<>();
addNumbers(intList, 3);
addNumbers(numList, 3);
addNumbers(objList, 3);
// addNumbers(List<Double>, 3); // compile error — Integer is not a subtype of Double
PECS in Action
// Collections.copy — the canonical PECS example
// src is a Producer (we read from it) — ? extends T
// dest is a Consumer (we write to it) — ? super T
public static <T> void copy(List<? super T> dest, List<? extends T> src) {
for (T item : src) {
dest.add(item);
}
}
List<Integer> source = List.of(1, 2, 3);
List<Number> target = new ArrayList<>();
copy(target, source); // Integer extends Number — works
System.out.println(target); // [1, 2, 3]
Type Erasure
The compiler replaces type parameters with their upper bound (or Object if unbounded) in bytecode:
// Source code
List<String> strings = new ArrayList<>();
strings.add("hello");
String s = strings.get(0);
// After erasure (what the JVM actually sees)
List strings2 = new ArrayList();
strings2.add("hello");
String s2 = (String) strings2.get(0); // cast inserted by compiler
// Type erasure limitations
<T> void example(T t) {
// These do NOT compile due to erasure:
// T obj = new T(); // cannot instantiate T
// if (t instanceof T) {} // cannot check T at runtime
// T[] arr = new T[10]; // cannot create generic array
// Class<T> cls = T.class; // T.class does not exist
}
Working Around Type Erasure with Class
// Pass the Class object explicitly to work around erasure
public class TypeSafeContainer<T> {
private final Class<T> type;
private final List<Object> items = new ArrayList<>();
public TypeSafeContainer(Class<T> type) { this.type = type; }
public void add(T item) { items.add(item); }
public T get(int index) {
return type.cast(items.get(index)); // safe cast using Class
}
public boolean contains(Object obj) {
return type.isInstance(obj); // runtime type check
}
}
TypeSafeContainer<String> c = new TypeSafeContainer<>(String.class);
c.add("hello");
System.out.println(c.get(0)); // hello
System.out.println(c.contains("world")); // false
System.out.println(c.contains(42)); // false
Generic Interfaces
// Generic repository interface
public interface Repository<T, ID> {
Optional<T> findById(ID id);
List<T> findAll();
T save(T entity);
void deleteById(ID id);
}
// Concrete implementation
public class InMemoryUserRepository implements Repository<User, Long> {
private final Map<Long, User> store = new HashMap<>();
private long nextId = 1;
@Override
public Optional<User> findById(Long id) {
return Optional.ofNullable(store.get(id));
}
@Override
public List<User> findAll() {
return List.copyOf(store.values());
}
@Override
public User save(User user) {
var saved = new User(nextId++, user.name(), user.email());
store.put(saved.id(), saved);
return saved;
}
@Override
public void deleteById(Long id) {
store.remove(id);
}
}
Comparable and Comparator with Generics
record Student(String name, double gpa) implements Comparable<Student> {
@Override
public int compareTo(Student other) {
return Double.compare(other.gpa, this.gpa); // descending by GPA
}
}
List<Student> students = new ArrayList<>(List.of(
new Student("Alice", 3.8),
new Student("Bob", 3.5),
new Student("Carol", 3.9)
));
Collections.sort(students); // uses Comparable
students.forEach(s -> System.out.printf("%s: %.1f%n", s.name(), s.gpa()));
// Carol: 3.9
// Alice: 3.8
// Bob: 3.5
// Comparator with generics
Comparator<Student> byName = Comparator.comparing(Student::name);
Comparator<Student> byGpa = Comparator.comparingDouble(Student::gpa).reversed();
Comparator<Student> byGpaThenName = byGpa.thenComparing(byName);
students.sort(byGpaThenName); Frequently Asked Questions
What is type erasure and why does it matter?
At compile time the compiler checks generic types and then erases them — the bytecode contains no type parameters. List<String> and List<Integer> are both just List at runtime. This means you cannot do: new T(), T.class, instanceof List<String>, or create generic arrays. It's the cost of backward compatibility with pre-generics Java.
What is the PECS principle?
Producer Extends, Consumer Super. If a parameterized type is a producer (you only read from it), use ? extends T. If it is a consumer (you only write to it), use ? super T. If it does both, use the exact type T. Example: Collections.copy(List<? super T> dest, List<? extends T> src).
What is the difference between List<?> and List<Object>?
List<Object> accepts only a List<Object> — you cannot pass a List<String> to it because generics are invariant. List<?> (unbounded wildcard) accepts any List regardless of type parameter — but you can only read from it as Object and cannot add elements (except null).
Can I use generics with primitive types?
No. Type parameters must be reference types. Use the wrapper classes: Integer for int, Double for double, etc. The JVM auto-boxes and unboxes, but this adds overhead. For performance-critical numeric code use primitive streams (IntStream, LongStream, DoubleStream) or specialised libraries.