Polymorphism is at the heart of Java, enabling flexibility and reusability. Generics enhance polymorphism by allowing type-safe subtyping relationships, but they also introduce rules that may surprise developers.
For example:
List<String> strings = new ArrayList<>();
List<Object> objects = strings; // ❌ Compile error!
At first glance, this looks like it should work because String
is a subtype of Object
. However, Java generics are invariant, meaning List<String>
is not a subtype of List<Object>
.
This tutorial explores subtyping rules in generics, explaining extends
, super
, wildcards, PECS, and how to design flexible APIs safely.
Core Definition and Purpose of Java Generics
Generics were introduced in Java 5 to:
- Provide compile-time type safety.
- Eliminate the need for explicit casting.
- Enhance polymorphism with flexible yet type-safe APIs.
Type Parameters in Generics
<T>
– General type.<E>
– Element (collections).<K, V>
– Key-Value pairs (maps).
class Box<T> {
private T value;
public void set(T value) { this.value = value; }
public T get() { return value; }
}
Invariance of Generics
Unlike arrays, generics are invariant.
String[] strArray = new String[10];
Object[] objArray = strArray; // Allowed
List<String> strList = new ArrayList<>();
// List<Object> objList = strList; // Compile error!
Why? Because invariance prevents runtime type corruption.
Covariance with extends
Covariance allows read-only access to generics.
List<? extends Number> list = new ArrayList<Integer>();
Number num = list.get(0); // Allowed
// list.add(10); // Compile error
Use extends
when the method produces data.
Contravariance with super
Contravariance allows write access.
List<? super Integer> list = new ArrayList<Number>();
list.add(10); // Allowed
Object obj = list.get(0); // Only Object can be retrieved
Use super
when the method consumes data.
Wildcards in Polymorphism
?
– Unknown type.? extends T
– Upper-bounded (read).? super T
– Lower-bounded (write).
public static void printList(List<?> list) {
for (Object obj : list) {
System.out.println(obj);
}
}
PECS Principle (Producer Extends, Consumer Super)
- Producer Extends – Use
? extends T
when the collection produces values. - Consumer Super – Use
? super T
when the collection consumes values.
public void copy(List<? extends Number> src, List<? super Number> dest) {
for (Number n : src) {
dest.add(n);
}
}
Multiple Type Parameters and Nested Generics
Map<String, List<Integer>> map = new HashMap<>();
Polymorphism applies only with correct wildcards, not raw subtyping.
Generics and Collections Framework
List<? extends Number>
acceptsList<Integer>
,List<Double>
.List<? super Integer>
acceptsList<Number>
,List<Object>
.
List<Integer> ints = Arrays.asList(1, 2, 3);
List<? extends Number> nums = ints; // Allowed
Raw Types vs Parameterized Types
List list = new ArrayList(); // Raw type
list.add("Hello");
list.add(123); // Unsafe
Parameterized types enforce polymorphism safely.
Type Inference and Diamond Operator
Map<String, Integer> map = new HashMap<>();
The compiler infers types, ensuring safe subtyping.
Type Erasure and Subtyping
At runtime, generics erase to raw types. Subtyping is enforced only at compile time.
System.out.println(new ArrayList<String>().getClass() == new ArrayList<Integer>().getClass()); // true
Reifiable vs Non-Reifiable Types in Polymorphism
List<?>
→ Reifiable.List<String>
→ Non-reifiable (erased toList
).
Recursive Type Bounds
public static <T extends Comparable<T>> T max(List<T> list) {
return list.stream().max(Comparator.naturalOrder()).orElse(null);
}
Compile-time subtyping ensures type safety.
Case Studies
Type-Safe Cache
class Cache<K, V> {
private Map<K, V> store = new HashMap<>();
public void put(K key, V value) { store.put(key, value); }
public V get(K key) { return store.get(key); }
}
Repository Pattern
interface Repository<T, ID> {
T findById(ID id);
}
Polymorphism via type parameters avoids unsafe casts.
Best Practices for Generics and Polymorphism
- Prefer wildcards over raw types.
- Use PECS principle.
- Keep APIs simple – avoid deeply nested generics.
- Don’t force covariance/contravariance without clear need.
Common Anti-Patterns
- Using raw types in collections.
- Forcing
List<Object>
to acceptList<String>
. - Overusing wildcards (
List<? super ? extends T>
).
Performance Considerations
Generics and polymorphism have no runtime cost due to erasure. Safety is enforced at compile time.
📌 What's New in Java for Generics?
- Java 5: Generics + type-safe collections.
- Java 7: Diamond operator simplifies polymorphism.
- Java 8: Streams/APIs rely heavily on covariance.
- Java 10:
var
works seamlessly with generics. - Java 17+: Sealed classes integrate with generic hierarchies.
- Java 21: Virtual threads with generic concurrent APIs.
Conclusion and Key Takeaways
Generics bring polymorphism with safety, but subtyping rules can be tricky:
- Generics are invariant by default.
- Use
extends
for producers,super
for consumers (PECS). - Wildcards provide flexibility without losing safety.
- Runtime uses erasure, so rules apply at compile time only.
FAQ on Generics and Polymorphism
Q1: Why isn’t List<String>
a subtype of List<Object>
?
Because generics are invariant.
Q2: When should I use ? extends T
?
When reading/producing values.
Q3: When should I use ? super T
?
When writing/consuming values.
Q4: What is the PECS principle?
Producer Extends, Consumer Super.
Q5: Do wildcards exist at runtime?
No, they are erased but checked at compile time.
Q6: Can I assign List<Integer>
to List<? extends Number>
?
Yes, covariance allows it.
Q7: Can I assign List<Integer>
to List<Number>
?
No, invariance prevents it.
Q8: Do generics affect runtime performance?
No, they’re erased at compile time.
Q9: Can I use generics with arrays?
Not safely; arrays are covariant, generics are invariant.
Q10: How do frameworks like Spring use polymorphism with generics?
They use wildcards and reflection to adapt generic repositories safely.