In this comprehensive guide, you’ll learn everything you need to know about Java generics, from the basics of generic classes and methods to advanced topics like wildcard generics and type erasure. 

By the end of this tutorial, you’ll be equipped with the knowledge to write more efficient, flexible, and maintainable code, taking your Java skills to the next level.

Fundamentals of Java Generics

Java Generics allow you to define classes, interfaces, and methods with type parameters

This enables them to operate on objects of various types while ensuring compile-time type safety. 

Generics enables you to write more flexible and reusable code, reduce the risk of runtime errors, and improve code readability. 

By using generics, you can also avoid the need for explicit type casting, which can lead to ClassCastException at runtime.

Generics also allows you to create reusable code that can work with different data types while maintaining type safety. 

This means you can write a single piece of code that can be used with various types of data, such as integers, strings, or custom objects, without the need for explicit casting or type conversions.

Let’s consider a simple example to illustrate the concept of generics. Suppose you want to create a class that can store a collection of objects. 

Without generics, you might write a class like this:

public class Container { 
  private Object obj; 
 
  public void set(Object obj) { 
    this.obj = obj; 
  } 

  public Object get() { 
    return obj; 
  } 
}

This class can store any type of object, but it has a major drawback: it doesn’t provide any type safety. 

If you try to retrieve an object from the container and assign it to a variable of the wrong type, you’ll get a ClassCastException at runtime.

Now, let’s rewrite the same class using generics:

public class Container<T> { 
  private T obj; 

  public void set(T obj) { 
    this.obj = obj; 
  } 

  public T get() { 
    return obj; 
  } 
}

In this version of the class, we’ve added a type parameter T, which represents the type of object that the container can hold. 

By using generics, we’ve ensured that the container can only store objects of the specified type, providing type safety and preventing runtime errors.

Working with Generic Classes

When you create a generic class, you’re defining a blueprint for a class that can work with multiple data types. 

This allows you to write code that’s more adaptable and can be applied to various scenarios, reducing the need for code duplication.

Let’s take a look at an example of a generic class:

public class Box<T> { 
  private T value; 
  public void setValue(T value) { 
    this.value = value; 
  } 
  
  public T getValue() { 
    return value; 
  } 
}

In this example, we’ve created a generic class called Box that takes a type parameter T

This type parameter can be replaced with any data type, such as Integer, String, or even a custom class. 

The setValue() and getValue() methods work with the type parameter T, allowing you to store and retrieve values of the specified type.

You can create instances of the Box class using different data types, like this:

Box<Integer> intBox = new Box<>(); 
intBox.setValue(10); 

Box<String> stringBox = new Box<>(); 
stringBox.setValue("Hello, World!");

As you can see, the Box class is reusable and can work with various data types.

Generic Methods

A generic method is a method that can operate on objects of various types while providing compile-time type safety. 

You can define a generic method in a non-generic class, which means you can use generics without creating a generic class. 

Let’s start with a simple example of a generic method:

public class MyClass { 
  public <T> void printArray(T[] array) { 
    for (T element : array) { 
      System.out.println(element); 
    } 
  } 
}

In this example, the printArray() method is a generic method that can print an array of any type. 

The <T> syntax before the method return type indicates that this method is generic, and T is the type parameter. 

You can call this method with an array of any type, such as String, Integer or even a custom class.

Now, let’s discuss type inference, which is a key feature of generic methods. 

Type inference allows you to omit the type argument when calling a generic method, making your code more concise and readable. 
For example:

String[] stringArray = {"hello", "world"}; 
// No need to specify the type argument
new MyClass().printArray(stringArray);

In this case, the compiler infers the type argument String from the method call, so you don’t need to explicitly specify it.

Understanding Wildcard Generics

Wildcard generics are used to represent an unknown type. 

They’re denoted by a question mark (?) and can be used as a type argument for a generic class or method. 

You can think of wildcards as a way to say, 

“I don’t care what type this is, just give me something that matches.”

There are three types of wildcards: 

A. unbounded, 
B. upper bounded, and 
C. lower bounded. 

Let’s explore each of these in more detail.

A. Unbounded Wildcards

An unbounded wildcard is simply a ?, which means it can represent any type. 

You can use it as a type argument for a generic class or method. 
For example:

List<?> myList = new ArrayList();

In this example, myList can hold a list of any type. 

However, you can’t add any elements to the list because the compiler doesn’t know what type of objects it’s supposed to hold.

B. Upper Bounded Wildcards

An upper bounded wildcard is denoted by <? extends Type>, where Type is a class or interface. 

This means that the wildcard can represent any type that is a subclass of Type. 

For example:

List<? extends Number> myNumberList = new ArrayList();

In this example, myNumberList can hold a list of any type that is a subclass of Number, such as Integer, Double or Float.

Remember that you cannot add elements to a collection that uses ? extends Type, because it’s not safe—you don’t know the exact type, and adding an incorrect type would violate type safety.

So, below code would result in a compiler error:

public void processNumbers(List<? extends Number> list) {
    list.add(5);
}

even though you are adding a valid type value but adding is not allowed with a collection of type ? extends Type.

C. Lower Bounded Wildcards

A lower bounded wildcard is denoted by <? super Type>, where Type is a class or interface. 

This means that the wildcard can represent any type that is a superclass of Type

For example:

List<? super Integer> myIntegerList = new ArrayList<>();

In this example, myIntegerList can hold a list of any type that is a superclass of Integer, such as Number or Object.

You can add elements to a collection that uses ? super Type because it’s safe—the collection can accept any subtype of the lower bound. 

However, retrieving elements from the collection may only give you an Object, since the exact type is unknown.

By using wildcards, you can create more flexible and reusable code that can work with a variety of types. 

However, you need to be careful when using wildcards because they can lead to type safety issues if not used correctly.

Below is a summary of differences between lower and upper bounded wildcards

FeatureUpper Bounded Wildcard (? extends Type)
Lower Bounded Wildcard (? super Type)
What it meansType is Type or a subclassType is Type or a superclass
Can you add elements?No because you don’t know the exact typeYes because any subclass of the type is allowed
Can you read elements?Yes with certainty that they are of at least TypeYes but as Object (no guarantee of type)
Typical use caseWhen you want to read from a collectionWhen you want to write to a collection
ExampleList<? extends Number> (can accept Integer; Double; etc.)List<? super Integer> (can accept Number; Object; etc.)

Implementing Generic Interfaces

Any Java developer who has worked with interfaces knows how powerful they can be in defining contracts for classes to implement. 

With the introduction of generics, you can now create generic interfaces that allow for more flexibility and type safety.

When implementing a generic interface, you need to provide type arguments for the type parameters defined in the interface. 

This ensures that your class is bound to specific types, which in turn enhances code reusability and type safety.

Let’s consider an example to understand this concept. 

Suppose you have a generic interface called Printable that has a single method print():

public interface Printable { 
  void print(T obj); 
}

Now, if you want to create a class called StringPrinter that implements the Printable interface, you would do so like this:

public class StringPrinter implements Printable { 
  @Override 
  public void print(String obj) { 
    System.out.println(obj); 
  } 
}

In this example, the StringPrinter class provides a type argument String for the type parameter T in the Printable interface. 

This means that the print() method in StringPrinter can only accept String objects as arguments, ensuring type safety.

You can also create a class that implements a generic interface with multiple type parameters. 

For instance, consider a generic interface called Pair that has two type parameters:

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

To implement this interface, you could create a class called StringIntegerPair like this:

public class StringIntegerPair implements Pair<String, Integer> { 
  private String key; 
  private Integer value; 

  public StringIntegerPair(String key, Integer value) { 
    this.key = key; 
    this.value = value; 
  } 

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

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

In this example, the StringIntegerPair class provides type arguments String and Integer for the type parameters K and V in the Pair interface, respectively.

By implementing generic interfaces, you can create classes that are more flexible and reusable, while also ensuring type safety. 

Generic Collections

Using generic collections, you can create collections that are type-safe, allowing you to store and retrieve objects of a specific type without the need for casting.

Let’s start with a simple example of a generic list. 

Suppose you want to create a list that can only hold strings. 
You can define it as follows:

List<String> stringList = new ArrayList<>();

In this example, the type parameter String specifies that the list can only hold strings. 

This means that you cannot add objects of any other type to the list, ensuring type safety.

Similarly, you can create a generic set or map. 

For instance, a generic set that can only hold integers can be defined as:

Set<Integer> integerSet = new HashSet<>();

A generic map that maps strings to integers can be defined as:

Map<String, Integer> stringIntegerMap = new HashMap<>();

Using generic collections provides several benefits. 

Firstly, it prevents ClassCastException at runtime, which can occur when you try to retrieve an object of the wrong type from a collection. 

Secondly, it makes your code more readable and maintainable, as the type of objects in the collection is clearly specified.

Another advantage of generic collections is that they can be used with other generic classes and methods. 

For example, you can create a generic class that uses a generic list:

public class GenericContainer<T> { 
  private List<T> list; 
  public GenericContainer() { 
    list = new ArrayList<>(); 
  } 

  public void add(T element) { 
    list.add(element); 
  } 

  public T get(int index) { 
    return list.get(index); 
  } 
}

In this example, the GenericContainer class uses a generic list to store objects of type T

You can create instances of this class with different types, such as:

GenericContainer<String> stringContainer = new GenericContainer<>(); 
GenericContainer<Integer> integerContainer = new GenericContainer<>();

By leveraging generic collections, you can write more flexible and reusable code that is also type-safe.

Type Erasure and Generics

Understanding Type Erasure

During compilation, Java removes type information from generics — a process known as type erasure. 

So, when you compile a declaration of the form

List<String> names = new ArrayList<>();

The compiler will erase all the type information and then generated byte code will have something of the form

List names = new ArrayList();


This is called Type Erasure.

Limitations of Generics

  • No Primitive Types
    Generics work with reference types, not primitives like int or double.
  • Cannot Create Instances
    You cannot create instances of generic types due to type erasure (e.g., T obj = new T()).
  • No Runtime Type Information
    You can’t use instanceof with parameterized types.

Best Practices for Using Generics

Naming Conventions

Use clear, descriptive type parameter names. 
For example, T for type, K and V for keys and values in maps.

Choosing Appropriate Type Parameters

Use bounded type parameters only when necessary. 
If no bounds are needed, simply use T.

Handling Unchecked Warnings

Sometimes, you might encounter unchecked warnings. 
Use @SuppressWarnings("unchecked") judiciously to suppress these warnings, but ensure you understand why the warning exists.

Generics and Java Versions

Generics in Java 5

Java 5 introduced generics, allowing type-safe collections and improving code quality by catching errors at compile time.

Enhancements in Java 7

Java 7 introduced the diamond operator (<>), allowing you to omit the generic type in the constructor if it can be inferred.

List<String> list = new ArrayList<>();

Improvements in Java 8 and Beyond

Java 8 added new functional programming features like lambda expressions and streams, which work seamlessly with generics.

Summing up

Throughout this guide, you’ve learned all about Java generics, how to create generic classes, methods, and interfaces, as well as effectively utilize wildcard generics and generic collections. 

By mastering these concepts and following best practices, you’ll be able to write more efficient and maintainable code.