9.3
Generics
One class that works with any type, without giving up type checking. Before generics you used Object and a cast, and the cast failed while the program was running.
Previously on
You know from Section 7.2 that a Unit variable can hold an AirUnit, and from Section 8.3 that every class in Java extends Object.
Put those two together and you get something powerful and dangerous: an Object variable can hold literally anything. This section is about the danger, and how Java removed it.
The problem
You want a box that holds one value. Any value.
Write it for a String and it only holds Strings. Write it again for Integer and now you have two nearly identical classes. Write it a third time for Double. This does not scale.
So use the one type that holds everything:
class Box {
private Object value;
Box(Object value) { this.value = value; }
Object get() { return value; }
}Now it holds anything, and two new problems arrive.
One. You cannot do anything with what comes out.
Box b = new Box(10);
System.out.println(b.get() + 5);
// ERROR: the operator + is undefined for the argument type ObjectThe compiler has no idea there is a number in there. As far as it knows, get() returns an Object, and Object cannot be added to anything.
Two. Casting fixes that, and the fix can explode.
Box b = new Box(10);
String s = (String) b.get(); // compiles fineThat compiles. It fails while the program is running, with a ClassCastException.
The compiler let it through because it genuinely cannot know. In real code the value comes from a file, or a user, or an API call, and none of those exist at compile time.
The idea
First, the two kinds of cast
| Upcasting | Downcasting | |
|---|---|---|
| Direction | specific to general | general to specific |
| Example | Dog to Animal | Animal to Dog |
| Cast needed | no | yes |
| Can it fail | never | yes, at run time |
| Why | a Dog is always an Animal | an Animal might be a Cat |
String s = "hello";
Object obj = s; // upcast. Always safe. No cast written.
Object o = 10;
String bad = (String) o; // downcast. Compiles. Throws at run time.The error message is worth reading once:
class java.lang.Integer cannot be cast to class java.lang.StringThe JVM found out at the moment it tried. Not before.
Generics: tell the compiler once
class Box<T> {
private T value;
Box(T value) { this.value = value; }
T get() { return value; }
}T is a type parameter. It is a placeholder. You fill it in when you create the box:
Box<Integer> b = new Box<>(10);
int n = b.get(); // no cast. The compiler knows it is an Integer.
System.out.println(b.get() + 5); // 15. Works.Box<String> s = new Box<>("hello");
s.get().toUpperCase(); // String methods available, no castAnd the mistake from before will not compile at all:
Box<Integer> b = new Box<>(10);
String bad = b.get(); // ERROR at COMPILE time, not run timeThat move, from a run time crash to a compile error, is the entire point of generics.
Under the hood
Going deeperGeneric methods
A whole class does not have to be generic. One method can be.
static <T> void printAll(List<T> list) {
for (T item : list) System.out.print(item + " ");
System.out.println();
}
printAll(List.of("a", "b")); // a b
printAll(List.of(1, 2)); // 1 2The <T> goes before the return type. It says “this method invents its own type parameter”, and Java works out what T is from the argument you pass.
Bounded types: <T extends Number>
Plain T could be anything, so the compiler assumes the worst. Inside your class, T behaves like Object, and Object has almost no methods.
class NumBox<T> {
private T v;
double doubled() { return v.doubleValue() * 2; } // ERROR. Object has no doubleValue().
}Narrow the promise and the methods appear:
class NumBox<T extends Number> {
private T v;
NumBox(T v) { this.v = v; }
double doubled() { return v.doubleValue() * 2; } // fine. T is at least a Number.
}
new NumBox<>(21).doubled(); // 42.0
new NumBox<String>("hi"); // ERROR at compile timeThe compiler’s message names the rule directly:
bound mismatch: the type String is not a valid substitute for the bounded parameter <T extends Number>You gave up flexibility and bought capability. Fewer types get in, and you can do more with the ones that do.
Type erasure: what the compiler actually does
Here is the fact that explains every strange rule about generics.
List<String> ls = new ArrayList<>();
List<Integer> li = new ArrayList<>();
System.out.println(ls.getClass() == li.getClass()); // true
System.out.println(ls.getClass().getName()); // java.util.ArrayListSame class. The <String> and <Integer> are gone.
Generics exist only at compile time. The compiler checks every use, inserts the casts you would otherwise have written, and then erases the type parameters. The bytecode has no idea any of it happened.
What javac does to Box<Integer>
- Check every use against TPutting a String into a
Box<Integer>fails here, at compile time. - Replace T with its boundPlain
TbecomesObject.T extends NumberbecomesNumber. - Insert the casts for youEvery
b.get()becomes(Integer) b.get()in the bytecode. - Throw the type parameters awayWhat runs is the old Object based code. Just written by the compiler, and already proved correct.
Why do it this way? Compatibility. Generics arrived in Java 5, and every library written before that had to keep working. Erasure meant the bytecode shape never changed, so old code and new code could run side by side.
What erasure stops you doing
Every one of these looks reasonable, and none of them compiles. All for the same reason: at run time, T does not exist.
| What you cannot write | Why not | |
|---|---|---|
| new T() | create a T | nobody knows what T is by then |
| new T[10] | an array of T | arrays need their real type at run time |
| x instanceof List<String> | check the type argument | only List survives; String is gone |
| void f(List<String> a) and void f(List<Integer> a) | two overloads | after erasure both are f(List), so they clash |
| class My<T> extends Exception | a generic exception | catch blocks are matched at run time |
Generics only work with objects
List<int> numbers; // will not compile
List<Integer> numbers; // fineErasure replaces T with Object, and an int is not an Object. So every generic type argument has to be a class.
This is the promise from Section 2.1 finally being explained, and it is why autoboxing from Section 7.3 exists. Every int you put into a List<Integer> becomes an object, with the memory and speed cost you measured there.
What it costs
The syntax gets heavy fast. Map<String, List<Map<Integer, String>>> is real code that people write, and nobody enjoys reading it.
Erasure leaves a set of rules that look arbitrary until you know the reason. No new T(), no T[], no instanceof with a type argument, no overloads that differ only by type argument. Each one is a fair surprise.
Error messages get long too. A mismatch deep inside nested generics produces a message several lines long, and finding the actual problem in it takes practice.
Raw types still compile, so the safety can be switched off by accident, usually by copying old code from the internet.
And none of it helps at run time. Generics catch mistakes before the program starts and then vanish. If a value arrives from a file or an API, you are back to checking it yourself.
What you get is worth all of that. Every collection in Java is generic. Because of that, the compiler catches type mistakes at the line that made them. Your users do not find a ClassCastException at 2am.
Check yourself
Answer from memory before opening the explanation. Then compare your rule with the answer and revisit the example if they differ.
Upcasting needs no cast. Downcasting does. Why is one safe and the other not?
Show the answer
Upcasting goes from a specific type to a general one. A
Dogis always anAnimal, so the compiler knows it can never fail. No cast needed.Downcasting goes the other way, from general to specific. An
Animalmight be aDog, or it might be aCat. The compiler cannot tell, because the object could have come from a file, a user, or an API call.So you write the cast to say "I know what this is". If you are wrong, the JVM finds out while the program is running and throws a
ClassCastException.That is the whole problem generics were invented to remove. A cast is you promising the compiler something it cannot check.
`List<String>` and `List<Integer>` have the same class at run time. What does that tell you?
Show the answer
That generics exist only for the compiler. The type is checked, then thrown away. This is called type erasure.
After compiling,
Box<String>andBox<Integer>are both justBox, holdingObject. The compiler inserted the casts for you, and checked them all before it did.It was done this way for compatibility. Generics arrived in Java 5, and code written before that had to keep working, so the bytecode could not change shape.
The cost shows up in odd rules: you cannot write
new T(), you cannot make aT[], and you cannot askif (x instanceof List<String>). At run time none of that information exists.Why does `<T extends Number>` let you call `doubleValue()` when plain `<T>` does not?
Show the answer
Because plain
Tcould be anything. The compiler has to assume the worst, which isObject, andObjecthas nodoubleValue().<T extends Number>narrows the promise. WhateverTturns out to be, it is at least aNumber. So every method onNumberis available inside your class.You are trading flexibility for capability. Fewer types are allowed in, and you can do more with the ones that get in.
Note the keyword:
extends, even when the bound is an interface.<T extends Comparable<T>>is normal Java. Generics reuseextendsfor both, and there is noimplementshere.
Exercises
Write each solution in VS Code, predict its result, and then run the checks. Reading an example does not replace implementing it.
4 exercises95 pointsabout 100 minutes
Watch the Cast Fail
ex-9-3-aTwo boxes, one problem, two very different failures.
Run the old one first and collect both errors: one from the compiler, one from the JVM at run time. Note which line each blames.
Then do the same with the generic version and watch the run time failure become a compile error.
Finish by answering the question properly. Both versions failed. Say why one failure is much better than the other, in terms of who finds out and when.
What your program must do
- Uncomment the addition and record the compile error
- Run the cast line and record the run time exception
- Do both with NewBox and show the second one becomes a compile error
- Say which of the two failures you would rather have, and why
class OldBox {
private Object value;
OldBox(Object value) { this.value = value; }
Object get() { return value; }
}
class NewBox<T> {
private T value;
NewBox(T value) { this.value = value; }
T get() { return value; }
}
public class BoxOfAnything {
public static void main(String[] args) {
OldBox old = new OldBox(10);
// System.out.println(old.get() + 5); // uncomment: what error?
String wrong = (String) old.get(); // compiles. Then what?
// TODO: do the same two things with NewBox<Integer> and compare
}
}
Hint 1
get() returns Object, and Object has no +. The compiler is right to refuse.Hint 2
Hint 3almost the answer
NewBox<Integer>, String s = box.get(); will not compile at all. The failure moved from run time to compile time, and that move is the entire point of generics.A Pair That Holds Two Types
ex-9-3-bTwo type parameters instead of one. This is where generic syntax starts earning its keep.
swap() is the interesting method. It returns a Pair with the two types the other way round, which means its return type is different from the class it is declared in. Getting that to compile tells you whether you have understood what the letters mean.
The static factory is worth writing too. Compare new Pair<String, Integer>("Atlas", 101) with Pair.of("Atlas", 101) and notice how much the compiler can work out for itself.
Finish by using the same class for two completely different type combinations, without changing a line of it.
What your program must do
- Write the class with two type parameters and working getters
- Implement swap so the returned Pair has the types the other way round
- Write a static factory of() so callers do not repeat the types
- Show the same class working with two different type combinations
public class Pair<K, V> {
// TODO: two fields, a constructor, and getters
// TODO: swap() -> returns a Pair<V, K> with the halves exchanged
// TODO: a STATIC generic method that builds a Pair without repeating the types:
// Pair<String,Integer> p = Pair.of("Atlas", 101);
// TODO: toString
public static void main(String[] args) {
// build a Pair<String,Integer>, print it, swap it, print that
// build a Pair<Integer,Boolean> and show the same code works
}
}
Hint 1
public class Pair<K, V>. Both letters are placeholders, and K and V are the convention for key and value.Hint 2
swap() returns a different type from the class it lives in: public Pair<V, K> swap() { return new Pair<>(value, key); }.Hint 3almost the answer
public static <A, B> Pair<A, B> of(A a, B b). It cannot use the class's K and V, because there is no object.Bound It So You Can Use It
ex-9-3-cA method that will not compile, and one word fixes it.
Work out why doubleValue() is rejected before you look at the hint. The reason is about what the compiler is allowed to assume, not about what you happen to put in the list.
Then add the bound and watch every Number method become available.
largest() is the one worth remembering. <T extends Comparable<T>> is the most common bound in Java, and Phase X is full of it. Note that it uses extends even though Comparable is an interface, and that there is no implements anywhere in generics.
What your program must do
- Explain why sum() does not compile with a plain T
- Add the bound so it does compile
- Show that Stats<String> now fails, and copy the exact error message
- Write largest() with a Comparable bound and use it on two different types
import java.util.*;
class Stats<T> {
private final List<T> values = new ArrayList<>();
void add(T v) { values.add(v); }
// TODO: this will not compile. Work out why, then fix the class header.
// double sum() {
// double total = 0;
// for (T v : values) total += v.doubleValue();
// return total;
// }
}
public class Bounds {
// TODO: static <T extends Comparable<T>> T largest(List<T> items)
public static void main(String[] args) {
// TODO: use Stats with Integer and with Double
// TODO: show new Stats<String>() is now a compile error, and read the message
// TODO: use largest() on a List<String> and a List<Integer>
}
}
Hint 1
T, the compiler assumes the worst case, which is Object. Object has no doubleValue(), so the call cannot be allowed.Hint 2
class Stats<T extends Number> promises that whatever T is, it is at least a Number. Every Number method becomes available.Hint 3almost the answer
largest, the bound is <T extends Comparable<T>>. Note it is extends even though Comparable is an interface. There is no implements in generics.Prove Erasure Yourself
ex-9-3-dProve that generics disappear, then use that fact to break one.
Start with the demonstration: two lists with different type arguments, one class at run time.
Then uncomment the four broken lines one at a time. Predict each failure before compiling. All four have the same cause, and being able to say it in one sentence is the deliverable.
Finish with the smuggling trick. Use a raw type to put an Integer into a List<String>, which compiles with only a warning. Then read it back and watch a ClassCastException thrown by a cast you never wrote.
The compiler wrote that cast. Seeing it fail is the clearest proof that erasure is real and that the old Object based code is still what runs.
What your program must do
- Show that List<String> and List<Integer> are the same class at run time
- Uncomment each broken line and explain its failure using erasure
- Use a raw type to smuggle an Integer into a List<String>
- Read it back as a String and record the exception
import java.util.*;
public class Erasure {
// TODO: uncomment one at a time. Each fails. Predict the reason first.
// static <T> T makeOne() { return new T(); }
// static <T> T[] makeArray() { return new T[10]; }
// static void f(List<String> a) { }
// static void f(List<Integer> a) { }
public static void main(String[] args) {
List<String> ls = new ArrayList<>();
List<Integer> li = new ArrayList<>();
System.out.println("same class at run time? " + (ls.getClass() == li.getClass()));
System.out.println("class name: " + ls.getClass().getName());
// TODO: use a raw type to put an Integer into a List<String>. It compiles.
// TODO: then read it back as a String and watch what happens.
}
}
Hint 1
T does not exist. There is nothing to create, nothing to size an array with, and nothing to tell two overloads apart.Hint 2
List raw = ls; raw.add(42);. The compiler warns about an unchecked operation and lets it through, because code written before Java 5 has to keep compiling.Hint 3almost the answer
After the credits
One question is left open, and it looks small.
A Dog is an Animal. So is a List<Dog> a List<Animal>?
The answer is no, and the reason is not obvious. If it were allowed, you could put a Cat into a list that everyone else thinks holds only Dogs. Java refuses in order to stop that.
Which leaves you stuck. How do you write one method that accepts a list of any kind of animal?
That is Section 9.4, and the answer is ?, extends and super. It is the last piece before the Collections Framework, and it is the piece that makes Collections.copy(dest, src) possible at all.
In Phase X, everything is generic. List<E>, Map<K, V>, Set<E>, Comparable<T>. You will not be learning a new idea there, only reading the one from this section in a lot of signatures.
Threads you opened in this section
- GenericsEvery collection in Java is generic. List<E>, Map<K,V>, Set<E>.Phase X. The Collections Framework
- GenericsFunction<T,R> and Predicate<T> are generic interfaces.Phase XI. Functional Java
- Type erasureIt is why a List cannot hold an int, and why every number gets boxed.Phase X. The Collections Framework
Generics will return in 9.4 - Wildcards: `extends` and `super`