Master Java Generics and Type Safety
Java Generics
Generics were introduced in Java 5 to provide compile-time type safety and eliminate the need for explicit casting. They enable classes, interfaces, and methods to operate on objects of various types while providing compile-time type checking.
Why Generics?
Before Generics (Java 1.4 and earlier)
// Without generics - prone to runtime errors
List list = new ArrayList();
list.add("Hello");
list.add("World");
list.add(123); // Oops! Integer in a list intended for Strings
String str1 = (String) list.get(0); // Explicit casting required
String str2 = (String) list.get(1); // Explicit casting required
String str3 = (String) list.get(2); // ClassCastException at runtime!
With Generics (Java 5+)
// With generics - type-safe and clean
List<String> list = new ArrayList<>();
list.add("Hello");
list.add("World");
// list.add(123); // Compile-time error!
String str1 = list.get(0); // No casting needed
String str2 = list.get(1); // No casting needed
// Type safety guaranteed at compile time
Basic Generic Syntax
Generic Classes
// Generic class definition
public class Box<T> {
private T content;
public Box(T content) {
this.content = content;
}
public T getContent() {
return content;
}
public void setContent(T content) {
this.content = content;
}
public boolean isEmpty() {
return content == null;
}
}
// Usage
Box<String> stringBox = new Box<>("Hello World");
Box<Integer> integerBox = new Box<>(42);
Box<List<String>> listBox = new Box<>(Arrays.asList("a", "b", "c"));
String value = stringBox.getContent(); // No casting needed
Integer number = integerBox.getContent(); // Type-safe
Generic Interfaces
// Generic interface
public interface Repository<T, ID> {
T save(T entity);
Optional<T> findById(ID id);
List<T> findAll();
void deleteById(ID id);
boolean existsById(ID id);
}
// Implementation
public class UserRepository implements Repository<User, Long> {
private final Map<Long, User> users = new HashMap<>();
@Override
public User save(User user) {
users.put(user.getId(), user);
return user;
}
@Override
public Optional<User> findById(Long id) {
return Optional.ofNullable(users.get(id));
}
@Override
public List<User> findAll() {
return new ArrayList<>(users.values());
}
@Override
public void deleteById(Long id) {
users.remove(id);
}
@Override
public boolean existsById(Long id) {
return users.containsKey(id);
}
}
Generic Methods
public class Utility {
// Generic static method
public static <T> void swap(T[] array, int i, int j) {
if (i >= 0 && i < array.length && j >= 0 && j < array.length) {
T temp = array[i];
array[i] = array[j];
array[j] = temp;
}
}
// Generic method with return type
public static <T> T getFirstElement(List<T> list) {
if (list.isEmpty()) {
return null;
}
return list.get(0);
}
// Generic method with multiple type parameters
public static <K, V> Map<K, V> createMap(K[] keys, V[] values) {
if (keys.length != values.length) {
throw new IllegalArgumentException("Arrays must have the same length");
}
Map<K, V> map = new HashMap<>();
for (int i = 0; i < keys.length; i++) {
map.put(keys[i], values[i]);
}
return map;
}
}
// Usage
String[] names = {"Alice", "Bob", "Charlie"};
Utility.swap(names, 0, 2); // Charlie, Bob, Alice
Integer[] numbers = {1, 2, 3, 4, 5};
Integer first = Utility.getFirstElement(Arrays.asList(numbers)); // 1
String[] keys = {"name", "age", "city"};
Object[] values = {"John", 30, "New York"};
Map<String, Object> person = Utility.createMap(keys, values);
Bounded Type Parameters
Upper Bounds (extends)
// T must be Number or a subclass of Number
public class NumberBox<T extends Number> {
private T number;
public NumberBox(T number) {
this.number = number;
}
public T getNumber() {
return number;
}
// Can call Number methods
public double getDoubleValue() {
return number.doubleValue();
}
public int getIntValue() {
return number.intValue();
}
// Generic method with bounded type
public static <T extends Number> double sum(List<T> numbers) {
return numbers.stream()
.mapToDouble(Number::doubleValue)
.sum();
}
}
// Usage
NumberBox<Integer> intBox = new NumberBox<>(42);
NumberBox<Double> doubleBox = new NumberBox<>(3.14);
// NumberBox<String> stringBox = new NumberBox<>("Hello"); // Compile error!
List<Integer> integers = Arrays.asList(1, 2, 3, 4, 5);
double sum = NumberBox.sum(integers); // 15.0
Multiple Bounds
// T must implement both Serializable and Comparable
public class SortableStorable<T extends Serializable & Comparable<T>> {
private final List<T> items = new ArrayList<>();
public void add(T item) {
items.add(item);
}
public List<T> getSorted() {
List<T> sorted = new ArrayList<>(items);
Collections.sort(sorted); // Possible because T extends Comparable
return sorted;
}
public void saveToFile(String filename) throws IOException {
// Possible because T extends Serializable
try (ObjectOutputStream oos = new ObjectOutputStream(
new FileOutputStream(filename))) {
oos.writeObject(items);
}
}
}
// Usage with String (implements both Serializable and Comparable)
SortableStorable<String> stringStorage = new SortableStorable<>();
stringStorage.add("Charlie");
stringStorage.add("Alice");
stringStorage.add("Bob");
List<String> sorted = stringStorage.getSorted(); // [Alice, Bob, Charlie]
Wildcards
Unbounded Wildcards (?)
public class WildcardExamples {
// Accepts list of any type
public static void printList(List<?> list) {
for (Object item : list) {
System.out.println(item);
}
}
// Get size of any list
public static int getSize(List<?> list) {
return list.size();
}
// Clear any list
public static void clearList(List<?> list) {
list.clear();
}
}
// Usage
List<String> strings = Arrays.asList("a", "b", "c");
List<Integer> integers = Arrays.asList(1, 2, 3);
List<Double> doubles = Arrays.asList(1.1, 2.2, 3.3);
WildcardExamples.printList(strings);
WildcardExamples.printList(integers);
WildcardExamples.printList(doubles);
Upper Bounded Wildcards (? extends)
public class UpperBoundedExamples {
// Accepts List of Number or any subclass of Number
public static double calculateSum(List<? extends Number> numbers) {
double sum = 0;
for (Number number : numbers) {
sum += number.doubleValue();
}
return sum;
}
// Copy from source (producer) to destination
public static <T> void copy(List<? extends T> source, List<? super T> destination) {
for (T item : source) {
destination.add(item);
}
}
// Find maximum in a list of comparable items
public static <T extends Comparable<T>> T findMax(List<? extends T> list) {
if (list.isEmpty()) {
return null;
}
T max = list.get(0);
for (T item : list) {
if (item.compareTo(max) > 0) {
max = item;
}
}
return max;
}
}
// Usage
List<Integer> integers = Arrays.asList(1, 2, 3, 4, 5);
List<Double> doubles = Arrays.asList(1.1, 2.2, 3.3);
List<Float> floats = Arrays.asList(1.1f, 2.2f, 3.3f);
double sum1 = UpperBoundedExamples.calculateSum(integers); // 15.0
double sum2 = UpperBoundedExamples.calculateSum(doubles); // 6.6
double sum3 = UpperBoundedExamples.calculateSum(floats); // 6.6
Integer maxInt = UpperBoundedExamples.findMax(integers); // 5
String maxStr = UpperBoundedExamples.findMax(Arrays.asList("apple", "banana", "cherry")); // cherry
Lower Bounded Wildcards (? super)
public class LowerBoundedExamples {
// Accepts List that can hold T or any superclass of T
public static <T> void addNumbers(List<? super T> list, T... items) {
for (T item : items) {
list.add(item);
}
}
// Fill list with specific value
public static <T> void fill(List<? super T> list, T value, int count) {
list.clear();
for (int i = 0; i < count; i++) {
list.add(value);
}
}
}
// Usage
List<Number> numbers = new ArrayList<>();
List<Object> objects = new ArrayList<>();
// Can add Integer to List<Number> or List<Object>
LowerBoundedExamples.addNumbers(numbers, 1, 2, 3);
LowerBoundedExamples.addNumbers(objects, 1, 2, 3);
// Fill with specific values
LowerBoundedExamples.fill(numbers, 42, 5); // [42, 42, 42, 42, 42]
LowerBoundedExamples.fill(objects, "hello", 3); // [hello, hello, hello]
PECS Principle (Producer Extends, Consumer Super)
public class PECSExample {
// Producer: use ? extends T (reading from collection)
public static <T> void copyAll(Collection<? extends T> source,
Collection<? super T> destination) {
for (T item : source) { // Reading from source (producer)
destination.add(item); // Writing to destination (consumer)
}
}
// Example of producer pattern
public static double sumAll(Collection<? extends Number> numbers) {
double sum = 0;
for (Number num : numbers) { // Reading (producing) numbers
sum += num.doubleValue();
}
return sum;
}
// Example of consumer pattern
public static void addAll(Collection<? super Integer> collection) {
collection.add(1); // Writing (consuming) integers
collection.add(2);
collection.add(3);
}
}
// Usage demonstrating PECS
List<Integer> integers = Arrays.asList(1, 2, 3);
List<Number> numbers = new ArrayList<>();
List<Object> objects = new ArrayList<>();
// Copy integers to numbers (Integer extends Number)
PECSExample.copyAll(integers, numbers);
// Copy integers to objects (Integer is Object)
PECSExample.copyAll(integers, objects);
// Sum any collection of Numbers or subtypes
double sum = PECSExample.sumAll(integers); // Works with Integer list
sum = PECSExample.sumAll(numbers); // Works with Number list
// Add integers to any collection that can hold Integer or supertypes
PECSExample.addAll(numbers); // List<Number> can hold Integer
PECSExample.addAll(objects); // List<Object> can hold Integer
Real-World Example: Generic Data Access Layer
// Generic entity base class
public abstract class BaseEntity<ID> {
protected ID id;
public ID getId() {
return id;
}
public void setId(ID id) {
this.id = id;
}
}
// Concrete entities
public class User extends BaseEntity<Long> {
private String username;
private String email;
// Constructors, getters, setters...
}
public class Product extends BaseEntity<String> {
private String name;
private BigDecimal price;
// Constructors, getters, setters...
}
// Generic repository interface
public interface GenericRepository<T extends BaseEntity<ID>, ID> {
T save(T entity);
Optional<T> findById(ID id);
List<T> findAll();
void delete(T entity);
void deleteById(ID id);
boolean exists(ID id);
long count();
}
// Generic repository implementation
public abstract class AbstractRepository<T extends BaseEntity<ID>, ID>
implements GenericRepository<T, ID> {
protected final Map<ID, T> storage = new HashMap<>();
@Override
public T save(T entity) {
storage.put(entity.getId(), entity);
return entity;
}
@Override
public Optional<T> findById(ID id) {
return Optional.ofNullable(storage.get(id));
}
@Override
public List<T> findAll() {
return new ArrayList<>(storage.values());
}
@Override
public void delete(T entity) {
storage.remove(entity.getId());
}
@Override
public void deleteById(ID id) {
storage.remove(id);
}
@Override
public boolean exists(ID id) {
return storage.containsKey(id);
}
@Override
public long count() {
return storage.size();
}
}
// Specific repository implementations
public class UserRepository extends AbstractRepository<User, Long> {
public Optional<User> findByUsername(String username) {
return storage.values().stream()
.filter(user -> username.equals(user.getUsername()))
.findFirst();
}
public List<User> findByEmailDomain(String domain) {
return storage.values().stream()
.filter(user -> user.getEmail().endsWith("@" + domain))
.collect(Collectors.toList());
}
}
public class ProductRepository extends AbstractRepository<Product, String> {
public List<Product> findByPriceRange(BigDecimal minPrice, BigDecimal maxPrice) {
return storage.values().stream()
.filter(product -> product.getPrice().compareTo(minPrice) >= 0)
.filter(product -> product.getPrice().compareTo(maxPrice) <= 0)
.collect(Collectors.toList());
}
public List<Product> findByNameContaining(String keyword) {
return storage.values().stream()
.filter(product -> product.getName().toLowerCase()
.contains(keyword.toLowerCase()))
.collect(Collectors.toList());
}
}
// Generic service layer
public abstract class GenericService<T extends BaseEntity<ID>, ID> {
protected final GenericRepository<T, ID> repository;
public GenericService(GenericRepository<T, ID> repository) {
this.repository = repository;
}
public T create(T entity) {
validate(entity);
return repository.save(entity);
}
public Optional<T> findById(ID id) {
return repository.findById(id);
}
public List<T> findAll() {
return repository.findAll();
}
public T update(T entity) {
if (!repository.exists(entity.getId())) {
throw new EntityNotFoundException("Entity not found");
}
return repository.save(entity);
}
public void deleteById(ID id) {
if (!repository.exists(id)) {
throw new EntityNotFoundException("Entity not found");
}
repository.deleteById(id);
}
protected abstract void validate(T entity);
}
// Specific service implementations
public class UserService extends GenericService<User, Long> {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
super(userRepository);
this.userRepository = userRepository;
}
@Override
protected void validate(User user) {
if (user.getUsername() == null || user.getUsername().trim().isEmpty()) {
throw new IllegalArgumentException("Username cannot be empty");
}
if (user.getEmail() == null || !user.getEmail().contains("@")) {
throw new IllegalArgumentException("Invalid email");
}
}
public Optional<User> findByUsername(String username) {
return userRepository.findByUsername(username);
}
public List<User> findByEmailDomain(String domain) {
return userRepository.findByEmailDomain(domain);
}
}
Type Erasure
Understanding Type Erasure
public class TypeErasureExample {
// These two methods have the same erasure - compilation error!
// public void process(List<String> list) { }
// public void process(List<Integer> list) { }
// This is what the compiler sees after type erasure:
// public void process(List list) { }
// public void process(List list) { }
// Correct approach - different method names or parameters
public void processStrings(List<String> list) {
for (String s : list) {
System.out.println(s.toUpperCase());
}
}
public void processIntegers(List<Integer> list) {
for (Integer i : list) {
System.out.println(i * 2);
}
}
// Generic method parameters survive erasure better
public <T> void processGeneric(List<T> list, Consumer<T> processor) {
for (T item : list) {
processor.accept(item);
}
}
}
// Runtime type checking limitations
public class TypeErasureLimitations {
@SuppressWarnings("unchecked")
public static void demonstrateErasure() {
List<String> stringList = new ArrayList<>();
List<Integer> integerList = new ArrayList<>();
// Both have the same runtime type
System.out.println(stringList.getClass() == integerList.getClass()); // true
// Cannot check generic type at runtime
// if (stringList instanceof List<String>) { } // Compilation error
// Can only check raw type
if (stringList instanceof List) {
System.out.println("It's a List");
}
// Unsafe cast - compiles but may fail at runtime
List rawList = stringList;
rawList.add(123); // No compile error, but wrong!
// This will throw ClassCastException when accessed
try {
for (String s : stringList) {
System.out.println(s);
}
} catch (ClassCastException e) {
System.out.println("Type safety violated!");
}
}
}
Best Practices
1. Use Generic Types When Possible
// Good - specific and type-safe
Map<String, User> userCache = new HashMap<>();
List<Product> products = new ArrayList<>();
// Avoid - raw types lose type safety
Map userCache = new HashMap(); // Raw type
List products = new ArrayList(); // Raw type
2. Follow PECS Principle
// Good - follows PECS
public void copyElements(Collection<? extends T> source,
Collection<? super T> destination) {
for (T element : source) {
destination.add(element);
}
}
// Less flexible - exact type required
public void copyElements(Collection<T> source, Collection<T> destination) {
// More restrictive
}
3. Use Bounded Wildcards for Flexibility
// Good - flexible, accepts any Number subtype
public static double sum(List<? extends Number> numbers) {
return numbers.stream().mapToDouble(Number::doubleValue).sum();
}
// Less flexible - only accepts exact Number type
public static double sum(List<Number> numbers) {
return numbers.stream().mapToDouble(Number::doubleValue).sum();
}
4. Prefer Generic Methods Over Generic Classes When Appropriate
// Good - generic method when only one method needs to be generic
public class Utility {
public static <T> void swap(T[] array, int i, int j) {
T temp = array[i];
array[i] = array[j];
array[j] = temp;
}
}
// Overkill - making entire class generic for one method
public class Utility<T> {
public void swap(T[] array, int i, int j) {
// ...
}
}
Common Pitfalls
1. Creating Generic Arrays
// This doesn't work - cannot create generic arrays
// T[] array = new T[10]; // Compilation error
// Workarounds:
@SuppressWarnings("unchecked")
public T[] createArray(int size) {
return (T[]) new Object[size]; // Unchecked cast
}
// Better - use collections
public List<T> createList(int size) {
return new ArrayList<>();
}
2. Static Context and Type Parameters
public class GenericClass<T> {
// Error - cannot reference T in static context
// private static T staticField;
// public static T getStaticValue() { return null; }
// Correct - static generic method
public static <U> U getStaticValue(U value) {
return value;
}
}
3. Exception Handling with Generics
// Cannot catch generic exception types
// catch (T e) { } // Compilation error
// Cannot throw generic exceptions directly
// throw new T(); // Compilation error
// Workaround for throwing
public <T extends Exception> void throwException(T exception) throws T {
throw exception;
}
Summary
- Generics provide compile-time type safety and eliminate casting
- Use bounded type parameters (
<T extends SomeClass>
) to restrict types - Apply wildcards (
?
,? extends
,? super
) for flexibility - Follow PECS principle: Producer Extends, Consumer Super
- Be aware of type erasure limitations
- Prefer generic methods when only specific methods need to be generic
- Use generics consistently throughout your API for type safety
Generics are essential for writing type-safe, reusable Java code and are the foundation for the Java Collections Framework and modern Java development patterns.