Skip to main content

Command Palette

Search for a command to run...

Generics In Java

Published
•15 min read•View as Markdown

Generics in Java is a concept that allows classes, interfaces, and methods to work with different types (such as Integer, String, etc.) while ensuring type safety and code reusability. Using generics, you can create a single piece of code that works with various data types without having to rewrite the same logic multiple times.

Example 1:-

import java.util.ArrayList;

public class Main {
    public static void main(String[] args) {
        ArrayList list = new ArrayList(); //arraylist of raw type 
//having type as Object class -> we can add any type value as each class
//in java extends Object class
        list.add("Hello");
        list.add(123);
        list.add(3.14);

        String str = (String) list.get(0);
        String str1 = (String) list.get(1);

    }
}
Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String (java.lang.Integer and java.lang.String are in module java.base of loader 'bootstrap')
 at com.engineeringdigest.corejava.Main.main(Main.java:13)

Major Issues with the Above Code:

  1. No Type Safety: The ArrayList is not type-safe, meaning any data type can be added to it.

  2. Manual casting: Manual casting is needed to retrieve data for specific types. Since the ArrayList is raw (having type as Object), getting an integer from an object requires manual casting. If casting is not done correctly, it will result in a java.lang.ClassCastException at runtime. In a large codebase, incorrect casting might not show an error at compile time but will cause issues at runtime. This makes coding difficult because we need to type cast data carefully, leading to a lot of casting in our application.

Suppose we accidentally add an incorrect value, like an integer, to the ArrayList. If our application is large and takes 2-3 minutes for build of our code, we will only discover the class cast exception after compiling. This slows down the coding process.

  1. No Compile-Time Checking: The type of data is not checked at compile time, which can result in a class cast exception.

How Do Generics Resolve These Issues?

public class Main {
    public static void main(String[] args) {
        ArrayList<String> list = new ArrayList<>();
//arraylist is of generic type we can define type using <>
        list.add("Hello");
        list.add("World");
        String s = list.get(0);
        String s1 = list.get(1);

    }
}

When generics were introduced in Java 5, it gained the ability to create type-safe collections. Now our ArrayList is type-safe, containing only string type data. No manual type casting is required, and the type of data is checked at compile time.

Generic Class:

If generics didn't exist, the first solution that comes to mind is to use the class Object as the type, allowing all data types to use the same code. But as we know, using Object will cause type safety issues, and we will need to do casting as well.

public class Box {
    private Object value;

    public Object getValue() {
        return value;
    }

    public void setValue(Object value) {
        this.value = value;
    }
}

public class Main {
    public static void main(String[] args) {
        Box box = new Box();
        box.setValue(1);
        String i = (String) box.getValue(); // EXCEPTION !!!
        System.out.println(i);
    }
}

A generic class allows us to use common code or to create objects for different types in a type-safe manner for a class. Here’s how the generics in the Box class can resolve the class cast exception issue:

public class Box<T> { //  one or more type parameters
//  These type parameters are placeholders(temporary symbols) that are replaced with specific 
//types when the class is instantiated or object of class is created.
    private T value;

    public T getValue() {
        return value;
    }

    public void setValue(T value) {
        this.value = value;
    }
}

public class Main {
    public static void main(String[] args) {
        Box<Integer> box = new Box<>();  // Box is now type-safe
        box.setValue(1);  // No issue, it's an Integer
        Integer i = box.getValue();  // No casting needed
        System.out.println(i);
    }
}

The type parameter T will be replaced with a specific type when an object of class Box is created. Now, the Box class is type-safe, and we will not encounter a ClassCastException because the types are enforced at compile time.

A generic class can have more than one type parameter.

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<>("Age", 30);
        System.out.println("Key: " + pair.getKey());   // Prints: Key: Age
        System.out.println("Value: " + pair.getValue()); // Prints: Value: 30
    }
}

Examples of generic classes in the collection framework include ArrayList, LinkedList, and HashMap.

Type Parameter Naming Conventions

While you can name type parameters anything, the convention is to use single letters that describe the purpose of the type parameter:

  • T: Type

  • E: Element (used in collections)

  • K: Key (used in maps)

  • V: Value (used in maps)

  • N: Number

Generic Interface:

Generic interfaces are commonly used when the type of the objects that the interface deals with is not known until runtime. Like in case of comparable we don’t know which objects will be passed for sorting purpose.

interface Pair<K, V> {
    K getKey();
    V getValue();
}

class KeyValuePair<K, V> implements Pair<K, V> {
    private K key;
    private V value;

    public KeyValuePair(K key, V value) {
        this.key = key;
        this.value = value;
    }

    @Override
    public K getKey() {
        return key;
    }

    @Override
    public V getValue() {
        return value;
    }
}

Pair<String, Integer> pair = new KeyValuePair<>("Age", 30);
System.out.println(pair.getKey() + ": " + pair.getValue());

Here, the class implementing the interface is also a generic type. We can also have a class implementing the interface with a specific type.

A common example of a generic interface in Java is the Comparable<T> interface, which is used to impose a natural ordering or sorting on objects. It is used by Java sort methods to sort custom objects.

class Employee implements Comparable<Employee> {
    private String name;
    private int age;

    public Employee(String name, int age) {
        this.name = name;
        this.age = age;
    }

    @Override
    public int compareTo(Employee other) {
        return Integer.compare(this.age, other.age);
    }
}

Bounded Types in Classes & Interfaces:-

Bounded types are used to restrict the types that can be used as arguments. This means that the type argument passed to the generic type must either extend a specific class or implement a specific interface.


interface NumberContainer<T extends Number & Serializable> {
    void add(T item);
    T get();
}

class IntegerContainer implements NumberContainer<Integer> {
    private Integer item;

    @Override
    public void add(Integer item) {
        this.item = item;
    }

    @Override
    public Integer get() {
        return item;
    }
}

In this example, the type parameter T is restricted to subclasses of Number, so only numeric types like Integer, Double, etc., can be used. Additionally, the type must implement the Serializable interface. Using other types or not implementing an interface Serializable will cause a compile-time error.

Bounded type parameters are used in the same way in our classes.

We can use classes and interfaces for bounded types at the same time. The class name must come first, followed by one or more interfaces for bounding.

<T extends ClassType & Interface1 & Interface2> means the type parameter T must extend a single class (ClassType) and can implement one or more interfaces (Interface1, Interface2) as multiple inheritance with classes is not possible in Java.

If no class bound is specified, the type parameter defaults to extending Object implicitly.

Bounded Type Parameters in Generic Constructors: Bounded type parameters restrict the types that can be used as arguments in the constructor.

class NumberPrinter {
    // Bounded type parameter for generic constructor
    <T extends Number> NumberPrinter(T number) {
        System.out.println("Number: " + number);
    }
}

public class Main {
    public static void main(String[] args) {
        new NumberPrinter(100);  // Integer is a subclass of Number
        new NumberPrinter(3.14);  // Double is a subclass of Number

        // The following would cause a compile-time error because String is not a subclass of Number
        // new NumberPrinter("Hello");  
    }
}

Generic Methods with Bounded Type Parameters:

public class Util {
    public static <T extends Number> void printDoubleValue(T value) {
        System.out.println(value.doubleValue());
    }

    public static void main(String[] args) {
        printDoubleValue(10);      // Output: 10.0
        printDoubleValue(3.14);    // Output: 3.14

        // The following will cause a compile-time error because String does not extend Number
        // printDoubleValue("Hello");
    }
}

Generic Enums:

Enums are inherently type-safe. You cannot assign a value to an enum that is not part of the defined constants.

For example:

Day day = Day.MONDAY; // Type-safe
// Day day = "MONDAY"; // Compile-time error

Enums alone do not support generics. To add type parameters to an enum, you need to define generic methods or use enums with generic classes or interfaces.

enum Operation {
    ADD, SUBTRACT, MULTIPLY, DIVIDE;

    public <T extends Number> double apply(T a, T b) {
        switch (this) {
            case ADD:
                return a.doubleValue() + b.doubleValue();
            case SUBTRACT:
                return a.doubleValue() - b.doubleValue();
            case MULTIPLY:
                return a.doubleValue() * b.doubleValue();
            case DIVIDE:
                return a.doubleValue() / b.doubleValue();
            default:
                throw new AssertionError("Unknown operation: " + this);
        }
    }
}

public class Main {
    public static void main(String[] args) {
        double result1 = Operation.ADD.apply(10, 20);
        double result2 = Operation.MULTIPLY.apply(5.5, 4);
        System.out.println(result1);  // Output: 30.0
        System.out.println(result2);  // Output: 22.0
    }
}

Generic Constructors:

A generic constructor can be defined in a generic class. However, the generic type parameter for the constructor may be different from the generic type parameter of the class:

class Test<T> {
    private T value;

    // Generic constructor
    <U> Test(U input) {
        System.out.println(input.getClass().getName());
    }
}
public class Main {
    public static void main(String[] args) {
        Test<Integer> test = new Test<>(12.34);  // Output: java.lang.Double
    }
}

Generic Static Members:

One restriction with generic classes is that static members cannot use type parameters. The reason for this is that static members belong to the class itself rather than to any instance, and the type parameter is tied to an instance. We can still create a static method using its type parameter.

class MyClass<T> {
    private T instanceVar;  // Valid
    static T staticVar;     // Invalid - static members cannot use T
}
class MyClass<T> {
    public static <U> void staticMethod(U param) {
        System.out.println(param);
    }
}

Generic Methods

Generic methods are methods that allow for type parameters, meaning they can be defined to accept different types of parameters in a type-safe way. By using generic methods, you can write code that works with any type and enforces compile-time type checking.

The syntax of a generic method is similar to that of a regular method, with the addition of type parameters declared in angle brackets (<>) before the return type. The type parameter can be any valid identifier but is often denoted by single capital letters like T, E, K, V, etc.

public class GenericMethodExample {
    public <T, U> void printTwoItems(T item1, U item2) {
        System.out.println(item1 + " and " + item2);
    }

    public static void main(String[] args) {
        GenericMethodExample example = new GenericMethodExample();

        example.printTwoItems(10, "Apples"); // Output: 10 and Apples
        example.printTwoItems("Hello", 3.14); // Output: Hello and 3.14
    }
}

How Generics Work (Type Erasure)

Generics in Java are implemented through a process called type erasure. This means that the generic types are replaced with their bounds or Object during compilation, and the resulting bytecode contains only ordinary classes, methods, and fields.

public class Box<T> {
    private T item;

    public void setItem(T item) {
        this.item = item;
    }

    public T getItem() {
        return item;
    }
}

public class Main {
    public static void main(String[] args) {
        Box<String> stringBox = new Box<>();
        stringBox.setItem("Hello, Generics!");
        String item = stringBox.getItem();
        System.out.println(item);
    }
}

When you instantiate Box<String>, the Java compiler checks the type at compile-time and ensures that only String objects are assigned to the Box. However, during compilation, this type of information (T = String) is erased, and the generic type T is replaced with its bound, which defaults to Object if no explicit bound is specified.

After type erasure, the generic class Box<T> is transformed by the compiler into the following code:

public class Box {
    private Object item;  // `T` is replaced with `Object`

    public void setItem(Object item) {  // `T` replaced with `Object`
        this.item = item;
    }

    public Object getItem() {  // `T` replaced with `Object`
        return item;
    }
}

At runtime, there is no knowledge of the type T being String. The class Box only knows that it stores an Object, and all operations on the generic type are done as if it were an Object. However, the code that interacts with Box<String> still works safely because the compiler has enforced the correct type usage at compile-time.

For example, when you write:

Box<String> stringBox = new Box<>();
stringBox.setItem("Hello, Generics!");
String item = stringBox.getItem();

The following things happen:

  1. Type-Safe Operations: At compile-time, the type safety of Box<String> is ensured. The compiler makes sure that only String objects are added to the Box.

  2. Type Erasure: At runtime, the JVM treats the stringBox as a Box<Object>. When you call getItem(), the JVM returns an Object (not a String), but the compiler-generated code has inserted an implicit cast to String during compilation:

String item = (String) stringBox.getItem();  // Implicit cast inserted by the compiler

Even though type erasure replaces T with Object, the cast back to String ensures that the original type is respected. If the type doesn't match the expected one, the program will throw a ClassCastException at runtime.

Wildcards in Generics:

In Java Generics, wildcards (?) are a special kind of type parameter that can be used in method parameters or class definitions to represent an unknown type.

Wildcards are useful when you don’t know the exact type at the time of defining a class or method.

public void processElements(List<?> elements) {
    // Cannot add value to elements list because we don't know the type.
    for (Object element : elements) {
        System.out.println(element);
    }
}

This method will work with any List, but since the type is unknown, we cannot safely add new elements to the list. We can read from it, but not write to it.

Limitations of Wildcards
One key limitation when using wildcards is that they are generally used in a read-only context. This means that while you can read elements from the collection, you cannot modify them in a type-safe manner.

For Example:

List<?> wildcardList = new ArrayList<String>();
wildcardList.add("Hello"); // This will cause a compilation error

This is because the compiler cannot guarantee type safety when adding elements.

Upper-Bounded Wildcards

In Java Generics, an upper-bounded wildcard restricts the types that can be passed as arguments to a parameterized type. We use the wildcard ? with an upper bound to indicate that the type can be any class that is a subclass of a specified class (including the class itself).
We cannot add to the list in this case because type safety is not ensured. The list is of the bound type Number, but Number can be of any type, such as Integer, Double, or Float.

import java.util.List;

public class UpperBoundedWildcardDemo {
    public static double sum(List<? extends Number> numbers) {
        double total = 0.0;
        for (Number number : numbers) {
            total += number.doubleValue();
        }
        return total;
    }

    public static void main(String[] args) {
        List<Integer> intList = List.of(1, 2, 3);
        List<Double> doubleList = List.of(1.1, 2.2, 3.3);

        System.out.println("Sum of integers: " + sum(intList));
        System.out.println("Sum of doubles: " + sum(doubleList));
    }
}

Covariance in Generics

Covariance allows a generic type to be assigned to another generic type with a broader bound. For example, a List<Integer> can be assigned to a List<? extends Number> but not the other way around.

List<? extends Number> numbers;
numbers = List.of(1, 2, 3); // List of Integer is valid
numbers = List.of(1.1, 2.2, 3.3); // List of Double is valid

Restricting Method Parameters

public static void printNumbers(List<? extends Number> list) {
    for (Number number : list) {
        System.out.println(number);
    }
}

List<Integer> intList = List.of(1, 2, 3);
List<Double> doubleList = List.of(1.1, 2.2, 3.3);

printNumbers(intList);  // Valid
printNumbers(doubleList);  // Valid

We can also create upper and lower-bounded classes, methods, and constructors with type parameters.

Lower-Bounded Wildcards:

Lower-bounded wildcards are a feature of Java generics that allows us to specify that a type must be a supertype of a given class. They are written using the ? super syntax.
For example, List<? super Integer> can accept Integer, Number, or Object (since Integer is a subclass of Number, which is a subclass of Object).

Lower-bounded wildcards are useful when you want to add objects to a collection. You can add objects of the specified type or any of its subclasses because we now have a list of a broader generic type, it can hold values of a less generic type.

public static void addToCollection(List<? super Number> list) {
    list.add(1);        // Integer (subclass of Number)
    list.add(1.5);      // Double (subclass of Number)
}

In this case, list can be a List<Number> or a List<Object>. You can add both Integer and Double because both are subclasses of Number.

When using lower-bounded wildcards, you lose the ability to retrieve elements of a specific type because you don’t know the exact type at runtime because we don’t know which values will be retrieved from the list as it can contain Integer, Double, Float, etc.

public static void processList(List<? super Number> list) {
    // We cannot retrieve a specific type from the list.
    //as there no guarantee which type data number will have, so most
//upper class is obj
    Object obj = list.get(0);  // This is the only safe type to retrieve.
}

Contravariant generics allow you to assign a collection of objects of a more specific type to a reference that expects a more general type.

List<? super Integer> list = new ArrayList<Number>();
list.add(123);  // This works

Exceptions in Generics:

Exception: An exception is an unexpected event that occurs during the execution of a program that disrupts the normal flow.

Exceptions are represented by classes, and they can be caught and handled using try-catch blocks. Java allows us to define our own exceptions by extending the Exception class or any of its subclasses.

However, when it comes to making exceptions generic, things get a bit more complicated due to the nature of type erasure and restrictions on generics.

class MyGenericException<T> extends Exception {  // Compilation error
    private T data;

    public MyGenericException(T data) {
        this.data = data;
    }
}

Java does not support generic exceptions due to type erasure. Type erasure means that generic type information is removed at runtime.
Since exceptions are closely tied to runtime operations(like catching them in try-catch blocks), having generic exceptions wouldn’t work as expected.
For example, if you had an exception class like MyGenericException<T>, at runtime, it can't be thrown with a specific type parameter, and we couldn't catch it with a specific type parameter because that type information would be erased at runtime.

We use generics to work with different types, but since generic type information is removed at runtime and exceptions are thrown at runtime, having generic exceptions wouldn't make sense. This is why Java doesn't support generic exceptions.

If we have MyGenericException<T> and try to catch it, we won't be able to catch a specific type exception at runtime, like MyGenericException<Integer> for Integer or MyGenericException<String> for String or any other class. Every exception we write with a type will be treated as MyGenericException.

A workaround is to create different exceptions for different types of objects or classes.

// Specific exception for Integer
public class MyIntegerException extends Exception {
    public MyIntegerException(String message) {
        super(message);
    }
}

// Specific exception for String
public class MyStringException extends Exception {
    public MyStringException(String message) {
        super(message);
    }
}

We can use a constructor with generic parameters to have different types of messages with one exception.

public class MyGenricException extends Exception {
    public <T> MyGenricException(T value) {
        super("Exception related to value : " + value.toString() 
        +"of Type :" + value.getClass().getName());
    }
}