Java SE Generics

Study notes on Java Generics, covering wildcards, PECS, type inference, and how to design generic classes, interfaces, and methods.

Introduction

Generics are a highly practical tool in programming. They let us write code that works with multiple data types without duplicating it for each one. Think of them as a flexible template that can be adapted to different needs.

For example, suppose you need a function that sorts a list of numbers. Without generics, you might have to write separate functions for integers, floats, and so on. With generics, a single function handles sorting for all numeric types.

Background of generics in Java

When a collection container class is designed and declared, the actual type of the objects it will hold cannot be determined. So before JDK 1.5, element types could only be declared as Object, which meant a forced cast was needed when retrieving elements. Such casts are unsafe and can throw a ClassCastException at runtime.

With generics, you can specify the element type when declaring a collection. Type checking then happens at compile time, eliminating the runtime exceptions caused by forced casts and improving both safety and readability.

Advantages of Generics

  • Type Safety & Elimination of casts: Java generics provide compile-time type-checking and reduce the need for casts.
  • Reduce code duplication: generics improve reusability and extensibility, making code more concise.
  • Readability: generics express the code’s intent more clearly, making it easier to read and maintain.
List<String> list = new ArrayList<>();list.add("Hello");// list.add(123); // Compile-time error: the collection can only hold Stringfor (String str : list) { // No cast needed    System.out.println(str);}
// Pre-JDK 1.5 example (no generics):List list = new ArrayList();list.add("Hello");list.add(123); // No error: the collection can hold any Objectfor (Object obj : list) {    String str = (String) obj; // Manual cast, may throw ClassCastException    System.out.println(str);}

Syntax

Java generics have the following syntax:

  • Generic class: class MyClass<T> { ... }
  • Generic interface: interface MyInterface<T> { ... }
  • Generic method: public <T> void myMethod(T data) { ... }

Naming conventions for generics:

  • T: Type parameter
  • E: Element or Entry type
  • K, V: Key and Value types

Finally, again let’s take note of the naming convention used for the type parameters. We use T for type, whenever there isn’t anything more specific about the type to distinguish it. This is often the case in generic methods. If there are multiple type parameters, we might use letters that neighbor T in the alphabet, such as S. If a generic method appears inside a generic class, it’s a good idea to avoid using the same names for the type parameters of the method and class, to avoid confusion. The same applies to nested generic classes.
Oracle Java Tutorials

Use Generic In Java

Using generics in Java improves type safety and reusability, but there are caveats and restrictions to keep in mind.

Best Practices

Method Overload

Overloaded methods may conflict after type erasure. Avoid defining overloads in the same class that differ only in their generic type arguments.

// Compiler error: Erasure of method method(List<String>) is the same as another method in type Mainpublic class Main {    public void method(List<String> list) { ... }    public void method(List<Integer> list) { ... }}

Utilize Type Inference

JDK 7 introduced Type Inference, the compiler’s ability to infer generic type arguments from context. It makes code more concise and readable by avoiding repeated type declarations.

Type inference in Java generics refers to the compiler’s ability to deduce generic type arguments from context, making code more concise and readable while avoiding redundant type declarations.

Benefits of type inference:

  • Better readability: fewer redundant type arguments make code easier to understand and maintain.
  • Less code: no need to specify type arguments explicitly, simplifying the code you write.
  • More flexibility: code adapts to change more easily; for example, changing a method’s return type does not require updating type arguments at every call site.

Where type inference applies:

  • constructor
  • method invocation
// 1. ConstructorMap<String, List<String>> myMap = new HashMap<String, List<String>>();// You can substitute the parameterized type of the constructor with an empty set of type parameters (<>):Map<String, List<String>> myMap = new HashMap<>();// 2. Method invocation// Code adapted from @pdai: https://pdai.tech/md/java/basic/java-basic-x-generic.htmlpublic class Test {    public static void main(String[] args) {        /** Without specifying the type argument */        int i = Test.add(1, 2); // Both arguments are Integer, so T is Integer        Number f = Test.add(1, 1.2); // One Integer and one Float, so the nearest common supertype is Number        Object o = Test.add(1, "asd"); // One Integer and one String, so the nearest common supertype is Object        /** With an explicit type argument */        int a = Test.<Integer>add(1, 2); // Integer specified, so only Integer or its subclasses are allowed        int b = Test.<Integer>add(1, 2.2); // Compile error: Integer specified, Float not allowed        Number c = Test.<Number>add(1, 2.2); // Number specified, so both Integer and Float are allowed    }    // A simple generic method    public static <T> T add(T x, T y) {        return y;    }}

Avoid using Raw Types

The code below uses the raw type List, which is discouraged in modern Java. Raw types exist only for backward compatibility with pre-Java 5 code. They bypass generic type checking and can lead to a ClassCastException at runtime.

// Warning: List is a raw type. References to generic type List<E> should be parameterizedList list = new ArrayList();list.add("Hello");// Recommended: Use generic type List<String>List<String> list = new ArrayList<>();

Restrictions on Generics

  • Cannot Instantiate Generic Types with Primitive Types
  • Cannot Create Instances of Type Parameters
  • Cannot Declare Static Fields Whose Types are Type Parameters
  • Cannot Use Casts or instanceof With Parameterized Types
  • Cannot Create Arrays of Parameterized Types
  • Cannot Create, Catch, or Throw Objects of Parameterized Types
  • Cannot Overload a Method Where the Formal Parameter Types of Each Overload Erase to the Same Raw Type

for more information, see Generic Restrictions —— docs.oracle

Design Effectively Using Generics

Wildcards

In Java generics, a wildcard is a symbol representing an unknown type. The common wildcards are ?, the upper bounded wildcard ? extends T, and the lower bounded wildcard ? super T.

  • Upper Bounds Wildcards ? extends T: ? can only be T or a subclass of T. After compilation, ? extends T is erased to T.
  • Lower Bounds Wildcards ? super T: ? can only be T or a superclass of T.
diagram

PECS

PECS (Producer Extends, Consumer Super) is an important guiding principle in Java generic programming, used to ensure type safety and correctness.

The PECS principle says: when a parameter produces data (Producer), use the upper bounded wildcard ? extends T; when a parameter consumes data (Consumer), use the lower bounded wildcard ? super T.

Producer ExtendsConsumer Superdescription
copysource collection srcdestination collection destpublic static <T> void copy(List<? super T> dest, List<? extends T> src)
sortthe list passed inpublic static <T extends Comparable<? super T>> void sort(List<T> list)
comparethe comparator, which reads both argumentspublic static <T> int compare(T a, T b, Comparator<? super T> c)

The Collections.copy() method is a good example of PECS in action. Among its parameters, src is the producer, used for reading data; dest is the consumer, used for writing data. So src uses the upper bounded wildcard ? extends T, and dest uses the lower bounded wildcard ? super T. This guarantees that the element type of dest is a supertype of the element type of src, preventing a ClassCastException.

/** * @param dest written to, acts as the consumer, uses the super wildcard * @param src read from, acts as the producer, uses the extends wildcard*/public static <T> void copy(List<? super T> dest, List<? extends T> src) {    int srcSize = src.size();    if (srcSize > dest.size())        throw new IndexOutOfBoundsException("Source does not fit in dest");    if (srcSize < COPY_THRESHOLD ||        (src instanceof RandomAccess && dest instanceof RandomAccess)) {        for (int i=0; i<srcSize; i++)            dest.set(i, src.get(i));    } else {        ListIterator<? super T> di=dest.listIterator();        ListIterator<? extends T> si=src.listIterator();        for (int i=0; i<srcSize; i++) {            di.next();            di.set(si.next());        }    }}

Generic Class

A generic class differs from a non-generic class in that it declares type parameters. There can be multiple type parameters, separated by commas. Generic classes are commonly used to build general-purpose data structures, such as collection classes (ArrayList<T> e.g).

class MyClass<T> {    private T data;    public void setData(T data) {        this.data = data;    }    public T getData() {        return data;    }}public class Main {    public static void main(String[] args) {        MyClass<String> myClass = new MyClass<>();        myClass.setData("Hello");        String str = myClass.getData();        System.out.println(str); // Output: Hello    }}
class Pair<K, V> {    private K key;    private V value;    public Pair(K key, V value) {        this.key = key;        this.value = value;    }    public K getKey() {        return key;    }    public V getValue() {        return value;    }}public class Main {    public static void main(String[] args) {        Pair<String, Integer> pair = new Pair<>("key", 123);        System.out.println(pair.getKey()); // Output: key        System.out.println(pair.getValue()); // Output: 123    }}

When writing a generic class, note that static methods cannot directly reference the class’s type parameter T. The reason is closely tied to the class loading mechanism and when type arguments are bound.

Why can’t static fields use type parameters?

A class’s static field is a class-level variable shared by all non-static objects of the class. Hence, static fields of type parameters are not allowed. Consider the following class:

public class MobileDevice<T> {    private static T os;    // ...}// If static fields of type parameters were allowed, then the following code would be confused:MobileDevice<Smartphone> phone = new MobileDevice<>();MobileDevice<Pager> pager = new MobileDevice<>();MobileDevice<TabletPC> pc = new MobileDevice<>();

Because the static field os is shared by phone, pager, and pc, what is the actual type of os? It cannot be Smartphone, Pager, and TabletPC at the same time. You cannot, therefore, create static fields of type parameters.

If a static member needs to work with generics, there are a few options:

public class Pair<T> {    private T key;    private T value;    public Pair(T key, T last) {        this.key = value;        this.value = valuet;    }    public T getFirst() { ... }    public T getLast() { ... }    // a. Use a generic method    // Declare the type parameter at the method level instead of the class level.    public static <K> Pair<K> create(K first, K last) {        return new Pair<K>(first, last);    }    // b. Use a wildcard or a concrete type    // In a static method, use a wildcard or a concrete type instead of the class's type parameter    public static void staticMethod(List<?> list) {...}    // Using a concrete type    public static void staticMethod(List<String> list) {...}}

Generic Interface

Like generic classes, generic interfaces can declare type parameters. When a class implements a generic interface, it can either specify a concrete type argument or keep the type parameter generic.

Here is an example of a generic interface:

import java.util.HashMap;import java.util.Map;interface Repository<T> {    void add(T item);    T getById(String id);    void delete(String id);}class Computer {    private String id;    private String name;    private double price;    public Computer(String id, String name, double price) {        this.id = id;        this.name = name;        this.price = price;    }    // getter    public String getId() {        return id;    }    public String getName() {        return name;    }    public double getPrice() {        return price;    }    @Override    public String toString() {        return "Computer{id='" + id + "', name='" + name + "', price=" + price + '}';    }}class ComputerRepository implements Repository<Computer> {    private Map<String, Computer> computers = new HashMap<>();    @Override    public void add(Computer computer) {        computers.put(computer.getId(), computer);    }    @Override    public Computer getById(String id) {        return computers.get(id);    }    @Override    public void delete(String id) {        computers.remove(id);    }}public class Main {    public static void main(String[] args) {        Repository<Computer> computerRepository = new ComputerRepository();        // Add computers to the repository        computerRepository.add(new Computer("1", "RedMi", 999.99));        computerRepository.add(new Computer("2", "Lenovo", 499.99));        // Retrieve and display a computer        Computer laptop = computerRepository.getById("1");        System.out.println("Retrieved: " + laptop);        // Delete a computer        computerRepository.delete("1");        System.out.println("computer with ID 1 deleted.");        // Attempt to retrieve the deleted computer        Computer deletedcomputer = computerRepository.getById("1");        System.out.println("After deletion, retrieved: " + deletedcomputer);    }}[OUTPUT]Retrieved: Computer{id='1', name='RedMi', price=999.99}computer with ID 1 deleted.After deletion, retrieved: null

Generic Method

A generic method declares its type parameters in angle brackets < > before the method’s return type.

Why use generic methods?
A generic class must fix its type argument at instantiation; switching to another type requires a new instance, which can be inflexible. A generic method lets you specify the type at call time, which is more flexible.

The java.util.Collections class provides many generic methods for working with collections. For example, the sort method uses generics to sort a list of any type.

public class Collections {    public static <T extends Comparable<? super T>> void sort(List<T> list) {        list.sort(null);    }    public static <T> void sort(List<T> list, Comparator<? super T> c) {        list.sort(c);    }}

The java.util.Arrays class provides many generic methods for working with arrays. For example, the asList method uses generics to convert an array into a list.

public class Arrays {    @SafeVarargs    public static <T> List<T> asList(T... a) {        return new ArrayList<>(a);    }}// usage examplepublic class Main {    public static void main(String[] args) {        List<String> list = Arrays.asList("Banana", "Apple", "Cherry");        Collections.sort(list);        System.out.println(list); // Output: [Apple, Banana, Cherry]    }}

The java.util.stream.Stream interface defines many non-static generic methods, such as flatMap, map, and collect.

public interface Stream<T> extends BaseStream<T, Stream<T>> {    <R> Stream<R> flatMap(Function<? super T, ? extends Stream<? extends R>> mapper);    <R> Stream<R> map(Function<? super T, ? extends R> mapper);    <R, A> R collect(Collector<? super T, A, R> collector);}

Reference

Generic Methods The Java™ Tutorials
Generics - Java Tutorial - Liao Xuefeng
Generic Restrictions
Type Inference