Everything You Need to Know About Generics in Java: A Practical Guide

Light
7 min readJan 27, 2024

--

Generics in Java are a feature that allows you to write code that can work with different types of objects, without having to repeat the same code for each type. Generics can help you achieve type safety, avoid type casting, and reduce code duplication. In this post, I will explain the basics of generics in Java, how to use them with collections, classes, methods, and wildcards, and some of the benefits and limitations of generics.

What are generics in Java?

Generics in Java are a way of parameterizing types, meaning that you can specify a type as a placeholder (usually denoted by a single uppercase letter, such as T, E, K, V, etc.) and then use that type as an argument when you declare or use a generic class, interface, or method. For example, you can declare a generic class as follows:

class Box<T> {
private T item;

public void setItem(T item) {
this.item = item;
}

public T getItem() {
return item;
}
}

Here, T is a type parameter that represents the type of the item that the box can hold. You can create an instance of this class by specifying the actual type argument, such as:

Box<Integer> intBox = new Box<Integer>();
intBox.setItem(10);
Integer i = intBox.getItem(); // no need to cast

Here, Integer is the type argument that replaces the type parameter T in the class declaration. You can also use the diamond operator (<>) to let the compiler infer the type argument from the context, such as:

Box<String> strBox = new Box<>(); // same as new Box<String>()
strBox.setItem("Hello");
String s = strBox.getItem();

You can also declare generic interfaces and methods using the same syntax. For example, you can declare a generic interface as follows:

interface Comparable<T> {
int compareTo(T other);
}

Here, T is a type parameter that represents the type of the object that can be compared with the current object. You can implement this interface by specifying the actual type argument, such as:

class Student implements Comparable<Student> {
private String name;
private int age;

public Student(String name, int age) {
this.name = name;
this.age = age;
}
}

Generics and Collections

Collections are data structures that store and manipulate groups of objects. Java provides a framework of interfaces and classes for working with collections, such as List, Set, Map, ArrayList, HashSet, HashMap, etc. However, before Java 5, collections could only store objects of type Object, which meant that you had to cast the objects to their actual types when you retrieved them from the collection. For example, if you had a list of strings, you had to do something like this:

List list = new ArrayList();
list.add("Hello");
list.add("World");
String s1 = (String) list.get(0); // cast is required
String s2 = (String) list.get(1); // cast is required

This was not only tedious, but also unsafe, because you could accidentally add an object of a different type to the list, and get a ClassCastException at runtime. For example, if you added an integer to the list, you would get an error when you tried to cast it to a string:

list.add(10); // no compile-time error
String s3 = (String) list.get(2); // ClassCastException at runtime

To solve this problem, Java 5 introduced generics for collections, which allowed you to specify the type of the objects that the collection can store, using the same syntax as generic classes and interfaces. For example, you can declare a list of strings as follows:

List<String> list = new ArrayList<String>();
list.add("Hello");
list.add("World");
String s1 = list.get(0); // no cast is required
String s2 = list.get(1); // no cast is required

Here, String is the type argument that replaces the type parameter E in the interface declaration of List<E>. By using generics, you can achieve type safety, meaning that the compiler will check that you only add objects of the specified type to the collection, and that you do not need to cast the objects when you retrieve them from the collection. For example, if you try to add an integer to the list, you will get a compile-time error:

list.add(10); // compile-time error

You can also use the diamond operator (<>) to let the compiler infer the type argument from the context, such as:

List<String> list = new ArrayList<>(); // same as new ArrayList<String>()

You can use generics with any collection interface or class that supports them, such as Set<E>, Map<K,V>, HashSet<E>, HashMap<K,V>, etc. For example, you can declare a set of integers as follows:

Set<Integer> set = new HashSet<Integer>();
set.add(1);
set.add(2);
set.add(3);
Integer i = set.iterator().next(); // no cast is required

You can also declare a map of strings and integers as follows:

Map<String, Integer> map = new HashMap<String, Integer>();
map.put("One", 1);
map.put("Two", 2);
map.put("Three", 3);
Integer i = map.get("One"); // no cast is required

Using generics with collections can make your code more readable, robust, and efficient. However, there are some limitations and challenges that you need to be aware of when using generics with collections, such as type erasure, raw types, bounded type parameters, and wildcards.

Type erasure : This is the process by which the compiler removes the type parameters and replaces them with their bounds or Object if they are unbounded. This is done to ensure compatibility with older versions of Java that do not support generics. However, this also means that some type information is lost at runtime, and some operations that depend on the type parameters are not possible.

For example, you cannot create an instance of a generic type using the new operator, such as new T(), because the compiler does not know the actual type of T at runtime. You also cannot use instance of or cast to a generic type, such as (List<String>) list, because the compiler erases the type parameter and treats the list as a raw type.

A raw type is a generic type that does not specify any type argument, such as List. Using raw types can cause warnings and errors, because they are not type-safe and can lead to ClassCastException at runtime.

Therefore, you should avoid using raw types and always specify the type arguments when using generic types.

Bounded type parameters : These are type parameters that have a restriction on the types that can be used as arguments. There are two types of bounds: upper bounds and lower bounds. An upper bound is specified using the extends keyword, and it means that the type argument must be a subtype of the bound.

For example, <T extends Number> means that T can be any type that is a subclass of Number, such as Integer, Double, etc. A lower bound is specified using the super keyword, and it means that the type argument must be a supertype of the bound.

For example, <T super Integer> means that T can be any type that is a superclass of Integer, such as Number, Object, etc. Bounded type parameters can help you restrict the types that can be used with your generic classes and methods, and also enable you to use some methods that are defined in the bound type.

For example, if you have a generic method that calculates the sum of a list of numbers, you can use an upper bound to ensure that the list elements are of a numeric type, and also use the doubleValue() method that is defined in the Number class:

public static <T extends Number> double sum(List<T> list) {
double sum = 0.0;
for (T element : list) {
sum += element.doubleValue();
}
return sum;
}

Wildcards : These are special type arguments that represent unknown types. There are three types of wildcards: unbounded, upper bounded, and lower bounded. An unbounded wildcard is specified using the ? symbol, and it means that the type argument can be any type.

For example, List<?> means a list of any type. An upper bounded wildcard is specified using the ? extends keyword, and it means that the type argument can be any type that is a subtype of the bound.

For example, List<? extends Number> means a list of any type that is a subclass of Number. A lower bounded wildcard is specified using the ? super keyword, and it means that the type argument can be any type that is a supertype of the bound.

For example, List<? super Integer> means a list of any type that is a superclass of Integer. Wildcards can help you write more flexible and generic code, especially when you are dealing with collections that are both input and output parameters.

For example, if you have a generic method that copies the elements from one list to another, you can use wildcards to make the method more general and accept any type of lists, as long as the source list is a subtype of the destination list:

public static void copy(List<? super Number> dest, List<? extends Number> src) {
for (Number n : src) {
dest.add(n);
}
}

Here, the source list can be any list of numbers or their subclasses, such as List<Integer>, List<Double>, etc. The destination list can be any list of numbers or their superclasses, such as List<Number>, List<Object>, etc. This method follows the PECS rule, which stands for Producer Extends, Consumer Super. This rule means that you should use an upper bounded wildcard when the generic type acts as a producer of data, and a lower bounded wildcard when the generic type acts as a consumer of data.

I hope this explanation helps you understand limitations of generics.

--

--

Light
Light

Written by Light

Upcoming CEO of "A" company

No responses yet