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 explicitTbased 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:
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
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.
List<? extends Number> source = List.of(2, 3, 5);
Number first = source.get(0);
// source.add(4); // Compilation failedThe 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.
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:
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:
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:
chapter12_pecs_root=$(mktemp -d)
readonly chapter12_pecs_root
mkdir -p "$chapter12_pecs_root/out"
cd "$chapter12_pecs_root"Save as WildcardsPecsDemo.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);
}
}
}javac --release 17 -Xlint:all -d out WildcardsPecsDemo.java
java -cp out WildcardsPecsDemoExpected output:
Copied quantities: [2, 3, 5, 2.5]
Tool exchange: [Plane, Saw, Hammer]
Wildcard and PECS checks passedAfter saving the records, clean up:
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
- JLS 4.5: Parameterized Types.
- JLS 4.5.1: Type Arguments and Substitution.
- JLS 4.10: Subtyping.
Collections: Compare the boundary design of standard library methods such ascopy,max, etc.
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.