Generics: generic methods, generic classes, wildcards
Methods and classes that take a type as a parameter: <T>, bounded types (T extends Comparable<T>), ArrayList<T> and an introduction to wildcards. The last big source of compile-error questions.
Generics means writing a class or method without binding it to a particular type, and stating the type when you use it. When you write ArrayList<String>, you are already using a generic class. This week you write your own generic methods and classes. In the exam this topic mostly comes as compile errors (primitive type argument, compareTo on an unbounded T, the List<Integer> vs List<Number> mismatch) and as a “write a generic method / class” question.
Golden rules:
- A type parameter can only be a reference type.
Integer, notint. - In a generic method,
<T>is written before the return type:static <T> void print(T[] a). - With an unbounded T, only Object methods can be called. For compareTo,
<T extends Comparable<T>>. - A
List<Integer>is not aList<Number>. For flexibility, use a wildcard:List<? extends Number>. - Type erasure: there is no T at run time;
new T()cannot be written.
Why generics?
Without generics, the only way to write shared code was to hold everything as Object. This has two problems: a cast is needed on every read, and a wrong type shows up only at run time (ClassCastException). Generics moves this error to compile time.
Box<String> bs = new Box<>("hello");
String s = bs.get(); // no cast
Integer n = bs.get(); // DOES NOT COMPILE: String cannot be converted to Integer
A test showing why the “raw type” usage from before generics is dangerous: putting an int into an untyped list and reading it as a String list compiles (it only gives a warning) but blows up at run time.
List raw = new ArrayList<String>(); // raw type: warning
raw.add(5);
List<String> typed = raw;
String first = typed.get(0); // at run time
ClassCastException
Generic methods
The type parameter list (<T>) is written before the return type. The compiler infers what T will be from the arguments at the call (type inference).
static <T> void printArray(T[] items) {
for (T item : items) System.out.print(item + " ");
System.out.println();
}
static <T> int countMatches(T[] arr, T target) {
int n = 0;
for (T x : arr) if (x.equals(target)) n++; // equals exists in Object: allowed
return n;
}
Integer[] ints = {1, 2, 3};
String[] strs = {"a", "b"};
Double[] ds = {1.5, 2.5};
printArray(ints);
printArray(strs);
printArray(ds);
System.out.println(countMatches(new String[]{"x", "y", "x"}, "x"));
1 2 3
a b
1.5 2.5
2
If the array were int[], it could not be called with printArray: T must be a reference type and int[] is not a T[]. That is why we used Integer[] in the examples.
Bounded type parameters
For an unbounded T, the compiler knows only Object’s methods (equals, toString, hashCode). If you want to call compareTo on T, you have to say that T is Comparable:
static <T extends Comparable<T>> T maximum(T a, T b, T c) {
T max = a;
if (b.compareTo(max) > 0) max = b;
if (c.compareTo(max) > 0) max = c;
return max;
}
System.out.println(maximum(3, 9, 4));
System.out.println(maximum("pear", "apple", "zebra"));
System.out.println(maximum(2.5, -1.0, 2.4));
9
zebra
2.5
Even if the bound is an interface, extends is written (not implements). If you call maximum with a class that is not Comparable, you get a compile error: inference variable T has incompatible bounds.
Generic classes
The type parameter is defined next to the class name and can be used everywhere in the class.
class Box<T> {
private T content;
Box(T content) { this.content = content; }
T get() { return content; }
void set(T content) { this.content = content; }
boolean isEmpty() { return content == null; }
@Override public String toString() { return "Box[" + content + "]"; }
}
Box<String> bs = new Box<>("hello"); // <> diamond: the type is not repeated on the right
Box<Integer> bi = new Box<>(42);
String s = bs.get();
int v = bi.get() + 8; // Integer → int (unboxing)
System.out.println(bs + " " + bi + " " + s.length() + " " + v);
bi.set(null);
System.out.println(bi.isEmpty());
Box[hello] Box[42] 5 50
true
Multiple type parameters are separated by commas. By convention a single capital letter is used: T (type), E (element), K (key), V (value), N (number).
class Pair<K, V> {
private final K key;
private final V value;
Pair(K k, V v) { key = k; value = v; }
K getKey() { return key; }
V getValue() { return value; }
Pair<V, K> swap() { return new Pair<>(value, key); }
@Override public String toString() { return "(" + key + ", " + value + ")"; }
}
Pair<String, Integer> p = new Pair<>("age", 21);
Pair<Integer, String> q = p.swap();
System.out.println(p + " " + q + " " + (q.getKey() + 1));
(age, 21) (21, age) 22
A bound can also be placed at the class level: inside class Stats<T extends Number>, x.doubleValue() can be called. When 4, 5, 9 were added to a Stats<Integer>, the average came out as 6.0. A Stats<String> does not compile.
ArrayList quick review
| Method | What does it do? |
|---|---|
add(x) / add(i, x) | Adds at the end / at index i |
get(i) / set(i, x) | Reads / changes |
remove(int i) | Removes the element at index i |
remove(Object o) | Removes the first element whose value is o |
size(), isEmpty() | Number of elements, whether it is empty |
contains(x), indexOf(x) | Whether it is there, its first index (−1 if not) |
ArrayList<Integer> list = new ArrayList<>();
list.add(5); list.add(10); list.add(15);
list.add(1, 7); // [5, 7, 10, 15]
list.remove(Integer.valueOf(10)); // value: [5, 7, 15]
list.remove(0); // index: [7, 15]
System.out.println(list + " size=" + list.size() + " get(1)=" + list.get(1));
System.out.println(list.contains(15) + " " + list.indexOf(15));
[7, 15] size=2 get(1)=15
true 1
PITFALL · which
remove(1)? In anArrayList<Integer>,remove(1)calls the index version because the argument is an int. On the list[10, 20, 30],remove(1)removes 20, the result is[10, 30]. To remove the element whose value is 1,remove(Integer.valueOf(1)).
Introduction to wildcards
Generics are invariant: even though Integer is a subclass of Number, List<Integer> is not a List<Number>. If it were, you could add a Double to an Integer list that behaves like a List<Number>, and the list would be corrupted. For flexibility, the question mark (wildcard) is used:
| Notation | What it accepts | Reading | Adding |
|---|---|---|---|
List<Number> | Only List | Number | Number and its subtypes |
List<? extends Number> | List | As Number | No (except null) |
List<? super Integer> | List | As Object | Integer |
List<?> | A list of any type | As Object | No |
static double sum(List<? extends Number> list) {
double s = 0;
for (Number n : list) s += n.doubleValue();
return s;
}
static void printAll(List<?> list) {
for (Object o : list) System.out.print(o + ";");
System.out.println();
}
static void addNumbers(List<? super Integer> list) {
list.add(1); list.add(2);
}
System.out.println(sum(List.of(1, 2, 3)) + " " + sum(List.of(0.5, 1.5)));
printAll(List.of("x", 1, 2.0));
List<Number> ln = new ArrayList<>();
addNumbers(ln);
List<Object> lo = new ArrayList<>();
addNumbers(lo);
System.out.println(ln + " " + lo);
6.0 2.0
x;1;2.0;
[1, 2] [1, 2]
TIP · extends to read, super to write. If you only read data from a collection, use
? extends T; if you only put data into it, use? super T. In this course, the exam usually asks for a sum or average method with? extends Number.
Type erasure
After the compiler does the type checks, it erases the type arguments; at run time Box<String> and Box<Integer> are the same Box class:
Box<String> b1 = new Box<>("x");
Box<Integer> b2 = new Box<>(1);
System.out.println(b1.getClass() == b2.getClass());
System.out.println(b1.getClass().getSimpleName());
true
Box
That is why there are things you cannot write in generic code:
new T()andnew T[10]:unexpected type; required: class, found: type parameter Tstatic T value;: a static field is shared by all Boxes, and it is not clear which T it would beobj instanceof Box<String>- A primitive type argument:
Box<int>
Common mistakes
| Wrong idea | The truth |
|---|---|
ArrayList<int> | ArrayList<Integer>; it cannot be a primitive type |
static T void print(...) | <T> before the return type: static <T> void print(...) |
a.compareTo(b) on an unbounded T | <T extends Comparable<T>> is needed |
If the bound is an interface, implements is written | Always extends |
A List<Integer> is a List<Number> | It is not; use List<? extends Number> |
An element can be added to a ? extends Number list | It cannot, it is read-only |
An object is created with new T() | Cannot be written because of type erasure |
remove(1) removes the one whose value is 1 | It removes index 1 |
| Generics check types at run time | The check is at compile time; the type information is erased at run time |
Test yourself
Exam-style questions. Solve them on paper first, then reveal the answer.
What is printed?
static <T extends Comparable<T>> T maximum(T a, T b, T c) {
T max = a;
if (b.compareTo(max) > 0) max = b;
if (c.compareTo(max) > 0) max = c;
return max;
}
// in main:
System.out.println(maximum(3, 9, 4));
System.out.println(maximum("pear", "apple", "zebra"));
System.out.println(maximum(2.5, -1.0, 2.4));Show answer
The same method works with three different types; T is inferred from the arguments at each call (Integer, String, Double). The ints are automatically boxed to Integer (autoboxing). For Strings, compareTo is dictionary order: zebra is last, that is, the largest.
Find the compile error.
static <T> T biggest(T a, T b) {
return a.compareTo(b) > 0 ? a : b;
}Show answer
For an unbounded T, the compiler allows only Object's methods. javac: cannot find symbol: method compareTo(T), location: variable a of type T. Fix: bound the type, static <T extends Comparable<T>> T biggest(T a, T b).
Find the compile error.
ArrayList<int> list = new ArrayList<>();Show answer
Generics work only with reference types. javac: unexpected type; required: reference, found: int. Use a wrapper class: Integer, Double, Character, Boolean. When you write list.add(5), 5 automatically becomes an Integer.
What is printed?
class Pair<K, V> {
private final K key; private final V value;
Pair(K k, V v) { key = k; value = v; }
K getKey() { return key; }
Pair<V, K> swap() { return new Pair<>(value, key); }
public String toString() { return "(" + key + ", " + value + ")"; }
}
// in main:
Pair<String, Integer> p = new Pair<>("age", 21);
Pair<Integer, String> q = p.swap();
System.out.println(p + " " + q + " " + (q.getKey() + 1));Show answer
swap exchanges the key and the value and flips the types too: Pair<Integer, String>. q.getKey() now returns an Integer and can be added without a cast: 21 + 1 = 22. The parentheses matter; without them String concatenation would happen and "211" would be printed.
Find the compile error.
static double sum(List<Number> list) { ... }
// in main:
List<Integer> li = new ArrayList<>();
sum(li);Show answer
Even though Integer is a subclass of Number, List<Integer> is not a subtype of List<Number> (generics are invariant). javac: incompatible types: List<Integer> cannot be converted to List<Number>. Fix: sum(List<? extends Number> list).
Find the compile error.
static void fill(List<? extends Number> list) {
list.add(3);
}Show answer
A List<? extends Number> could also be a List<Double>; the compiler cannot know that 3 (an Integer) can safely go in there. javac: int cannot be converted to CAP#1. The extends wildcard is for reading. If you want to add, use List<? super Integer>.
What is printed?
ArrayList<Integer> list = new ArrayList<>();
list.add(5); list.add(10); list.add(15);
list.add(1, 7);
list.remove(Integer.valueOf(10));
list.remove(0);
System.out.println(list + " size=" + list.size());Show answer
[5, 10, 15] → add(1, 7) inserts at index 1: [5, 7, 10, 15]. remove(Integer.valueOf(10)) removes the object (the one with value 10): [5, 7, 15]. remove(0) is the version with an int parameter: it removes index 0: [7, 15].
What is printed?
ArrayList<Integer> nums = new ArrayList<>(List.of(10, 20, 30));
nums.remove(1);
System.out.println(nums);Show answer
Because the argument of remove(1) is an int, remove(int index) is chosen: the 20 at index 1 is removed. If you wanted to remove the value 1, you would have to write remove(Integer.valueOf(1)). The most common trap of ArrayList<Integer>.
Find the compile error.
class Box<T> {
T make() { return new T(); }
}Show answer
Because of type erasure, what T is is not known at run time, so an object of it cannot be created. javac: unexpected type; required: class, found: type parameter T. For the same reason new T[10] and a static T field cannot be written either.
What is printed?
Box<String> b1 = new Box<>("x");
Box<Integer> b2 = new Box<>(1);
System.out.println(b1.getClass() == b2.getClass());Show answer
Type erasure: after the compiler does the type checks, it erases the type arguments. At run time both are the same Box class. That is why checks like b1 instanceof Box<String> and new T() are not possible.
Which method header accepts List<Integer>, List<Double> and List<Number> and lets you call doubleValue() on each element?
- double sum(List<Number> list)
- double sum(List<Object> list)
- double sum(List<? extends Number> list)
- double sum(List<? super Number> list)
Show answer
? extends Number: a list of Number or any of its subclasses; the elements are read as Number, so doubleValue can be called. In the test run: 6.0 for List.of(1, 2, 3), 2.0 for List.of(0.5, 1.5). List<Number> accepts only exactly List<Number>. ? super Number gives the element read as Object.
Write a generic class Stack<E> backed by an ArrayList<E> with push(E e), pop(), peek(), isEmpty() and size(). pop and peek throw NoSuchElementException("stack is empty") on an empty stack. After pushing a, b, c: pop() + peek() + size() gives cb2.
Show answer
The type parameter is defined once next to the class name, and then used as E in fields and methods. The last element is the top of the stack:
import java.util.ArrayList;
import java.util.NoSuchElementException;public class Stack<E> {
private final ArrayList<E> items = new ArrayList<>();public void push(E e) { items.add(e); }
public E pop() {
if (items.isEmpty()) throw new NoSuchElementException("stack is empty");
return items.remove(items.size() - 1);
}
public E peek() {
if (items.isEmpty()) throw new NoSuchElementException("stack is empty");
return items.get(items.size() - 1);
}
public boolean isEmpty() { return items.isEmpty(); }
public int size() { return items.size(); }
}After a, b, c, pop returns "c", peek "b", size 2: cb2. If we push 1, 4, 9 onto a Stack<Integer> and pop until empty, the total is 14.Verification: All code examples and answers were run with JDK 26 (java verify/SEN1002/W14.java); the compile errors were tried with javac (verify/SEN1002/err/E14a-h.java).