Skip to content
academia.sh

Lesson 10 / 13

Bounded Wildcards

Four container forms and two operations give eight pairs; only four of them compile. Covariance opens reading, contravariance opens writing, and the two never open together; an unbounded wildcard, in turn, allows only writing a null value. The entire direction bound lives in the signature attribute, and leaves no trace at runtime.

Contents

The previous lesson named a store with a single type and measured that a check instruction got added to the read, not to the write. The write was already closed off at compile time. The same question 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?

This lesson tries four container forms against two operations and counts how many pairs compile. This course’s question applies here too, but the direction of the answer is known in advance: the bound a wildcard places belongs only to the declared type. The measurement shows both how many gates that bound closes, and what is left of it at runtime.

Four Container Forms, Two Operations

  • GE8 — The measured source is written within the lesson; only the container form and the tried line change, the surrounding types are the same in every attempt.
  • GE9 — The Scale core comes from the shared setup; the previous lesson’s two measurements, instruction names and the signature attribute, are used as they are.
  • GE10 — An attempt either compiles or does not; the compiler’s message text is not printed, because what gets measured is not the message’s content, it is whether the gate is open.
// 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("-");
    }
}
// Direction.java - four container forms, two operations: how many pairs compile
import java.lang.classfile.*;

public class Direction {
    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 PATTERN = TYPES + "class Trial { static void y(%s d) { %s } }";

    static final String[] CONTAINERS =
            {"Store<Base>", "Store<? extends Base>", "Store<? super Derived>", "Store<?>"};

    static final String READ = "Base u = d.get();";
    static final String WRITE = "d.put(new Derived());";

    static boolean compiles(String container, String operation) {
        try {
            Scale.compile(PATTERN.formatted(container, operation), "Trial");
            return true;
        } catch (Exception e) {
            return false;
        }
    }

    public static void main(String[] args) throws Exception {
        System.out.printf("%-22s %-18s %s%n", "container form", "Base u = d.get()", "d.put(new Derived())");
        int passed = 0;
        for (String container : CONTAINERS) {
            boolean r = compiles(container, READ), w = compiles(container, WRITE);
            passed += (r ? 1 : 0) + (w ? 1 : 0);
            System.out.printf("%-22s %-18s %s%n", container,
                    r ? "compiled" : "did not compile", w ? "compiled" : "did not compile");
        }
        System.out.println("compiled count of eight pairs: " + passed);
    }
}
container form         Base u = d.get()   d.put(new Derived())
Store<Base>            compiled           compiled
Store<? extends Base>  compiled           did not compile
Store<? super Derived> did not compile    compiled
Store<?>               did not compile    did not compile
compiled count of eight pairs: 4

Four of the eight pairs compile, four do not. The first row is the reference row: in an exactly named store, both gates are open. The next three rows close the gates one by one.

The writing Store<? extends Base> establishes covariance: a store of any type under Base fits this name. Reading is open, because whatever the container holds is at least a Base. Writing is closed, because it is not known which type the container is actually a store of; it could be Store<Derived>, or the store of some other type under Base. Writing a Derived would be wrong in the second case, and since the compiler does not know which it is, it closes both.

The writing Store<? super Derived> establishes contravariance, and swaps the two gates. Writing is open: the container is the store of some type above Derived, and a Derived can be written to the store of any such type. Reading is closed, because the container could be Store<Object>, and the read value being a Base is not guaranteed.

A measured rule follows from this: to read from a container, write an upper bound; to write to a container, write a lower bound; the two never open together. What a wildcard earns is a wider set of accepted types; what it costs is one of the two operations closing.

The Direction Bound Lives Only in the Signature

// Trace.java - does a wildcard leave a trace in the class file
import java.lang.classfile.*;

public class Trace {
    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 PATTERN = TYPES + "class Trial { static void y(%s d) { %s } }";

    static final String COVARIANT = PATTERN.formatted("Store<? extends Base>", "Base u = d.get();");
    static final String UNBOUNDED = PATTERN.formatted("Store<?>", "Object o = d.get();");

    public static void main(String[] args) throws Exception {
        for (MethodModel m : Scale.compile(COVARIANT, "Trial").methods())
            if (m.methodName().stringValue().equals("y"))
                System.out.println("y descriptor : " + m.methodType().stringValue()
                        + "   signature: " + Scale.signature(m));
        System.out.println("read Store<? extends Base> : "
                + Scale.instructions(COVARIANT, "Trial", "y"));
        System.out.println("read Store<?>               : "
                + Scale.instructions(UNBOUNDED, "Trial", "y"));
    }
}
y descriptor : (LStore;)V   signature: (LStore<+LBase;>;)V
read Store<? extends Base> : [aload_0, invokevirtual, checkcast, astore_1, return]
read Store<?>               : [aload_0, invokevirtual, astore_1, return]

This measurement says where the bound lives. The descriptor the virtual machine reads is (LStore;)V: the parameter has neither a type parameter nor a wildcard. The signature attribute, though, has the entire bound written — the plus sign in +LBase; marks an upper-bounded wildcard. The previous lesson’s distinction shows up here once more: the information sits in the class file, but the virtual machine does not look at it.

The instruction lines point the same way. The read through the covariant view carries a checkcast instruction, because the declared type Base diverges from the erased type Object. The read as Object through the unbounded view carries no check. Not a single instruction belongs to the direction bound; there is no mark in the class file saying “cannot be written here,” because that decision was made at compile time and ended there.

An Unbounded Wildcard Accepts Only a Null Value

  • GE11 — In the second table, reading happens as Object and the written value is null; the container forms are the same as the first table’s.
// Null.java - what an unbounded wildcard allows
public class Null {
    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 PATTERN = TYPES + "class Trial { static void y(%s d) { %s } }";

    static final String[] CONTAINERS =
            {"Store<Base>", "Store<? extends Base>", "Store<? super Derived>", "Store<?>"};

    static String attempt(String container, String operation) {
        try {
            Scale.compile(PATTERN.formatted(container, operation), "Trial");
            return "compiled";
        } catch (Exception e) {
            return "did not compile";
        }
    }

    public static void main(String[] args) {
        System.out.printf("%-24s %-18s %s%n", "container form", "Object o = d.get()", "d.put(null)");
        for (String container : CONTAINERS)
            System.out.printf("%-24s %-18s %s%n", container,
                    attempt(container, "Object o = d.get();"), attempt(container, "d.put(null);"));
        System.out.println();
        System.out.printf("%-26s %s%n", "Store<?>    d.put(d.get())", attempt("Store<?>", "d.put(d.get());"));
        System.out.printf("%-26s %s%n", "Store<Base> d.put(d.get())", attempt("Store<Base>", "d.put(d.get());"));
    }
}
container form           Object o = d.get() d.put(null)
Store<Base>              compiled           compiled
Store<? extends Base>    compiled           compiled
Store<? super Derived>   compiled           compiled
Store<?>                 compiled           compiled

Store<?>    d.put(d.get()) did not compile
Store<Base> d.put(d.get()) compiled

In the second table, eight of eight pairs compile, and this is this lesson’s boundary measurement. None of the first table’s four closed gates was actually locked; two were only narrowed, and two were only closed for a specific value.

Reading never closes in any form. Reading through Store<? super Derived> and Store<?> had fallen in the first table, because the result was being assigned to a Base. Read as Object, all four compile. An unbounded wildcard does not forbid reading, it drops the read value’s declared type to Object.

Writing never fully closes either. All four container forms accept a null value, because null fits every reference type and raises no question about which type the container is a store of. This gives Store<?>’s measured definition: reading only as Object, writing only with null. An unbounded wildcard does not state a type, it states that it will not ask the type question.

The last two lines are the same rule at its narrowest point. On Store<Base>, the writing d.put(d.get()) — reading from a container and writing straight back into it — compiles. The same line does not compile on Store<?> — since the read value’s type is unknown, even coming from the same container does not make the write valid. Here, the compiler tracks not the type’s identity, only its name.

What Separating Direction Buys on the Calling Side

  • GE12 — Nine calls each get tried for two signatures; the trial set is the same for both, and only the called method’s parameter form changes.
// Copy.java - how many pairs separating direction opens on the calling side
public class Copy {
    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 EXACT = "static void copy(Store<Base> target, Store<Base> source) { target.put(source.get()); }";
    static final String WILDCARD =
            "static void copy(Store<? super Base> target, Store<? extends Base> source) { target.put(source.get()); }";

    static final String[] STORES = {"Store<Object>", "Store<Base>", "Store<Derived>"};

    static int count(String signature) {
        int passed = 0;
        System.out.printf("%-14s %-12s %-12s %s%n", "target \\ source",
                STORES[0], STORES[1], STORES[2]);
        for (String target : STORES) {
            String[] cells = new String[STORES.length];
            for (int i = 0; i < STORES.length; i++) {
                String source = TYPES + "class C { " + signature + " static void c("
                        + target + " target, " + STORES[i] + " source) { copy(target, source); } }";
                try {
                    Scale.compile(source, "C");
                    cells[i] = "compiled";
                    passed++;
                } catch (Exception e) {
                    cells[i] = "-";
                }
            }
            System.out.printf("%-14s %-12s %-12s %s%n", target, cells[0], cells[1], cells[2]);
        }
        return passed;
    }

    public static void main(String[] args) {
        System.out.println("copy(Store<Base>, Store<Base>)");
        System.out.println("compiled count of nine pairs: " + count(EXACT));
        System.out.println();
        System.out.println("copy(Store<? super Base>, Store<? extends Base>)");
        System.out.println("compiled count of nine pairs: " + count(WILDCARD));
    }
}
copy(Store<Base>, Store<Base>)
target \ source Store<Object> Store<Base>  Store<Derived>
Store<Object>  -            -            -
Store<Base>    -            compiled     -
Store<Derived> -            -            -
compiled count of nine pairs: 1

copy(Store<? super Base>, Store<? extends Base>)
target \ source Store<Object> Store<Base>  Store<Derived>
Store<Object>  -            compiled     compiled
Store<Base>    -            compiled     compiled
Store<Derived> -            -            -
compiled count of nine pairs: 4

Same body, two separate parameter forms: the first accepts one of nine call pairs, the second four. The two closed operations pay off here. Since only reading happens from the source, an upper bound can be put on it; since only writing happens to the target, a lower bound can be put on it, and the method opens up to four separate calls.

The empty cells are the same rule’s measure too. Store<Derived> cannot be the target, because a Base could get written there; Store<Object> cannot be the source, because the value coming from it is not known to be a Base. The widening is not arbitrary, it matches the operation’s direction exactly.

The Bound Does Not Exist at Runtime

  • GE13 — Object identity gets asked only with ==; no identity number is printed.
  • GE14 — The raw-type write’s result is not asserted, it is measured with instanceof.
// Flow.java - does the direction bound exist at runtime
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 Flow {
    @SuppressWarnings({"unchecked", "rawtypes"})
    public static void main(String[] args) {
        Store<Derived> derivedStore = new Store<>();
        derivedStore.put(new Derived());

        Store<? extends Base> readable = derivedStore;
        Store<? super Derived> writable = derivedStore;
        Store<?> closed = derivedStore;
        System.out.println("four declared types, how many objects : "
                + ((readable == writable) && (writable == closed) && (closed == derivedStore) ? 1 : 2));
        System.out.println("object's runtime class                 : "
                + derivedStore.getClass().getSimpleName());

        System.out.println("read through covariant view            : " + readable.get().name());
        writable.put(new Derived());
        System.out.println("write through contravariant view       : " + derivedStore.get().name());

        Store raw = readable;
        raw.put(new Base());
        System.out.println("was a Base written through raw type    : "
                + (derivedStore.get() instanceof Derived ? "no" : "yes"));
        try {
            Derived a = derivedStore.get();
            System.out.println("read result                            : " + a.name());
        } catch (ClassCastException e) {
            System.out.println("error at the READ site                 : "
                    + e.getClass().getSimpleName());
        }
    }
}
four declared types, how many objects : 1
object's runtime class                 : Store
read through covariant view            : derived method
write through contravariant view       : derived method
was a Base written through raw type    : yes
error at the READ site                 : ClassCastException

The first two lines reduce this lesson’s whole table to a single sentence: four separate declared types, one object. The container’s runtime class is just Store; neither the type parameter nor the direction bound is there. The read and write gates open and close not on the object, but on the name looking at it.

The third and fourth lines confirm the open gates work. The fifth line shows what the closed gate was closed with. Writing a Base to the covariant view did not compile; once the same view gets named with the raw type, the write goes through, and the item in the store is no longer a Derived. The only party enforcing the direction bound is the compiler; once that party is disabled, no one is left to enforce it again.

The last line writes down where the price fell. The defect showed up not at the line where the wrong type got written, but at the read expecting a Derived — and this time there is also a change of view in between: the writing code knew the container through its covariant view, the failing code through its actual type.

Summary

  • Four of the eight pairs of four container forms and two operations compile. A wildcard widens the accepted type set, and closes one of the two operations in return.
  • Covariance (? extends) opens reading, closes writing; contravariance (? super) opens writing, drops reading to the Object level. The two never open together.
  • Boundary measurement: no gate is actually locked. All four forms can be read as Object, and all four can have a null value written to them; this is the only value an unbounded wildcard allows writing.
  • On Store<?>, writing what was read from a container back into that same container does not compile, while on Store<Base> it does: the compiler tracks the type’s name, not its identity.
  • The entire direction bound lives in the signature attribute. The descriptor carries no trace of the wildcard, not a single instruction gets produced for it, and four separate declared types are one object at runtime.

Next Step

Across two lessons, the gates the compiler closes got counted: writing to the wrong type did not compile, writing to a covariant view did not compile, writing back what got read from a container did not compile. In every one of these, the compiler placed a prohibition. Java also has something else the compiler imposes on source: a requirement — a method cannot be written without declaring or handling certain events. The next lesson measures that requirement — what exactly does the distinction between a checked and an unchecked exception force at compile time, and does the virtual machine check that requirement’s counterpart in the class file?

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close