Why Generics#
// Without generics — no type safety, requires casting
List rawList = new ArrayList();
rawList.add("hello");
rawList.add(42); // No compile error!
String s = (String) rawList.get(1); // ClassCastException at RUNTIME
// With generics — compile-time type safety
List<String> typedList = new ArrayList<>();
typedList.add("hello");
// typedList.add(42); // Compile error!
String s = typedList.get(0); // No cast needed
Generic Classes#
public class Pair<A, B> {
private final A first;
private final B second;
public Pair(A first, B second) {
this.first = first;
this.second = second;
}
public A first() { return first; }
public B second() { return second; }
public <C> Pair<C, B> mapFirst(Function<A, C> mapper) {
return new Pair<>(mapper.apply(first), second);
}
@Override
public String toString() {
return "(%s, %s)".formatted(first, second);
}
}
// Usage
Pair<String, Integer> nameAge = new Pair<>("Alice", 30);
Pair<Integer, Integer> coordinates = new Pair<>(10, 20);
Generic Interface with Multiple Implementations#
public interface Repository<T, ID> {
Optional<T> findById(ID id);
List<T> findAll();
T save(T entity);
void deleteById(ID id);
boolean existsById(ID id);
}
public class InMemoryUserRepository implements Repository<User, String> {
private final Map<String, User> store = new ConcurrentHashMap<>();
@Override
public Optional<User> findById(String id) {
return Optional.ofNullable(store.get(id));
}
@Override
public List<User> findAll() {
return List.copyOf(store.values());
}
@Override
public User save(User user) {
store.put(user.id(), user);
return user;
}
@Override
public void deleteById(String id) {
store.remove(id);
}
@Override
public boolean existsById(String id) {
return store.containsKey(id);
}
}
Bounded Type Parameters#
// Upper bound: T must be Comparable
public static <T extends Comparable<T>> T max(T a, T b) {
return a.compareTo(b) >= 0 ? a : b;
}
max(3, 7); // 7
max("apple", "banana"); // "banana"
// Multiple bounds: T must implement BOTH interfaces
public static <T extends Comparable<T> & Serializable> void process(T item) {
// Can use both Comparable and Serializable methods
}
// Bounded in class definition
public class SortedList<T extends Comparable<T>> {
private final List<T> items = new ArrayList<>();
public void add(T item) {
int index = Collections.binarySearch(items, item);
if (index < 0) index = -(index + 1);
items.add(index, item);
}
public List<T> asList() {
return Collections.unmodifiableList(items);
}
}
Wildcards#
graph TD
A["Object"] --> B["Number"]
B --> C["Integer"]
B --> D["Double"]
B --> E["Long"]
style A fill:#e1f5fe
style B fill:#fff3e0
style C fill:#c8e6c9
style D fill:#c8e6c9
style E fill:#c8e6c9// Unbounded wildcard: ? — read-only, any type
void printAll(List<?> list) {
for (Object item : list) {
System.out.println(item);
}
// list.add(???); // Can't add — don't know the type!
}
// Upper bounded: ? extends Number — "producer" (read from)
double sum(List<? extends Number> numbers) {
double total = 0;
for (Number n : numbers) {
total += n.doubleValue(); // Can read as Number
}
// numbers.add(42); // Can't add — might be List<Double>!
return total;
}
sum(List.of(1, 2, 3)); // Works with List<Integer>
sum(List.of(1.5, 2.5)); // Works with List<Double>
// Lower bounded: ? super Integer — "consumer" (write to)
void addNumbers(List<? super Integer> list) {
list.add(1); // Can add Integer (or subtype)
list.add(2);
// Integer x = list.get(0); // Can't read as Integer — might be List<Object>!
Object x = list.get(0); // Can only read as Object
}
addNumbers(new ArrayList<Integer>()); // OK
addNumbers(new ArrayList<Number>()); // OK
addNumbers(new ArrayList<Object>()); // OK
PECS: Producer Extends, Consumer Super#
// Copy from source (producer) to destination (consumer)
public static <T> void copy(List<? extends T> source, List<? super T> dest) {
for (T item : source) {
dest.add(item);
}
}
List<Integer> ints = List.of(1, 2, 3);
List<Number> nums = new ArrayList<>();
copy(ints, nums); // Integer extends Number, Number super Integer
Type Erasure#
What Happens at Runtime#
// What you write:
List<String> strings = new ArrayList<>();
List<Integer> integers = new ArrayList<>();
// What the JVM sees (after erasure):
List strings = new ArrayList(); // Generic type info is GONE
List integers = new ArrayList();
// Consequence:
strings.getClass() == integers.getClass(); // true! Both are ArrayList
// You CANNOT do:
// if (obj instanceof List<String>) {} // Compile error
// new T() // Compile error
// new T[] // Compile error
// T.class // Compile error
Working Around Erasure#
// Problem: can't create generic arrays
// T[] array = new T[10]; // Won't compile
// Solution 1: pass Class<T> token
public class TypeSafeContainer<T> {
private final Class<T> type;
private final Object[] storage;
private int size = 0;
public TypeSafeContainer(Class<T> type, int capacity) {
this.type = type;
this.storage = new Object[capacity];
}
public void add(T item) {
storage[size++] = item;
}
public T get(int index) {
return type.cast(storage[index]); // Safe cast using Class token
}
}
// Solution 2: use @SuppressWarnings with documented reason
@SuppressWarnings("unchecked") // Safe: array never exposed outside class
T[] createArray(int size) {
return (T[]) new Object[size];
}
Erasure and Overloading#
// This WON'T compile — both erase to process(List)
// void process(List<String> strings) { }
// void process(List<Integer> integers) { }
// Fix: use different method names or a single generic method
<T> void process(List<T> items, Class<T> type) { }
Generic Methods#
public class Collections {
// Generic method — type parameter declared before return type
public static <T> List<T> singletonList(T item) {
return List.of(item);
}
// Multiple type parameters
public static <K, V> Map<K, V> mapOf(K key, V value) {
return Map.of(key, value);
}
// Bounded generic method
public static <T extends Comparable<T>> Optional<T> min(Collection<T> items) {
return items.stream().min(Comparable::compareTo);
}
// Recursive type bound (self-referential)
public static <T extends Comparable<T>> void sort(List<T> list) {
list.sort(Comparable::compareTo);
}
}
// Type inference — compiler figures out T
List<String> list = Collections.singletonList("hello"); // T = String
Optional<Integer> min = Collections.min(List.of(3, 1, 4)); // T = Integer
Hypothetical Use Case: Type-Safe Builder#
public class QueryBuilder<T> {
private final Class<T> entityType;
private final List<String> conditions = new ArrayList<>();
private String orderBy;
private int limit = -1;
private QueryBuilder(Class<T> entityType) {
this.entityType = entityType;
}
public static <T> QueryBuilder<T> from(Class<T> entityType) {
return new QueryBuilder<>(entityType);
}
public QueryBuilder<T> where(String condition) {
conditions.add(condition);
return this;
}
public QueryBuilder<T> orderBy(String field) {
this.orderBy = field;
return this;
}
public QueryBuilder<T> limit(int limit) {
this.limit = limit;
return this;
}
public Query<T> build() {
return new Query<>(entityType, conditions, orderBy, limit);
}
}
// Usage — fully type-safe
Query<User> query = QueryBuilder.from(User.class)
.where("age > 18")
.where("active = true")
.orderBy("name")
.limit(50)
.build();
List<User> users = database.execute(query); // Returns List<User>, not List<Object>
Key Takeaways#
- Generics provide compile-time type safety — catch errors before runtime
- Type erasure means generic info is gone at runtime — can’t do
instanceof List<String> - PECS — Producer Extends, Consumer Super (read from extends, write to super)
- Bounded types (
<T extends X>) restrict what types can be used - Wildcards (
?) are for flexibility in method parameters - Pass
Class<T> when you need type info at runtime (erasure workaround) - Prefer generic methods over raw types — never use raw
List, always List<T>