The Basics of Java Generics

1. Introduction

Java Generics were introduced in JDK 5.0 with the aim of reducing bugs and adding an extra layer of abstraction over types.

This article is a quick intro to Generics in Java, the goal behind them and how they can be used to improve the quality of our code.

Further reading:

Method References in Java

A quick and practical overview of method references in Java.

Read more

Retrieve Fields from a Java Class Using Reflection

Learn how to get the fields of a class using reflection, including inherited fields

Read more

2. The Need for Generics

Let’s imagine a scenario where we want to create a list in Java to store Integer; we can be tempted to write:

List list = new LinkedList();
list.add(new Integer(1));
Integer i = list.iterator().next();

Surprisingly, the compiler will complain about the last line. It doesn’t know what data type is returned. The compiler will require an explicit casting:

Integer i = (Integer) list.iterator.next();

There is no contract that could guarantee that the return type of the list is an Integer. The defined list could hold any object. We only know that we are retrieving a list by inspecting the context. When looking at types, it can only guarantee that it is an Object, thus requires an explicit cast to ensure that the type is safe.

This cast can be annoying, we know that the data type in this list is an Integer. The cast is also cluttering our code. It can cause type-related runtime errors if a programmer makes a mistake with the explicit casting.

It would be much easier if programmers could express their intention of using specific types and the compiler can ensure the correctness of such type. This is the core idea behind generics.

Let’s modify the first line of the previous code snippet to:

List<Integer> list = new LinkedList<>();

By adding the diamond operator <> containing the type, we narrow the specialization of this list only to Integer type i.e. we specify the type that will be held inside the list. The compiler can enforce the type at compile time.

In small programs, this might seem like a trivial addition, however, in larger programs, this can add significant robustness and makes the program easier to read.

3. Generic Methods

Generic methods are those methods that are written with a single method declaration and can be called with arguments of different types. The compiler will ensure the correctness of whichever type is used. These are some properties of generic methods:

  • Generic methods have a type parameter (the diamond operator enclosing the type) before the return type of the method declaration

  • Type parameters can be bounded (bounds are explained later in the article)

  • Generic methods can have different type parameters separated by commas in the method signature

  • Method body for a generic method is just like a normal method

An example of defining a generic method to convert an array to a list:

public <T> List<T> fromArrayToList(T[] a) {
    return Arrays.stream(a).collect(Collectors.toList());
}

In the previous example, the <T> in the method signature implies that the method will be dealing with generic type T. This is needed even if the method is returning void.

As mentioned above, the method can deal with more than one generic type, where this is the case, all generic types must be added to the method signature, for example, if we want to modify the above method to deal with type T and type G, it should be written like this:

public static <T, G> List<G> fromArrayToList(T[] a, Function<T, G> mapperFunction) {
    return Arrays.stream(a)
      .map(mapperFunction)
      .collect(Collectors.toList());
}

We’re passing a function that converts an array with the elements of type T to list with elements of type G. An example would be to convert Integer to its String representation:

@Test
public void givenArrayOfIntegers_thanListOfStringReturnedOK() {
    Integer[] intArray = {1, 2, 3, 4, 5};
    List<String> stringList
      = Generics.fromArrayToList(intArray, Object::toString);

    assertThat(stringList, hasItems("1", "2", "3", "4", "5"));
}

It is worth noting that Oracle recommendation is to use an uppercase letter to represent a generic type and to choose a more descriptive letter to represent formal types, for example in Java Collections T is used for type, K for key, V for value.

3.1. Bounded Generics

As mentioned before, type parameters can be bounded. Bounded means “restricted“, we can restrict types that can be accepted by a method.

For example, we can specify that a method accepts a type and all its subclasses (upper bound) or a type all its superclasses (lower bound).

To declare an upper bounded type we use the keyword extends after the type followed by the upper bound that we want to use. For example:

public <T extends Number> List<T> fromArrayToList(T[] a) {
    ...
}

The keyword extends is used here to mean that the type T extends the upper bound in case of a class or implements an upper bound in case of an interface.

3.2. Multiple Bounds

A type can also have multiple upper bounds as follows:

<T extends Number & Comparable>

If one of the types that are extended by T is a class (i.e Number), it must be put first in the list of bounds. Otherwise, it will cause a compile-time error.

4. Using Wildcards with Generics

Wildcards are represented by the question mark in Java “?” and they are used to refer to an unknown type. Wildcards are particularly useful when using generics and can be used as a parameter type but first, there is an important note to consider.

It is known that Object is the supertype of all Java classes, however, a collection of Object is not the supertype of any collection.

For example, a List<Object> is not the supertype of List<String> and assigning a variable of type List<Object> to a variable of type List<String> will cause a compiler error. This is to prevent possible conflicts that can happen if we add heterogeneous types to the same collection.

The Same rule applies to any collection of a type and its subtypes. Consider this example:

public static void paintAllBuildings(List<Building> buildings) {
    buildings.forEach(Building::paint);
}

if we imagine a subtype of Building, for example, a House, we can’t use this method with a list of House, even though House is a subtype of Building. If we need to use this method with type Building and all its subtypes, then the bounded wildcard can do the magic:

public static void paintAllBuildings(List<? extends Building> buildings) {
    ...
}

Now, this method will work with type Building and all its subtypes. This is called an upper bounded wildcard where type Building is the upper bound.

Wildcards can also be specified with a lower bound, where the unknown type has to be a supertype of the specified type. Lower bounds can be specified using the super keyword followed by the specific type, for example, <? super T> means unknown type that is a superclass of T (= T and all its parents).

5. Type Erasure

Generics were added to Java to ensure type safety and to ensure that generics wouldn’t cause overhead at runtime, the compiler applies a process called type erasure on generics at compile time.

Type erasure removes all type parameters and replaces it with their bounds or with Object if the type parameter is unbounded. Thus the bytecode after compilation contains only normal classes, interfaces and methods thus ensuring that no new types are produced. Proper casting is applied as well to the Object type at compile time.

This is an example of type erasure:

public <T> List<T> genericMethod(List<T> list) {
    return list.stream().collect(Collectors.toList());
}

With type erasure, the unbounded type T is replaced with Object as follows:

// for illustration
public List<Object> withErasure(List<Object> list) {
    return list.stream().collect(Collectors.toList());
}

// which in practice results in
public List withErasure(List list) {
    return list.stream().collect(Collectors.toList());
}

If the type is bounded, then the type will be replaced by the bound at compile time:

public <T extends Building> void genericMethod(T t) {
    ...
}

would change after compilation:

public void genericMethod(Building t) {
    ...
}

6. Generics and Primitive Data Types

A restriction of generics in Java is that the type parameter cannot be a primitive type.

For example, the following doesn’t compile:

List<int> list = new ArrayList<>();
list.add(17);

To understand why primitive data types don’t work, let’s remember that generics are a compile-time feature, meaning the type parameter is erased and all generic types are implemented as type Object.

As an example, let’s look at the add method of a list:

List<Integer> list = new ArrayList<>();
list.add(17);

The signature of the add method is:

boolean add(E e);

And will be compiled to:

boolean add(Object e);

Therefore, type parameters must be convertible to Object. Since primitive types don’t extend Object, we can’t use them as type parameters.

However, Java provides boxed types for primitives, along with autoboxing and unboxing to unwrap them:

Integer a = 17;
int b = a;

So, if we want to create a list which can hold integers, we can use the wrapper:

List<Integer> list = new ArrayList<>();
list.add(17);
int first = list.get(0);

The compiled code will be the equivalent of:

List list = new ArrayList<>();
list.add(Integer.valueOf(17));
int first = ((Integer) list.get(0)).intValue();

Future versions of Java might allow primitive data types for generics. Project Valhalla aims at improving the way generics are handled. The idea is to implement generics specialization as described in JEP 218.

7. Conclusion

Java Generics is a powerful addition to the Java language as it makes the programmer’s job easier and less error-prone. Generics enforce type correctness at compile time and, most importantly, enable implementing generic algorithms without causing any extra overhead to our applications.

The source code that accompanies the article is available over on GitHub.

Leave a Reply

Your email address will not be published.