Generics in Java: Enhancing Code Safety and Flexibility

The Generics feature was introduced in Java version 5.0 and quickly became one of the most powerful tools in a Java developer's toolkit. Generics allow classes, interfaces, and methods to be parameterized by types. This functionality not only increases code reusability but also adds safety by ensuring that the code is more type-checked at compile time.

What Are Generics?

Generics are a way to parameterize classes, interfaces, and methods with data types. This allows you to create a class or method that can operate on objects of various types while providing type safety at compile time. Without Generics, you would have to resort to non-specific types, such as Object, which can expose your code to runtime errors.

Let's imagine a scenario where we want to create a list in Java to store Integer.

We might try to write the following:

List list = new ArrayList();
list.add("Hello"); 
String text = list.get(0)

The compiler will complain about the last line. It doesn’t know what data type is returned.

The compiler will require an explicit casting:

String text = (String) list.get(0);

Now, let's make a change to our code:

List<String> list = new ArrayList<>();

By adding the diamond operator <> containing the type, we narrow the specialization of this list to only String type. In other words, we specify the type held inside the list. The compiler can enforce the type at compile time.

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

Generic Methods

We write generic methods with a single method declaration, and we can call them with arguments of different types. The compiler will ensure the correctness of whichever type we use.

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 (we explain bounds later in this article).

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

  • The method body for a generic method is just like a normal method.

Here’s an example of defining a generic method to print everything passed by the parameter concatenating it with "!!!!!":

public static <T> void printEverything(T parameter){
    System.out.println(parameter + "!!!!!");
}

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, the method can deal with more than one generic type. Where this is the case, we must add all generic types to the method signature.

Here is another method to deal with type T and type G:

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

Bounded Generics

Bounded Generics

Bounded Generics are a feature in Java that allows you to restrict the types that can be used as type arguments in a generic parameter. This is done using the keywords extends and super to define upper and lower bounds, respectively. These bounds ensure that generic methods and classes are used in a more type-safe and specific manner.

Why Use Bounded Generics?

  1. Type Safety: Bounded Generics help avoid runtime errors by ensuring that methods and classes receive types that are compatible with their operations.

  2. Flexibility with Restrictions: Allows developers to write more generic and reusable code that still leverages methods and properties of certain types or their hierarchies.

Practical Examples of Bounded Generics

Example 1: Restricting to a Specific Type and Its Subclasses

Suppose you want to create a utility that can compare elements, but only those that implement the Comparable interface. This ensures that the elements passed to the method can be compared to each other in an ordered fashion.

public class ComparatorUtil {
    // Método genérico com um limite superior
    public static <T extends Comparable<T>> T getMax(T x, T y) {
        return (x.compareTo(y) > 0) ? x : y;
    }

    public static void main(String[] args) {
        System.out.println(getMax(10, 20));  // Saída: 20
        System.out.println(getMax("hello", "world"));  // Saída: world
    }
}

Example 2: Restricting to Classes That Implement Multiple Interfaces

Another useful example is when you need a generic parameter to implement multiple interfaces. This is useful to ensure that the argument type has all the necessary methods for the planned operations within the generic method.

public class MultiBoundUtil {
    // Um método genérico que exige que os objetos sejam Serializable e Comparable
    public static <T extends Serializable & Comparable<T>> void printSorted(List<T> list) {
        Collections.sort(list);
        list.forEach(System.out::println);
    }

    public static void main(String[] args) {
        List<String> words = Arrays.asList("banana", "apple", "pear", "orange");
        printSorted(words);  // Saída: apple, banana, orange, pear
    }
}

Explanation:

  • In the first example, the getMax method uses bounded generics to restrict its parameters to types that implement the Comparable interface. This allows the compareTo method to be called, ensuring that the types can be compared.

  • In the second example, printSorted requires the elements of the list to be both Serializable and Comparable. This is useful for operations that need to serialize objects after sorting them, ensuring that the type used supports both operations.

Wildcards with Generics in Java

Wildcards in Java Generics are a feature that allows greater flexibility in generic programming by making type variables more versatile. The use of wildcards is indicated by a question mark (?) and can be further qualified with the keywords extends (for an upper bound) or super (for a lower bound). These wildcards are especially useful in situations where you want to write code that is type-independent.

Why Use Wildcards?

Wildcards are extremely useful in APIs where you want the method to accept arguments of various generic types or when you want to ensure type safety while keeping the code flexible and reusable.

Practical Examples of Wildcards

Example 1: Using ? extends Type

This wildcard is used to indicate that the variable can be of the specified type or any subtype of it. It is useful in methods that do not need to modify the collections, only read data from them.

Example: A method that prints the elements of any list containing types that are subclasses of Number.

public class DataPrinter {
    public static void printNumbers(List<? extends Number> numbers) {
        for (Number num : numbers) {
            System.out.println(num);
        }
    }

    public static void main(String[] args) {
        List<Integer> integers = Arrays.asList(1, 2, 3);
        List<Double> doubles = Arrays.asList(3.14, 1.68, 2.76);

        printNumbers(integers);
        printNumbers(doubles);
    }
}

Example 2: Using ? super Type

This wildcard is used to indicate that the variable can be of the specified type or any supertype of it. It is useful in methods that need to modify collections by adding data to them.

Example: A method that adds an element to a list of any type that is a superclass of Integer.

public class DataAdder {
    public static void addNumber(List<? super Integer> list) {
        list.add(25);  // Adiciona um Integer a uma lista de Integer ou de qualquer superclasse de Integer
    }

    public static void main(String[] args) {
        List<Number> numbers = new ArrayList<>();
        addNumber(numbers);
        System.out.println(numbers);  // Saída: [25]

        List<Object> objects = new ArrayList<>();
        addNumber(objects);
        System.out.println(objects);  // Saída: [25]
    }
}

Explanation of Examples

  • In the first example, the printNumbers method can accept a list of any class that extends Number (such as Integer, Double, etc.), thanks to the wildcard ? extends Number. This allows the method to be flexible about the type of list it accepts while still operating safely on elements it knows will be numbers.

  • In the second example, the addNumber method can add an Integer to lists of different types, as long as those types are superclasses of Integer (Number, Object, etc.), thanks to the wildcard ? super Integer. This is useful when you want to ensure that your method can add elements to a variety of lists without worrying about the specificity of the list type.

Type Erasure

Generics were added to Java to ensure type safety. And to ensure that generics won’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 them with their bounds or with Object if the type parameter is unbounded. This way, the bytecode after compilation contains only normal classes, interfaces, and methods, ensuring that no new types are produced. Proper casting is applied as well to the Object type at compile time.

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:

// 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, the type will be replaced by the bound at compile time:

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

and would change after compilation:

public void genericMethod(Building t) {
    ...
}
  1. Limitations and Considerations

    Key Limitations

    1. Use with Primitive Types: Generics cannot be used with primitive types, such as int, float, etc. You need to use corresponding wrapper classes like Integer, Float, etc.

    2. Type Erasure: At runtime, generic class, and method instances do not retain their generic types. This is known as type erasure and can limit certain operations you might want to perform with reflection.

Examples of Limitations

Generics with Primitive Types:

    // List<int> myList = new ArrayList<>(); // Compilation error
    List<Integer> myList = new ArrayList<>(); // Correct
  1. Type Erasure and reflection:

     List<Integer> list = new ArrayList<>();
     System.out.println(list instanceof List<?>); // True
     // System.out.println(list instanceof List<Integer>); // Compilation error
    
  2. Conclusion

    Generics represent a powerful abstraction capability in Java, providing not only type safety but also incredible flexibility to develop algorithms and collections that can operate transparently on different data types.

0
Subscribe to my newsletter

Read articles from André Felipe Costa Bento directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

André Felipe Costa Bento
André Felipe Costa Bento

Fullstack Software Engineer.