1.2 Generics and Type Variance: Producers, Consumers, and Runtime Representation
In the Developer Workshop, containers must reuse algorithms while preventing incorrect types from being written into them. Once inheritance enters the read-write interface, subtype relationships become less intuitive.
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 immutable
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 DogThus, mutable Box<T> for type T are typically invariant.
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 throwing ArrayStoreException when writing a Dog. 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 Are the Means of Implementation Choice
Java generics primarily rely on erasure during compilation: parameterized types are mapped to their raw classes or bounds, the compiler inserts explicit casts, and bridge methods are generated when polymorphic behavior needs to be preserved. As a result, List<String> and List<Integer> often share the same runtime class, and certain type parameters are not reflectable.
Therefore, it is not possible to create instances of new T() or new T[], nor can x instanceof List<String> be directly tested at runtime. When runtime type information is required, explicit transmission of Class<T>, type tokens, or the use of reified generic language features can be employed.
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. Dynamic dictionaries or type-class translation achieve runtime type capabilities instead. No single strategy consistently outperforms the others across all use cases.
Bounds
static <T extends Comparable<? super T>> T max(List<? extends T> xs)This signature indicates that the input production T can be compared with itself or with a supertype. The purpose of complex constraints is not to show off, but to encode implementation dependencies directly into the interface, enabling both callers and the compiler to verify them.
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.
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