Skip to content

12.2 (Mid): Invariance, Wildcards, and PECS

Complete Type Parameters and Generic Methods first, then spend about 35 minutes running the Java 17 examples in this lesson. The goal is to be able to explain generic invariance and choose between extends, super, or an explicit T based on whether the parameter is producing or consuming data.

Two Material Request Forms Cannot Be Directly Stacked

The toolbench already remembers item types. Ah Hua, however, encountered a new problem when merging material request forms: although Integer is a subclass of Number, why can't List<Integer> be passed to the method that receives List<Number>?

The answer lies in "what can be done after receiving." If assignment is valid, the latter can be placed into Double. Originally only integer-accepting lists could be contaminated. Here, generics choose invariance, blocking errors before they are written.

1. Elements Have Inheritance, List Does Not Follow Covariance

The following assignment will not compile:

java
List<Integer> quantities = new ArrayList<>(List.of(2, 3, 5));
// List<Number> numbers = quantities;

If the second line is valid, the caller can then execute numbers.add(3.14). The same object would be considered both "containing only Integer" and "able to write any Number," creating conflicting promises.

List<Number> is not equal to List<Object>. It only accepts objects of type Number and its subclasses; List<Object> can also be placed into strings and other references. Only when you need to express "some specific but unknown List" does the wildcard come into play.

2. List<?>: Unknown Type, but Consistent in Context

java
static void printAll(List<?> values) {
    for (Object value : values) {
        System.out.println(value);
    }
}

The List<?> can reference a list of List<String>, List<Integer>, or other element types. The question mark does not mean that each element has its own type; rather, it indicates that there is a determined type present, but the current method does not know its name.

Therefore, the result of reading can only be treated as an Object. Except for null, it is not safe to write any specific value to it: the compiler cannot prove that this value conforms to the hidden element type. If the method is not concerned with the element type and does not need to write, List<?> is safer than raw List.

3. ? extends T: Reading from T

Ah Hua first received a stack of view-only material requisition forms. She didn't know the exact type of each number on the forms, but she knew they were at least Number. This "read-only from inside" direction is perfectly suited for ? extends T.

java
List<? extends Number> source = List.of(2, 3, 5);
Number first = source.get(0);
// source.add(4); // Compilation failed

The actual type of source could be List<Integer> or List<Double>. Regardless of which, the elements read are at least Number; but writing to Integer is only safe for the first case.

Technically, it's possible to write to most of these lists with null, so "extends read-only" is a practical approximation, not a complete API contract. The list itself might also support clear, remove, or position swapping. The accurate statement is: you cannot safely add a specific T to it.

4. ? super T: Write T to the parameter

Next, she needs to copy the integer quantity into a ledger that can accommodate Integer, Number, or Object. At this point, the concern is "what can I safely write into it," and the direction has reversed.

java
List<? super Integer> target = new ArrayList<Number>();
target.add(8);
Object first = target.get(0);

The actual element type of the target may be Integer, Number, or Object. Writing to Integer is safe for all three. However, when reading, you can only be certain that you will receive an Object.

If the caller needs to treat the result as a Number directly, ? super Integer does not provide this guarantee. The wildcard describes which containers are allowed and what safe operations can be performed on it, not opening a back door for conversions.

5. Look at the role of parameters in methods for PECS

Two stacks of material requests can finally be merged: source delivers data, target consumes it. PECS is just a shorthand for this flow, and cannot be memorized in isolation from the method's behavior.

Copy the integer material quantity into the Number list like this:

java
static <T> void copyAll(
        List<? extends T> source,
        List<? super T> target) {
    for (T value : source) {
        target.add(value);
    }
}

Source produces T, using extends; target consumes T, using super. This is Producer Extends, Consumer Super.

PECS is not a mantra that must be applied to all generic declarations. If a parameter both reads and writes T, using List<T> directly is often clearer. Returning List<? extends Item> passes the burden of unknown types to the caller; unless the API intentionally hides specific subtypes, it's better to return a clearly parameterized type.

The JDK's Collections.copy, Collections.max, and similar methods are worth reading side by side: boundaries serve the actual data flow, not the pursuit of a complex-looking signature.

6. Wildcard Capture for Temporary Names of Unknown Types

When swapping two positions in List<?>, both elements belong to the same unknown type, but the public method can't directly write back. A private helper can capture it:

java
static void swap(List<?> values, int left, int right) {
    swapCaptured(values, left, right);
}

private static <T> void swapCaptured(
        List<T> values, int left, int right) {
    T previous = values.set(left, values.get(right));
    values.set(right, previous);
}

The caller can still pass in a list of any element type; the helper lets the compiler use T to represent that unknown type in this particular call. When error messages mention CAP#1 or capture of ?, first check if this kind of capture is needed, rather than immediately writing unchecked cast.

Capture solves the type expression problem and does not change the mutability of the collection. Passing List.of("hammer", "saw") to swap compiles successfully, but it will throw UnsupportedOperationException at set.

7. Back to the Repository: Run and Capture Copying

Put the material quantity, tool order, and unknown type list into the same Java 17 program. The acceptance criteria aren't based on "compiling successfully": the success path must have assertions, and the failure path of an immutable list must be explicitly captured.

First, prepare a temporary directory:

bash
chapter12_pecs_root=$(mktemp -d)
readonly chapter12_pecs_root
mkdir -p "$chapter12_pecs_root/out"
cd "$chapter12_pecs_root"

Save as WildcardsPecsDemo.java:

java
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

public class WildcardsPecsDemo {
    public static void main(String[] args) {
        List<Integer> quantities = List.of(2, 3, 5);
        List<Number> totals = new ArrayList<>();
        copyAll(quantities, totals);
        totals.add(2.5);
        checkEquals(List.of(2, 3, 5, 2.5), totals);

        List<String> tools = new ArrayList<>(
                List.of("Hammer", "Saw", "Plane"));
        swap(tools, 0, 2);
        checkEquals(List.of("Plane", "Saw", "Hammer"), tools);

        List<? extends Number> producer = quantities;
        checkEquals(10, sum(producer));

        List<? super Integer> consumer = new ArrayList<Object>();
        consumer.add(8);
        consumer.add(13);
        checkEquals(List.of(8, 13), consumer);

        expect(UnsupportedOperationException.class,
                () -> swap(List.of("fixed", "list"), 0, 1));

        System.out.println("Copy count:" + totals);
        System.out.println("Exchange tool:" + tools);
        System.out.println("Wildcards and PECS Check passed");
    }

    static <T> void copyAll(
            List<? extends T> source,
            List<? super T> target) {
        for (T value : source) target.add(value);
    }

    static int sum(List<? extends Number> values) {
        int result = 0;
        for (Number value : values) result += value.intValue();
        return result;
    }

    static void swap(List<?> values, int left, int right) {
        swapCaptured(values, left, right);
    }

    private static <T> void swapCaptured(
            List<T> values, int left, int right) {
        T previous = values.set(left, values.get(right));
        values.set(right, previous);
    }

    static <T extends Throwable> T expect(
            Class<T> type, Runnable action) {
        try {
            action.run();
        } catch (Throwable failure) {
            if (type.isInstance(failure)) return type.cast(failure);
            throw new AssertionError(
                    "expected=" + type + ", actual=" + failure, failure);
        }
        throw new AssertionError("expected exception=" + type.getName());
    }

    static void checkEquals(Object expected, Object actual) {
        if (!Objects.equals(expected, actual)) {
            throw new AssertionError(
                    "expected=" + expected + ", actual=" + actual);
        }
    }
}
bash
javac --release 17 -Xlint:all -d out WildcardsPecsDemo.java
java -cp out WildcardsPecsDemo

Expected output:

text
Copied quantities: [2, 3, 5, 2.5]
Tool exchange: [Plane, Saw, Hammer]
Wildcard and PECS checks passed

After saving the records, clean up:

bash
cd
ls -ld -- "$chapter12_pecs_root"
rm -r -- "$chapter12_pecs_root"

Before Leaving the Repository, Read the Method Signature

Seeing List<? extends Number> indicates that you can at least read Number, but you cannot safely add a specific Number. Seeing List<? super Integer> indicates that you can write Integer, but when reading, you can only treat it as an Object. If a list needs to be read from and written to with the same T, don't hide it just to follow PECS.

Now intentionally compile three error codes: assign List<Integer> to List<Number>, add an integer to the producer, and directly assign the value read by the consumer to Integer. The three rejections from the compiler exactly outline the boundary of the wildcard.

Referencing the Official Rules of Java 17

Next Step: Runtime After Erasure

The type relationships in the source code are now clear. The next lesson will continue to ask: what does the compiler erase, why bridge methods are generated, and to which layer of runtime checks can Class<T> recover.

Continue learning about type erasure and runtime boundaries

Built with VitePress | Software Systems Atlas