Generic

Generics are a mechanism that make it possible to use one function or class to handle many different data types. By using generics, we can design a single function/class that operates on data of many types, instead of having to create a separate function/class for each type. In this chapter we would first look at using generics with functions and then move on to using generics with classes.    

Generic Functions

In Java, generics provide a way to write more flexible and reusable code. By using generics, you can create methods or classes that work with any data type, reducing code duplication and increasing maintainability. Here’s a breakdown of how generics help, particularly in the context of printing arrays and comparing or sorting elements of different types.

Problem with Overloaded Methods

Initially, to print arrays of different data types such as int, float, and char, we might write separate overloaded functions like this:

void printArr(int[] arr) {
    for (int i : arr) System.out.println(i);
}

void printArr(float[] arr) {
    for (float i : arr) System.out.println(i);
}

void printArr(char[] arr) {
    for (char i : arr) System.out.println(i);
}

However, this approach has several disadvantages:

  1. Code Duplication: The same logic is repeated for each data type.
  2. Maintenance Issues: Any change in logic requires modification in all overloaded methods.
  3. Increased Code Size: Multiple versions of the function take up more space and require extra time to write and debug.

Solution: Generic Functions

Instead of writing separate functions for each data type, we can use generics to create a single function that works with any type.

Example: Generic Function for Printing Arrays

We can use a generic function to replace the multiple overloaded printArr functions:

public static <T> void printArray(T[] arr) {
    for (T element : arr) {
        System.out.printf("%s ", element);
    }
    System.out.println();
}

Here, T is a type parameter that acts as a placeholder for any type. The function can now be used with any data type:

public static void main(String[] args) {
    Integer[] intArr = {10, -2, 37, 42, 15};
    Float[] floatArr = {3.14f, 6.28f, -1.5f, -3.44f, 7.234f};
    Character[] charArr = {'Q', 'U', 'E', 'S', 'T'};

    printArray(intArr);
    printArray(floatArr);
    printArray(charArr);
}

Output:

10 -2 37 42 15
3.14 6.28 -1.5 -3.44 7.234
Q U E S T

This generic function works with any standard type, such as Integer, Float, Character, etc., significantly reducing code size and improving flexibility.

How Generics Work

The generic function is defined as:

public static <T> void printArray(T[] arr) {
    for (T element : arr) {
        System.out.printf("%s ", element);
    }
    System.out.println();
}
  • T: A type parameter that stands for any reference type. It is a placeholder for the actual type that will be passed when the function is called.
  • The function can now accept any array of objects (like Integer[], Float[], Character[]), but not primitive arrays (like int[], float[], char[]), because generics work only with reference types.

Generic Functions with Constraints

In some cases, you may want to restrict the types a generic function can work with. For example, you can use the Comparable interface to compare elements.

Example: Generic Function to Find the Minimum Value

public static <T extends Comparable<T>> T minimum(T a, T b) {
    return (a.compareTo(b) < 0) ? a : b;
}
  • The type parameter T is restricted to types that implement the Comparable interface.
  • The compareTo method is used to compare the values.

Usage Example:

public static void main(String[] args) {
    Float a = 3.14f, b = -6.28f;
    Character ch = 'A', dh = 'Z';

    System.out.println(minimum(a, b));  // Output: -6.28
    System.out.println(minimum(ch, dh));  // Output: A
}

Sorting Arrays with a Generic Function

You can also use generics to create a sorting function that works for any type that implements Comparable.

Example: Generic Sorting Function

public static <T extends Comparable<T>> void sort(T[] arr) {
    for (int i = 0; i < arr.length - 1; i++) {
        for (int j = i + 1; j < arr.length; j++) {
            if (arr[i].compareTo(arr[j]) > 0) {
                // Swap arr[i] and arr[j]
                T temp = arr[i];
                arr[i] = arr[j];
                arr[j] = temp;
            }
        }
    }
}

Usage Example:

public static void main(String[] args) {
    Float[] floatArr = {5.4f, 3.23f, 2.15f, 1.09f, 34.66f};
    Integer[] intArr = {-12, 23, 14, 0, 245, 78, 66, -9};

    sort(floatArr);
    sort(intArr);

    System.out.println(Arrays.toString(floatArr));  // Output: [1.09, 2.15, 3.23, 5.4, 34.66]
    System.out.println(Arrays.toString(intArr));  // Output: [-12, -9, 0, 14, 23, 66, 78, 245]
}

Multiple Argument Types

In Java, generics allow us to write methods, classes, and interfaces that can work with any data type while still providing type safety. Generics are particularly useful when we want to write code that can handle different types of objects in a flexible and reusable manner.

When we talk about multiple argument types in generic methods, we refer to the ability of a generic method to accept parameters of different types, without requiring separate methods for each possible combination of argument types.

Understanding Multiple Argument Types in Generic Methods

In Java, generics allow us to write methods, classes, and interfaces that can work with any data type while still providing type safety. Generics are particularly useful when we want to write code that can handle different types of objects in a flexible and reusable manner.

When we talk about multiple argument types in generic methods, we refer to the ability of a generic method to accept parameters of different types, without requiring separate methods for each possible combination of argument types.

What Does It Mean to Have Multiple Argument Types?

In Java, a generic method can be defined to accept multiple arguments, each of a different type. These types are specified using generic type parameters. Unlike a non-generic method, which only accepts arguments of specific types, a generic method with multiple argument types uses type parameters to allow different types to be passed in.

For example, in a method that accepts three different types of arguments, we can use type parameters to represent these types.

Generic Method with Multiple Argument Types

Here’s a breakdown of how you can create and use a generic method with multiple argument types:

Generic Method Definition with Multiple Type Parameters

A generic method with multiple argument types is defined by specifying the type parameters in angle brackets (< >) after the method’s name. These type parameters can represent different data types, and the method can accept arguments of different types during the call.

Example:

public static <T, S, Z> void printTypes(T a, S b, Z c) {
    System.out.println("a = " + a);
    System.out.println("b = " + b);
    System.out.println("c = " + c);
}

In this method definition:

  • <T, S, Z>: These are the generic type parameters. T, S, and Z can represent any data types (e.g., Integer, Float, Character, etc.).
  • T a, S b, Z c: These are the method parameters that accept different types. Each of these parameters will be replaced by the types passed when the method is called.

Example of Method Invocation with Different Argument Types

public static void main(String[] args) {
    Integer i = 10;
    Float j = 3.14f;
    Character ch = 'A';

    // Call the generic method with three different types
    printTypes(i, j, ch);
}
  • Here, i is an Integer, j is a Float, and ch is a Character.
  • The generic method printTypes can accept these three different types because it is defined with multiple type parameters (T, S, Z).

The output of this code will be:

a = 10
b = 3.14
c = A

Example of Method Invocation with Same Argument Types

The same generic method can also be used when all the arguments are of the same type. For example:

Integer m = 20, n = 30;

printTypes(m, m, n);  // All arguments are of type Integer

In this case, the method will still work perfectly because the generic method is flexible enough to handle the same type for all arguments.

The output will be:

a = 20
b = 20
c = 30

Why Use Multiple Argument Types?

Using multiple argument types in generic methods is beneficial because:

  1. Flexibility: A single generic method can handle different types of inputs, avoiding the need to create overloaded methods for each combination of argument types.
  2. Type Safety: Generics provide compile-time type checking, ensuring that the types passed to the method are compatible with the types expected by the method.
  3. Reusability: A method that can accept multiple types can be reused in many different situations without duplicating code.

Use Cases for Multiple Argument Types

  1. Printing different types: You could write a generic method to print values of different types, as demonstrated earlier with printTypes. This could be useful in logging or debugging.
  2. Data processing: If you are processing collections of various types, you could use generic methods to apply the same processing logic to various types of data.
  3. Handling mixed data: When you need to handle different types of objects in a flexible way (e.g., in containers like stacks, queues, etc.), generic methods with multiple argument types can simplify the implementation.

Example: Generic Method for Swapping Values

Let’s look at another example where a generic method with multiple argument types could be useful. We’ll create a method that swaps two values, regardless of their types:

public class GenericSwap {
    public static <T, S> void swap(T a, S b) {
        System.out.println("Before swap: a = " + a + ", b = " + b);

        // Swapping values (for demonstration purposes)
        // We can't swap values in the traditional sense, but we can print them
        System.out.println("After swap: a = " + b + ", b = " + a);
    }

    public static void main(String[] args) {
        Integer i = 5;
        String s = "Hello";
        swap(i, s);  // swapping Integer and String values

        Float f = 3.14f;
        Character ch = 'X';
        swap(f, ch);  // swapping Float and Character values
    }
}

Output:

Before swap: a = 5, b = Hello
After swap: a = Hello, b = 5
Before swap: a = 3.14, b = X
After swap: a = X, b = 3.14

This example shows how we can use multiple argument types (T and S) to swap values of different types, even though swapping isn’t logically meaningful for all data types. The point here is flexibility — the method can be used with Integer, String, Float, Character, or any other types.

Generic Classes

Detailed Explanation of Generic Classes in Java

What is a Generic Class?

In Java, a generic class is a class that can operate on objects of any type. By using generics, you can create a class where the type of the data is specified at runtime when an object is instantiated. This allows the class to handle different types of objects without writing multiple versions of the same class.

Generics in Java help with:

  • Type safety: Prevents type errors during runtime by ensuring that objects are only used with the correct data type.
  • Code reusability: Enables writing flexible code that can work with any data type.

Key Concepts of Generic Classes

  1. Type Parameterization:
  • In a generic class, the type of the object the class will operate on is specified using a type parameter (e.g., T, E, K, V, etc.). This is written in angle brackets (<>) after the class name.
  • For example, in the class Stack<T>, the T is a placeholder that represents any type the stack will store.
  1. Using Generic Types:
  • You can declare a generic type and use it for fields, method parameters, and return types in the class.
  • This allows a generic class to operate on different types of data without compromising type safety.
  1. Type Inference and Instantiation:
  • When creating an object of a generic class, you specify the actual type to replace the type parameter. This is called instantiating the generic class.
  1. Type Bounds:
  • You can restrict the types that can be used as a parameter to a generic class using bounded type parameters. For example, class Stack<T extends Number> would restrict the generic type T to classes that extend Number.

Structure of a Generic Class

The basic structure of a generic class is:

class ClassName<T> {
    // Fields, methods, and constructors using T
}
  • <T>: This defines a type parameter T. The name T is arbitrary; it can be any valid identifier like E, K, V, etc. It just represents a placeholder for a specific type that will be provided when an instance of the class is created.
  • Fields, Constructors, and Methods: You can use T in fields, constructor parameters, return types, and method parameters. This makes the class flexible enough to accept different types at runtime.

Example: Generic Stack Class

Let’s walk through a detailed explanation of the generic stack implementation, which is a basic data structure that follows the Last In, First Out (LIFO) principle. The Stack class can hold any type of object, such as Integer, Float, Character, or even custom objects.

Generic Stack Class Code

class Stack<T> {
    private T[] arr;      // Array to hold stack elements of type T
    private int top;      // Index of the top element in the stack
    private int size;     // Size of the stack

    // Constructor to initialize the stack with a given size
    Stack(int sz) {
        size = sz;
        top = -1;          // Indicates that the stack is empty initially
        arr = (T[]) new Object[sz]; // Typecasting to a generic array
    }

    // Method to check if the stack is full
    boolean isFull() {
        return top == size - 1;
    }

    // Method to push an element onto the stack
    void push(T data) {
        if (!isFull()) {
            arr[++top] = data; // Add the element and update the top index
        } else {
            System.out.println("Stack is full");
        }
    }

    // Method to check if the stack is empty
    boolean isEmpty() {
        return top == -1;
    }

    // Method to pop an element from the stack
    T pop() {
        if (!isEmpty()) {
            return arr[top--]; // Return the top element and update the top index
        }
        return null; // Return null if stack is empty
    }
}

Explanation of the Code:

  1. Generic Type Parameter (T):
  • The class Stack<T> is a generic class where T represents the type of elements the stack will hold. When you create a stack object, you specify what type the stack will hold (e.g., Stack<Integer> for a stack of integers).
  1. Array of Type T (arr):
  • The stack uses an array arr to store elements. The array is created with the generic type T at runtime, allowing it to store any type of object.
  • The array is initialized as (T[]) new Object[sz]. Since Java doesn’t allow the creation of a generic array directly (new T[] is not allowed), we create an Object[] array and cast it to T[].
  1. Constructor:
  • The constructor initializes the stack with a given size sz, creates the array arr of that size, and sets top to -1, indicating the stack is initially empty.
  1. isFull() and isEmpty():
  • isFull() checks if the stack has reached its maximum capacity (i.e., top == size - 1).
  • isEmpty() checks if the stack is empty (i.e., top == -1).
  1. push(T data):
  • The push() method adds an element of type T to the stack, as long as the stack isn’t full. The element is added to the top of the stack (incrementing the top index).
  1. pop():
  • The pop() method removes the element at the top of the stack and returns it. The top index is then decremented.

Example Usage of Generic Stack

In the following example, we create stacks of different types (Integer, Float, and Complex), demonstrate pushing and popping elements, and print the popped elements:

public class GenericStackDemo {
    public static void main(String[] args) {
        // Stack of Integers
        Stack<Integer> s1 = new Stack<>(10); // Stack of Integer with size 10
        s1.push(10);
        s1.push(20);
        s1.push(30);

        // Popping and printing Integer elements
        if (!s1.isEmpty()) {
            System.out.println(s1.pop());  // Output: 30
        }
        if (!s1.isEmpty()) {
            System.out.println(s1.pop());  // Output: 20
        }

        // Stack of Floats
        Stack<Float> s2 = new Stack<>(10); // Stack of Float with size 10
        s2.push(10.5f);
        s2.push(20.5f);
        s2.push(18.5f);

        // Popping and printing Float elements
        if (!s2.isEmpty()) {
            System.out.println(s2.pop());  // Output: 18.5
        }
        if (!s2.isEmpty()) {
            System.out.println(s2.pop());  // Output: 20.5
        }

        // Stack of Complex Numbers
        Stack<Complex> s3 = new Stack<>(10); // Stack of Complex with size 10
        Complex c1 = new Complex(1.1f, 2.2f);
        Complex c2 = new Complex(3.3f, 4.4f);
        Complex c3 = new Complex(5.5f, 6.6f);

        s3.push(c1);
        s3.push(c2);
        s3.push(c3);

        // Popping and printing Complex objects
        if (!s3.isEmpty()) {
            Complex c = s3.pop();
            c.printData();  // Output: Real = 5.5 Imag = 6.6
        }
        if (!s3.isEmpty()) {
            Complex c = s3.pop();
            c.printData();  // Output: Real = 3.3 Imag = 4.4
        }
    }
}
  • Output:
30
20
18.5
20.5
Real = 5.5 Imag = 6.6
Real = 3.3 Imag = 4.4

Advantages of Using Generic Classes

  1. Type Safety:
    • Generics provide compile-time type checking, which prevents ClassCastExceptions at runtime. For example, if you try to push a String onto a Stack<Integer>, the compiler will give an error.
  2. Code Reusability:
    • The same Stack class can be reused for different types without rewriting the class for each data type. This reduces code duplication and increases maintainability.
  3. Flexibility:
    • Generics allow you to define more general and flexible classes that work with various data types. You can write code that is not dependent on specific data types but can still enforce strong type constraints.
  4. Improved Readability:
    • Generics improve code readability by making it clear what types of objects the class operates on.

Bounded Generics

Bounded Generics in Java

Bounded Generics allow us to restrict the types that can be used with a generic class or method. In other words, it allows you to limit the generic type to certain classes or interfaces. This ensures that the generic class can only accept types that meet specific criteria, making the code more robust and type-safe.

In your example, the class Statistics<T> is bounded so that it can only accept types that are derived from the Number class. This prevents using non-numeric types (like String) with this generic class.

Bounded Generics Syntax

The bounded generic type is defined using the extends keyword:

class Statistics<T extends Number> {
    // Class body
}
  • T extends Number: This restricts the generic type T to only those types that are subtypes of Number, such as Integer, Float, Double, etc. It ensures that only numeric types can be passed to the class.

Example: Statistics Class with Bounded Generics

Here’s the example program that demonstrates the use of bounded generics in a Statistics class, which calculates the average of an array of Integer or Float values, but not other types like String.

package statsdemo;

public class StatsDemo {
    public static void main(String[] args) {
        Integer[] iarr = {1, 2, 3, 4, 5};  // Array of Integers
        Statistics<Integer> iobj;          // Instantiate Statistics class with Integer
        double avg1;                       // Variable to store the average

        iobj = new Statistics<Integer>(iarr);  // Pass Integer array to Statistics class
        avg1 = iobj.getAverage();             // Get average of Integer array
        System.out.println("avg1 = " + avg1); // Output: avg1 = 3.0

        Float[] farr = {1.1f, 2.1f, 1.0f};  // Array of Floats
        Statistics<Float> fobj;             // Instantiate Statistics class with Float
        double avg2;                        // Variable to store the average

        fobj = new Statistics<Float>(farr);  // Pass Float array to Statistics class
        avg2 = fobj.getAverage();            // Get average of Float array
        System.out.println("avg2 = " + avg2); // Output: avg2 = 1.7333333...
    }
}

class Statistics<T extends Number> {
    private T[] arr;  // Array to hold the numeric values

    // Constructor: Initializes the Statistics object with an array of numbers
    Statistics(T[] obj) {
        arr = obj;
    }

    // Method to compute the average of the values in the array
    public double getAverage() {
        double sum = 0.0;
        for (int i = 0; i < arr.length; i++) {
            sum = sum + arr[i].doubleValue();  // Convert each number to double and add to sum
        }
        return sum / arr.length;  // Return the average
    }
}

How the Program Works

  1. Statistics Class:
  • The Statistics class is a generic class with a type parameter T that is bounded by Number. This means the type T must be a subclass of Number (i.e., Integer, Float, Double, etc.).
  • The Statistics class contains an array of type T that holds the numeric values.
  • The constructor accepts an array of T and initializes the internal arr field with this array.
  • The method getAverage() calculates the average by iterating through the array, summing the elements using the doubleValue() method (which is available in all subclasses of Number), and then dividing by the length of the array.
  1. Type Bounding (T extends Number):
  • The line class Statistics<T extends Number> ensures that only classes that inherit from Number (like Integer, Float, Double) can be used as the generic type T. This prevents non-numeric types like String or Boolean from being used, thus avoiding runtime errors.
  1. Using the Class in StatsDemo:
  • The program demonstrates how to create Statistics objects with different numeric types (Integer and Float).
  • For each array (iarr for integers and farr for floats), the program creates a corresponding Statistics object (iobj for integers and fobj for floats), computes the average, and prints the result.

Output of the Program

avg1 = 3.0
avg2 = 1.7333333333333334

Key Points:

  • T extends Number: This restricts T to be a subclass of Number, allowing only numeric types. This ensures that methods like doubleValue() can be called on T, which is required to perform arithmetic operations like sum and average.
  • Type Safety: By restricting the generic type to Number, we avoid passing in non-numeric types like String to the Statistics class, which ensures that the class works only with numeric values.
  • Reusability: The Statistics class can now work with any numeric type (such as Integer, Float, Double, etc.), and the code does not need to be rewritten for each new numeric type.

Bounded Generics: Why Use Them?

  1. Type Safety: Bounded generics ensure that the code only works with appropriate types, preventing invalid types at compile time. For example, if we try to pass a String to the Statistics class, the compiler will throw an error.
  2. Flexibility with Restrictions: While bounded generics provide flexibility by working with different types, they allow you to place restrictions on what types can be used. This ensures that only types that make sense for a particular class (in this case, numeric types) are allowed.
  3. Preventing Runtime Errors: By bounding the generic type, we avoid runtime errors like ClassCastException that could occur if inappropriate types were passed to the class.