Lesson 22 of 25

Generics

The Problem Generics Solve

You have been using generics since the Collections lesson every time you wrote List<String>. This lesson explains what those angle brackets are, and how to write your own.

Generics were added in Java 5 to answer a specific problem. Before them, a collection stored plain Object, so it accepted anything. Adding a number to a list of names produced no complaint at all, and the mistake only surfaced later as a ClassCastException in some completely unrelated line that tried to cast the value back. Every read also needed an explicit cast, which was noise on every line.

A type parameter fixes both. List<String> tells the compiler this list holds Strings, so it rejects add(42) immediately and returns a String from get() with no cast. The error moves from run time to compile time, and from a distant line to the exact line that is wrong.

That is the whole purpose, and it is worth stating plainly: generics move type errors from run time to compile time. They generate no faster code and add no features at run time. They make mistakes impossible to write, which is worth far more.

Example
import java.util.*;

// ---- Before generics (still legal, still a bad idea) ----
List raw = new ArrayList();
raw.add("Ananya");
raw.add(42);                            // no complaint
String name = (String) raw.get(0);      // cast needed on every read
// String bad = (String) raw.get(1);
// ClassCastException at run time, nowhere near the real mistake

// ---- With generics ----
List<String> names = new ArrayList<>();
names.add("Ananya");
// names.add(42);
// error: incompatible types: int cannot be converted to String
String first = names.get(0);            // no cast

// The diamond <> infers the type from the left-hand side
Map<String, List<Integer>> marks = new HashMap<>();

// Type parameters must be reference types
// List<int> nums;                       // does not compile
List<Integer> nums = new ArrayList<>();  // use the wrapper class
Notes
  • The conventional single-letter names are T for a general type, E for an element in a collection, K and V for a map's key and value, N for a number and R for a return type. They are only conventions, but following them makes your code instantly readable to other Java developers.

Writing a Generic Class

You declare a type parameter by putting it in angle brackets after the class name: public class Box<T>. Inside the class, T is then used exactly like a real type — as a field type, a parameter type, a return type. When someone writes new Box<String>(), the compiler treats every T in that class as String for that particular use.

Compare the alternatives to see the value. A Box that stores Object works for everything but returns Object, so every caller casts and any caller can cast wrongly. Writing StringBox, IntegerBox and StudentBox separately is type-safe and is three copies of the same code. A generic class gives you both: one implementation, and full type safety for every use.

A class may declare several type parameters, which is how Map<K, V> works. Below is a small Pair<K, V> — genuinely useful, since Java has no built-in pair type and methods often need to return two things at once.

Two limits are worth stating now and are explained fully at the end of this lesson. You cannot write new T() inside a generic class, and you cannot create an array of T directly. Both follow from how generics are implemented.

Example
// One type parameter
public class Box<T> {
    private T content;

    public void put(T item) { this.content = item; }
    public T get()          { return content; }
    public boolean isEmpty(){ return content == null; }

    @Override public String toString() { return "Box[" + content + "]"; }
}

Box<String> nameBox = new Box<>();
nameBox.put("Ananya");
String s = nameBox.get();          // no cast needed
// nameBox.put(42);                // compile error — exactly what we want

Box<Integer> markBox = new Box<>();
markBox.put(92);
int m = markBox.get();

// Two type parameters — a pair, which Java does not provide
public class Pair<K, V> {
    private final K key;
    private final V value;

    public Pair(K key, V value) { this.key = key; this.value = value; }

    public K getKey()   { return key; }
    public V getValue() { return value; }

    @Override public String toString() { return "(" + key + ", " + value + ")"; }
}

Pair<String, Integer> topper = new Pair<>("Ananya", 96);
System.out.println(topper.getKey());     // Ananya — typed as String
System.out.println(topper.getValue());   // 96     — typed as Integer

// A method can now return two values with full type safety
static Pair<String, Double> analyse(java.util.List<Integer> marks) {
    double avg = marks.stream().mapToInt(Integer::intValue).average().orElse(0);
    return new Pair<>(avg >= 40 ? "PASS" : "FAIL", avg);
}
Notes
  • A generic class cannot have a static field of type T. A static field is shared by every use of the class, and Box<String> and Box<Integer> would disagree about what type it should be, so the language forbids it.

Generic Methods and Bounded Types

A single method can be generic without its class being generic. The type parameter is declared before the return type: public static <T> void printAll(List<T> items). That position looks strange at first, but it is consistent — the declaration always comes before the first use of the parameter.

You almost never have to specify the type when calling one. The compiler infers it from the arguments, so printAll(names) works with no angle brackets at the call site.

By default a type parameter could be anything, so inside the method you may only call methods that Object provides. That is often not enough. If you want to find the largest element you need to compare items, and not every type can be compared.

Bounded type parameters solve this. <T extends Comparable<T>> says T may be any type that can be compared with itself, and inside the method you may now call compareTo. Passing a type that does not qualify is a compile error at the call site, which is exactly where you want it.

Note that the keyword is always extends, even when the bound is an interface — T extends Comparable, never T implements Comparable. You can also give several bounds joined by &, in which case a class bound must come first.

Example
import java.util.*;

// A generic method: <T> comes before the return type
public static <T> void printAll(List<T> items) {
    for (T item : items) {
        System.out.println("- " + item);
    }
}

public static <T> List<T> repeat(T item, int times) {
    List<T> result = new ArrayList<>();
    for (int i = 0; i < times; i++) result.add(item);
    return result;
}

printAll(List.of("Ananya", "Rahul"));       // T inferred as String
printAll(List.of(90, 85));                  // T inferred as Integer
List<String> dashes = repeat("-", 20);

// ---- Bounded: T must be comparable, so compareTo is available ----
public static <T extends Comparable<T>> T findMax(List<T> list) {
    if (list.isEmpty()) throw new IllegalArgumentException("Empty list");
    T max = list.get(0);
    for (T item : list) {
        if (item.compareTo(max) > 0) max = item;   // legal because of the bound
    }
    return max;
}

System.out.println(findMax(List.of(45, 92, 78)));            // 92
System.out.println(findMax(List.of("Rahul", "Ananya")));     // Rahul

// ---- Bounded by a class: every Number has doubleValue() ----
public static double sumAll(List<? extends Number> list) {
    double total = 0;
    for (Number n : list) total += n.doubleValue();
    return total;
}

// ---- Several bounds: class first, then interfaces, joined by & ----
// public static <T extends Number & Comparable<T>> T largestNumber(List<T> l) { ... }
Notes
  • Bounds are also documentation. <T extends Comparable<T>> tells a reader immediately that this method sorts or compares, without them reading the body.

Wildcards, and Why List&lt;Object&gt; Will Not Take a List&lt;String&gt;

Here is the fact that surprises everyone. A String is an Object, but a List<String> is not a List<Object>. A method declared to take List<Object> will not accept a list of Strings, and the error looks nonsensical the first time you see it.

The reason becomes obvious once you look at what the alternative would allow. If List<String> could be passed as a List<Object>, the method could legally add an Integer to it — because a List<Object> accepts any object — and the caller would then pull an Integer out of their list of Strings. Generics are deliberately invariant to make that impossible.

Arrays, which were designed before generics, do allow it: Object[] arr = new String[3]; compiles, and storing an Integer into it throws ArrayStoreException at run time. Generics chose the compile-time error instead, which is strictly better.

Wildcards give you back the flexibility safely. List<? extends Number> means "a list of some unknown type that is Number or a subtype". You may read from it as Number, but you may not add anything, because nobody knows whether the actual list is of Integers or Doubles. List<? super Integer> means the opposite: some unknown supertype of Integer, so you may safely add Integers but may only read elements out as Object.

The rule for choosing has a memorable name: PECS — Producer Extends, Consumer Super. If the parameter produces values for you to read, use extends. If it consumes values you write into it, use super. If it does both, use a plain type parameter with no wildcard.

Example
import java.util.*;

List<String> names = List.of("Ananya", "Rahul");

// List<Object> everything = names;
// error: incompatible types: List<String> cannot be converted to List<Object>

// Arrays let the same mistake through — and fail at RUN time instead
Object[] arr = new String[2];
// arr[0] = 42;      // compiles, then throws ArrayStoreException

// ---- Producer: you READ from it, so use extends ----
public static double total(List<? extends Number> marks) {
    double sum = 0;
    for (Number n : marks) sum += n.doubleValue();   // reading is fine
    // marks.add(1);     // not allowed — the real element type is unknown
    return sum;
}
total(List.of(90, 85));         // List<Integer>  ✓
total(List.of(90.5, 85.25));    // List<Double>   ✓

// ---- Consumer: you WRITE into it, so use super ----
public static void addFirstFive(List<? super Integer> target) {
    for (int i = 1; i <= 5; i++) target.add(i);       // writing is fine
    // int x = target.get(0);   // not allowed — only Object comes back out
}
List<Number> numbers = new ArrayList<>();
List<Object> objects = new ArrayList<>();
addFirstFive(numbers);          // ✓
addFirstFive(objects);          // ✓

// ---- Unbounded: you only care that it is a list ----
public static int sizeOf(List<?> anyList) {
    return anyList.size();      // reading as Object and size() are all you get
}

// PECS in the standard library:
// Collections.copy(List<? super T> dest, List<? extends T> src)
//                       consumer                  producer
Notes
  • If you find yourself fighting wildcards in your own code, you may be over-engineering. Wildcards matter most when writing a library that other people call. For application code, a plain type parameter is usually enough.

Type Erasure: What Generics Cannot Do

Generics exist only at compile time. Once the compiler has finished checking your types, it erases them: List<String> becomes plain List in the bytecode, and type parameters are replaced by their bounds, or by Object if unbounded. Any casts that are needed are inserted for you.

This was done for compatibility. Java 5 had to run all the code written for Java 1.4, so generics could not change the classes at run time. The cost is that a running program genuinely does not know what type a collection was declared to hold.

That single fact explains every restriction on generics, and interviewers use it as a way to test whether you understand the mechanism or have only memorised syntax. At run time, List<String> and List<Integer> are the same class — getClass() returns the same object for both — so you cannot ask whether something is a List<String>, cannot create a T, and cannot overload two methods that differ only in their type argument.

In practice these limits rarely block application code. When they do, the standard workaround is to pass a Class<T> object as a parameter, so the method has the type information at run time that erasure removed.

Example
import java.util.*;

// At run time these are the same class
List<String>  a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
System.out.println(a.getClass() == b.getClass());   // true

class Holder<T> {

    // private T value = new T();              // cannot instantiate T
    // private T[] array = new T[10];          // cannot create an array of T
    // private static T shared;                // no static field of type T

    // The standard workaround: take a Class<T> so the type survives erasure
    private final Class<T> type;
    private T value;

    Holder(Class<T> type) { this.type = type; }

    T createNew() throws ReflectiveOperationException {
        return type.getDeclaredConstructor().newInstance();
    }
}

// Cannot overload on type argument alone — both erase to print(List)
// void print(List<String> l) { }
// void print(List<Integer> l) { }
// error: name clash: both methods have the same erasure

// instanceof cannot see type arguments
Object o = new ArrayList<String>();
// if (o instanceof List<String>) { }     // error
if (o instanceof List<?> list) {          // this is allowed
    System.out.println("a list of size " + list.size());
}
  • Type parameters are erased at compile time — the bytecode sees raw types
  • You cannot write new T() or new T[n]
  • You cannot test instanceof against a parameterised type; use List<?>
  • You cannot have a static field whose type is a type parameter
  • You cannot overload two methods that differ only in their type arguments
  • You cannot use a primitive as a type argument — use the wrapper class
  • A generic class cannot extend Throwable, so you cannot catch a generic exception type
Notes
  • Erasure is also why an unchecked cast such as (List<String>) someObject only produces a warning rather than an error. The compiler is telling you honestly that it cannot verify the claim, and that if you are wrong the failure will surface later as a ClassCastException somewhere else.
Ask AI