12.2 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.
Substitutable Elements Do Not Imply Substitutable Containers
Integer is a subclass of Number, so why can't a List<Integer> be passed to a method that accepts List<Number>?
The answer lies in what the receiver may do next. If that assignment were valid, the method could insert a Double into a list that promises to contain only integers. Java generics use invariance to reject the program before that write can happen.
1. List Does Not Inherit Its Element Type's 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 hold strings and other reference values. 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);
}
}List<?> can refer to a List<String>, List<Integer>, or a list with another element type. 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: Read T from the Parameter
A method may need to read quantities as Number without caring whether the elements are Integer or Double. That parameter can use ? extends Number.
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.
Type checking permits writing null, but the actual list may reject modifications or null values; 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
A method that writes Integer values can accept a target whose element type is Integer, Number, Object, or another supertype that accepts Integer. Here the constraint follows the write direction.
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 an Integer is type-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.
This copyAll requires source and target not to share storage affected by appending, and target must support adding the source elements. A failed addition can leave earlier additions in place; it offers no rollback. Collections.copy instead replaces existing positions and requires a sufficiently large destination size. Collections.max and similar methods are also useful comparisons: 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. Run the Copying and Capture Examples
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 unmodifiable 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. SumAsDouble only demonstrates reading double values from Number; an empty list yields 0.0. Conversion and addition may round, large integers may lose precision, and NaN/Infinity can propagate. It is not an exact-money or arbitrary-precision sum.
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.0, sumAsDouble(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("Copied quantities: " + totals);
System.out.println("Tool exchange: " + tools);
System.out.println("Wildcard and PECS checks passed");
}
static <T> void copyAll(
List<? extends T> source,
List<? super T> target) {
for (T value : source) target.add(value);
}
static double sumAsDouble(List<? extends Number> values) {
double result = 0.0;
for (Number value : values) result += value.doubleValue();
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 -encoding UTF-8 -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"Read the Signature Along the Data Flow
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 invalid snippets: 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 Containment.
- 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.