- Create and use generic classes with type parameters
- Implement generic methods in both generic and non-generic classes
- Master bounded type parameters and multiple constraints
- Apply generics in real-world scenarios like factories and utilities
Generic Classes and Methods in Java
You've seen how generics work. Now let's build your own generic classes and methods—the kind of flexible, reusable code that powers libraries like the Collections Framework.
Designing Generic Classes
When to Make a Class Generic
Make a class generic when:
- It operates on data without needing to know the specific type
- The same logic applies to many different types
- You want type safety without code duplication
Containers, wrappers, data structures, utility classes—all good candidates.
Single Type Parameter Classes
The simplest generic class has one type parameter:
public class Container<T> {
private T item;
public Container() {
this.item = null;
}
public Container(T item) {
this.item = item;
}
public T getItem() {
return item;
}
public void setItem(T item) {
this.item = item;
}
public boolean hasItem() {
return item != null;
}
public void clear() {
this.item = null;
}
}
Key insight: Inside the class, T acts as a regular type. You can declare fields, parameters, and return types with it.
Multiple Type Parameters
When your class works with multiple types, use multiple parameters:
public class Pair<K, V> {
private final K first;
private final V second;
public Pair(K first, V second) {
this.first = first;
this.second = second;
}
public K getFirst() { return first; }
public V getSecond() { return second; }
@Override
public String toString() {
return "(" + first + ", " + second + ")";
}
// Static factory method (note the type parameters)
public static <K, V> Pair<K, V> of(K first, V second) {
return new Pair<>(first, second);
}
}
Usage demonstrates the flexibility:
Pair<String, Integer> nameAge = new Pair<>("Alice", 30);
Pair<Integer, Integer> coordinates = new Pair<>(10, 20);
Pair<String, List<String>> personHobbies = new Pair<>("Bob", Arrays.asList("reading", "gaming"));
// Using static factory
Pair<String, Double> product = Pair.of("Coffee", 2.99);
Self-Referential Type Parameters
Sometimes a class needs to refer to itself in its type parameter. This pattern is common in builder classes and comparable implementations:
public abstract class Builder<T extends Builder<T>> {
protected String name;
protected int value;
@SuppressWarnings("unchecked")
public T withName(String name) {
this.name = name;
return (T) this; // Return the actual subtype
}
@SuppressWarnings("unchecked")
public T withValue(int value) {
this.value = value;
return (T) this;
}
public abstract Object build();
}
// Concrete builder
public class ProductBuilder extends Builder<ProductBuilder> {
private String category;
public ProductBuilder withCategory(String category) {
this.category = category;
return this; // Returns ProductBuilder, not Builder
}
@Override
public Product build() {
return new Product(name, value, category);
}
}
// Fluent usage
Product product = new ProductBuilder()
.withName("Laptop")
.withValue(999)
.withCategory("Electronics")
.build();
Generic Methods
Generic methods have their own type parameters, independent of any class-level parameters.
Why Generic Methods?
Use generic methods when:
- The type varies per method call, not per instance
- The method is in a non-generic class (like utility classes)
- You need a different type parameter than the class provides
Syntax and Declaration
The type parameter comes before the return type:
public class ArrayUtils {
// Generic method to find an element
public static <T> int indexOf(T[] array, T target) {
for (int i = 0; i < array.length; i++) {
if (array[i].equals(target)) {
return i;
}
}
return -1;
}
// Generic method to swap elements
public static <T> void swap(T[] array, int i, int j) {
T temp = array[i];
array[i] = array[j];
array[j] = temp;
}
// Generic method with multiple type parameters
public static <T, R> R[] transform(T[] source, Function<T, R> transformer,
IntFunction<R[]> arrayFactory) {
R[] result = arrayFactory.apply(source.length);
for (int i = 0; i < source.length; i++) {
result[i] = transformer.apply(source[i]);
}
return result;
}
}
Type Inference
Usually, you don't need to specify the type explicitly - the compiler infers it:
String[] names = {"Alice", "Bob", "Charlie"};
int index = ArrayUtils.indexOf(names, "Bob"); // T inferred as String
Integer[] numbers = {1, 2, 3};
ArrayUtils.swap(numbers, 0, 2); // T inferred as Integer
// Transform strings to their lengths
String[] words = {"hello", "world"};
Integer[] lengths = ArrayUtils.transform(
words,
String::length,
Integer[]::new
); // T=String, R=Integer inferred
When inference fails, you can specify types explicitly:
// Explicit type argument (rarely needed)
ArrayUtils.<String>swap(names, 0, 1);
Generic Methods in Generic Classes
A generic class can have generic methods with additional type parameters:
public class Box<T> {
private T content;
public void setContent(T content) {
this.content = content;
}
public T getContent() {
return content;
}
// Generic method with its own type parameter U
public <U> Pair<T, U> pairWith(U other) {
return new Pair<>(content, other);
}
// Another generic method
public <R> R transform(Function<T, R> transformer) {
return transformer.apply(content);
}
}
// Usage
Box<String> box = new Box<>();
box.setContent("Hello");
Pair<String, Integer> pair = box.pairWith(42); // U is Integer
Integer length = box.transform(String::length); // R is Integer
Design Patterns with Generics
Generic Factory Pattern
public interface Factory<T> {
T create();
}
public class StringFactory implements Factory<String> {
@Override
public String create() {
return new String();
}
}
public class ListFactory<E> implements Factory<List<E>> {
@Override
public List<E> create() {
return new ArrayList<>();
}
}
// Using factories
Factory<String> stringFactory = new StringFactory();
Factory<List<Integer>> intListFactory = new ListFactory<>();
String str = stringFactory.create();
List<Integer> numbers = intListFactory.create();
Generic Repository Pattern
public interface Repository<T, ID> {
T findById(ID id);
List<T> findAll();
T save(T entity);
void delete(ID id);
boolean exists(ID id);
}
public class UserRepository implements Repository<User, Long> {
private Map<Long, User> storage = new HashMap<>();
@Override
public User findById(Long id) {
return storage.get(id);
}
@Override
public List<User> findAll() {
return new ArrayList<>(storage.values());
}
@Override
public User save(User entity) {
storage.put(entity.getId(), entity);
return entity;
}
@Override
public void delete(Long id) {
storage.remove(id);
}
@Override
public boolean exists(Long id) {
return storage.containsKey(id);
}
}
Generic Event System
public interface Event { }
public interface EventListener<E extends Event> {
void onEvent(E event);
}
public class EventBus {
private Map<Class<?>, List<EventListener<?>>> listeners = new HashMap<>();
public <E extends Event> void subscribe(Class<E> eventType, EventListener<E> listener) {
listeners.computeIfAbsent(eventType, k -> new ArrayList<>()).add(listener);
}
@SuppressWarnings("unchecked")
public <E extends Event> void publish(E event) {
List<EventListener<?>> eventListeners = listeners.get(event.getClass());
if (eventListeners != null) {
for (EventListener<?> listener : eventListeners) {
((EventListener<E>) listener).onEvent(event);
}
}
}
}
// Usage
class UserCreatedEvent implements Event {
private final String username;
public UserCreatedEvent(String username) { this.username = username; }
public String getUsername() { return username; }
}
EventBus bus = new EventBus();
bus.subscribe(UserCreatedEvent.class, event ->
System.out.println("User created: " + event.getUsername()));
bus.publish(new UserCreatedEvent("alice"));
Best Practices
1. Choose Meaningful Type Parameter Names
// Poor naming
class Thing<X, Y, Z> { }
// Good naming
class Cache<K, V> { } // Key and Value
class Converter<S, T> { } // Source and Target
2. Prefer Generic Methods to Raw Types
// Avoid
public Object getFirst(List list) {
return list.get(0);
}
// Prefer
public <T> T getFirst(List<T> list) {
return list.get(0);
}
3. Use Bounded Types When Appropriate
When you need specific capabilities from the type:
// Can call compareTo() because T must implement Comparable
public <T extends Comparable<T>> T max(T a, T b) {
return a.compareTo(b) > 0 ? a : b;
}
4. Document Type Parameters
/**
* A generic cache implementation.
* @param <K> the type of keys maintained by this cache
* @param <V> the type of cached values
*/
public class Cache<K, V> { }
Generic classes and methods let you write once, use everywhere—with type safety. Declare type parameters after the class name (class Box<T>) or before the return type for methods (<T> T method(T arg)). The compiler infers types from context. Document your type parameters so people know what they're for.
