Generics in Java: Enhancing Code Safety and Flexibility
The Generics feature was introduced in Java version 5.0 and quickly became one of the most powerful tools in a Java developer's toolkit. Generics allow classes, interfaces, and methods to be parameterized by types. This functionality not only increases code reusability but also adds safety by ensuring that the code is more type-checked at compile time.
What Are Generics?
Generics are a way to parameterize classes, interfaces, and methods with data types. This allows you to create a class or method that can operate on objects of various types while providing type safety at compile time. Without Generics, you would have to resort to non-specific types, such as Object
, which can expose your code to runtime errors.
Let's imagine a scenario where we want to create a list in Java to store Integer
.
We might try to write the following:
List list = new ArrayList();
list.add("Hello");
String text = list.get(0)
The compiler will complain about the last line. It doesn’t know what data type is returned.
The compiler will require an explicit casting:
String text = (String) list.get(0);
Now, let's make a change to our code:
List<String> list = new ArrayList<>();
By adding the diamond operator <> containing the type, we narrow the specialization of this list to only String type. In other words, we specify the type held inside the list. The compiler can enforce the type at compile time.
In small programs, this might seem like a trivial addition. But in larger programs, this can add significant robustness and make the program easier to read.
Generic Methods
We write generic methods with a single method declaration, and we can call them with arguments of different types. The compiler will ensure the correctness of whichever type we use.
These are some properties of generic methods:
Generic methods have a type parameter (the diamond operator enclosing the type) before the return type of the method declaration.
Type parameters can be bounded (we explain bounds later in this article).
Generic methods can have different type parameters separated by commas in the method signature.
The method body for a generic method is just like a normal method.
Here’s an example of defining a generic method to print everything passed by the parameter concatenating it with "!!!!!":
public static <T> void printEverything(T parameter){
System.out.println(parameter + "!!!!!");
}
The <T> in the method signature implies that the method will be dealing with generic type T. This is needed even if the method is returning void.
As mentioned, the method can deal with more than one generic type. Where this is the case, we must add all generic types to the method signature.
Here is another method to deal with type T and type G:
public static <T, G> List<G> fromArrayToList(T[] a, Function<T, G> mapperFunction) {
return Arrays.stream(a)
.map(mapperFunction)
.collect(Collectors.toList());
}
Bounded Generics
Bounded Generics
Bounded Generics are a feature in Java that allows you to restrict the types that can be used as type arguments in a generic parameter. This is done using the keywords extends
and super
to define upper and lower bounds, respectively. These bounds ensure that generic methods and classes are used in a more type-safe and specific manner.
Why Use Bounded Generics?
Type Safety: Bounded Generics help avoid runtime errors by ensuring that methods and classes receive types that are compatible with their operations.
Flexibility with Restrictions: Allows developers to write more generic and reusable code that still leverages methods and properties of certain types or their hierarchies.
Practical Examples of Bounded Generics
Example 1: Restricting to a Specific Type and Its Subclasses
Suppose you want to create a utility that can compare elements, but only those that implement the Comparable
interface. This ensures that the elements passed to the method can be compared to each other in an ordered fashion.
public class ComparatorUtil {
// Método genérico com um limite superior
public static <T extends Comparable<T>> T getMax(T x, T y) {
return (x.compareTo(y) > 0) ? x : y;
}
public static void main(String[] args) {
System.out.println(getMax(10, 20)); // Saída: 20
System.out.println(getMax("hello", "world")); // Saída: world
}
}
Example 2: Restricting to Classes That Implement Multiple Interfaces
Another useful example is when you need a generic parameter to implement multiple interfaces. This is useful to ensure that the argument type has all the necessary methods for the planned operations within the generic method.
public class MultiBoundUtil {
// Um método genérico que exige que os objetos sejam Serializable e Comparable
public static <T extends Serializable & Comparable<T>> void printSorted(List<T> list) {
Collections.sort(list);
list.forEach(System.out::println);
}
public static void main(String[] args) {
List<String> words = Arrays.asList("banana", "apple", "pear", "orange");
printSorted(words); // Saída: apple, banana, orange, pear
}
}
Explanation:
In the first example, the
getMax
method uses bounded generics to restrict its parameters to types that implement theComparable
interface. This allows thecompareTo
method to be called, ensuring that the types can be compared.In the second example,
printSorted
requires the elements of the list to be bothSerializable
andComparable
. This is useful for operations that need to serialize objects after sorting them, ensuring that the type used supports both operations.
Wildcards with Generics in Java
Wildcards in Java Generics are a feature that allows greater flexibility in generic programming by making type variables more versatile. The use of wildcards is indicated by a question mark (?
) and can be further qualified with the keywords extends
(for an upper bound) or super
(for a lower bound). These wildcards are especially useful in situations where you want to write code that is type-independent.
Why Use Wildcards?
Wildcards are extremely useful in APIs where you want the method to accept arguments of various generic types or when you want to ensure type safety while keeping the code flexible and reusable.
Practical Examples of Wildcards
Example 1: Using ? extends Type
This wildcard is used to indicate that the variable can be of the specified type or any subtype of it. It is useful in methods that do not need to modify the collections, only read data from them.
Example: A method that prints the elements of any list containing types that are subclasses of Number
.
public class DataPrinter {
public static void printNumbers(List<? extends Number> numbers) {
for (Number num : numbers) {
System.out.println(num);
}
}
public static void main(String[] args) {
List<Integer> integers = Arrays.asList(1, 2, 3);
List<Double> doubles = Arrays.asList(3.14, 1.68, 2.76);
printNumbers(integers);
printNumbers(doubles);
}
}
Example 2: Using ? super Type
This wildcard is used to indicate that the variable can be of the specified type or any supertype of it. It is useful in methods that need to modify collections by adding data to them.
Example: A method that adds an element to a list of any type that is a superclass of Integer
.
public class DataAdder {
public static void addNumber(List<? super Integer> list) {
list.add(25); // Adiciona um Integer a uma lista de Integer ou de qualquer superclasse de Integer
}
public static void main(String[] args) {
List<Number> numbers = new ArrayList<>();
addNumber(numbers);
System.out.println(numbers); // Saída: [25]
List<Object> objects = new ArrayList<>();
addNumber(objects);
System.out.println(objects); // Saída: [25]
}
}
Explanation of Examples
In the first example, the
printNumbers
method can accept a list of any class that extendsNumber
(such asInteger
,Double
, etc.), thanks to the wildcard? extends Number
. This allows the method to be flexible about the type of list it accepts while still operating safely on elements it knows will be numbers.In the second example, the
addNumber
method can add anInteger
to lists of different types, as long as those types are superclasses ofInteger
(Number
,Object
, etc.), thanks to the wildcard? super Integer
. This is useful when you want to ensure that your method can add elements to a variety of lists without worrying about the specificity of the list type.
Type Erasure
Generics were added to Java to ensure type safety. And to ensure that generics won’t cause overhead at runtime, the compiler applies a process called type erasure on generics at compile time.
Type erasure removes all type parameters and replaces them with their bounds or with Object
if the type parameter is unbounded. This way, the bytecode after compilation contains only normal classes, interfaces, and methods, ensuring that no new types are produced. Proper casting is applied as well to the Object
type at compile time.
Example of Type Erasure:
public <T> List<T> genericMethod(List<T> list) {
return list.stream().collect(Collectors.toList());
}
With type erasure, the unbounded type T is replaced with Object:
// for illustration
public List<Object> withErasure(List<Object> list) {
return list.stream().collect(Collectors.toList());
}
// which in practice results in
public List withErasure(List list) {
return list.stream().collect(Collectors.toList());
}
If the type is bounded, the type will be replaced by the bound at compile time:
public <T extends Building> void genericMethod(T t) {
...
}
and would change after compilation:
public void genericMethod(Building t) {
...
}
Limitations and Considerations
Key Limitations
Use with Primitive Types: Generics cannot be used with primitive types, such as
int
,float
, etc. You need to use corresponding wrapper classes likeInteger
,Float
, etc.Type Erasure: At runtime, generic class, and method instances do not retain their generic types. This is known as type erasure and can limit certain operations you might want to perform with reflection.
Examples of Limitations
Generics with Primitive Types:
// List<int> myList = new ArrayList<>(); // Compilation error
List<Integer> myList = new ArrayList<>(); // Correct
Type Erasure and reflection:
List<Integer> list = new ArrayList<>(); System.out.println(list instanceof List<?>); // True // System.out.println(list instanceof List<Integer>); // Compilation error
Conclusion
Generics represent a powerful abstraction capability in Java, providing not only type safety but also incredible flexibility to develop algorithms and collections that can operate transparently on different data types.
Subscribe to my newsletter
Read articles from André Felipe Costa Bento directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
André Felipe Costa Bento
André Felipe Costa Bento
Fullstack Software Engineer.