Lesson 09 / 13
Generic Types
The type parameter gets written to the class file in two separate places: erased in the descriptor the virtual machine reads, present in the signature attribute. The measurement shows the consequence — the check exists only at compile time, two generic types are the same class at runtime, and a write made through the raw type falls at the read site.
Contents
The previous lesson counted the members the compiler generates for a record: accessors, equality, hash, and string conversion entered the class file without being written in the source. If the compiler can generate members, what does it do with the type information it holds — does it write that somewhere too, or does it use it and let it go?
Generics were built in the TypeScript course around type
parameters and bounds, in terms of expressive power; this lesson does
not repeat that. The question here is this course’s question: in the
writing Store<Base>, who decides using the Base information, and
when? The answer sits at an extreme in this topic — the entire
decision belongs to the declared type, and the runtime type has
nothing left to say. The measurement shows three consequences of this:
can two containers written with two separate type parameters be told
apart at runtime, where does the check get placed, and once the party
doing the checking is gone, who pays for a wrong write?
The Type Parameter Gets Written to the Class File Twice
- GE1 — The measured source text is written within the lesson. Since we know what we wrote, every difference found in the class file is the compiler’s decision.
- GE2 — The Scale core comes from the shared setup. This lesson
adds a single measurement to it:
signature, which returns a member’s signature attribute. The behavior of the existing measurements does not change. - GE3 — Descriptor and signature text get printed in their raw form from the class file; not abbreviated, not converted to a readable form.
// Scale.java - shared measurement core: compiles a class file and reads it
import java.io.PrintWriter;
import java.io.Writer;
import java.lang.classfile.*;
import java.nio.file.*;
import java.util.*;
import java.util.spi.ToolProvider;
class Scale {
static ClassModel compile(String source, String className) throws Exception {
Path dir = Files.createTempDirectory("scale");
Path file = dir.resolve(className + ".java");
Files.writeString(file, source);
PrintWriter silent = new PrintWriter(Writer.nullWriter());
if (ToolProvider.findFirst("javac").orElseThrow()
.run(silent, silent, "-d", dir.toString(), file.toString()) != 0)
throw new IllegalStateException("did not compile: " + className);
return ClassFile.of().parse(dir.resolve(className + ".class"));
}
static List<String> instructions(String source, String className, String methodName) throws Exception {
List<String> names = new ArrayList<>();
for (MethodModel m : compile(source, className).methods())
if (m.methodName().stringValue().equals(methodName))
for (CodeElement e : m.code().orElseThrow())
if (e instanceof Instruction i)
names.add(i.opcode().name().toLowerCase(Locale.ROOT));
return names;
}
static String signature(AttributedElement e) {
return e.findAttribute(Attributes.signature())
.map(a -> a.signature().stringValue()).orElse("-");
}
}
// Signature.java - where the type parameter sits in the class file
import java.lang.classfile.*;
public class Signature {
static final String STORE = """
class Store<T> {
private T item;
void put(T item) { this.item = item; }
T get() { return item; }
}
""";
public static void main(String[] args) throws Exception {
ClassModel cm = Scale.compile(STORE, "Store");
System.out.printf("%-14s %-26s %s%n", "member", "what the VM sees", "signature attribute");
System.out.printf("%-14s %-26s %s%n", "Store class", "-", Scale.signature(cm));
for (FieldModel f : cm.fields())
System.out.printf("%-14s %-26s %s%n", "field " + f.fieldName().stringValue(),
f.fieldType().stringValue(), Scale.signature(f));
for (MethodModel m : cm.methods())
System.out.printf("%-14s %-26s %s%n", "method " + m.methodName().stringValue(),
m.methodType().stringValue(), Scale.signature(m));
}
}
member what the VM sees signature attribute Store class - <T:Ljava/lang/Object;>Ljava/lang/Object; field item Ljava/lang/Object; TT; method <init> ()V - method put (Ljava/lang/Object;)V (TT;)V method get ()Ljava/lang/Object; ()TT;
The middle column is type erasure. Every place T was written in
the source has become Object in the class file’s descriptor: the
field is Ljava/lang/Object;, the put method takes an Object, the
get method returns an Object. The virtual machine looks at this
column when it calls a method; to it, Store is an ordinary
single-field class, and there is no such thing as T.
The right column, though, is not erased. The class’s signature
writes that T exists and that its upper bound is Object; the
field’s signature is TT;, that is, “type variable T”; put and
get‘s signatures carry the name T too. The information written in
the source did not disappear, it got written to a second place.
The constructor has no signature, because no type parameter appears in
the constructor’s definition.
This gives this topic’s answer to the course’s question. Only the compiler reads the right column; the virtual machine executes the left column. Every decision about the type parameter rests on a text the runtime type never looks at.
The Check Goes on the Read Side, Not the Write Side
- GE4 — The same write gets tried in three separate sources; two compile, one does not. The compiler’s message text is not printed, only whether it compiled gets reported.
// Check.java - what instruction a generic call leaves in the class file
public class Check {
static final String TYPES = """
class Base { String name() { return "base method"; } }
class Derived extends Base { @Override String name() { return "derived method"; } }
class Store<T> { private T item; void put(T o) { item = o; } T get() { return item; } }
""";
static final String USAGE = TYPES + """
class Usage {
static Base read(Store<Base> s) { return s.get(); }
@SuppressWarnings("rawtypes")
static Object rawRead(Store s) { return s.get(); }
static void write(Store<Base> s, Derived d) { s.put(d); }
}
""";
static final String WRITE =
TYPES + "class Write { static void w(%s s) { s.put(new Derived()); } }";
static void attempt(String label, String source) {
try {
Scale.compile(source, "Write");
System.out.printf("%-30s -> compiled%n", label);
} catch (Exception e) {
System.out.printf("%-30s -> %s%n", label, e.getMessage());
}
}
public static void main(String[] args) throws Exception {
System.out.println("read Store<Base> : " + Scale.instructions(USAGE, "Usage", "read"));
System.out.println("read raw Store : " + Scale.instructions(USAGE, "Usage", "rawRead"));
System.out.println("write Store<Base> : " + Scale.instructions(USAGE, "Usage", "write"));
System.out.println();
attempt("Store<Base> s; s.put(Derived)", WRITE.formatted("Store<Base>"));
attempt("Store<String> s; s.put(Derived)", WRITE.formatted("Store<String>"));
attempt("Store s (raw); s.put(Derived)", WRITE.formatted("Store"));
}
}
read Store<Base> : [aload_0, invokevirtual, checkcast, areturn] read raw Store : [aload_0, invokevirtual, areturn] write Store<Base> : [aload_0, aload_1, invokevirtual, return] Store<Base> s; s.put(Derived) -> compiled Store<String> s; s.put(Derived) -> did not compile: Write Store s (raw); s.put(Derived) -> compiled
The first two lines are separated by exactly one instruction. The read
through Store<Base> is four instructions, and the fourth is
checkcast; the same read through the raw type is three
instructions, with no check. The method called is the same method in
both cases — the class file has a single get that returns Object.
What makes the difference is not the called side, it is the
calling side: the compiler inserts a check to fit the read value
to the declared type.
The third line is this lesson’s quietest measurement. The write is
three instructions and carries no check at all: load the
reference, load the value, call. A check gets placed at the read site,
not at the write site. The reason is in the lower table: the write is
already checked at compile time. Source that writes Derived into
a store named Store<String> does not compile. The decision comes
entirely from the declared type, and since compilation stops, no work
is left for runtime.
The lower table’s third row writes down the price paid. When the same store gets named with a raw type, the same write compiles. The compiler produces a warning here, but the warning does not stop compilation; once the declared type is gone, there is no party left to check.
A Bound Changes the Erased Type
- GE5 — All five attempts are built on the same bounded class; only the tried line changes, the types around it stay fixed.
// Bound.java - a bound changes the type that gets erased
import java.lang.classfile.*;
public class Bound {
static final String BOUNDED = """
class Base { String name() { return "base method"; } }
class Derived extends Base { @Override String name() { return "derived method"; } }
class Bounded<T extends Base> {
private T item;
void put(T item) { this.item = item; }
T get() { return item; }
}
""";
static final String READING = BOUNDED + """
class Reading {
static Base baseRead(Bounded<Base> b) { return b.get(); }
static Derived derivedRead(Bounded<Derived> b) { return b.get(); }
}
""";
static void attempt(String label, String declaration, String body) {
try {
Scale.compile(BOUNDED + "class Forbidden" + declaration + " { " + body + " }", "Forbidden");
System.out.printf("%-38s -> compiled%n", label);
} catch (Exception e) {
System.out.printf("%-38s -> %s%n", label, e.getMessage());
}
}
public static void main(String[] args) throws Exception {
ClassModel cm = Scale.compile(BOUNDED, "Bounded");
System.out.printf("%-14s %-26s %s%n", "member", "what the VM sees", "signature attribute");
System.out.printf("%-14s %-26s %s%n", "Bounded class", "-", Scale.signature(cm));
for (FieldModel f : cm.fields())
System.out.printf("%-14s %-26s %s%n", "field " + f.fieldName().stringValue(),
f.fieldType().stringValue(), Scale.signature(f));
for (MethodModel m : cm.methods())
if (!m.methodName().stringValue().equals("<init>"))
System.out.printf("%-14s %-26s %s%n", "method " + m.methodName().stringValue(),
m.methodType().stringValue(), Scale.signature(m));
System.out.println();
System.out.println("Bounded<Base> read : " + Scale.instructions(READING, "Reading", "baseRead"));
System.out.println("Bounded<Derived> read : " + Scale.instructions(READING, "Reading", "derivedRead"));
System.out.println();
attempt("<T> calling o.name()", "<T>", "String s(T o) { return o.name(); }");
attempt("<T extends Base> calling o.name()", "<T extends Base>", "String s(T o) { return o.name(); }");
attempt("new T[3]", "<T extends Base>", "Object[] d() { return new T[3]; }");
attempt("x instanceof Bounded<Base>", "",
"boolean t(Object x) { return x instanceof Bounded<Base>; }");
attempt("x instanceof Bounded<?>", "",
"boolean t(Object x) { return x instanceof Bounded<?>; }");
}
}
member what the VM sees signature attribute Bounded class - <T:LBase;>Ljava/lang/Object; field item LBase; TT; method put (LBase;)V (TT;)V method get ()LBase; ()TT; Bounded<Base> read : [aload_0, invokevirtual, areturn] Bounded<Derived> read : [aload_0, invokevirtual, checkcast, areturn] <T> calling o.name() -> did not compile: Forbidden <T extends Base> calling o.name() -> compiled new T[3] -> did not compile: Forbidden x instanceof Bounded<Base> -> did not compile: Forbidden x instanceof Bounded<?> -> compiled
The descriptor column changed: a bounded type parameter erases not
to Object, but to its bound. The field is LBase;, put takes
a Base, get returns a Base. The signature column, though, stays
the same — it still reads TT;, and the class signature carries the
bound too.
This has its first consequence in the lower table’s first two rows.
Source calling o.name() through an unbounded type parameter does not
compile, because the erased type is Object, and Object has no such
method. The same line compiles once a bound gets added. A bound is
not documentation; it changes the descriptor in the class file, and
widens the set of methods a call can bind to.
The second consequence is in the read lines and completes the previous
section’s measurement. There is no checkcast in the read through
Bounded<Base>, and there is one in the read through
Bounded<Derived>. A check does not get added to every generic read;
it gets added only when the declared type and the erased type
diverge. In the first measurement, Store<T> erased to Object,
and a Base read diverged from that; here, since the erased type is
already Base, there is nothing left to add.
The last three lines are erasure’s direct prohibitions. new T[3]
does not compile: an array carries its element type within itself and
checks it on every write — measured in the Java Fundamentals course —
but since no type called T exists at runtime, there is nothing to
carry. x instanceof Bounded<Base> does not compile either, while
x instanceof Bounded<?> does. The only question that can be asked of
the virtual machine is the raw class; a wildcard means exactly
“do not ask about the type parameter.”
Two Generic Types Are the Same Class at Runtime
- GE6 — Identity gets asked only with
==; no identity number is printed. - GE7 — The raw-typed write’s result is not written as an
assertion, it is measured with
instanceof; only the class name gets printed from the caught exception.
// Erasure.java - what type erasure leaves behind at runtime
import java.util.Arrays;
class Base {
String name() { return "base method"; }
}
class Derived extends Base {
@Override String name() { return "derived method"; }
}
class Store<T> {
private T item;
void put(T item) { this.item = item; }
T get() { return item; }
}
public class Erasure {
static Store<Base> field;
@SuppressWarnings({"unchecked", "rawtypes"})
public static void main(String[] args) throws Exception {
Store<Base> carriers = new Store<>();
Store<String> texts = new Store<>();
System.out.println("Store<Base> and Store<String> same class : "
+ (carriers.getClass() == texts.getClass()));
System.out.println("type parameters at runtime : "
+ Arrays.toString(Store.class.getTypeParameters()));
carriers.put(new Derived());
System.out.println("runtime type of the item read : "
+ carriers.get().getClass().getSimpleName());
Store raw = carriers;
raw.put("north");
System.out.println("was the raw-type write stopped : "
+ (carriers.get() instanceof Base ? "yes" : "no"));
try {
Base b = carriers.get();
System.out.println("read result : " + b.name());
} catch (ClassCastException e) {
System.out.println("error not at the write site, at the READ site : "
+ e.getClass().getSimpleName());
}
System.out.println("field's type read from the signature attribute : "
+ Erasure.class.getDeclaredField("field").getGenericType());
}
}
Store<Base> and Store<String> same class : true type parameters at runtime : [T] runtime type of the item read : Derived was the raw-type write stopped : no error not at the write site, at the READ site : ClassCastException field's type read from the signature attribute : Store<Base>
The first two lines are the descriptor column’s consequence.
Store<Base> and Store<String> are the same class at runtime,
and that class’s type parameter sits under the name T alone, with no
value. Two separate types were written in the source; there is a
single class on the heap.
The third line sharpens the distinction. The object read from the
store has the runtime type Derived — that is, the object’s own class
is known and not hidden. What is unknown is what the
container holds. An object’s type exists at runtime; a
container’s type parameter does not.
The fourth and fifth lines measure the price. The string written
through the raw type was never stopped: the store accepted it, and
the item in the store is no longer a Base. The error fell not at the
line where the write happened, but at the line where the read
happened — because the only check is the checkcast instruction
sitting there. This is the price a programmer looking at the wrong
side pays: the line producing the defect and the line where the defect
shows up sit in separate places, and the distance between them can
be as long as the program itself.
What Gets Erased Is Not the Information, It Is the Check
The last line is this lesson’s boundary measurement, and it flips the
thesis. A field declared as Store<Base> has its generic type
readable at runtime: Store<Base>. The information was not
erased, it sits in the signature attribute, and it can be retrieved
from there.
What gets erased, then? The first measurement’s two columns give the
answer. The descriptor the virtual machine executes reads Object;
the virtual machine does not look at the signature attribute when it
checks a call. Type information sits in the class file as data,
but it does not get used as a check. The name “type erasure,” for
this reason, describes not the information disappearing, but the
check that rests on that information disappearing. Reading a
field’s signature and stopping a write are separate things: the first
is reading a text back, the second is work that never happens at
runtime at all.
Summary
- The type parameter gets written to the class file in two separate
places: erased as
Objectin the descriptor the virtual machine reads, written under the nameTin the signature attribute. - A generic read call adds a
checkcastinstruction in the class file; the same read through the raw type has no such instruction. The check sits on the calling side, not the called side. - A generic write call leaves no check instruction, because the write
is checked at compile time: source writing
Derivedinto aStore<String>does not compile. - A bounded type parameter erases to its bound, not to
Object; this both opens up calling a method throughT, and removes thecheckcastinstruction once the declared type and the erased type coincide. - At runtime,
Store<Base>andStore<String>are the same class; a raw-type write goes unstopped, and the error falls not at the write site, but at the read site. - Boundary measurement: a field’s generic type can be read back at runtime from the signature attribute. What gets erased is not the information, it is the check resting on that information.
Next Step
This lesson named a store with a single type, and a difference in
checking came out between writing to that type and reading from it: an
instruction got added to the read, not to the write. The same
difference can be asked at the type level too. If, instead of
Store<Base>, we want to write “a store holding Base or any type
below it,” does reading from that store stay as open as writing to it?
The next lesson tries four separate container forms against two
operations and counts how many of the pairs compile.
To keep your progress and take notes, Log in
My notes
Log in to take notes.