1. java
  2. /advanced
  3. /generics

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.