Module 3: Generics

Module Overview

Understand generics in Java, type parameters, and generic methods.

Learning Objectives

  • Understand the concept and benefits of generics in Java
  • Learn how to create generic classes and interfaces
  • Implement generic methods to improve code reusability
  • Work with bounded type parameters and wildcards

Key Concepts

Introduction to Generics

Generics enable you to create classes, interfaces, and methods that operate on types that are specified at compilation time. They provide stronger type checks, eliminate the need for casting, and enable programmers to implement generic algorithms.

Benefits of Generics:

  • Type safety - detect errors at compile time instead of runtime
  • Elimination of casts - no need for explicit type casting
  • Code reusability - write algorithms that work with different types
  • Type-specific operations - perform operations specific to the input type

Example - Without Generics:


// Non-generic collection
public class Box {
    private Object object;
    
    public void set(Object object) {
        this.object = object;
    }
    
    public Object get() {
        return object;
    }
}

// Client code - requires casting and is not type-safe
Box box = new Box();
box.set("Hello World");  // Store a String

// Type casting is required - potential runtime error
String message = (String) box.get();

// This would compile but fail at runtime
Integer wrongType = (Integer) box.get();  // ClassCastException
                

Example - With Generics:


// Generic collection
public class Box {
    private T t;
    
    public void set(T t) {
        this.t = t;
    }
    
    public T get() {
        return t;
    }
}

// Client code - type-safe and no casting required
Box stringBox = new Box<>();
stringBox.set("Hello World");

// No casting required, and type safety is guaranteed
String message = stringBox.get();

// This won't compile - caught at compile time
// stringBox.set(10);  // Error: incompatible types
                

All Java Objects are Objects!

All instances of classes are in a way also instances of the Java class Object. We'll get more into this when we cover inheritance, but it's important to know that any Java object can be stored in a reference of its actual type or of the Object type.

For example:

Restaurant baBar = new Restaurant("Ba Bar"); // Declare a Restaurant
Object myObject = baBar;                     // Object reference can point to the Restaurant

or even:

Object myObject = new Restaurant("Ba Bar"); // Assign Restaurant instance to Object reference

We say that "Restaurant inherits from Object" and that a Restaurant "is-a" Object to explain this relationship between Restaurant and Object, and it's true of all Java classes, not just Restaurant.

We can't go the other way automatically (assign an Object to a variable declared as a specific class type). To do that, we need to explicitly cast from Object to the type. This is analogous to the primitive type casting you may have seen earlier, but here it's from one class type to another. It works like this:

Object myObject = methodThatReturnsAnObject();  // As long as it returns an object, this works
Restaurant myRestaurant = (Restaurant)myObject; // Casting: hope it's really a Restaurant!

And in this case, myObject should hopefully actually be a Restaurant, or else Java will complain at runtime with a ClassCastException. This is no fun for anyone, so it is to be avoided. But programmers did something like this all the time in general-purpose Java data types until the advent of generics.

Let's learn more about those next!

Generic Methods

Generic methods allow type parameters to be used in a single method declaration, making it possible to implement algorithms that operate on multiple types while providing type safety.

Example:


public class Util {
    // Generic method to find maximum of two comparable values
    public static > T findMax(T a, T b) {
        if (a.compareTo(b) > 0) {
            return a;
        } else {
            return b;
        }
    }
    
    // Generic method to print array elements
    public static  void printArray(E[] array) {
        for (E element : array) {
            System.out.print(element + " ");
        }
        System.out.println();
    }
}

// Client code
public class TestGenerics {
    public static void main(String[] args) {
        // Using the generic method with Integers
        Integer max = Util.findMax(10, 20);
        System.out.println("Maximum of 10 and 20: " + max);
        
        // Using the generic method with Strings
        String maxString = Util.findMax("apple", "orange");
        System.out.println("Maximum of 'apple' and 'orange': " + maxString);
        
        // Using the generic printArray method
        Integer[] intArray = {1, 2, 3, 4, 5};
        String[] stringArray = {"Hello", "World"};
        
        Util.printArray(intArray);
        Util.printArray(stringArray);
    }
}
                

Bounded Type Parameters

Bounded type parameters restrict the types that can be used as type arguments in a generic class, interface, or method. This allows you to invoke methods of the bound type without casting.

Types of Bounds:

  • Upper bounded: <T extends UpperBound> - T must be a subtype of UpperBound
  • Multiple bounds: <T extends Class1 & Interface1 & Interface2> - T must implement all specified types
  • Wildcards: <? extends Type> or <? super Type> - for flexible method parameters

Example:


// Upper bounded type parameter
public class NumericCalculator {
    private T number;
    
    public NumericCalculator(T number) {
        this.number = number;
    }
    
    // Can call methods of Number class without casting
    public double getDoubleValue() {
        return number.doubleValue();
    }
    
    public double square() {
        return number.doubleValue() * number.doubleValue();
    }
}

// Example with wildcards
public class WildcardExample {
    // Producer - use "extends" (read-only)
    public static void printList(List list) {
        for (Number n : list) {
            System.out.print(n + " ");
        }
        System.out.println();
    }
    
    // Consumer - use "super" (write-only)
    public static void addNumbers(List list) {
        for (int i = 1; i <= 5; i++) {
            list.add(i);
        }
    }
    
    public static void main(String[] args) {
        // Using bounded type parameter
        NumericCalculator intCalc = new NumericCalculator<>(5);
        System.out.println("Square: " + intCalc.square());
        
        NumericCalculator doubleCalc = new NumericCalculator<>(2.5);
        System.out.println("Double value: " + doubleCalc.getDoubleValue());
        
        // Using wildcards
        List intList = new ArrayList<>();
        List doubleList = new ArrayList<>();
        
        addNumbers(intList);  // Works fine
        // addNumbers(doubleList);  // Won't compile - Double is not a supertype of Integer
        
        printList(intList);     // Works fine - Integer extends Number
        printList(doubleList);  // Works fine - Double extends Number
    }
}
                

Additional Resources