Hi! We're going to talk about Java Generics. I must say that you will learn a lot! Not only this lesson, but also the next few lessons, will be devoted to generics. So, if you're interested in generics, today's you're lucky day: you will learn a lot about the features of generics. And if not, resign yourself and relax! :) This is a very important topic, and you need to know it. Let's start with the simple: the "what" and the "why".

What are Java Generics?

Generics are types that have a parameter. When creating a generic type, you specify not only a type, but also the data type that it will work with. I'm guessing the most obvious example has already come to your mind: ArrayList! This is how we usually create one in a program:

import java.util.ArrayList;
import java.util.List;

public class Main {

   public static void main(String[] args) {

       List<String> myList1 = new ArrayList<>();
       myList1.add("Test String 1");
       myList1.add("Test String 2");
   }
}
As you might guess, a feature of this list is that we can't stuff everything into it: it works exclusively with String objects. Now let's take a little digression into Java's history and try to answer the question "why?" To do this, we'll write our own simplified version of the ArrayList class. Our list only knows how to add data to and retrieve data from an internal array:

public class MyListClass {

   private Object[] data;
   private int count;

   public MyListClass() {
       this.data = new Object[10];
       this.count = 0;
   }

   public void add(Object o) {
       this.data[count] = o;
       count++;
   }

   public Object[] getData() {
       return data;
   }
}
Suppose we want our list to only store Integers. We're not using a generic type. We don't want to include an explicit "instanceof Integer" check in the add() method. If we did, our whole class would be suitable only for Integer, and we would have to write a similar class for every other data type in the world! We'll rely on our programmers, and just leave a comment in the code to ensure that they don't add anything we don't want:

// Use this class ONLY with the Integer data type
public void add(Object o) {
   this.data[count] = o;
   count++;
}
One of the programmers missed this comment and inadvertently put several Strings in a list of numbers and then calculated their sum:

public class Main {

   public static void main(String[] args) {

       MyListClass list = new MyListClass();
       list.add(100);
       list.add(200);
       list.add("Lolkek");
       list.add("Shalala");

       Integer sum1 = (Integer) list.getData()[0] + (Integer) list.getData()[1];
       System.out.println(sum1);

       Integer sum2 = (Integer) list.getData()[2] + (Integer) list.getData()[3];
       System.out.println(sum2);
   }
}
Console output:

300 
Exception in thread "main" java.lang.ClassCastException: java.lang.String cannot be cast to java.lang.Integer 
      at Main.main (Main.java:14)
What's the worst part of this situation? Certainly not the carelessness of the programmer. The worst part is that incorrect code ended up in an important place in our program and compiled successfully. Now we'll encounter the bug not while writing code, but only during testing (and this is the best case scenario!). Fixing bugs in later stages of development costs much more — both in terms of money and time. This is precisely where generics benefit us: a generic class lets the unlucky programmer detect the error right away. The program simply won't compile!

import java.util.ArrayList;
import java.util.List;

public class Main {

   public static void main(String[] args) {

       List<Integer> myList1 = new ArrayList<>();
      
       myList1.add(100);
       myList1.add(100);
       myList1.add ("Lolkek"); // Error!
       myList1.add("Shalala"); // Error!
   }
}
The programmer immediately realizes his or her mistake and instantly gets better. By the way, we didn't have to create our own List class to see this sort of error. Simply remove the angle brackets and type (<Integer>) from an ordinary ArrayList!

import java.util.ArrayList;
import java.util.List;

public class Main {

   public static void main(String[] args) {

      List list = new ArrayList();

      list.add(100);
      list.add(200);
      list.add("Lolkek");
      list.add("Shalala");

       System.out.println((Integer) list.get(0) + (Integer) list.get(1));
       System.out.println((Integer) list.get(2) + (Integer) list.get(3));
   }
}
Console output:

300 
Exception in thread "main" java.lang.ClassCastException: java.lang.String cannot be cast to java.lang.Integer 
     at Main.main(Main.java:16)
In other words, even using Java's "native" mechanisms, we can make this sort of mistake and create an unsafe collection. However, if we paste this code into an IDE, we get a warning: "Unchecked call to add(E) as a member of raw type of java.util.List" We're told that something may go wrong when adding an item to a collection that lacks a generic type. But what does the phrase "raw type" mean? A raw type is a generic class whose type has been removed. In other words, List myList1 is a raw type. The opposite of a raw type is a generic type — a generic class with an indication of the parameterized type(s). For example, List<String> myList1. You might ask why the language allows the use of raw types? The reason is simple. Java's creators left support for raw types in the language in order to avoid creating compatibility problems. By the time Java 5.0 was released (generics first appeared in this version), a lot of code had already been written using raw types. As a result, this mechanism is still supported today. We have repeatedly mentioned Joshua Bloch's classic book "Effective Java" in the lessons. As one of the creators of the language, he didn't skip raw types and generic types in his book. What are generics in Java? - 2Chapter 23 of the book has a very eloquent title: "Don't use raw types in new code" This is what you need to remember. When using generic classes, never turn a generic type into a raw type.

Generic methods

Java lets you parameterize individual methods by creating so-called generic methods. How are such methods helpful? Above all, they are helpful in that they let you work with different types of method parameters. If the same logic can be safely applied to different types, a generic method can be a great solution. Consider this a very simple example: Suppose we have a some list called myList1. We want to remove all values from the list and fill all the empty spaces with new values. Here's what our class looks like with a generic method:

public class TestClass {

   public static <T> void fill(List<T> list, T val) {
       for (int i = 0; i < list.size(); i++)
           list.set(i, val);
   }

   public static void main(String[] args) {

       List<String> strings = new ArrayList<>();
       strings.add("Old String 1");
       strings.add("Old String 2");
       strings.add("Old String 3");

       fill(strings, "New String");

       System.out.println(strings);

       List<Integer> numbers = new ArrayList<>();
       numbers.add(1);
       numbers.add(2);
       numbers.add(3);

       fill(numbers, 888);
       System.out.println(numbers);
   }
}
Pay attention to the syntax. It looks a bit unusual:

public static <T> void fill(List<T> list, T val)
We write <T> before the return type. This indicates that we're dealing with a generic method. In this case, the method accepts 2 parameters as an input: a list of T objects and another separate T object. By using <T>, we parameterize the method's parameter types: we can't pass in a list of Strings and an Integer. A list of Strings and a String, a list of Integers and an Integer, a list of our own Cat objects and another Cat object — that's what we need to do. The main() method illustrates how the fill() method can be easily used to work with different types of data. First, we use the method with a list of Strings and a String as input, and then with a list of Integers and an Integer. Console output:

[New String, New String, New String] [888, 888, 888]
Imagine if we didn't have generic methods and needed the logic of the fill() method for 30 different classes. We would have to write the same method 30 times for different data types! But thanks to generic methods, we can reuse our code! :)

Generic classes

You aren't limited to the generic classes provided in the standard Java libraries — you can create your own! Here's a simple example:

public class Box<T> {

   private T t;

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

   public T get() {
       return t;
   }

   public static void main(String[] args) {

       Box<String> stringBox = new Box<>();

       stringBox.set("Old String");
       System.out.println(stringBox.get());
       stringBox.set("New String");

       System.out.println(stringBox.get());
      
       stringBox.set(12345); // Compilation error!
   }
}
Our Box<T> class is a generic class. Once we assign a data type (<T>) during creation, we are no longer able to place objects of other types in it. This can be seen in the example. When creating our object, we indicated that it would work with Strings:

Box<String> stringBox = new Box<>();
And in the last line of code, when we try to put the number 12345 inside the box, we get a compilation error! It's that easy! We've created our own generic class! :) With that, today's lesson comes to an end. But we aren't saying goodbye to generics! In the next lessons, we'll talk about more advanced features, so don't you go away! ) To reinforce what you learned, we suggest you watch a video lesson from our Java Course

By now, you’ve got a solid grip on Java Generics — how they help you write flexible and type-safe code. But wait, there’s more! Let's take it a step further and explore some advanced concepts like type erasure, its impact on reflection, and how to use wildcards effectively. Don’t worry, I’ll walk you through it all. Ready? Let’s go!

What is Type Erasure in Java Generics?

Let’s kick things off with something that happens behind the scenes—type erasure. So, what’s that all about?

Java’s Generics are a compile-time feature. When your code gets compiled, the generic type information is erased and replaced with raw types or bounds. This means that at runtime, Java doesn’t know what specific type you used for a generic.

Example of Type Erasure

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

if (stringList.getClass() == intList.getClass()) {
    System.out.println("Both are the same at runtime!");  // Output: Both are the same at runtime!
}

Whoa! Even though one list holds String values and the other holds Integer values, Java treats them as the same at runtime. Why? Because of type erasure. Both lists are just ArrayList at runtime!

Why Did Java Implement Type Erasure?

Now you might be wondering, "Why would Java do that?" Great question! Java introduced generics in version 5, but it needed to maintain backward compatibility with older versions of Java that didn’t have generics. Type erasure was the solution.

Key Reasons for Type Erasure:

  • Backward Compatibility: Allowed older code to work without modifications.
  • Reduced Overhead: No extra runtime cost for generics.

Basically, Java wanted to give us the power of generics without breaking existing code. Pretty thoughtful, right?

How Type Erasure Affects Reflection and Runtime Operations

Here’s where things get interesting (and a little tricky). Because of type erasure, generic type information is gone by the time the program runs. This can make reflection and some runtime operations a bit... limited.

Reflection with Generics

import java.util.ArrayList;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;

public class ReflectionExample {
    public static void main(String[] args) {
        ArrayList list = new ArrayList<>();
        System.out.println(list.getClass());  // Output: class java.util.ArrayList
    }
}

Notice something? We can’t tell that this ArrayList was supposed to hold String objects! Java erases that information at runtime.

Impact: You can’t directly access or check the type parameter (<String>) using reflection. This can be a bit of a headache when working with frameworks or libraries that rely on runtime type information.

Unlocking the Power of Wildcards in Generics

Let’s lighten things up with something super practical—wildcards in generics. Wildcards are like the Swiss Army knife of generics, giving you more flexibility with types.

Wildcards Basics

There are three main types of wildcards in Java:

  • Unbounded Wildcard: ? (Accepts any type)
  • Upper-Bounded Wildcard: ? extends T (Accepts T or any subclass of T)
  • Lower-Bounded Wildcard: ? super T (Accepts T or any superclass of T)

Example 1: Unbounded Wildcard

public void printList(List list) {
    for (Object item : list) {
        System.out.println(item);
    }
}

This method can accept any type of list—List<String>, List<Integer>, you name it!

Example 2: Upper-Bounded Wildcard


public void printNumbers(List list) {
    for (Number num : list) {
        System.out.println(num);
    }
}

This method works for List<Integer>, List<Double>, or any subclass of Number. Pretty flexible, right?

Example 3: Lower-Bounded Wildcard

public void addNumbers(List list) {
    list.add(10);
    list.add(20);
}

This method allows you to add integers to a list that could be a List<Integer>, List<Number>, or even List<Object>.