🧩 Java Generics: A Comprehensive Guide
📚 Introduction to Java Generics
Generics are one of Java's most powerful features, introduced in Java 5 (also known as Java 1.5) to enhance type safety and eliminate the need for explicit casting. Before generics, collections could hold any type of object, which often led to runtime errors when incorrect types were retrieved. Generics solve this problem by allowing you to specify the exact types that a collection or class can work with at compile time.
Think of generics as a way to tell the Java compiler what types of objects a particular class can work with. This enables the compiler to perform stronger type checking at compile time, reducing the risk of ClassCastExceptions and other runtime errors.
In this comprehensive tutorial, we'll explore:
- What generics are and why they're important
- How to use generic classes, interfaces, and methods
- Type parameters and bounded types
- Wildcards and their applications
- Common pitfalls and best practices
- Real-world applications and use cases
Whether you're a beginner just starting with Java or an intermediate developer looking to deepen your understanding, this guide will provide you with a solid foundation in Java generics.
🔍 Understanding Java Generics
What Are Generics in Java?
Generics allow you to create classes, interfaces, and methods that operate on a type parameter. This type parameter is a placeholder for a specific type that will be provided when the code is used. The most common example is Java's collections framework, which uses generics extensively:
// Without generics (pre-Java 5)
List myList = new ArrayList();
myList.add("Hello");
myList.add(42); // This is allowed but might cause problems later
String s = (String) myList.get(0); // Explicit casting required
String s2 = (String) myList.get(1); // Runtime error: ClassCastException
// With generics (Java 5 and later)
List<String> myList = new ArrayList<>();
myList.add("Hello");
myList.add(42); // Compile-time error: incompatible types
String s = myList.get(0); // No casting needed
Benefits of Generics in Java
- Type Safety: Detect type errors at compile time rather than runtime
- Elimination of Casts: No need for explicit casting when retrieving elements
- Enabling Generic Algorithms: Write methods that work on collections of different types
- Code Reusability: Create classes and methods that can work with any type
Generic Classes and Interfaces in Java
A generic class or interface is defined with one or more type parameters enclosed in angle brackets (<>
). By convention, type parameters are single uppercase letters, with the most common being:
T
- TypeE
- ElementK
- KeyV
- ValueN
- NumberS
,U
,V
etc. - 2nd, 3rd, 4th types
Here's a simple example of a generic class:
public class Box<T> {
private T content;
public void put(T content) {
this.content = content;
}
public T get() {
return content;
}
}
You can use this class with any type:
Box<String> stringBox = new Box<>();
stringBox.put("Hello Generics");
String s = stringBox.get(); // No casting needed
Box<Integer> intBox = new Box<>();
intBox.put(42);
Integer i = intBox.get(); // No casting needed
Generic Methods
You can also create generic methods within non-generic classes:
public class Utilities {
// Generic method
public static <T> void printArray(T[] array) {
for (T element : array) {
System.out.println(element);
}
}
}
To call a generic method, you can either let the compiler infer the type or specify it explicitly:
// Let the compiler infer the type
String[] strings = {"Hello", "World"};
Utilities.printArray(strings);
// Explicitly specify the type
Utilities.<String>printArray(strings);
🛠️ Java Advanced Generics Concepts
Type Parameters and Bounded Types
Sometimes you want to restrict the types that can be used with your generic class or method. You can do this using bounded type parameters:
// T must be a subclass of Number
public class MathBox<T extends Number> {
private T value;
public MathBox(T value) {
this.value = value;
}
public double sqrt() {
return Math.sqrt(value.doubleValue());
}
}
Now you can only use MathBox
with numeric types:
MathBox<Integer> intBox = new MathBox<>(16);
System.out.println(intBox.sqrt()); // 4.0
MathBox<String> stringBox = new MathBox<>("Hello"); // Compile-time error
You can also specify multiple bounds:
// T must implement both Comparable and Serializable
public class DataProcessor<T extends Comparable<T> & Serializable> {
// ...
}
Wildcards in Java Generics
Wildcards are represented by the ?
symbol and are used when you want to work with unknown types. There are three types of wildcards:
- Unbounded Wildcard (
?
): Represents any type - Upper Bounded Wildcard (
? extends Type
): Represents any type that is a subtype ofType
- Lower Bounded Wildcard (
? super Type
): Represents any type that is a supertype ofType
Unbounded Wildcard in Java Generics
Use the unbounded wildcard when you want to work with a collection of unknown type:
public static void printList(List<?> list) {
for (Object elem : list) {
System.out.println(elem);
}
}
This method can accept a list of any type:
List<Integer> integers = Arrays.asList(1, 2, 3);
List<String> strings = Arrays.asList("one", "two", "three");
printList(integers);
printList(strings);
Upper Bounded Wildcard in Java Generics
Use the upper bounded wildcard when you want to work with a collection of a specific type or its subtypes:
public static double sumOfList(List<? extends Number> list) {
double sum = 0.0;
for (Number num : list) {
sum += num.doubleValue();
}
return sum;
}
This method can accept a list of Number
or any of its subclasses:
List<Integer> integers = Arrays.asList(1, 2, 3);
List<Double> doubles = Arrays.asList(1.1, 2.2, 3.3);
System.out.println(sumOfList(integers)); // 6.0
System.out.println(sumOfList(doubles)); // 6.6
Lower Bounded Wildcard in Java Generics
Use the lower bounded wildcard when you want to work with a collection of a specific type or its supertypes:
public static void addNumbers(List<? super Integer> list) {
for (int i = 1; i <= 5; i++) {
list.add(i);
}
}
This method can accept a list of Integer
or any of its superclasses:
List<Integer> integers = new ArrayList<>();
List<Number> numbers = new ArrayList<>();
List<Object> objects = new ArrayList<>();
addNumbers(integers);
addNumbers(numbers);
addNumbers(objects);
System.out.println(integers); // [1, 2, 3, 4, 5]
System.out.println(numbers); // [1, 2, 3, 4, 5]
System.out.println(objects); // [1, 2, 3, 4, 5]
The PECS Principle (Producer Extends, Consumer Super)
A helpful mnemonic for using wildcards correctly is "PECS":
- Use
extends
when you only get values out of a structure (Producer) - Use
super
when you only put values into a structure (Consumer) - Use explicit type parameters when you both get and put values
// Producer - only gets values (extends)
public void processElements(List<? extends Number> elements) {
for (Number n : elements) {
// Process n
}
}
// Consumer - only puts values (super)
public void addElements(List<? super Integer> list) {
list.add(1);
list.add(2);
}
// Both get and put - use explicit type parameter
public <T> void copyElements(List<T> source, List<T> destination) {
destination.addAll(source);
}
📋 Complete Example: Generic Data Structures
Let's explore a comprehensive example that demonstrates various aspects of generics by implementing a simple generic data structure - a pair class that can hold two values of different types:
Click to expand the code
/**
* A generic class that holds a pair of values of different types.
*
* @param <K> the type of the first value
* @param <V> the type of the second value
*/
public class Pair<K, V> {
private K first;
private V second;
/**
* Constructs a new Pair with the specified values.
*
* @param first the first value
* @param second the second value
*/
public Pair(K first, V second) {
this.first = first;
this.second = second;
}
/**
* Returns the first value of the pair.
*
* @return the first value
*/
public K getFirst() {
return first;
}
/**
* Sets the first value of the pair.
*
* @param first the new first value
*/
public void setFirst(K first) {
this.first = first;
}
/**
* Returns the second value of the pair.
*
* @return the second value
*/
public V getSecond() {
return second;
}
/**
* Sets the second value of the pair.
*
* @param second the new second value
*/
public void setSecond(V second) {
this.second = second;
}
/**
* Returns a string representation of the pair.
*
* @return a string representation of the pair
*/
@Override
public String toString() {
return "(" + first + ", " + second + ")";
}
/**
* Checks if this pair is equal to another object.
*
* @param obj the object to compare with
* @return true if the objects are equal, false otherwise
*/
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
Pair<?, ?> pair = (Pair<?, ?>) obj;
if (first != null ? !first.equals(pair.first) : pair.first != null) return false;
return second != null ? second.equals(pair.second) : pair.second == null;
}
/**
* Returns a hash code for this pair.
*
* @return a hash code for this pair
*/
@Override
public int hashCode() {
int result = first != null ? first.hashCode() : 0;
result = 31 * result + (second != null ? second.hashCode() : 0);
return result;
}
/**
* Creates a new pair with the specified values.
*
* @param <K> the type of the first value
* @param <V> the type of the second value
* @param first the first value
* @param second the second value
* @return a new pair with the specified values
*/
public static <K, V> Pair<K, V> of(K first, V second) {
return new Pair<>(first, second);
}
}
Now, let's create a utility class with generic methods to work with our Pair
class:
/**
* Utility class with generic methods for working with Pair objects.
*/
public class PairUtils {
/**
* Swaps the elements of a pair.
*
* @param <K> the type of the first value
* @param <V> the type of the second value
* @param pair the pair to swap
* @return a new pair with the elements swapped
*/
public static <K, V> Pair<V, K> swap(Pair<K, V> pair) {
return new Pair<>(pair.getSecond(), pair.getFirst());
}
/**
* Creates a pair with duplicate values.
*
* @param <T> the type of both values
* @param value the value to duplicate
* @return a new pair with the same value for both elements
*/
public static <T> Pair<T, T> duplicate(T value) {
return new Pair<>(value, value);
}
/**
* Finds the minimum and maximum values in a list.
*
* @param <T> the type of the elements in the list
* @param list the list to search
* @return a pair containing the minimum and maximum values
* @throws IllegalArgumentException if the list is empty
*/
public static <T extends Comparable<T>> Pair<T, T> findMinMax(List<T> list) {
if (list == null || list.isEmpty()) {
throw new IllegalArgumentException("List cannot be null or empty");
}
T min = list.get(0);
T max = list.get(0);
for (T item : list) {
if (item.compareTo(min) < 0) {
min = item;
}
if (item.compareTo(max) > 0) {
max = item;
}
}
return new Pair<>(min, max);
}
/**
* Filters a list of pairs based on a predicate for the first element.
*
* @param <K> the type of the first value in the pairs
* @param <V> the type of the second value in the pairs
* @param pairs the list of pairs to filter
* @param predicate the predicate to apply to the first element
* @return a new list containing only the pairs that satisfy the predicate
*/
public static <K, V> List<Pair<K, V>> filterByFirst(
List<Pair<K, V>> pairs,
Predicate<K> predicate) {
List<Pair<K, V>> result = new ArrayList<>();
for (Pair<K, V> pair : pairs) {
if (predicate.test(pair.getFirst())) {
result.add(pair);
}
}
return result;
}
/**
* Maps a list of pairs to a new list by applying a function to each pair.
*
* @param <K> the type of the first value in the input pairs
* @param <V> the type of the second value in the input pairs
* @param <R> the type of the result
* @param pairs the list of pairs to map
* @param mapper the function to apply to each pair
* @return a new list containing the results of applying the function to each pair
*/
public static <K, V, R> List<R> mapPairs(
List<Pair<K, V>> pairs,
Function<Pair<K, V>, R> mapper) {
List<R> result = new ArrayList<>();
for (Pair<K, V> pair : pairs) {
result.add(mapper.apply(pair));
}
return result;
}
}
Finally, let's create a main class to demonstrate the usage of our generic classes and methods:
import java.util.Arrays;
import java.util.List;
import java.util.function.Predicate;
import java.util.function.Function;
/**
* Main class to demonstrate the usage of generics.
*/
public class GenericsDemo {
public static void main(String[] args) {
// Creating pairs with different types
Pair<String, Integer> nameAndAge = new Pair<>("John", 30);
Pair<Double, Double> point = new Pair<>(3.14, 2.71);
Pair<String, String> fullName = new Pair<>("John", "Doe");
System.out.println("Name and age: " + nameAndAge);
System.out.println("Point: " + point);
System.out.println("Full name: " + fullName);
// Using the static factory method
Pair<String, List<Integer>> personAndScores = Pair.of("Alice", Arrays.asList(95, 87, 92));
System.out.println("Person and scores: " + personAndScores);
// Using the utility methods
Pair<Integer, String> ageAndName = PairUtils.swap(nameAndAge);
System.out.println("Age and name (swapped): " + ageAndName);
Pair<String, String> duplicatedName = PairUtils.duplicate("Bob");
System.out.println("Duplicated name: " + duplicatedName);
// Finding min and max
List<Integer> numbers = Arrays.asList(5, 2, 8, 1, 9, 3);
Pair<Integer, Integer> minMax = PairUtils.findMinMax(numbers);
System.out.println("Min and max numbers: " + minMax);
// Filtering pairs
List<Pair<String, Integer>> people = Arrays.asList(
new Pair<>("Alice", 25),
new Pair<>("Bob", 30),
new Pair<>("Charlie", 35),
new Pair<>("David", 40)
);
Predicate<String> startsWithA = name -> name.startsWith("A");
List<Pair<String, Integer>> peopleStartingWithA = PairUtils.filterByFirst(people, startsWithA);
System.out.println("People whose names start with 'A': " + peopleStartingWithA);
// Mapping pairs
Function<Pair<String, Integer>, String> formatPerson = pair ->
pair.getFirst() + " is " + pair.getSecond() + " years old";
List<String> formattedPeople = PairUtils.mapPairs(people, formatPerson);
System.out.println("Formatted people: " + formattedPeople);
// Demonstrating type safety
// The following would cause a compile-time error:
// nameAndAge.setFirst(42); // Type mismatch: cannot convert from int to String
// nameAndAge.setSecond("Thirty"); // Type mismatch: cannot convert from String to Integer
// Demonstrating bounded type parameters
List<Integer> intList = Arrays.asList(1, 2, 3, 4, 5);
List<Double> doubleList = Arrays.asList(1.1, 2.2, 3.3, 4.4, 5.5);
System.out.println("Sum of integers: " + sumOfList(intList));
System.out.println("Sum of doubles: " + sumOfList(doubleList));
// Demonstrating wildcards
List<Object> objectList = Arrays.asList("Hello", 42, 3.14, true);
printList(objectList);
printList(intList);
printList(doubleList);
}
/**
* Calculates the sum of a list of numbers.
*
* @param <T> the type of numbers in the list
* @param list the list of numbers
* @return the sum of the numbers
*/
public static <T extends Number> double sumOfList(List<T> list) {
double sum = 0.0;
for (T item : list) {
sum += item.doubleValue();
}
return sum;
}
/**
* Prints the elements of a list.
*
* @param list the list to print
*/
public static void printList(List<?> list) {
System.out.println("List contents:");
for (Object item : list) {
System.out.println(" - " + item + " (" + item.getClass().getSimpleName() + ")");
}
}
}
Code Explanation
This example demonstrates various aspects of generics:
-
Generic Classes:
Pair<K, V>
is a generic class with two type parameters- It provides type-safe access to its elements
-
Generic Methods:
PairUtils.swap()
swaps the elements of a pairPairUtils.duplicate()
creates a pair with duplicate valuesPairUtils.findMinMax()
finds the minimum and maximum values in a listPairUtils.filterByFirst()
filters a list of pairs based on a predicatePairUtils.mapPairs()
maps a list of pairs to a new list
-
Bounded Type Parameters:
<T extends Comparable<T>>
infindMinMax()
ensures that the elements can be compared<T extends Number>
insumOfList()
ensures that the elements are numbers
-
Wildcards:
List<?>
inprintList()
allows it to accept a list of any type
-
Type Safety:
- The compiler prevents assigning incompatible types to the elements of a pair
⚠️ Common Pitfalls with Java Generics
1. Type Erasure in Java Generics
Java implements generics using type erasure, which means that generic type information is removed at runtime. This has several implications:
// These are equivalent at runtime due to type erasure
List<String> stringList = new ArrayList<>();
List<Integer> intList = new ArrayList<>();
// This check doesn't work as expected
if (stringList.getClass() == intList.getClass()) {
System.out.println("Same class!"); // This will print
}
You also cannot create arrays of generic types directly:
// This won't compile
Pair<String, Integer>[] pairs = new Pair<String, Integer>[10]; // Error
// You need to use a raw type and cast
@SuppressWarnings("unchecked")
Pair<String, Integer>[] pairs = (Pair<String, Integer>[]) new Pair[10]; // Works but with unchecked warning
2. Java Generics and Primitive Types
Generics only work with reference types, not primitive types:
// This won't compile
List<int> intList = new ArrayList<>(); // Error
// You need to use the corresponding wrapper class
List<Integer> intList = new ArrayList<>(); // Works
3. Cannot Create Instances of Type Parameters
You cannot create instances of type parameters because the actual type is unknown at runtime:
public class Factory<T> {
public T create() {
return new T(); // Error: Cannot instantiate the type T
}
}
A workaround is to pass a Class
object or a supplier function:
public class Factory<T> {
private final Class<T> type;
public Factory(Class<T> type) {
this.type = type;
}
public T create() throws InstantiationException, IllegalAccessException {
return type.newInstance(); // Works, but requires a no-arg constructor
}
}
4. Cannot Use instanceof
with Generic Types
Due to type erasure, you cannot use instanceof
with generic types:
List<String> stringList = new ArrayList<>();
// This won't compile
if (stringList instanceof List<String>) { // Error
// ...
}
// You can only use the raw type
if (stringList instanceof List) { // Works
// ...
}
5. Overloading Methods with Different Generic Types
You cannot overload methods that differ only in their generic type parameters:
public void process(List<String> list) {
// ...
}
public void process(List<Integer> list) { // Error: erasure collision
// ...
}
This is because after type erasure, both methods would have the same signature: process(List list)
.
6. Confusion with Wildcards
Wildcards can be confusing, especially when deciding between ? extends T
and ? super T
:
// This allows reading from the list but not writing to it
void readOnly(List<? extends Number> list) {
Number n = list.get(0); // OK
list.add(1); // Error: cannot add to a list with an unknown element type
}
// This allows writing to the list but limits reading from it
void writeOnly(List<? super Integer> list) {
list.add(1); // OK
Integer i = list.get(0); // Error: cannot convert from Object to Integer
}
Remember the PECS principle: "Producer Extends, Consumer Super".
🏆 Java Generics Best Practices
1. Use Generics for Type Safety
Always use generics when working with collections to ensure type safety:
// Bad practice
List names = new ArrayList(); // Raw type
names.add("John");
names.add(42); // This will cause problems later
// Good practice
List<String> names = new ArrayList<>();
names.add("John");
names.add(42); // Compile-time error
2. Eliminate Unchecked Warnings
Address unchecked warnings by using proper generic types or suppressing them with @SuppressWarnings("unchecked")
when you're sure the code is safe:
// Bad practice
@SuppressWarnings("unchecked") // Suppressing without understanding the risk
List<String> names = (List<String>) getNames();
// Good practice
// Either ensure getNames() returns List<String>
List<String> names = getNames();
// Or suppress with a comment explaining why it's safe
@SuppressWarnings("unchecked") // Safe because getNames() always returns a List of Strings
List<String> names = (List<String>) getNames();
3. Use Diamond Operator
Since Java 7, you can use the diamond operator (<>
) to avoid repeating generic type information:
// Before Java 7
Map<String, List<Integer>> map = new HashMap<String, List<Integer>>();
// Java 7 and later
Map<String, List<Integer>> map = new HashMap<>(); // Diamond operator
4. Prefer Lists to Arrays
When working with generics, prefer lists to arrays because arrays have runtime type checking, which can lead to unexpected errors with generics:
// Problematic with arrays
Pair<String, Integer>[] pairs = (Pair<String, Integer>[]) new Pair[10];
Object[] objects = pairs;
objects[0] = new Pair<Integer, String>(1, "One"); // Runtime error
// Better with lists
List<Pair<String, Integer>> pairs = new ArrayList<>();
5. Use Bounded Wildcards Appropriately
Use bounded wildcards to make your code more flexible:
// Too restrictive
void processStrings(List<String> list) {
// ...
}
// More flexible
void processStrings(List<? extends CharSequence> list) {
// ...
}
6. Follow the PECS Principle
Remember "Producer Extends, Consumer Super":
// Producer - use extends
void copyElements(List<? extends T> source, List<T> destination) {
for (T item : source) {
destination.add(item);
}
}
// Consumer - use super
void addElements(List<? super T> destination, T... elements) {
for (T item : elements) {
destination.add(item);
}
}
7. Provide Type Witnesses When Necessary
Sometimes the compiler needs help inferring types. In such cases, provide explicit type arguments:
// Compiler error: cannot infer type arguments
List<String> list = Collections.emptyList();
// Provide type witness
List<String> list = Collections.<String>emptyList();
8. Use Generic Methods for Flexibility
Prefer generic methods over methods that use wildcards when you need to preserve the relationship between parameter types:
// Less flexible with wildcards
void copy(List<? extends T> source, List<? super T> destination) {
// ...
}
// More flexible with generic method
<T> void copy(List<T> source, List<T> destination) {
// ...
}
🌐 Why Generics Matter in Java
1. Type Safety
Generics provide compile-time type checking, which helps catch errors early:
List<String> names = new ArrayList<>();
names.add("John");
names.add(42); // Compile-time error: incompatible types
String name = names.get(0); // No casting needed
Without generics, these errors would only be caught at runtime, potentially causing application crashes.
2. Code Reusability
Generics allow you to write code that works with different types without duplication:
// Without generics, you would need separate methods for each type
public void printStringArray(String[] array) { /* ... */ }
public void printIntegerArray(Integer[] array) { /* ... */ }
// With generics, a single method works for all types
public <T> void printArray(T[] array) { /* ... */ }
3. Performance
Generics eliminate the need for casting, which can improve performance:
// Without generics
List names = new ArrayList();
names.add("John");
String name = (String) names.get(0); // Casting required
// With generics
List<String> names = new ArrayList<>();
names.add("John");
String name = names.get(0); // No casting needed
4. API Design
Generics are essential for designing flexible and type-safe APIs:
// Java's collections API uses generics extensively
List<String> names = new ArrayList<>();
Map<String, Integer> ages = new HashMap<>();
Optional<User> user = findUserById(id);
CompletableFuture<Result> future = fetchDataAsync();
5. Framework Development
Generics are crucial for developing frameworks that can work with any type:
// Example from Spring Framework
@Autowired
private Repository<User> userRepository;
// Example from Hibernate
Session session = sessionFactory.openSession();
Query<Product> query = session.createQuery("from Product", Product.class);
List<Product> products = query.list();
📝 Exercises and Mini-Projects
Let's put your knowledge of generics into practice with some exercises and mini-projects.
Exercise 1: Java Generic Stack Implementation
Task: Implement a generic stack data structure with push, pop, peek, and isEmpty operations.
Requirements:
- The stack should be generic, allowing it to work with any type
- Implement the basic stack operations: push, pop, peek, and isEmpty
- Ensure proper exception handling for operations like pop and peek on an empty stack
- Include a method to convert the stack to an array
Solution:
Click to reveal solution
import java.util.Arrays;
import java.util.EmptyStackException;
/**
* A generic implementation of a stack data structure.
*
* @param <T> the type of elements in the stack
*/
public class GenericStack<T> {
private static final int DEFAULT_CAPACITY = 10;
private Object[] elements;
private int size;
/**
* Constructs a new stack with the default capacity.
*/
public GenericStack() {
elements = new Object[DEFAULT_CAPACITY];
size = 0;
}
/**
* Constructs a new stack with the specified capacity.
*
* @param initialCapacity the initial capacity of the stack
* @throws IllegalArgumentException if the initial capacity is negative
*/
public GenericStack(int initialCapacity) {
if (initialCapacity < 0) {
throw new IllegalArgumentException("Initial capacity cannot be negative");
}
elements = new Object[initialCapacity];
size = 0;
}
/**
* Pushes an element onto the top of the stack.
*
* @param element the element to push
*/
public void push(T element) {
ensureCapacity();
elements[size++] = element;
}
/**
* Removes and returns the element at the top of the stack.
*
* @return the element at the top of the stack
* @throws EmptyStackException if the stack is empty
*/
public T pop() {
if (isEmpty()) {
throw new EmptyStackException();
}
@SuppressWarnings("unchecked")
T element = (T) elements[--size];
elements[size] = null; // Help garbage collection
return element;
}
/**
* Returns the element at the top of the stack without removing it.
*
* @return the element at the top of the stack
* @throws EmptyStackException if the stack is empty
*/
public T peek() {
if (isEmpty()) {
throw new EmptyStackException();
}
@SuppressWarnings("unchecked")
T element = (T) elements[size - 1];
return element;
}
/**
* Checks if the stack is empty.
*
* @return true if the stack is empty, false otherwise
*/
public boolean isEmpty() {
return size == 0;
}
/**
* Returns the number of elements in the stack.
*
* @return the number of elements in the stack
*/
public int size() {
return size;
}
/**
* Converts the stack to an array.
*
* @return an array containing all elements in the stack
*/
public Object[] toArray() {
return Arrays.copyOf(elements, size);
}
/**
* Converts the stack to an array of the specified type.
*
* @param <U> the type of the array elements
* @param type the class object of the array element type
* @return an array containing all elements in the stack
* @throws ArrayStoreException if the runtime type of the specified array
* is not a supertype of the runtime type of every element in this stack
*/
@SuppressWarnings("unchecked")
public <U> U[] toArray(Class<U> type) {
U[] array = (U[]) java.lang.reflect.Array.newInstance(type, size);
for (int i = 0; i < size; i++) {
array[i] = (U) elements[i];
}
return array;
}
/**
* Ensures that the stack has enough capacity to add a new element.
*/
private void ensureCapacity() {
if (size == elements.length) {
int newCapacity = elements.length * 2;
elements = Arrays.copyOf(elements, newCapacity);
}
}
/**
* Clears all elements from the stack.
*/
public void clear() {
for (int i = 0; i < size; i++) {
elements[i] = null;
}
size = 0;
}
/**
* Returns a string representation of the stack.
*
* @return a string representation of the stack
*/
@Override
public String toString() {
if (isEmpty()) {
return "[]";
}
StringBuilder sb = new StringBuilder("[");
for (int i = 0; i < size; i++) {
sb.append(elements[i]);
if (i < size - 1) {
sb.append(", ");
}
}
sb.append("]");
return sb.toString();
}
}
Let's test our generic stack implementation:
/**
* Test class for the GenericStack implementation.
*/
public class GenericStackTest {
public static void main(String[] args) {
// Test with String elements
GenericStack<String> stringStack = new GenericStack<>();
System.out.println("Testing stack with String elements:");
stringStack.push("First");
stringStack.push("Second");
stringStack.push("Third");
System.out.println("Stack: " + stringStack);
System.out.println("Size: " + stringStack.size());
System.out.println("Peek: " + stringStack.peek());
System.out.println("Pop: " + stringStack.pop());
System.out.println("Stack after pop: " + stringStack);
// Test with Integer elements
GenericStack<Integer> intStack = new GenericStack<>();
System.out.println("\nTesting stack with Integer elements:");
intStack.push(10);
intStack.push(20);
intStack.push(30);
System.out.println("Stack: " + intStack);
System.out.println("Size: " + intStack.size());
// Convert to array
Integer[] intArray = intStack.toArray(Integer.class);
System.out.println("Array: " + Arrays.toString(intArray));
// Clear the stack
intStack.clear();
System.out.println("Stack after clear: " + intStack);
// Test exception handling
try {
intStack.pop();
} catch (EmptyStackException e) {
System.out.println("Exception caught: " + e.getClass().getSimpleName());
}
}
}
Your turn: Try extending the GenericStack
class to implement a GenericQueue
with operations like enqueue
, dequeue
, and peek
. Make sure to handle edge cases and exceptions properly.
Exercise 2: Java Generic Binary Tree example
Task: Implement a generic binary tree data structure with methods for insertion, traversal, and searching.
Requirements:
- The binary tree should be generic, allowing it to work with any comparable type
- Implement methods for inserting elements, traversing the tree (in-order, pre-order, post-order), and searching for elements
- Include a method to check if the tree is balanced
Solution:
Click to reveal solution
import java.util.ArrayList;
import java.util.List;
/**
* A generic implementation of a binary tree data structure.
*
* @param <T> the type of elements in the tree, must be comparable
*/
public class GenericBinaryTree<T extends Comparable<T>> {
private Node<T> root;
/**
* Inner class representing a node in the binary tree.
*
* @param <T> the type of element in the node
*/
private static class Node<T> {
T data;
Node<T> left;
Node<T> right;
Node(T data) {
this.data = data;
this.left = null;
this.right = null;
}
}
/**
* Constructs an empty binary tree.
*/
public GenericBinaryTree() {
root = null;
}
/**
* Inserts an element into the binary tree.
*
* @param data the element to insert
*/
public void insert(T data) {
root = insertRec(root, data);
}
/**
* Recursive helper method for inserting an element.
*
* @param root the root of the subtree
* @param data the element to insert
* @return the new root of the subtree
*/
private Node<T> insertRec(Node<T> root, T data) {
if (root == null) {
return new Node<>(data);
}
int comparison = data.compareTo(root.data);
if (comparison < 0) {
root.left = insertRec(root.left, data);
} else if (comparison > 0) {
root.right = insertRec(root.right, data);
}
return root;
}
/**
* Performs an in-order traversal of the binary tree.
*
* @return a list of elements in in-order
*/
public List<T> inOrderTraversal() {
List<T> result = new ArrayList<>();
inOrderTraversalRec(root, result);
return result;
}
/**
* Recursive helper method for in-order traversal.
*
* @param root the root of the subtree
* @param result the list to store the elements
*/
private void inOrderTraversalRec(Node<T> root, List<T> result) {
if (root != null) {
inOrderTraversalRec(root.left, result);
result.add(root.data);
inOrderTraversalRec(root.right, result);
}
}
/**
* Performs a pre-order traversal of the binary tree.
*
* @return a list of elements in pre-order
*/
public List<T> preOrderTraversal() {
List<T> result = new ArrayList<>();
preOrderTraversalRec(root, result);
return result;
}
/**
* Recursive helper method for pre-order traversal.
*
* @param root the root of the subtree
* @param result the list to store the elements
*/
private void preOrderTraversalRec(Node<T> root, List<T> result) {
if (root != null) {
result.add(root.data);
preOrderTraversalRec(root.left, result);
preOrderTraversalRec(root.right, result);
}
}
/**
* Performs a post-order traversal of the binary tree.
*
* @return a list of elements in post-order
*/
public List<T> postOrderTraversal() {
List<T> result = new ArrayList<>();
postOrderTraversalRec(root, result);
return result;
}
/**
* Recursive helper method for post-order traversal.
*
* @param root the root of the subtree
* @param result the list to store the elements
*/
private void postOrderTraversalRec(Node<T> root, List<T> result) {
if (root != null) {
postOrderTraversalRec(root.left, result);
postOrderTraversalRec(root.right, result);
result.add(root.data);
}
}
/**
* Searches for an element in the binary tree.
*
* @param data the element to search for
* @return true if the element is found, false otherwise
*/
public boolean search(T data) {
return searchRec(root, data);
}
/**
* Recursive helper method for searching an element.
*
* @param root the root of the subtree
* @param data the element to search for
* @return true if the element is found, false otherwise
*/
private boolean searchRec(Node<T> root, T data) {
if (root == null) {
return false;
}
int comparison = data.compareTo(root.data);
if (comparison == 0) {
return true;
} else if (comparison < 0) {
return searchRec(root.left, data);
} else {
return searchRec(root.right, data);
}
}
/**
* Checks if the binary tree is balanced.
* A balanced tree is one where the height difference between
* the left and right subtrees of any node is not more than 1.
*
* @return true if the tree is balanced, false otherwise
*/
public boolean isBalanced() {
return isBalancedRec(root) != -1;
}
/**
* Recursive helper method for checking if the tree is balanced.
*
* @param root the root of the subtree
* @return the height of the subtree if it's balanced, -1 otherwise
*/
private int isBalancedRec(Node<T> root) {
if (root == null) {
return 0;
}
int leftHeight = isBalancedRec(root.left);
if (leftHeight == -1) {
return -1;
}
int rightHeight = isBalancedRec(root.right);
if (rightHeight == -1) {
return -1;
}
if (Math.abs(leftHeight - rightHeight) > 1) {
return -1;
}
return Math.max(leftHeight, rightHeight) + 1;
}
/**
* Returns the height of the binary tree.
*
* @return the height of the tree
*/
public int height() {
return heightRec(root);
}
/**
* Recursive helper method for calculating the height.
*
* @param root the root of the subtree
* @return the height of the subtree
*/
private int heightRec(Node<T> root) {
if (root == null) {
return 0;
}
return Math.max(heightRec(root.left), heightRec(root.right)) + 1;
}
/**
* Checks if the binary tree is empty.
*
* @return true if the tree is empty, false otherwise
*/
public boolean isEmpty() {
return root == null;
}
}
Let's test our generic binary tree implementation:
/**
* Test class for the GenericBinaryTree implementation.
*/
public class GenericBinaryTreeTest {
public static void main(String[] args) {
// Test with Integer elements
GenericBinaryTree<Integer> intTree = new GenericBinaryTree<>();
intTree.insert(50);
intTree.insert(30);
intTree.insert(70);
intTree.insert(20);
intTree.insert(40);
intTree.insert(60);
intTree.insert(80);
System.out.println("Testing binary tree with Integer elements:");
System.out.println("In-order traversal: " + intTree.inOrderTraversal());
System.out.println("Pre-order traversal: " + intTree.preOrderTraversal());
System.out.println("Post-order traversal: " + intTree.postOrderTraversal());
System.out.println("Search for 40: " + intTree.search(40));
System.out.println("Search for 90: " + intTree.search(90));
System.out.println("Is balanced: " + intTree.isBalanced());
System.out.println("Height: " + intTree.height());
// Test with String elements
GenericBinaryTree<String> stringTree = new GenericBinaryTree<>();
stringTree.insert("dog");
stringTree.insert("cat");
stringTree.insert("elephant");
stringTree.insert("bird");
stringTree.insert("fish");
System.out.println("\nTesting binary tree with String elements:");
System.out.println("In-order traversal: " + stringTree.inOrderTraversal());
System.out.println("Is balanced: " + stringTree.isBalanced());
}
}
Your turn: Try extending the GenericBinaryTree
class to implement a self-balancing binary search tree (like an AVL tree or a Red-Black tree). Make sure to maintain the balance property during insertions and deletions.
🎯 Java Generics: Key Takeaways
Let's summarize the key points about generics in Java:
-
Purpose of Generics:
- Provide compile-time type safety
- Eliminate the need for explicit casting
- Enable the creation of reusable, type-safe code
-
Generic Classes and Interfaces:
- Defined with type parameters in angle brackets (
<T>
) - Type parameters are placeholders for actual types
- Common type parameter names:
T
(Type),E
(Element),K
(Key),V
(Value)
- Defined with type parameters in angle brackets (
-
Generic Methods:
- Can be defined in both generic and non-generic classes
- Type parameters are declared before the return type
- Type inference allows the compiler to determine the type arguments
-
Bounded Type Parameters:
- Restrict the types that can be used as type arguments
- Upper bounds:
<T extends UpperBound>
- Multiple bounds:
<T extends UpperBound1 & UpperBound2>
-
Wildcards:
- Unbounded:
<?>
- Upper bounded:
<? extends UpperBound>
- Lower bounded:
<? super LowerBound>
- PECS principle: "Producer Extends, Consumer Super"
- Unbounded:
-
Type Erasure:
- Generic type information is removed at runtime
- Implications for arrays, instanceof checks, and overloading
-
Best Practices:
- Use generics for type safety
- Eliminate unchecked warnings
- Use the diamond operator
- Prefer lists to arrays
- Use bounded wildcards appropriately
- Follow the PECS principle
-
Exception Handling:
- Understand the exception hierarchy
- Catch exceptions in order from most specific to most general
- Use multi-catch blocks for related exceptions
- Leverage try-with-resources for automatic resource management
- Be aware of limitations when using generics with exceptions
Remember, the best way to master generics is through practice. Try implementing the exercises and mini-projects provided in this tutorial, and then create your own projects that leverage generics to solve real-world problems.
Happy coding! 🚀