Generics in Java
There are situations when methods and classes do not depend on the data types on which they operate. For example, the algorithm to find an element in an array can process arrays of strings, integers or custom classes. It does not matter what the array stores: the algorithm is always the same. Yet, we cannot write this algorithm as a single method, because it requires different arguments (int[]
, String[]
, etc).
Since version 5, Java has supported generic programming that introduces abstraction over types. Generic methods and classes can handle different types in the same general way. A concrete type is determined only when a developer creates an object of the class or invokes the method. This approach enables us to write more abstract code and develop reusable software libraries. Let us consider it step by step using examples written in Java.
Type parameters
A generic type is a generic class (or interface) that is parameterized over types. To declare a generic class, we need to declare a class with the type parameter section delimited by angle brackets <
>
following the class name.
In the following example, the GenericType
class has a single type parameter named T
. We assume that the type T
is "some type" and write the class body regardless of the concrete type.
class GenericType<T> {
/**
* A field of "some type"
*/
private T t;
/**
* Takes a value of "some type" and assigns it to the field
*/
public GenericType(T t) {
this.t = t;
}
/**
* Returns a value of "some type"
*/
public T get() {
return t;
}
/**
* Takes a value of "some type", assigns it to a field and then returns it
*/
public T set(T t) {
this.t = t;
return this.t;
}
}
After being declared, a type parameter can be used inside the class body as an ordinary type. For instance, the above example uses the type parameter T
as:
- a type for a field;
- a constructor argument type;
- an instance method argument and return type.
The behavior of both instance methods does not depend on the concrete type of T
; it can take/return a string or a number in the same way.
A class can have any number of type parameters. For example, the following class has three.
class Three<T, U, V> {
T t;
U u;
V v;
}
But most generic classes have just one or two type parameters.
The naming convention for type parameters
There is a naming convention that restricts type parameter name choices to single uppercase letters. Without this convention, it would be difficult to tell the difference between a type variable and an ordinary class name.
The most commonly used type parameter names are:
T
– TypeS
,U
,V
etc. – 2nd, 3rd, 4th typesE
– Element type (used extensively by different collections)K
– KeyV
– ValueN
– Number
Creating objects of generic classes
To create an object of a generic class (standard or custom), we need to specify the type argument following the type name.
GenericType<Integer> obj1 = new GenericType<Integer>(10);
GenericType<String> obj2 = new GenericType<String>("abc");
It is important to note that a type argument must be a reference type. Primitive types like int or double are invalid type arguments.
Java 7 made it possible to replace the type arguments required to invoke the constructor of a generic class with an empty set of type arguments, as long as the compiler can infer the type arguments from the context.
GenericType<Integer> obj1 = new GenericType<>(10);
GenericType<String> obj2 = new GenericType<>("abc");
We will use this format in all further examples.
The pair of angle brackets <> is informally called the diamond operator.
Sometimes, declaring a variable with a generic type can be lengthy and difficult to read. Starting from Java 10, we can write var
instead of a specific type to force automatic type inference based on the type of assigned value.
var obj3 = new GenericType<>("abc");
After we have created an object with a specified type argument, we can invoke methods of the class that take or return the type parameter:
Integer number = obj1.get(); // 10
String string = obj2.get(); // "abc"
System.out.println(obj1.set(20)); // prints the number 20
System.out.println(obj2.set("def")); // prints the string "def"
If a class has multiple type parameters, we need to specify all of them when creating instances:
GenericType<Type1, Type2, ..., TypeN> obj = new GenericType<>(...);
Custom generic array
As a more complicated example, let us consider the following class which represents a generic immutable array. It has one field to store items of type T
, a constructor to set items, a method to get an item by its index, and another method to get the length of the internal array. The class is immutable because it does not provide methods to modify the items array.
public class ImmutableArray<T> {
private final T[] items;
public ImmutableArray(T[] items) {
this.items = items.clone();
}
public T get(int index) {
return items[index];
}
public int length() {
return items.length;
}
}
This class shows that a generic class can have methods (like length) that do not use the parameter type at all.
The following code creates an instance of ImmutableArray
to store three strings and then output the items to the standard output.
var stringArray = new ImmutableArray<>(new String[] {"item1", "item2", "item3"});
for (int i = 0; i < stringArray.length(); i++) {
System.out.print(stringArray.get(i) + " ");
}
It is possible to parameterize ImmutableArray
with any reference type, including arrays, standard classes, or your own classes.
var doubleArray = new ImmutableArray<>(new Double[] {1.03, 2.04});
MyClass obj1 = ..., obj2 = ...; // suppose, you have two objects of your custom class
var array = new ImmutableArray<>(new MyClass[] {obj1, obj2});
We used var
in the above examples to improve readability. Instead of using var, we could have explicitly specified the type, e.g. ImmutableArray<String> stringArray = ...;
and so on.
Reusing code with generics
Let's consider a generic class named GenericType
that stores a value of "some type".
class GenericType<T> {
private T t;
public GenericType(T t) {
this.t = t;
}
public T get() {
return t;
}
}
It is possible to create an object with a concrete type (e.g., String
):
GenericType<String> instance1 = new GenericType<>("abc");
String str = instance1.get();
We can also create instances with other types (Integer
, Character
) and then invoke the get
method to access the internal field. In this manner, generics allow us to use the same class and methods for processing different types.
Reusing code with Object
But there is another way to improve code reusability. If we declare a field of type Object
, we can assign a value of any reference type to it. This approach was widely used before the introduction of generics in Java 5.
The following class demonstrates this concept:
class NonGenericClass {
private Object val;
public NonGenericClass(Object val) {
this.val = val;
}
public Object get() {
return val;
}
}
Now, we can create an instance of this class with the same string as in the previous example (see GenericType
).
NonGenericClass instance2 = new NonGenericClass("abc");
It is also possible to create an instance by passing in a value of type Integer
, Character
, or any other reference type.
Using the Object
class this way allows us to reuse the same class to store different data types.
The advantage of generics: from run-time to compile-time
After an invocation of the get()
method we obtain an Object
, not a String
or an Integer
. We cannot get a string directly from the method.
NonGenericClass instance2 = new NonGenericClass("abc");
String str = instance2.get(); // Compile-time error: Incompatible types
To get the string back, we must perform an explicit typecast to the String
class.
String str = (String) instance2.get(); // "abc"
This works because a string was passed into instance2
. But what if the instance does not store a string? If this is the case, the code throws an exception. Here is an example:
NonGenericClass instance3 = new NonGenericClass(123);
String str = (String) instance3.get(); // throws java.lang.ClassCastException
Now we can see the main advantage of generics over the Object
class. Because there is no need to perform an explicit typecast, we never get a runtime exception. If we do something wrong, we can see it at compile time.
GenericType<String> instance4 = new GenericType<>("abc");
String str = instance4.get(); // There is no typecasting here
Integer num = instance4.get(); // It does not compile
A compile-time error will be detected by the programmer, not a user of the program. Because generics let the compiler take care of typecasting, generics are both safer and more flexible compared to the Object
class.
Generics without specifying a type of argument
When you create an instance of a generic class, you have the option to not specify an argument type at all.
GenericType instance5 = new GenericType("my-string");
In this case, the field of the class is Object
, and the get
method returns an Object
as well.
The above code is equivalent to the following line:
GenericType<Object> instance5 = new GenericType<>("abc"); // it is parameterized with Object
Usually, you will not use generics parameterized by Object
due to the same problems as presented in the previous section. Just remember that this possibility exists.
Conclusion
A class can declare one or more type parameters and use them inside the class body as types for fields, method arguments, return values, and local variables. In this case, the class does not know the concrete type on which it operates. The concrete type must be specified when creating instances of the class. This approach allows you to write classes and methods that can process many different types in the same way.
Remember that only a reference type (an array, a standard class, a custom class) can be used as a concrete type for Java generics. This means that instead of primitive types, we use wrapper classes such as Integer
, Double
, Boolean
, and so on when creating an object of a generic class.
Both generics and Objects allow you to write generalized code. Using Object
, however, may require explicit typecasting, which can lead to error-prone code. Generics provide type safety by shifting type-checking responsibilities to the Java compiler.