---
title: 'Bounded Wildcards'
source: 'https://academia.sh/en/courses/java-object-model/bounded-wildcards'
course: 'Object-Oriented Java'
language: en
updated: '2026-08-17T18:09:41+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```java
// 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("-");
    }
}
```

```java
// 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

```java
// 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.

```java
// 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.

```java
// 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`.

```java
// 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?
