DEV Community

Yoshi
Yoshi

Posted on

First Experience with Java Generics: Key Learnings and Struggles

When creating classes or methods, we often deal with data of some kind. While the data types may vary, the logic remains largely the same. Generalizing such logic makes it much more versatile. This is where Generics come in—they allow us to specify data types dynamically, adding a powerful layer of flexibility. In this article, I’ll share my personal experience with implementing Java Generics for the first time, including points I struggled with and lessons I learned along the way.


Type Parameters

In Java, when working with collections, you can define a class like public class LinkedList<E>. The <E> specifies the type of data the class or method will handle. For example, setting E to String ensures only String objects are accepted, while using a custom class like Car restricts it to that class. This type parameter (<E>) acts as a template. Java Generics are designed to enhance type safety, allowing flexibility without sacrificing security.

The letters within < > can technically be anything, but by convention, the following are commonly used:

Symbol Meaning/Use Case
E Element: Often used in collections like List<E> or Set<E>.
T Type: Represents a generic type, used for broad cases.
K Key: Used in maps to represent keys (Map<K, V>).
V Value: Used in maps to represent values (Map<K, V>).
N Number: Represents numerical types.
R Return: Represents return types in methods.
? Wildcard: Represents an unknown type.

How Is a Type Parameter Different from Method Parameters?

At first, I thought type parameters might just be another form of method parameters, but they serve very different purposes.

Method Parameters

These are used to pass values to a method and are tied to specific data types. For example:

public void addIntNode(int num) {
    System.out.println(num);
}
Enter fullscreen mode Exit fullscreen mode

Here, num is an integer parameter passed to the method. It is fixed to the int type and cannot handle any other type.

Type Parameters

These define a class or method's ability to handle different types dynamically. For instance:

public class LinkedList<E> { // E is the type parameter
    private class Node {
        E data; // The type of data depends on the generic parameter
        Node next;
    }

    public void add(E element) { // Accepts the generic type E
        System.out.println(element);
    }
}
Enter fullscreen mode Exit fullscreen mode

Here, E is a placeholder for a type that will be defined when the class is instantiated. For example, LinkedList means E will be replaced with Integer, while LinkedList means E will be replaced with String.


Is <E> Just Another Name for Object?

Initially, I thought E in Generics was simply equivalent to Object. After all, it seemed like it could represent any type. However, there’s a significant difference between Object and E.

Feature Object E (Generic Type)
Scope Can represent any type (superclass of all). Restricted to a specific type like String.
Type Safety No type safety (casting is often needed). Type-safe (type checking occurs at compile time).
Use Case General methods or classes without Generics. Generic methods or classes for specific types.

Generics enhance type safety by ensuring the type is checked at compile time. Unlike Object, which requires casting, E eliminates such boilerplate code.


Before Generics: The Pre-Java 5 World

Before Java 5 introduced Generics in 2004, collections relied heavily on the Object type, which allowed them to store any type of data. While flexible, this approach lacked type safety and required frequent casting.

// Pre-Generics Example
import java.util.ArrayList;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List list = new ArrayList(); // Non-generic list
        list.add("Hello");
        list.add(123); // Different types can be added

        String str = (String) list.get(0); // Requires casting
        Integer num = (Integer) list.get(1); // Requires casting
        System.out.println(str + " " + num);
    }
}

Enter fullscreen mode Exit fullscreen mode

This approach had two major downsides:

  1. Lack of type safety—mixing incompatible types in a collection led to runtime errors.
  2. Verbose and error-prone casting for every element retrieval.

With Generics, the code becomes simpler and safer:

// Post-Generics Example
import java.util.ArrayList;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        list.add("Hello");
        // list.add(123); // Compile-time error: Type safety ensured

        String str = list.get(0); // No casting required
        System.out.println(str);
    }
}
Enter fullscreen mode Exit fullscreen mode

Two Main Goals of Type Erasure

In the previous section, I speculated that type parameters might essentially be treated as Object, and I wasn’t entirely wrong. Through the mechanism of type erasure, type parameters specified at compile time are interpreted as their concrete types, ensuring type safety. However, during runtime, generic type parameters (e.g., <E>) are effectively treated as Object.

Initially, I wondered why Java would erase types in this way, but it seems there are two main reasons:

  • Maintaining Backward Compatibility

    • Before the introduction of Generics, collections and other APIs were designed using Object.
    • Type erasure allows older code and newer generic code to coexist seamlessly.
  • Runtime Efficiency

    • If Java retained type parameters, the JVM would need to create separate classes for each type (e.g., List<String>, List<Integer>).
    • Type erasure simplifies JVM implementation by treating all generic types as a single class.

The primary goal of Generics is to improve type safety and prevent runtime errors. Type erasure ensures that type checks are performed at compile time while maintaining uniform behavior at runtime. However, a downside of type erasure is that the type information is lost during runtime. As a result, you cannot directly use operations like instanceof with type parameters.

This area still leaves room for further exploration and learning.


Parameter Bounds in Generics

In Generics, bounds allow you to restrict the types that can be used as type parameters (e.g., <T> or <E>). In Java, the extends and super keywords are used to define upper bounds and lower bounds for type parameters.

For example, in the following code, the type parameter T is restricted to the Number class and its subclasses (e.g., Integer, Double). The mechanism of specifying a superclass (extends) or a subclass (super) as a boundary is referred to as upper bounds and lower bounds, respectively.

public <T extends Number> void printDoubleValue(T number) {
    System.out.println(number.doubleValue());
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

At first, I found working with Generics confusing, but once I understood the basics, I realized it wasn’t as difficult as it seemed. In fact, it’s an incredibly useful feature. By abstracting types, Generics achieve a great balance of flexibility, safety, and efficiency, which I find fascinating.

This article provides an overview of the basic concepts and my initial experiences. I hope it serves as a helpful introduction for those exploring Java Generics for the first time.

References

Top comments (0)