Skip to content
academia.sh

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 Object in the descriptor the virtual machine reads, written under the name T in the signature attribute.
  • A generic read call adds a checkcast instruction 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 Derived into a Store<String> does not compile.
  • A bounded type parameter erases to its bound, not to Object; this both opens up calling a method through T, and removes the checkcast instruction once the declared type and the erased type coincide.
  • At runtime, Store<Base> and Store<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.

Start typing to search.

↑↓ Esc navigate · open · close