Deep Dive into Java Generics
As a Java engineer, we may have worked with Generics at some point without understanding fully how they work. Generics was introduced in JDK 5.0 to reduce bugs mostly related to type checking. In the next sections, we would dive into the need for Generics and typical scenarios in demonstrating its uses.
What are Generics?
For starters, imagine that you were tasked with creating a class that takes in an integer in its constructor, and prints out its super class and the value passed in. One of the simplest ways to go about this would be to implement it as shown in the code snippet below:
public class IntegerSuperPrinter {
private final Integer integer;
public IntegerSuperPrinter(Integer integer) {
this.integer = integer;
superClass();
}
private void printSuperClass() {
System.out.println("My Super Class is: "+ integer.getClass().getSuperclass());
System.out.println("Printing the value passed: "+integer);
}
The output would look something like this when you pass in 2:
This seems like it does the work right? What happens when we want to have a printer for Long, Double, etc? We would have to create a different class for each one which may lead to class implosion.
A better way would be to have a type assigned during initialization, otherwise referred to as generic type. A generic type is a class or interface that is parameterized over types. The assigned type could also be used to restrict and enforce what can be passed in the constructor. Then this generic class can do the printing. There won’t be any need to create multiple classes.
The code snippet below shows a better way to implement to super class printer feature:
public class GenericSuperClassPrinter<T> {
private final T object;
public GenericSuperClassPrinter(T object) {
this.object = object;
superClassPrinter();
}
public void superClassPrinter() {
System.out.println("My Super Class is: "+ object.getClass().getSuperclass());
System.out.println("Printing the value passed: "+object);
}
}
With the GenericSuperClassPrinter, we have introduced a class definition with a diamond operator <T> and changed the field to be of generic type T. The type parameter definition ‘T’ can be anything but by convention, T is used to define the type parameter.
To use GenericSuperClassPrinter above, you have to assign the type of the object ‘T’ in the diamond operator during initialization:
GenericSuperClassPrinter<Integer> integer = new GenericSuperClassPrinter<>(3);
GenericSuperClassPrinter<Double> doubleValue = new GenericSuperClassPrinter<>(3.0);
GenericSuperClassPrinter<Long> longValue = new GenericSuperClassPrinter<>(3L);
GenericSuperClassPrinter<String> string = new GenericSuperClassPrinter<>("3");
It is not possible to use primitive types with Generics; Only reference types can be used.
Benefits of Generics
We have discussed one of the benefits of using Generics-to prevent duplication of classes leading to class implosion. There are other benefits that come with Generics:
Consider another example of having a list that stores any object. We could write something as follows:
List objectHolder = new ArrayList();
objectHolder.add("Hello World");
objectHolder.add(200);
If we wanted to perform an operation on the integer in the list, we must cast the value to Integer reference type:
Integer intValue = (Integer) objectHolder.get(1);
This is because there is no guarantee that the value returned would be an integer since it is a list that holds any object. This is error-prone because a developer can mistakenly cast to the wrong type leading to a ClassCastException:
Integer intValue = (Integer) objectHolder.get(0); //will lead to ClassCastException
Generics also eliminate the need to use casts as shown above thereby making our code a little cleaner.
Multiple Type Generics
A generic class can have multiple type parameters by placing a comma-separated list of types between the angle brackets. An example of this can be found in Map.java used to hold key-pair values.
For demonstration purposes, Let’s implement a custom Key-Value pair generic class to hold any key and any value respectively:
public class KeyValueGenerics<K, V> {
private final K key;
private final V value;
public KeyValueGenerics(K key, V value) {
this.key = key;
this.value = value;
}
public K getKey() {
return key;
}
public V getValue() {
return value;
}
}
The above class can be used to hold any two different types during initialization. To use this class:
KeyValueGenerics<String, Integer> stringIntegerKeyValue = new KeyValueGenerics<>("two", 2);
System.out.println(stringIntegerKeyValue.getKey()); //two
System.out.println(stringIntegerKeyValue.getValue());//2
KeyValueGenerics<Integer, Double> integerDoubleKeyValue = new KeyValueGenerics<>(3, 3.0);
System.out.println(integerDoubleKeyValue.getKey()); //3
System.out.println(integerDoubleKeyValue.getValue());//3.0
Note that with generics, we have eliminated the need for casting. So, getKey() and getValue() will always return the type used during initialization, thereby eliminating the need for casting.
Bounded Generics
As much as Generics offer a way we can create classes to store different objects, sometimes we may need to restrict what types can be specified during initialization. This can be done by using the extends or the super keyword in the implementation class to restrict usage to upper bound or lower bound respectively.
Restricting to a subtype of a specific type is known as upper bound. The syntax is found below:
<T extends UpperBoundType>
Likewise, restricting to specific type or a supertype of that specific type is known as lower bound:
<T super LowerBoundType>
For instance, consider the example that we started with in this article. If we wanted to restrict the the type of GenericSuperClassPrinter to lower bound of type Number (i.e class of Number or its subclasses), we would use the extends keyword as shown below:
public class BoundedGenericSuperClassPrinter<T extends Number> {
private final T object;
public BoundedGenericSuperClassPrinter(T object) {
this.object = object;
superClassPrinter();
}
public void superClassPrinter() {
---
}
}
BoundedGenericSuperClassPrinter will restrict every type specified to be of type Number.
BoundedGenericSuperClassPrinter<Integer> integerBound = new BoundedGenericSuperClassPrinter<>(2);//retrict to only Number
BoundedGenericSuperClassPrinter<Integer> stringBound =
new BoundedGenericSuperClassPrinter<>("2");//compile error since String is not a subclass of Number
Generic Methods
We can have a generic method that may accept different types of argument. The arguments can contain generic types and the method in turn can return generic types.
Let’s implement a simple method that sums all the values in a list. We would use Generics to ensure that the list passed in the method call is of type Number:
public <N extends Number> double add(List<N> numbers){
return numbers.stream()
.mapToDouble(Number::doubleValue)
.sum();
}
Wildcards with Generics
Wildcards refer to unknown types. In Java, we use ‘?’ to represent wildcards. It can be used as type of parameter, field or local variable and sometimes as a return type (though it better to be more specific instead of specifying an unknown return type).
Let’s implement a simple method that takes in a list of wildcard objects and prints out the values:
public void printOut(List<?> values) {
values.forEach(System.out::println);
}
The wildcard (?) specified in the snippet above indicates that this method takes in a list of any object. Wildcards can also be restricted using upper bounds or lower bounds.
To restrict the above code snippet to accept only list of Number, we would use the extends keyword as shown below:
public void printOut(List<? extends Number> values) {
values.forEach(System.out::println);
}
Conclusion
Generics in Java is one of the most powerful features in Java language. With Generics, we can ensure stronger type-checking and eliminate casts thereby building less error-prone applications.