why generics exist
life before generics was ClassCastException at 3am.
- chapter
- chapter 05.01
- reading time
- 9 min read
before java 5, every collection held Object. you cast on the way out, and a wrong element blew up at the read, far away from the line that put it there. generics let you write List<String>, so the compiler checks every add and writes every cast for you.
same bug, found at compile time instead of at 3am. that's the whole reason they exist.
the picture
you know how a ClassCastException stack trace always points at a line that looks perfectly fine? that's because the line that crashed isn't the line that's wrong. something else, somewhere else, put the wrong thing in the box. the crash is just where someone finally opened it.
think of a warehouse with unlabelled crates (a raw List holds Object). anyone can put anything in any crate, and you only find out a crate of shoes is in the fruit aisle when somebody opens it to make a smoothie. generics are the label on the crate, checked at the loading dock (javac). the wrong delivery gets turned away at the door, with the driver still standing there.
- 1static List loadNames() { // a 2003-style API
- 2 List names = new ArrayList();
- 3 names.add("ada");
- 4 names.add(42);← the bug is here
- 5 return names;
- 6}
- 7
- 8void main() {
- 9 List names = loadNames();
- 10 for (Object o : names) {
- 11 String s = (String) o;← it explodes here
- 12 IO.println(s.toUpperCase());
- 13 }
- 14}
Note: Main.java uses unchecked or unsafeoperations.Note: Recompile with -Xlint:unchecked fordetails.compiled. (-Xlint:all shows 6 warnings)
ADAException in thread "main"java.lang.ClassCastException: classjava.lang.Integer cannot be cast to classjava.lang.String (...)at Main.main(Main.java:11)
it compiles (two notes, easy to ignore), it runs, it prints ADA, and then it dies on line 11. the stack trace points at the cast, seven lines and one method away from the real bug. in a real codebase that's a different file, and maybe a different team.
List<String>. same program, same bug on line 4. watch where it gets caught: the real bug versus where java complains, and whether that happens before or after the program runs. all output is real jdk 25.that distance is the whole problem. with a raw list, the error shows up where the list is read. with a generic one, it shows up where the list is written, which is where the bug actually is.
life before java 5
java 1.0 shipped in 1996 with Vector and Hashtable, and java 1.2 added the collections framework. all of it was built on one idea: everything is an Object, so a collection of Object can hold anything. true, and kind of useless, because you never want "anything". you want strings, or orders, or a map from names to lists of scores.
Map scores = new HashMap();
List list = new ArrayList();
list.add(Integer.valueOf(91));
scores.put("ada", list);
int first = ((Integer) ((List) scores
.get("ada")).get(0)).intValue();Map<String, List<Integer>> scores =
new HashMap<>();
scores.computeIfAbsent("ada",
k -> new ArrayList<>()).add(91);
int first = scores.get("ada").get(0);the left side has two casts and an intValue(), and nothing on that page tells you a scores value is a List of Integers. that knowledge lived in comments, in variable names, and in people's heads. generics moved it into the type, where javac can check it.
void main() {
Map<String, List<Integer>> scores = new HashMap<>();
scores.computeIfAbsent("ada", k -> new ArrayList<>()).add(91);
scores.computeIfAbsent("ada", k -> new ArrayList<>()).add(78);
int first = scores.get("ada").get(0); // no casts, two levels deep
for (int s : scores.get("ada")) IO.println(s);
IO.println("first: " + first);
}91 78 first: 91
hover a stage.
what the compiler does for you
two jobs, and that's all generics are:
- check every write.
names.add(42)on aList<String>doesn't compile. - write every cast.
String s = names.get(0)compiles to a call that returnsObject, followed by a cast toStringthat javac inserted. you never type it, and as long as nobody goes around job 1, it can never fail.
notice what's missing: the list itself didn't change. an ArrayList<String> and an ArrayList<Integer> are the same class at run time, both backed by a plain Object[]. the type argument lives in the compiler's head, not in the object.
names is declared List<String>, raw is a raw List. the object has no idea which one you used. its array is an Object[], and the Integer got in through the raw door.that's why you can sneak a wrong element in: the checks are a compile-time contract, and raw types let you step outside it. and when nobody needs the element to be a String, nothing even notices.
List<String> names = new ArrayList<>();
List raw = names;
raw.add(42); // a warning, not an error
IO.println(names.size());
IO.println(names.get(0));
String s = names.get(0);why not something else?
why not keep Object and be careful? people did, for eight years. "be careful" doesn't scale past one person and one file. the whole point of a type system is that the computer is careful for you.
why not arrays? String[] is already typed. it is, and arrays do check every store, but at run time, with an ArrayStoreException. that's earlier than a cast failing on the way out, still later than a compile error. and arrays have their own holes (arrays, varargs and other limits).
why not a class per element type, StringList, OrderList? that's roughly what C++ templates do: the compiler stamps out a separate copy of the code for every type argument. java chose the opposite. one ArrayList class, shared by every type argument, with the types checked by javac and then thrown away. it meant the JVM didn't have to change at all, and old class files kept working next to new ones. the price of that choice is type erasure, and most of the weird corners in this part come from it.
raw types still compile
for compatibility, every generic type still has a raw form: List with no angle brackets. it exists so code from 2003 compiles today. it also switches the checks off, and javac only mutters about it. by default you get two Note: lines. ask for everything and you see what it's actually worried about (first line of each warning, from javac 25, for the raw program in the figure):
$ javac -Xlint:all Main.java
Main.java:1: warning: [rawtypes] found raw type: List
Main.java:2: warning: [rawtypes] found raw type: List
Main.java:2: warning: [rawtypes] found raw type: ArrayList
Main.java:3: warning: [unchecked] unchecked call to add(E) as a member of the raw type List
Main.java:4: warning: [unchecked] unchecked call to add(E) as a member of the raw type List
Main.java:9: warning: [rawtypes] found raw type: List
6 warnings- before java 5, collections held
Objectand every read was a cast you wrote by hand. - a raw collection fails where it's read. a generic one fails where it's written, which is where the bug is.
- generics do two jobs: check every write, insert every cast.
- the object doesn't know its type argument. the compiler does.
- raw types still compile for compatibility. treat their warnings as bugs.