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 Twhen the collection produces values. - Consumer Super – Use
? super Twhen 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:
varworks 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
extendsfor producers,superfor 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.