1.2 Generics and Type Variance: Producers, Consumers, and Runtime Representation
Generics allow containers and algorithms to retain element type information. However, when type parameters are both readable and writable, subtype relationships must be handled with greater care.
Mutable Containers Are Usually Invariant
Suppose we allow Box<Cat> to be treated as Box<Animal>:
cats: Box<Cat>
animals: Box<Animal> = cats
animals.set(Dog)
cat = cats.get() ← now retrieves a DogA container that both reads and writes T is therefore usually invariant in T. Invariance describes subtype relationships between parameterized types; immutability describes whether a value can change. They answer different questions.
Interfaces that only produce T can be covariant: if Cat <: Animal, then Producer<Cat> can be treated as Producer<Animal>. Interfaces that only consume T can be contravariant: any consumer that accepts an arbitrary Animal can certainly accept Cat.
Java uses use-site variance:
List<? extends Animal> source; // Can be safely read as Animal, Cannot write arbitrarily
List<? super Cat> sink; // Writable Cat, Reading can only be considered as ObjectPECS is a mnemonic: Producer Extends, Consumer Super. It is not a replacement for grammar rules; a parameter that both reads and writes should generally remain invariant in its exact type.
Array covariance is a historical choice with runtime checks
Java arrays allow Cat[] to be assigned to Animal[], but storing a Dog through that reference throws ArrayStoreException. Arrays know their component type at runtime, while generic List<Cat> prohibits such assignments at compile time, preventing the error before execution.
This demonstrates that "the language allows an assignment" does not guarantee complete static safety; some compatibility trades error detection for runtime enforcement.
Erasure and Reification
Java compiles generics mainly through erasure: a parameterized class maps to its raw class, and a type variable maps to its leftmost bound. The compiler inserts casts and, when necessary, bridge methods. Two ordinary ArrayList objects share the same runtime class whether their declared element types are String or Integer. Declaration metadata may retain generic signatures, but an arbitrary list object does not reveal its element argument through getClass().
You cannot construct new T() or new T[] directly. If x has type Object, x instanceof List<String> is also illegal: the test cannot establish the erased element type. Since Java 16, some parameterized tests are allowed when they need no unchecked cast, such as testing a List<Integer> against ArrayList<Integer>. The runtime still checks the class, not every element. Use Class<T>, a factory, or a type token when an API needs explicit runtime type information.
Languages like Rust and C++ typically instantiate specific types with dedicated code (monomorphization), enabling better value layout and static dispatch, though this may increase compilation time and code size. Dictionary passing supplies implementations of required operations as extra arguments; a compiler may also specialize or inline them. No single strategy consistently outperforms the others across all use cases.
Bounds State Required Capabilities
static <T extends Comparable<? super T>> T max(List<? extends T> xs)This is a method signature sketch. The input produces values compatible with T; T supplies a comparison that accepts T or a supertype. The bound records what the implementation needs so callers and the compiler can check it.
Overly general types lead to confusing errors and poorly designed APIs. If the domain has only three clearly defined types, a closed algebraic data type might be clearer than ten layers of generics.
Try the Compile-Time and Runtime Checks
Save this as VarianceDemo.java and run java VarianceDemo.java with JDK 17 or later. The shorter Java snippets above show declarations and signatures, rather than standalone programs.
import java.util.ArrayList;
import java.util.List;
class VarianceDemo {
public static void main(String[] args) {
List<Integer> integers = new ArrayList<>(List.of(1, 2));
List<? extends Number> source = integers;
List<? super Integer> sink = new ArrayList<Number>();
sink.add(source.get(0).intValue());
System.out.println(sink.get(0));
// source.add(3);
// List<Number> numbers = integers;
Number[] array = new Integer[] {1};
try {
array[0] = 1.5;
} catch (ArrayStoreException expected) {
System.out.println(expected.getClass().getSimpleName());
}
Object unknown = integers;
System.out.println(unknown instanceof List<?>);
// System.out.println(unknown instanceof List<Integer>);
System.out.println(integers instanceof ArrayList<Integer>);
}
}The four output lines are 1, ArrayStoreException, true, and true. Uncomment each disabled line separately: adding through the producer view, assigning List<Integer> to List<Number>, and testing the unknown object against List<Integer> must each fail compilation. The array assignment passes compilation and fails at runtime. The compiler and runtime split the work; neither lets that Double quietly become an Integer.
Generic API Inspection
- Is the type parameter produced, consumed, or bidirectional?
- Does the runtime need to know about T? How much type information does the current implementation retain?
- Are there unchecked casts? Where is the safety invariant proven?
- How do
null, empty collections, and exceptions enter the type contract? - Does generality truly enable reuse, or does it hide domain constraints behind documentation?
The next lesson uses sum/product types and exhaustive matching to turn the slogan "invalid states are not representable" into a concrete method for modeling data.
References
Oracle, JLS: Type Erasure
Oracle, JLS: Subtyping
Rust, Monomorphization
Oracle, JLS: The instanceof Operator