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 theObjectlevel. 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 onStore<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.