Generics In Depth

Course 2 · Ch 1
Generics In Depth
Type erasure — a genuinely different strategy from C++ and Rust's shared monomorphization approach

Java Fundamentals used generics constantly — List<String>, List<Integer> — without ever asking what actually happens to that type information once compiled. This chapter answers that, and it's where Java's generics genuinely part ways with C++ and Rust.

Generic Classes & Methods — A Quick Recap

public class Box<T> { private T value; public void set(T value) { this.value = value; } public T get() { return value; } } public static <T> T first(List<T> list) { return list.get(0); } // a generic method

A generic class or method is parameterized by one or more type variables — T here — filled in with a real type at each use site: new Box<String>(), new Box<Integer>().

Type Erasure — Java's Real Strategy

List<String> strings = new ArrayList<>(); List<Integer> ints = new ArrayList<>(); strings.getClass() == ints.getClass(); // true — both are just java.util.ArrayList at runtime

Generic type information is used only at compile time, for type checking — the compiler then erases it. At runtime, List<String> and List<Integer> are the exact same class, running the exact same bytecode, with no trace of which type argument was ever used.

This is genuinely different from both languages already covered on this site. cpp2-1's C++ templates and rust2-3's Rust generics both use monomorphization: the compiler generates a fully separate, specialized copy of the code for every distinct concrete type actually used — a real Box<String>-shaped set of machine instructions, entirely distinct from a real Box<int>-shaped one. Java never does this. One Box class, one set of bytecode, for every type argument that's ever used with it.

Why Type Erasure Exists

Generics arrived in Java 5, added on top of a bytecode format and roughly a decade of already-compiled, already-deployed code that had no concept of generics at all. Erasure was the deliberate trade-off that made this possible without breaking anything: generic code compiles down to the same bytecode shape pre-generics code always used, so old and new code can interoperate freely. The cost is exactly the runtime type information monomorphization would have preserved.

The Real Consequences of Erasure

public class Box<T> { // T value2 = new T(); // will not compile — T doesn't exist at runtime, nothing to "new" // T[] arr = new T[10]; // will not compile — same reason, no real type to build an array of } if (obj instanceof List) { } // fine — List itself still exists at runtime // if (obj instanceof List) { } // will not compile — String is erased, nothing to check against

Because the type argument doesn't exist at runtime, a generic class can't construct a new instance of its own type parameter, can't build an array of it, and code can't check which type argument a generic object was created with — only that it's some List, never specifically a List<String>.

Bounded Type Parameters

public static <T extends Comparable<T>> T max(T a, T b) { return a.compareTo(b) > 0 ? a : b; }

T extends Comparable<T> restricts T to types that implement Comparable, letting the compiler verify a.compareTo(b) is actually legal — narrowing what a type parameter can be still buys real compile-time checking, even with erasure in play afterward.

AspectC++ templates (cpp2-1)Rust generics (rust2-3)Java generics
Strategymonomorphizationmonomorphizationtype erasure
Runtime type infofull — distinct compiled code per typefull — distinct compiled code per typenone — one shared implementation
Compiled code sizegrows with each distinct type usedgrows with each distinct type usedfixed — one copy regardless of type args
Can hold a raw primitiveyes — real int stored directlyyes — real i32 stored directlyno — must box (java1-2)
Use bounded type parameters to give the compiler something real to check
An unbounded <T> only guarantees T is some reference type — bounding it with extends lets the compiler verify real method calls on T at compile time, recovering some of what erasure otherwise gives up.
You cannot check a generic type argument at runtime
obj instanceof List<String> does not compile — there is no String left to check against once the class is loaded. Only the raw type (List) survives erasure; the type argument is gone by the time any instanceof check could run.

Coding Challenges

Challenge 1

Write code that creates a List<String> and a List<Double>, then prints whether their getClass() values are equal, explaining the result in a comment in terms of type erasure.

📄 View solution
Challenge 2

Write a generic class Pair<T> and attempt to add a method inside it that does `T value = new T();`. Show the resulting compile error and explain why erasure is the root cause.

📄 View solution
Challenge 3

Write a bounded generic method smaller(T a, T b) that requires T to implement Comparable<T>, returning whichever argument is smaller, and demonstrate it working with both Integer and String.

📄 View solution

Chapter 1 Quick Reference

  • Type erasure: generic type info is used for compile-time checking only, then discarded — one shared class/bytecode for every type argument
  • C++ (cpp2-1) and Rust (rust2-3) instead use monomorphization — a real, distinct compiled copy per concrete type
  • Consequences of erasure: no `new T()`, no `new T[]`, no `instanceof List<String>` — only the raw type survives
  • Bounded type parameters (`T extends X`) recover real compile-time checking despite erasure
  • Java generics still can't hold raw primitives — boxing (java1-2) is unavoidable, unlike C++/Rust's monomorphized code
  • Next chapter: Collections Framework II — Map, Set, and Queue in depth