Introduction to Generics in Java

Image from https://unsplash.com/.

Overview

1. Introduction

Working with generics in Java allows us to create more flexible types.

In this tutorial, we'll examine what generics are, why they exist, and how to use them in classes, methods, and records.

2. Why were Generics Introduced in Java?

The J2SE5.0 introduced Generics to "allow a type, class or method to operate on objects of various types while providing compile-time type safety" as per the release documentation.

That means we can create a particular type that operates generically in the scope of a class, method, or record. The compiler can infer different types without the dreadful and error-prone casts.

Java Generics doesn't break the static type system. Once the compiler infers a generic variable type, it can't change at runtime.

2.1. Generics vs. Object

Before Generics, developers created such generics APIs using the Object class. Since all classes implicitly inherit Object, we can create a variable as Object and then cast it to a particular class.

For example, the following code was a common practice in older Java applications:

1List myList = new ArrayList();
2myList.add(1);
3Integer myNumber = (Integer) myList.get(0);

Why do we need a cast the result of get(0) to Integer?

That's because the ArrayList used an Object type as an argument in past versions, and an explicit cast is needed from Object to Integer.

However, casting adds more boilerplate code and introduces the possibility of ClassCastException. By looking at that specific code, we know that the list contains only Integers, but can we ensure that for the entire application? The answer is typically no.

So, to create a versatile type and keep the static typing system to avoid cast and type errors, we can use generics. Here's the same code shown previously, but now using generics:

1List<Integer> myList = new ArrayList<>();
2myList.add(1);
3Integer myNumber = myList.get(0);

The code above doesn't need any cast to get numbers from the list. And we can use the same List interface with other types like Double:

1List<Double> myList1 = new ArrayList<>();
2myList1.add(2.0);
3Double myNumber2 = myList1.get(0);

Imagine using casts everywhere in our applications simply to retrieve elements from a collection. Pretty annoying. That's what generics solve in Java.

Java allows Generics usage in classes, methods, and records. We'll look at how to create them in the following sections.

3. Creating Generic Classes

Let's pick the List interface as an example. Here's the signature of List in JDK 17:

1public interface List<E> extends Collection<E> { ... }

The <E> means that we're defining a generic. Thus, the type used around <> is the type of object that the list can hold. For example, the List<Integer> reference used previously creates a list that holds only integers.

We can also define our classes with generics:

1public class GenericClass<T, U, V> {
2}

In the example above, we've defined a class with three generic types that can be used anywhere in the class scope. The generic type name can be anything, though the convention is to use a single uppercase letter.

To reference GenericClass, we can do something like this:

1GenericClass<Integer, Double, String> myClass = new GenericClass<>();

Notice that the right side doesn't contain the types defined on the left side. That's because the <> operator, or diamond operator, already infers the types from the left side.

4. Defining Generic Methods

It's also possible to define generic types that exist only at the method scope. Let's look at the genericMethod() signature that accepts a generic type as an argument and returns a Double:

1private static <I> Double genericMethod(I input) { ... }

To call that method, we have two alternatives. The first one lets the compiler infer the types:

1Double result = genericMethod(2);

In that case, the compiler infers an Integer as the argument type of genericMethod(2) call.

The second one explicitly says which type we're dealing with:

1GenericClass.<Integer>genericMethod(2);

Note: Since the method is static, we used the class name to reference the method. But, we can also reference generics methods this way using the object name, this, or super.

5. Illustrating Generic Records

We can define generics in Java 14 Records like the following:

1public record GenericRecord<U, V>(U first, V second) {
2}

Now we can have generic and immutable objects!

6. Bounded Generics

In short, bounded generics are types fixed to other class definitions. We can define types that are at least a subclass or superclass of another type.

6.1. Upper Bounded Generics

Upper bounding a generic type means the type used must be a subclass of another class. Let's define a class with two upper-bounded generic types:

1public class UpperBoundedGeneric<S extends String, I extends Integer>{
2}

The UpperBoundedGeneric class takes two generics types. The S type must be a String or any sub-class of String. The second type must be an Integer or any sub-class of Integer. That means the code below doesn't compile:

1UpperBoundedGeneric<CharSequence, Integer> myType = null;

The myType variable defines a CharSequence for its first generic type, which not extends or implements the String class. Trying to compile that code gives a very informative error message:

1Type parameter 'java.lang.CharSequence' is not within its bound; should implement 'java.lang.String'

Note: Be attentive to not misuse generics like in the example above! The String, and Integer classes are declared final. Thus, no class can extend them, so the UpperBoundedGeneric class only accepts String and Integer as generic types. Therefore, generics in that example are dispensable and might cause more confusion.

6.2. Lower Bounded Generics

Lower-bounding means the generic type used must be a superclass of another class. Lower-bounding uses wildcards (the <?> identifier) to work properly. We'll see wildcards shortly.

Let's use one example to illustrate lower-bound generics:

1Map<? super String, ? super Integer> myType = new HashMap<CharSequence, Integer>();

The code runs normally and creates a new instance of a HashMap where the key is a CharSequence and the value is an Integer. The CharSequence class is a superclass of String, so it satisfies <? super String>. The expression <? super Integer> is also fine since it's the same type.

For instance, the code below doesn't compile as a Double doesn't fit in Integer:

1Map<? super String, ? super Integer> myType = new HashMap<CharSequence, Double>();

7. Wildcards

Last but not least, another feature that Java Generics API offers is wildcards. A wildcard is an unknown type denoted by a question mark (?) that can only be used as reference types for variables and method arguments. Wildcards can be lower-bounded, upper-bounded, or unbounded.

Let's see three examples of type references created as wildcards:

1List<? extends Serializable> list1 = new ArrayList<>();
2List<? super String> list2 = new ArrayList<>();
3List<?> list3 = new ArrayList<>();

The list1 accepts any generic type that implements the Serializable interface. The list2 accepts a list whose elements are superclasses of String (e.g., CharSequence, Serializable). Finally, list3 holds elements of any class.

Wildcards are especially useful for creating methods that accept different types without having to cast variables. For example, let's say we need to create a method to check if the first element of a list is empty:

1private boolean firstElementIsEmpty(List<? extends CharSequence> list){
2    return list.get(0).isEmpty();
3}

The method above uses a list of <? extends CharSequence> to gain access to the isEmpty() method. Thus, we can use a list of any CharSequence implementation as an argument, for example:

1firstElementIsEmpty(Arrays.asList(new StringBuilder(""))); //1
2firstElementIsEmpty(Arrays.asList(new StringBuffer(""))); //2
3firstElementIsEmpty(Arrays.asList("")); //3

The same method uses a list of StringBuilder, StringBuffer, and a String as an argument without any problems.

8. Pros and Cons of Generics

Let's consider when we can use generics and what their limitations are.

8.1. Pros of Generics in Java:

We've seen throughout the article the main benefits of using generics in Java. Summarizing the benefits:

  • Strong type checks at compile time.
  • Elimination of casts.
  • Enable developers to implement generic APIs.

8.2. Cons or Limitations of Generics in Java

The main limitations of generics types in Java are:

  1. Cannot Instantiate Generic Types with Primitive Types.
  2. Cannot Create Instances of Generics.
  3. Cannot Declare Static Fields Whose Types are Generics
  4. Cannot Use Casts or instanceof With Generic Types
  5. Cannot Create Arrays of Generic Types
  6. Cannot Create, Catch, or Throw Objects of Generic Types

Let's look at a code example for each one of them.

  1. Cannot Instantiate Generic Types with Primitive Types
1List<int> primitiveList = new ArrayList<>(); //does not compile
  1. Cannot Create Instances of Generics
1public class GenericClass<T> {
2    public void instantiate() {
3        T myGeneric = new T(); //does not compile
4}
  1. Cannot Declare Static Fields Whose Types are Generics
1public class GenericClass<T> {   
2    static T staticGeneric; //does not compile
3}
  1. Cannot Use Casts or instanceof With Generic Types

Both lines above do not compile independently of each other:

1private void compareAndCast(T myType) {
2    if (T instanceof String) { //does not compile
3        var casted = (String) T; //does not compile
4    }
5}
  1. Cannot Create Arrays of Generic Types
1public class GenericClass<T> {
2    T[] array = new T[]{}; //does not compile
3}
  1. Cannot Create, Catch, or Throw Objects of Generic Types

All lines below do not compile independently of each other:

1public class GenericClass<T extends RuntimeException> {
2    public void catchAndThrow() {
3        try {
4            //do something
5        } catch (T exception) { //does not compile
6            throw new T(); //does not compile
7        }
8    }
9}

However, using the throws keyword with a generic type is possible. The code below compiles without issues:

1public class GenericClass<T extends RuntimeException> {
2    public void catchAndThrow() throws T{
3        try {
4            //do something
5        } catch (NullPointerException exception) { 
6            throw new RuntimeException();
7        }
8    }
9}

Remember that the code above compiles because we upper-bounded the generic type and threw a type allowed. The code below doesn't compile because Exception is not a sub-class of RuntimeException:

1public class GenericClass<T extends RuntimeException> {
2    public void catchAndThrow() throws T{
3        try {
4            //do something
5        } catch (NullPointerException exception) { 
6            throw new Exception(); //does not compile
7        }
8    }
9}

9. Conclusion

This article covered the basics of generics: how to create them, why they are helpful, and when they are not viable.

Generics and wildcards help developers to create APIs that accept different types without losing static typing.