12.1 (Upper): Type Parameters and Generic Methods
After learning about classes, interfaces, collections, and exceptions, you can complete this Java 17 example in about 35 minutes. By the end, you should be able to declare generic classes, records, interfaces, and methods, and accurately express "what capabilities a type must have" using upper bounds.
Master Chen asks you to write a "retrieve item" method for the toolbox. The hammer box returns String, the parts box returns Part, and the ledger box returns Ledger. The three boxes have identical structures, differing only in item type.
“Copying a box isn’t hard,” he tapped the lid, “the hard part is remembering to update the lock three times every time you make a change.”
Declaring everything as Object isn't ideal: callers would have to perform explicit casting every time they retrieve an item, and type mismatches wouldn't be caught until runtime. Generics take a different approach, making the type part of the declaration, with callers providing the type at usage time.
1. Raw type defers errors to runtime
Ah Hua shoved a digital inventory list into the "tool name" drawer, and the drawer didn't stop her. It wasn't until you accessed the second item by string that the error blew up. A raw type is like an old wooden box with no label: it can hold anything, but it also delays diagnosis until the last moment.
Traditional raw List does not record element types:
List raw = new ArrayList();
raw.add("Hammer");
raw.add(42);
String tool = (String) raw.get(1); // Runtime ClassCastExceptionErrors don't only occur on the last line. The real pollution point is integers being written into a set meant only to hold tool names. Parameterized types push diagnostics back to the write location:
List<String> tools = new ArrayList<>();
tools.add("Hammer");
// tools.add(42); // Compilation failedWhen migrating old code, don't use @SuppressWarnings("unchecked") to cover the entire class. First, identify the unsafe conversions, and keep the checks and suppressions to the smallest possible scope.
2. Box<T> Declare once only
Chen took the redundant wooden planks from the three boxes, leaving only one structure, and wrote "what's inside" as a parameter T. The box design remains the same, but the caller must specify the item type when using it.
final class Box<T> {
private final T value;
Box(T value) {
this.value = java.util.Objects.requireNonNull(value, "value");
}
T value() {
return value;
}
}T is a type parameter; Box<String>'s String is a type argument. The compiler thus treats the return type of value() as String:
Box<String> toolBox = new Box<>("Hammer");
String tool = toolBox.value();The right <> is a diamond. The compiler infers types from the assignment target and constructor parameters, but inference does not mean "automatic identification at runtime." If the context is insufficient or overloaded constructors compete, explicit type arguments are still required.
3. Records and interfaces can also be parameterized
A binary result can retain two independent types:
record Pair<K, V>(K key, V value) {
Pair {
java.util.Objects.requireNonNull(key, "key");
java.util.Objects.requireNonNull(value, "value");
}
}For Pair<String, Integer>, key() returns String, and value() returns Integer. A record merely generates a template for value types and does not alter generic rules.
Interfaces can defer type selection to the implementation class:
interface Repository<ID, E> {
java.util.Optional<E> find(ID id);
void save(ID id, E entity);
}
final class ToolRepository implements Repository<String, String> {
private final java.util.Map<String, String> values = new java.util.HashMap<>();
public java.util.Optional<String> find(String id) {
return java.util.Optional.ofNullable(values.get(id));
}
public void save(String id, String tool) {
values.put(id, tool);
}
}4. Static members cannot use the class type parameter T
Box<String> and Box<Integer> share the same static state. Therefore, type parameters of such a class cannot appear directly in static field or static method declarations:
final class Box<T> {
// static T lastValue; // Compilation failed
static <E> Box<E> of(E value) {
return new Box<>(value);
}
private final T value;
private Box(T value) { this.value = value; }
T value() { return value; }
}of declared its method type parameters E. It has no inheritance relationship with class-level T; merely coincidentally the same name; actual code using different letters can reduce misreading.
5. Declare generic method parameters before the return type
static <T> T first(java.util.List<T> values) {
if (values.isEmpty()) {
throw new IllegalArgumentException("empty values");
}
return values.get(0);
}When calling first(List.of("iron", "wood")), the compiler infers T to be String. Type inference is based on the declaration and call context, and the method body is not examined to guess an arbitrary return type.
6. Upper Bound Explains "What This Type Must Be Able To Do"
The toolbox can hold any T, but the "select largest item" feature in the warehouse still requires a comparison capability. Master Chen didn't list every allowed material; instead, he encoded this capability into the type upper bound.
Taking the maximum requires comparative ability:
static <T extends Comparable<? super T>> T max(java.util.List<T> values) {
if (values.isEmpty()) {
throw new IllegalArgumentException("empty values");
}
T result = values.get(0);
for (int index = 1; index < values.size(); index++) {
if (values.get(index).compareTo(result) > 0) {
result = values.get(index);
}
}
return result;
}Comparable<? super T> is wider than Comparable<T>: if the parent class already defines rules for comparing with the parent type, the child class list can also use them. Multiple upper bounds are written as <T extends Base & Audited & Comparable<T>>, and if a class upper bound exists, it must be placed at the leftmost position.
7. Run the Basic Agreement on the Workbench
The wooden box, the binary material requisition record, and the "take the first item" method have all been explained separately; now we place them on the same workbench. The compiler prevents type-mismatched items, while assertions check runtime results, these two checks address different issues.
First, prepare a temporary directory:
chapter12_basics_root=$(mktemp -d)
readonly chapter12_basics_root
mkdir -p "$chapter12_basics_root/out"
cd "$chapter12_basics_root"Save as GenericsBasicsDemo.java:
import java.util.List;
import java.util.Objects;
public class GenericsBasicsDemo {
public static void main(String[] args) {
Box<String> box = Box.of("Hammer");
checkEquals("Hammer", box.value());
Pair<String, Integer> stock = new Pair<>("iron", 12);
checkEquals("iron", stock.key());
checkEquals(12, stock.value());
checkEquals("iron", first(List.of("iron", "wood")));
checkEquals(9, max(List.of(3, 9, 5)));
expectIllegal(() -> first(List.of()));
System.out.println("Generic basics check passed");
}
static <T> T first(List<T> values) {
if (values.isEmpty()) throw new IllegalArgumentException("empty values");
return values.get(0);
}
static <T extends Comparable<? super T>> T max(List<T> values) {
if (values.isEmpty()) throw new IllegalArgumentException("empty values");
T result = values.get(0);
for (int index = 1; index < values.size(); index++) {
if (values.get(index).compareTo(result) > 0) result = values.get(index);
}
return result;
}
static void expectIllegal(Runnable action) {
try {
action.run();
throw new AssertionError("expected IllegalArgumentException");
} catch (IllegalArgumentException expected) {
// Empty input contract fails as expected.
}
}
static void checkEquals(Object expected, Object actual) {
if (!Objects.equals(expected, actual)) {
throw new AssertionError("expected=" + expected + ", actual=" + actual);
}
}
static final class Box<T> {
private final T value;
private Box(T value) { this.value = Objects.requireNonNull(value); }
static <E> Box<E> of(E value) { return new Box<>(value); }
T value() { return value; }
}
record Pair<K, V>(K key, V value) { }
}javac --release 17 -Xlint:all -d out GenericsBasicsDemo.java
java -cp out GenericsBasicsDemoExpected output Generic basics check passed. Clean up after saving record:
cd
ls -ld -- "$chapter12_basics_root"
rm -r -- "$chapter12_basics_root"Before you leave the tool box
Distinguish type parameters from type arguments, explain the source of diamond inference, and clarify why static methods must declare their own type parameters. Finally, provide an example where a parent class implements Comparable<Parent> and a child class inherits it, verifying the significance of Comparable<? super T>.
Next, let's tackle the most confusing part: why List<Integer> can't be directly passed to List<Number>, and what each of extends and super allows.
→ Next Step: Invariance, Wildcards, and PECS
Standard entry: JLS §4.5: Parameterized Types, JLS §8.4.4: Generic Methods.