Java, from nothing · chapter 8 of 33
Generics, as far as you actually need them
Chapter 1.8 · Part 1, Java from nothing · about 30 minutes
What you need before this chapter: chapters 1.1 through 1.7. You should be comfortable with classes, objects, fields, methods, constructors, private, references versus values, == versus equals, and null. You should also know how inheritance and polymorphism work, how interfaces and abstract classes differ, and how to use List/Map/Set.
When you finish this chapter you will be able to:
- Explain, with a real crash, why Java added the angle-bracket syntax you have been typing since 1.7
- Write
List<String>correctly and know what the compiler checks for you that a rawListcannot - Write your own small generic class,
Box<T>, and use it with two different types - Say why a
List<Dog>is not aList<Animal>, even though aDogis anAnimal - State, in one sentence, what type erasure means and why it stops one specific thing you might try
1. The idea in plain words
Before Java had the angle-bracket syntax, a List held plain Object references and nothing more specific. That sounds fine, because every class in Java is an Object, so a List could hold anything: String, Ticket, Integer, all in the same list. The problem shows up the moment you take something back out. Object is all the list remembers, so to use what you get back as a String you have to tell the compiler "trust me, this one is a String" with a cast. If you are wrong, and one Integer slipped into a list you believed only held plates, the mistake does not show up at the line that put it there. It shows up later, at the cast, as a crash with no connection in the stack trace back to where the bad value was added.
Generics are the fix, and the idea is smaller than the syntax makes it look. List<String> is a List that has agreed, once, at compile time, to hold only String. The compiler then refuses to compile any line that tries to put something else in, and every value that comes back out is already known to be a String, no cast required. You have been typing this since chapter 1.7 without seeing the failure it prevents. This chapter shows you that failure once, directly, so List<Ticket> stops being a shape you copy and starts being a decision you understand.
2. Type this
First, the version without generics, the way collections worked before Java 5. Type this as RawListPain.java:
import java.util.ArrayList;
import java.util.List;
public class RawListPain {
public static void main(String[] args) {
List spots = new ArrayList();
spots.add("A12");
spots.add(4);
String first = (String) spots.get(0);
System.out.println("first: " + first);
String second = (String) spots.get(1);
System.out.println("second: " + second);
}
}
List spots = new ArrayList();, with no angle brackets at all, is called a raw type. It compiles, because raw types still exist for old code that predates generics, but nothing here is stopping you from putting a String and an Integer in the same list.
3. Run it
javac RawListPain.java
The compiler lets it through, but it is not silent about it:
Note: RawListPain.java uses unchecked or unsafe operations.
Note: Recompile with -Xlint:unchecked for details.
Run it:
java RawListPain
Output:
first: A12
Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String (java.lang.Integer and java.lang.String are in module java.base of loader 'bootstrap')
at RawListPain.main(RawListPain.java:14)
The first cast succeeds, because index 0 really does hold a String. The second one fails at runtime, on a line that has nothing to do with where the bad value was added three lines earlier.
4. What just happened, line by line
List spots = new ArrayList(); creates a raw list. Nothing about its type says what it holds. spots.add(4) autoboxes the int 4 into an Integer and the raw list accepts it without complaint, because as far as the compiler is concerned this list holds Object, and an Integer is an Object.
(String) spots.get(0) is a cast: an instruction to the compiler that says "treat whatever comes back as a String". The compiler takes your word for it and generates code that checks at runtime whether the value really is a String. When it is not, as with spots.get(1), the check fails and throws ClassCastException.
Now the fix. Change one line, adding the type argument on both sides:
List<String> spots = new ArrayList<>();
spots.add("A12");
spots.add(4);
This does not run. It does not even compile:
TypedList.java:9: error: incompatible types: int cannot be converted to String
spots.add(4);
^
List<String> told the compiler, at the point you wrote it, that this list holds String and nothing else. spots.add(4) is now rejected while you are still looking at the line that is wrong, not three calls later inside a cast you have long since stopped thinking about. Remove that line and the version that only adds strings compiles and runs cleanly:
List<String> spots = new ArrayList<>();
spots.add("A12");
spots.add("B04");
String first = spots.get(0);
System.out.println("first: " + first);
first: A12
No cast anywhere. spots.get(0) already has type String, because the list's own declaration says every element is a String.
The <T> you will see in a class you write yourself works the same way, just with a name you choose instead of a fixed type. T is a placeholder, filled in with a real type wherever the class gets used:
public class Box<T> {
private T contents;
public void put(T item) {
contents = item;
}
public T get() {
return contents;
}
}
Box<String> is a box that only ever holds a String; Box<Integer> is a different box, for Integer. One class, Box, produces both, and the compiler enforces which one you asked for:
Box<String> plateBox = new Box<>();
plateBox.put("KA-01-4432");
String plate = plateBox.get();
System.out.println("plate: " + plate);
Box<Integer> spotBox = new Box<>();
spotBox.put(12);
int spot = spotBox.get();
System.out.println("spot: " + spot);
plate: KA-01-4432
spot: 12
You already know, from chapter 1.4, that a Dog is an Animal, and that a method expecting an Animal parameter happily accepts a Dog. Generics do not carry that relationship over automatically, and it is worth watching this fail once:
static void printAll(List<Animal> animals) {
for (Animal a : animals) {
System.out.println(a.speak());
}
}
List<Dog> dogs = new ArrayList<>();
dogs.add(new Dog());
printAll(dogs);
Invariance.java:14: error: incompatible types: List<Dog> cannot be converted to List<Animal>
printAll(dogs);
^
A Dog upcasts to Animal cleanly, but List<Dog> does not upcast to List<Animal>, and the restriction exists to protect you, not to get in your way. If it compiled, printAll would be holding a List<Animal> reference to what is really, underneath, the caller's List<Dog>. Nothing would stop printAll from calling animals.add(new Cat()), since as far as its own parameter type is concerned a Cat is a perfectly good Animal. The caller would then get a Cat back out of a list it declared as List<Dog>. The resulting ClassCastException would surface far from this line, on whatever later call assumed every element was a Dog.
Java's generics are invariant: List<Dog> and List<Animal> are unrelated types no matter how Dog and Animal relate. That is what closes off this failure at compile time instead of letting it happen at runtime. A wildcard, List<? extends Animal>, exists for the case where a method only reads from the list and never adds to it. It is a mention here, not a tool this chapter asks you to use.
Here is the one honest paragraph on type erasure, and it matters because you will meet its effects directly in the next section. Java's generics exist only while javac is compiling your code. Once compilation finishes, Box<String> and Box<Integer> are compiled down to the exact same class file, plain Box, with the type parameter erased and casts inserted automatically wherever a value comes back out. Generics buy you a compile-time promise, not a runtime one. The check happens once, when you write the code, and the type information it was based on is gone by the time the program runs.
5. Errors you are likely to hit
Mismatched type arguments. Declare a Box<String> and try to hand it an Integer box:
Box<String> plateBox = new Box<Integer>();
BoxMismatch.java:3: error: incompatible types: Box<Integer> cannot be converted to Box<String>
Box<String> plateBox = new Box<Integer>();
^
Box<Integer> and Box<String> are different types as far as the compiler is concerned, even though they come from the same class. This is generics doing its job: it caught the mismatch before the program ran.
A primitive type where a generic expects a reference type. Generics only work with reference types, never with int, double, boolean, and so on:
List<int> spots = new ArrayList<>();
PrimitiveGeneric.java:6: error: unexpected type
List<int> spots = new ArrayList<>();
^
required: reference
found: int
This is exactly why Integer exists alongside int: List<Integer> works, because Integer is a class, not a primitive. Java autoboxes back and forth between the two so this rarely feels like a limitation in practice.
Forgetting the raw-type warning is a warning about something real. Reusing the raw spots list from section 2 in a context that expects List<String> compiles with the same unchecked note you already saw, and it is not decoration. It means the compiler could not verify type safety at that line and is trusting you. Every time you see it, go find out why.
6. Your turn
Write a generic class Pair<A, B> that holds two values of possibly different types, with a constructor that sets both and a getter for each. Use it to pair a ticket's plate with the spot number it was assigned.
Do it before reading on.
The answer:
public class Pair<A, B> {
private A first;
private B second;
public Pair(A first, B second) {
this.first = first;
this.second = second;
}
public A getFirst() {
return first;
}
public B getSecond() {
return second;
}
}
Pair<String, Integer> assignment = new Pair<>("KA-01-4432", 12);
System.out.println(assignment.getFirst() + " -> spot " + assignment.getSecond());
Running it prints:
KA-01-4432 -> spot 12
Pair needs two independent type parameters, A and B, because the two values it holds are usually different types. This is the same idea as Box<T>, used twice in one class.
Going deeper
Type erasure is not just an implementation detail. It has consequences you can trigger and see for yourself. They are the kind of thing an interviewer asks about, to check you understand generics below the surface rather than by pattern-matching the syntax.
You cannot create an array of a type parameter. Try it inside a generic class:
public class NewTArray<T> {
public T[] makeArray(int size) {
return new T[size];
}
}
NewTArray.java:3: error: generic array creation
return new T[size];
^
An array in Java carries its element type at runtime and enforces it on every store; a String[] refuses to accept an Integer the moment you try. T is erased by the time the program runs, so the JVM has no real type to give the array. It could not enforce the check an array is meant to guarantee, so Java refuses to compile the line rather than allow an array that lies about what it holds.
List<String> and List<Integer> are the same class at runtime. This follows directly from erasure, and you can print the proof:
List<String> plates = new ArrayList<>();
List<Integer> spots = new ArrayList<>();
System.out.println(plates.getClass() == spots.getClass());
true
Both are ArrayList, the exact same class object, because the <String> and <Integer> never survive to become part of the compiled type. Anything that relies on the type parameter still being visible at runtime, such as instanceof List<String>, will not compile at all.
You cannot overload a method on two different generic versions of the same raw type. This is the consequence that surprises people who otherwise understand erasure fine in the abstract:
void print(List<String> plates) {
System.out.println("strings");
}
void print(List<Integer> spots) {
System.out.println("integers");
}
OverloadErasure.java:8: error: name clash: print(List<Integer>) and print(List<String>) have the same erasure
void print(List<Integer> spots) {
^
Overload resolution in Java happens at compile time based on the erased signature, and print(List<String>) and print(List<Integer>) both erase to print(List). As far as the part of the compiler that decides which overload you meant is concerned, you tried to declare the same method twice.
7. Why this matters in an interview
Most reusable data structures you design in later parts of this course, a cache, a queue, a registry keyed by an interface type, will be generic. "Works for one specific class" is rarely the actual requirement. Interviewers read List<Ticket> in your code as a small, quiet signal that you know what the brackets are doing rather than having copied them.
The overload-on-erasure error in this chapter is also a real interview moment. A candidate proposes two overloaded methods differing only by a generic type parameter, then looks surprised when it does not compile. That surprise shows the interviewer they have never had to think about erasure. You now have that error memorised, with the real message, before it can happen to you live.
Next: chapter 1.9, Exceptions, and choosing what to throw. ClassCastException and IndexOutOfBoundsException have already crashed two of your programs in this course. The next chapter covers what they are, and how to decide when your own code should throw one on purpose.
← 1.7 The three collections you need: List, Map, Set · All chapters · 1.9 Exceptions, and choosing what to throw →