Generic Class
A generic class in Java is a class parameterized by one or more type parameters, written as class Box<T> or class Pair<K, V>. Type parameters allow a single class definition to work correctly with many different types while preserving full compile-time type safety — eliminating the need for casts, preventing ClassCastException at runtime, and making the intended type contract explicit in the API. The compiler erases type parameters at bytecode level (type erasure), replacing them with their bounds or Object, but all type checking occurs at compile time before erasure. This entry covers type parameter declaration and naming conventions, bounded type parameters (extends, super), multiple type parameters, raw types and why they are unsafe, type erasure and its consequences, wildcards (?, ? extends T, ? super T), the PECS principle, recursive type bounds, generic interfaces, inheritance with generic classes, and how to design generic classes that work correctly across all parameterizations.
Type Parameter Declaration, Naming, and Bounds
// ── Basic generic class ───────────────────────────────────────────────
public class Box<T> {
private T value;
public Box(T value) { this.value = value; }
public T get() { return value; }
public void set(T value) { this.value = value; }
@Override
public String toString() { return "Box[" + value + "]"; }
}
Box<String> strBox = new Box<>("hello");
Box<Integer> intBox = new Box<>(42);
String s = strBox.get(); // no cast needed — compile-time type safety
int n = intBox.get(); // unboxed automatically
// ── Multiple type parameters ───────────────────────────────────────────
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 + ")";
}
}
Pair<String, Integer> p = new Pair<>("age", 30);
System.out.println(p.first()); // age
System.out.println(p.second()); // 30
// ── Upper bounded type parameter ──────────────────────────────────────
public class SortedBox<T extends Comparable<T>> {
private T value;
public SortedBox(T value) { this.value = value; }
public boolean isGreaterThan(T other) {
return value.compareTo(other) > 0; // compareTo available because T extends Comparable<T>
}
public T get() { return value; }
}
SortedBox<Integer> sb = new SortedBox<>(10);
System.out.println(sb.isGreaterThan(5)); // true
System.out.println(sb.isGreaterThan(20)); // false
// SortedBox<StringBuilder> won't compile — StringBuilder doesn't implement Comparable
// ── Multiple bounds ───────────────────────────────────────────────────
public class NumericBox<T extends Number & Comparable<T>> {
private T value;
public NumericBox(T value) { this.value = value; }
public double doubleValue() {
return value.doubleValue(); // from Number bound
}
public boolean isGreaterThan(T other) {
return value.compareTo(other) > 0; // from Comparable bound
}
}
NumericBox<Double> db = new NumericBox<>(3.14);
System.out.println(db.doubleValue()); // 3.14
System.out.println(db.isGreaterThan(2.71)); // true
// ── Static context cannot reference T ─────────────────────────────────
public class Registry<T> {
private T instance;
// COMPILE ERROR — static field cannot use T:
// private static T shared; // error: non-static type variable T cannot be referenced from a static context
// COMPILE ERROR — static method cannot use T:
// public static T create() { ... }
// OK — instance member uses T:
public T getInstance() { return instance; }
}Type Erasure, Raw Types, and Runtime Behavior
// ── Type erasure — same bytecode for all parameterizations ──────────
Box<String> s = new Box<>("hi");
Box<Integer> i = new Box<>(42);
// At runtime, both are just "Box" — type parameters erased:
System.out.println(s.getClass()); // class Box
System.out.println(s.getClass() == i.getClass()); // true — same class
// instanceof with raw type is allowed:
System.out.println(s instanceof Box); // true
// instanceof with parameterized type is a compile error:
// System.out.println(s instanceof Box<String>); // ERROR
// ── Cannot create instances of type parameters ─────────────────────────
public class Container<T> {
// COMPILE ERROR:
// private T instance = new T(); // cannot instantiate type parameter
// Workaround 1: Class token
private final Class<T> type;
private T instance;
public Container(Class<T> type) throws Exception {
this.type = type;
this.instance = type.getDeclaredConstructor().newInstance();
}
// Workaround 2: Supplier factory (preferred in modern Java)
public static <T> Container2<T> of(Supplier<T> factory) {
return new Container2<>(factory.get());
}
}
Container<ArrayList> c = new Container<>(ArrayList.class);
// Using supplier:
Box<StringBuilder> sb = Box.of(StringBuilder::new);
// ── Raw types disable all type checking ───────────────────────────────
Box<String> typed = new Box<>("hello");
Box raw = typed; // raw type assignment — unchecked warning
raw.set(42); // no compile error — raw Box.set takes Object
String result = typed.get(); // ClassCastException at RUNTIME — not at raw.set()!
// The exception is misleading: it happens at .get() not at .set(42)
// ── Heap pollution example ────────────────────────────────────────────
List<String> strings = new ArrayList<>();
List raw2 = strings; // heap pollution begins
raw2.add(42); // puts Integer into a List<String>
for (String str : strings) {
System.out.println(str.length()); // ClassCastException here — confusing!
}
// ── Generic array workaround ──────────────────────────────────────────
public class GenericStack<T> {
private Object[] elements; // Object[] instead of T[]
private int size = 0;
@SuppressWarnings("unchecked")
public GenericStack(int capacity) {
elements = new Object[capacity]; // safe: only T values ever stored
}
public void push(T item) { elements[size++] = item; }
@SuppressWarnings("unchecked")
public T pop() {
// Safe: elements[--size] is always a T because push() enforces it
return (T) elements[--size];
}
}
GenericStack<String> stack = new GenericStack<>(10);
stack.push("hello");
stack.push("world");
System.out.println(stack.pop()); // world — no cast warning at call siteWildcards, PECS, and Generic Class Inheritance
// ── Unbounded wildcard — read only, type is unknown ──────────────────
public static void printBox(Box<?> box) {
Object value = box.get(); // OK — get() returns Object for Box<?>
System.out.println(value);
// box.set(something); // COMPILE ERROR — cannot write to Box<?>
}
printBox(new Box<>("hello")); // OK
printBox(new Box<>(42)); // OK
printBox(new Box<>(3.14)); // OK — any Box<?> accepted
// ── Upper-bounded wildcard — covariant read ───────────────────────────
public static double sumBoxes(List<Box<? extends Number>> boxes) {
double sum = 0;
for (Box<? extends Number> box : boxes) {
sum += box.get().doubleValue(); // get() returns Number — doubleValue() available
}
return sum;
}
List<Box<Integer>> intBoxes = List.of(new Box<>(1), new Box<>(2), new Box<>(3));
List<Box<Double>> doubleBoxes = List.of(new Box<>(1.1), new Box<>(2.2));
// Both work — Box<Integer> and Box<Double> are subtypes of Box<? extends Number>:
// (But List<Box<Integer>> is NOT a subtype of List<Box<? extends Number>> — need cast or wildcard at list level)
// Full covariance at list level:
public static double sumBoxList(List<? extends Box<? extends Number>> list) {
return list.stream()
.mapToDouble(b -> b.get().doubleValue())
.sum();
}
System.out.println(sumBoxList(intBoxes)); // 6.0
System.out.println(sumBoxList(doubleBoxes)); // 3.3
// ── Lower-bounded wildcard — contravariant write ──────────────────────
public static void fillBox(Box<? super Integer> box, Integer value) {
box.set(value); // OK — Box<? super Integer> can accept Integer
Object v = box.get(); // only Object comes back — type is unknown supertype
}
Box<Integer> intBox = new Box<>(0);
Box<Number> numBox = new Box<>(0.0);
Box<Object> objBox = new Box<>(null);
fillBox(intBox, 42); // OK
fillBox(numBox, 42); // OK — Number is a supertype of Integer
fillBox(objBox, 42); // OK — Object is a supertype of Integer
// ── PECS — copy from producer (extends) to consumer (super) ──────────
public static <T> void copy(List<? super T> dest, List<? extends T> src) {
for (T item : src) { // src produces T
dest.add(item); // dest consumes T
}
}
List<Number> destination = new ArrayList<>();
List<Integer> source = List.of(1, 2, 3, 4, 5);
copy(destination, source);
System.out.println(destination); // [1, 2, 3, 4, 5]
// ── Generic class inheritance ─────────────────────────────────────────
// Invariance: Box<Integer> is NOT a subtype of Box<Number>
Box<Number> numBox2 = new Box<>(42);
// Box<Number> numBox3 = new Box<Integer>(42); // COMPILE ERROR
// Fixed type argument inheritance:
class IntBox extends Box<Integer> {
public IntBox(Integer value) { super(value); }
public int doubled() { return get() * 2; }
}
// Propagated type parameter:
class TaggedBox<T> extends Box<T> {
private final String tag;
public TaggedBox(T value, String tag) { super(value); this.tag = tag; }
public String getTag() { return tag; }
}
// Bounded type parameter:
class NumericBox2<T extends Number> extends Box<T> {
public NumericBox2(T value) { super(value); }
public double asDouble() { return get().doubleValue(); }
}
TaggedBox<String> tBox = new TaggedBox<>("content", "important");
NumericBox2<Float> fBox = new NumericBox2<>(3.14f);
System.out.println(tBox.getTag()); // important
System.out.println(fBox.asDouble()); // 3.140000104904175