4. Generics and type erasure
Full examples: lessons/l04.
Same syntax, different machinery
Section titled “Same syntax, different machinery”List<String>, generic methods and constraints read almost like C#. The difference is underneath. The CLR reifies generics: List<int> and List<string> are distinct types at run time, and the JIT generates specialised code for value types. Java generics are checked by the compiler and then erased: the bytecode only knows List, and every T becomes its bound (usually Object). This kept Java 5 compatible with older class files, and it explains almost everything in this lesson.
List<String> strings = new ArrayList<>();List<Integer> numbers = new ArrayList<>();System.out.println(strings.getClass() == numbers.getClass());System.out.println(strings.getClass().getName());truejava.util.ArrayListThe C# side prints False for typeof(List<string>) == typeof(List<int>), and System.Collections.Generic.List`1[System.Int32] for the runtime type.
What erasure forbids
Section titled “What erasure forbids”| C# | Java | Why |
|---|---|---|
List<int> |
List<Integer> |
a type argument must be a reference type |
new T() with where T : new() |
pass a Supplier<T> |
there is no T at run time to instantiate |
typeof(T) |
pass a Class<T> |
same |
new T[n] |
(T[]) new Object[n] or IntFunction<T[]> |
arrays know their element type at run time; T doesn’t exist |
o is List<string> |
o instanceof List<?> |
the type argument can’t be checked |
overloads F(List<string>) and F(List<int>) |
different method names | both erase to F(List) |
static T field in Cache<T> |
not allowed | there is one class, shared by every Cache<…> |
Each of these is a compile error, with messages worth recognising.
import java.util.List;
class Scores { List<int> values;}PrimitiveTypeArgument.java:4: error: unexpected type List<int> values; ^ required: reference found: int1 errorclass Factory<T> { T create() { return new T(); }}NewT.java:3: error: unexpected type return new T(); ^ required: class found: type parameter T where T is a type-variable: T extends Object declared in class Factory1 errorclass Registry<T> { String typeName() { return T.class.getName(); }}ClassLiteralOfT.java:3: error: cannot select from a type variable return T.class.getName(); ^1 errorclass Stack<T> { private T[] items = new T[16];}GenericArray.java:2: error: generic array creation private T[] items = new T[16]; ^1 errorimport java.util.List;
class Checks { boolean isNames(Object value) { return value instanceof List<String>; }}InstanceofGeneric.java:5: error: Object cannot be safely cast to List<String> return value instanceof List<String>; ^1 errorimport java.util.List;
class Printer { void print(List<String> names) { }
void print(List<Integer> numbers) { }}SameErasure.java:7: error: name clash: print(List<Integer>) and print(List<String>) have the same erasure void print(List<Integer> numbers) { ^1 errorclass Singleton<T> { static T instance;}StaticT.java:2: error: non-static type variable T cannot be referenced from a static context static T instance; ^1 errorThe workarounds: pass what erasure removed
Section titled “The workarounds: pass what erasure removed”When code needs to create a T or test for it, the caller passes the missing information explicitly — a factory or a class token:
// No `new T()`: pass a factory instead.static <T> List<T> filled(int count, Supplier<T> factory) { var list = new ArrayList<T>(); for (int i = 0; i < count; i++) { list.add(factory.get()); } return list;}
// No `typeof(T)`: pass a Class<T> token when the type is needed at run time.static <T> T firstOfType(List<?> items, Class<T> type) { for (Object item : items) { if (type.isInstance(item)) { return type.cast(item); } } return null;}System.out.println(filled(3, StringBuilder::new).size());List<Object> mixed = List.of(1, "two", 3.0);System.out.println(firstOfType(mixed, String.class));3twoYou will meet class tokens everywhere in Java libraries: objectMapper.readValue(json, Order.class) in Jackson, context.getBean(OrderService.class) in Spring. They exist because the library cannot ask T what it is.
Boxing is the price of List<Integer>
Section titled “Boxing is the price of List<Integer>”Every element of a List<Integer> is a separate Integer object, where a C# List<int> stores the values inline. Boxing also creates an overload trap: List<Integer> has both remove(int index) and remove(Object value).
List<Integer> numbers = new ArrayList<>();for (int i = 0; i < 5; i++) { numbers.add(i * 10);}
numbers.remove(1);System.out.println(numbers);numbers.remove(Integer.valueOf(30));System.out.println(numbers);[0, 20, 30, 40][0, 20, 40]remove(1) removed the element at index 1 (the value 10), not the value 1. For numeric work, the primitive streams IntStream, LongStream and DoubleStream avoid boxing: IntStream.rangeClosed(1, 100).sum() is 5050 without allocating a single Integer.
Raw types and heap pollution
Section titled “Raw types and heap pollution”A generic type used without type arguments is a raw type, kept for pre-Java 5 code. The compiler warns, and it has good reason to:
import java.util.ArrayList;import java.util.List;
class Legacy { void run() { List names = new ArrayList(); names.add("Ada"); }}RawType.java:6: warning: [rawtypes] found raw type: List List names = new ArrayList(); ^ missing type arguments for generic class List<E> where E is a type-variable: E extends Object declared in interface ListRawType.java:6: warning: [rawtypes] found raw type: ArrayList List names = new ArrayList(); ^ missing type arguments for generic class ArrayList<E> where E is a type-variable: E extends Object declared in class ArrayListRawType.java:7: warning: [unchecked] unchecked call to add(E) as a member of the raw type List names.add("Ada"); ^ where E is a type-variable: E extends Object declared in interface List3 warningsThrough a raw reference, anything can go into a List<String>. Nothing fails where the wrong value is added; the ClassCastException appears later, wherever a String is read back:
@SuppressWarnings({"rawtypes", "unchecked"}) // deliberately unsafe: the lesson explains heap pollutionstatic void pollute(List<String> names) { List raw = names; raw.add(42);}var names = new ArrayList<>(List.of("Ada"));pollute(names);System.out.println(names.size());String second = names.get(1); // the example catches the exception and prints its message2ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String (java.lang.Integer and java.lang.String are in module java.base of loader 'bootstrap')The cast was inserted by the compiler at names.get(1), where erasure turned String back into Object. In C#, List<string> would have rejected the Add itself. Treat rawtypes and unchecked warnings as errors in new code.
Variance: use-site wildcards instead of in and out
Section titled “Variance: use-site wildcards instead of in and out”In C#, variance is declared once, on the interface: IEnumerable<out T> is covariant, so an IEnumerable<string> is an IEnumerable<object>. Java has no declaration-site variance. List<String> is never a List<Object>:
import java.util.ArrayList;import java.util.List;
class Zoo { void run() { List<String> names = new ArrayList<>(); List<Object> objects = names; }}InvariantList.java:7: error: incompatible types: List<String> cannot be converted to List<Object> List<Object> objects = names; ^1 errorInstead, each method says how it uses its parameter, with a wildcard:
// Reads numbers: any List of a subtype of Number is accepted ("producer extends").static double sum(List<? extends Number> numbers) { double total = 0; for (Number n : numbers) { total += n.doubleValue(); } return total;}
// Writes integers: any List that can hold an Integer is accepted ("consumer super").static void addOneTwoThree(List<? super Integer> target) { target.add(1); target.add(2); target.add(3);}List<Integer> ints = List.of(1, 2, 3);List<Double> doubles = List.of(1.5, 2.5);System.out.println(sum(ints) + " " + sum(doubles));
List<Number> numbers = new ArrayList<>();List<Object> objects = new ArrayList<>();addOneTwoThree(numbers);addOneTwoThree(objects);System.out.println(numbers + " " + objects);6.0 4.0[1, 2, 3] [1, 2, 3]| C# | Java | Can read T |
Can add T |
|---|---|---|---|
IEnumerable<out T> parameter |
List<? extends T> |
yes | no |
IComparer<in T>, Action<in T> |
List<? super T> |
only as Object |
yes |
IList<T> (invariant) |
List<T> |
yes | yes |
The rule of thumb is PECS: producer extends, consumer super. The compiler enforces the “no” cells; its message mentions a capture (CAP#1), the unknown type the wildcard stands for:
import java.util.List;
class Totals { void addZero(List<? extends Number> numbers) { numbers.add(0); }}AddToExtends.java:5: error: incompatible types: int cannot be converted to CAP#1 numbers.add(0); ^ where CAP#1 is a fresh type-variable: CAP#1 extends Number from capture of ? extends NumberNote: Some messages have been simplified; recompile with -Xdiags:verbose to get full output1 errorThe List<? extends Number> might really be a List<Double>, so adding an Integer could corrupt it.
Bounds on type parameters use extends too, for classes and interfaces alike, and & to combine them:
// A bounded type parameter, like `where T : IComparable<T>`.static <T extends Comparable<? super T>> T max(List<T> items) { T best = items.getFirst(); for (T item : items) { if (item.compareTo(best) > 0) { best = item; } } return best;}max(List.of("pear", "apple", "quince")) returns quince. The ? super T lets it accept a type whose compareTo is inherited from a supertype.
Arrays are covariant in both languages
Section titled “Arrays are covariant in both languages”Both languages inherited covariant arrays, and both check every store at run time:
Object[] slots = new String[2];slots[0] = 42;ArrayStoreException: java.lang.IntegerThe C# side throws ArrayTypeMismatchException: Attempted to access an element as a type incompatible with the array. One more reason to prefer List<T> in both.
Key takeaways
Section titled “Key takeaways”- Java checks generics at compile time and erases them:
List<String>andList<Integer>are the same class at run time. - No primitives as type arguments, no
new T(), noT.class, no generic arrays, no overloads that differ only by type argument. - Pass a
Supplier<T>or aClass<T>when code needs the type at run time. - Raw types defeat the type system; the failure shows up later as a
ClassCastException. - Variance is chosen per method with
? extends(read) and? super(write) — PECS.
Exercises
Section titled “Exercises”- Translate this C# method. What must the caller now provide?
static T[] Fill<T>(int count) where T : new(){ var result = new T[count]; for (int i = 0; i < count; i++) result[i] = new T(); return result;}Solution
static <T> T[] fill(int count, IntFunction<T[]> newArray, Supplier<T> factory) { T[] result = newArray.apply(count); for (int i = 0; i < count; i++) { result[i] = factory.get(); } return result;}
StringBuilder[] builders = fill(3, StringBuilder[]::new, StringBuilder::new);The caller provides what erasure removed twice: how to create the array (StringBuilder[]::new) and how to create an element (StringBuilder::new). It is the same pattern as Collection.toArray(IntFunction).
- Write the signature of a method
copythat copies every element of a source list into a destination list, so that copying aList<Integer>into aList<Number>compiles.
Solution
static <T> void copy(List<? super T> destination, List<? extends T> source) { for (T item : source) { destination.add(item); }}The source produces Ts (extends), the destination consumes them (super). For copy(numbers, integers), both T = Integer and T = Number satisfy the bounds, so the call compiles. This is the signature of Collections.copy.
- C# code filters a heterogeneous list with
items.OfType<T>(). WriteofTypein Java. Can it select only theList<String>elements of aList<Object>?
Solution
static <T> List<T> ofType(List<?> items, Class<T> type) { return items.stream().filter(type::isInstance).map(type::cast).toList();}No: the class token for a list is List.class, which matches every List whatever its elements, and List<String>.class does not exist. At run time a List<String> and a List<Integer> are indistinguishable; you would have to inspect the elements.
Sources
Section titled “Sources”- dev.java — Generics and Type erasure
- JLS §4.6 — Type erasure and §4.8 — Raw types
- The Java Tutorials — Wildcards and Restrictions on generics
- C# — Covariance and contravariance in generics
- Project Valhalla — the long-running work on value classes and specialised generics