Generics: Bounded Types and Wildcards
Generics let you write classes, interfaces, and methods that operate on a type parameter determined at compile time. They eliminate casts, catch type errors early, and make APIs self-documenting. But generics go well beyond simple
Why Generics?
Before generics (Java 1.4 and earlier), collections stored Object
// Pre-generics code — dangerous
List names = new ArrayList();
names.add("Alice");
names.add(42); // No compiler error — anything goes
String first = (String) names.get(0); // OK at runtime
String second = (String) names.get(1); // ClassCastException at runtime!
Generics parameterise types, moving the type check to compile time:
// With generics — safe at compile time
List<String> names = new ArrayList<>();
names.add("Alice");
names.add(42); // Compiler error — int is not a String
String first = names.get(0); // No cast needed — type is known
Generics are implemented via type erasure: at runtime, the JVM sees raw types. The type
parameter is a compile-time-only concept. This means you cannot do new T()instanceof List<String>
List<String>List<Integer>ListGeneric Classes
A generic class declares one or more type parameters in angle brackets after the class name. By convention,
single capital letters are used: TEKVR
// A generic Pair holding 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 getFirst() { return first; }
public B getSecond() { return second; }
// Swap — returns a new Pair with types reversed
public Pair<B, A> swap() {
return new Pair<>(second, first);
}
@Override
public String toString() {
return "(" + first + ", " + second + ")";
}
public static void main(String[] args) {
Pair<String, Integer> entry = new Pair<>("score", 100);
System.out.println(entry); // (score, 100)
System.out.println(entry.swap()); // (100, score)
Pair<Integer, Integer> coords = new Pair<>(10, 20);
System.out.println(coords); // (10, 20)
}
}
// Generic stack built on top of a dynamic array
public class Stack<E> {
private Object[] elements;
private int size = 0;
@SuppressWarnings("unchecked")
public Stack(int capacity) {
elements = new Object[capacity];
}
public void push(E item) {
ensureCapacity();
elements[size++] = item;
}
@SuppressWarnings("unchecked")
public E pop() {
if (size == 0) throw new java.util.EmptyStackException();
E item = (E) elements[--size];
elements[size] = null; // Eliminate stale reference
return item;
}
@SuppressWarnings("unchecked")
public E peek() {
if (size == 0) throw new java.util.EmptyStackException();
return (E) elements[size - 1];
}
public boolean isEmpty() { return size == 0; }
public int size() { return size; }
private void ensureCapacity() {
if (size == elements.length) {
elements = java.util.Arrays.copyOf(elements, size * 2 + 1);
}
}
}
Generic Methods
Methods can introduce their own type parameters, independent of any type parameter on the enclosing class. The type parameter list appears before the return type.
public class Collections {
// Type inferred from arguments — no cast needed at call site
public static <T> List<T> repeat(T item, int times) {
List<T> list = new ArrayList<>();
for (int i = 0; i < times; i++) list.add(item);
return list;
}
// Swap two elements in an array — works for any array type
public static <T> void swap(T[] array, int i, int j) {
T temp = array[i];
array[i] = array[j];
array[j] = temp;
}
// Return the first non-null value from a varargs list
@SafeVarargs
public static <T> T firstNonNull(T... values) {
for (T v : values) {
if (v != null) return v;
}
throw new NullPointerException("All values are null");
}
public static void main(String[] args) {
List<String> hellos = repeat("hello", 3);
System.out.println(hellos); // [hello, hello, hello]
String[] arr = {"a", "b", "c"};
swap(arr, 0, 2);
System.out.println(java.util.Arrays.toString(arr)); // [c, b, a]
String result = firstNonNull(null, null, "found");
System.out.println(result); // found
}
}
The compiler infers the type argument from context. You can supply it explicitly when inference fails:
Collections.<String>repeat("x", 5)
Upper Bounded Wildcards (? extends T)
? extends T
// Works with List<Integer>, List<Double>, List<Number>, List<Object>? — NO!
// Works with List<Integer>, List<Double>, List<BigDecimal> — anything extends Number
public static double sumList(List<? extends Number> list) {
double sum = 0;
for (Number n : list) {
sum += n.doubleValue(); // Safe to call Number methods
}
return sum;
}
public static void main(String[] args) {
List<Integer> ints = List.of(1, 2, 3);
List<Double> doubles = List.of(1.5, 2.5);
List<Long> longs = List.of(10L, 20L, 30L);
System.out.println(sumList(ints)); // 6.0
System.out.println(sumList(doubles)); // 4.0
System.out.println(sumList(longs)); // 60.0
}
The trade-off: you cannot call list.add()List<? extends Number>null
// Copying elements from a source into a destination — upper bounded source
public static <T> void copy(List<? extends T> source, List<? super T> dest) {
for (T item : source) {
dest.add(item);
}
}
public static void main(String[] args) {
List<Integer> source = List.of(1, 2, 3);
List<Number> dest = new ArrayList<>();
copy(source, dest);
System.out.println(dest); // [1, 2, 3]
}
Lower Bounded Wildcards (? super T)
? super T
// Fill a list with n copies of value — list must be able to hold T or its supertypes
public static <T> void fill(List<? super T> list, T value, int count) {
for (int i = 0; i < count; i++) {
list.add(value); // Safe — list accepts T or anything above T
}
}
public static void main(String[] args) {
List<Number> numbers = new ArrayList<>();
fill(numbers, 42, 3); // Integer is a Number — OK
fill(numbers, 3.14, 2); // Double is a Number — OK
System.out.println(numbers); // [42, 42, 42, 3.14, 3.14]
List<Object> objects = new ArrayList<>();
fill(objects, "hello", 2); // String super-bounded by Object — OK
System.out.println(objects); // [hello, hello]
}
The PECS mnemonic summarises when to reach for each wildcard:
- Producer (you read from it) →
? extends T - Consumer (you write into it) →
? super T
// Real-world example: sort using a Comparator that can compare supertypes
public static <T extends Comparable<? super T>> void sort(List<T> list) {
// Comparator<? super T> allows a Comparator<Number> to sort a List<Integer>
java.util.Collections.sort(list);
}
// Practical: addAll from a collection of a subtype
public <E> boolean addAll(Collection<? extends E> c) {
boolean modified = false;
for (E e : c) {
if (add(e)) modified = true;
}
return modified;
}
Unbounded Wildcards (?)
?Object
// Prints any list regardless of element type
public static void printList(List<?> list) {
for (Object elem : list) {
System.out.print(elem + " ");
}
System.out.println();
}
// Count nulls in any collection
public static int countNulls(Collection<?> collection) {
int count = 0;
for (Object o : collection) {
if (o == null) count++;
}
return count;
}
public static void main(String[] args) {
printList(List.of(1, 2, 3)); // 1 2 3
printList(List.of("a", "b", "c")); // a b c
printList(List.of(true, false)); // true false
List<String> withNulls = new ArrayList<>(Arrays.asList("x", null, "y", null));
System.out.println(countNulls(withNulls)); // 2
}
nullList<?>Object
A subtle but important distinction: List<Object>List<?>List<Object>List<String>List<Object>List<String>List<?>
Multiple Bounds
A type parameter can be constrained by multiple bounds using &
// T must extend Comparable AND implement Serializable
public static <T extends Comparable<T> & java.io.Serializable>
T findMax(List<T> list) {
if (list.isEmpty()) throw new java.util.NoSuchElementException();
T max = list.get(0);
for (T item : list) {
if (item.compareTo(max) > 0) max = item;
}
return max;
}
// Clamp a value between min and max — T must be Comparable
public static <T extends Comparable<T>> T clamp(T value, T min, T max) {
if (value.compareTo(min) < 0) return min;
if (value.compareTo(max) > 0) return max;
return value;
}
// Useful pattern: require both Printable and Closeable
interface Printable { void print(); }
public static <T extends Printable & AutoCloseable> void printAndClose(T resource)
throws Exception {
resource.print();
resource.close();
}
public static void main(String[] args) {
System.out.println(findMax(List.of("banana", "apple", "cherry"))); // cherry
System.out.println(findMax(List.of(3, 1, 4, 1, 5, 9))); // 9
System.out.println(clamp(15, 0, 10)); // 10 (clamped to max)
System.out.println(clamp(-3, 0, 10)); // 0 (clamped to min)
System.out.println(clamp(7, 0, 10)); // 7 (within range)
}
Multiple bounds are also useful for generic algorithms that need to call methods from two different interfaces on the same object:
// Process items that are both Comparable (for sorting) and Iterable (for iteration)
public static <T extends Comparable<T> & Iterable<T>> void process(T container) {
// Can call container.compareTo(other) AND iterate with for-each
for (T element : container) {
System.out.println(element);
}
}
Note: