Los conceptos básicos de los genéricos de Java

1. Introducción

Java Generics se introdujo en JDK 5.0 con el objetivo de reducir errores y agregar una capa extra de abstracción sobre los tipos.

Este artículo es una introducción rápida a los genéricos en Java, el objetivo detrás de ellos y cómo se pueden utilizar para mejorar la calidad de nuestro código.

2. La necesidad de genéricos

Imaginemos un escenario en el que queremos crear una lista en Java para almacenar Integer ; podemos tener la tentación de escribir:

List list = new LinkedList(); list.add(new Integer(1)); Integer i = list.iterator().next(); 

Sorprendentemente, el compilador se quejará de la última línea. No sabe qué tipo de datos se devuelve. El compilador requerirá una conversión explícita:

Integer i = (Integer) list.iterator.next();

No existe ningún contrato que pueda garantizar que el tipo de devolución de la lista sea un entero. La lista definida puede contener cualquier objeto. Solo sabemos que estamos recuperando una lista al inspeccionar el contexto. Al mirar los tipos, solo puede garantizar que sea un Objeto , por lo que requiere una conversión explícita para garantizar que el tipo sea seguro.

Esta transmisión puede ser molesta, sabemos que el tipo de datos en esta lista es un entero . El elenco también está saturando nuestro código. Puede causar errores de tiempo de ejecución relacionados con el tipo si un programador comete un error con la conversión explícita.

Sería mucho más fácil si los programadores pudieran expresar su intención de usar tipos específicos y el compilador pudiera asegurar la exactitud de dicho tipo. Esta es la idea central detrás de los genéricos.

Modifiquemos la primera línea del fragmento de código anterior a:

List list = new LinkedList();

Al agregar el operador de diamante que contiene el tipo, reducimos la especialización de esta lista solo al tipo Integer, es decir, especificamos el tipo que se mantendrá dentro de la lista. El compilador puede aplicar el tipo en tiempo de compilación.

En programas pequeños, esto puede parecer una adición trivial, sin embargo, en programas más grandes, esto puede agregar una robustez significativa y hace que el programa sea más fácil de leer.

3. Métodos genéricos

Los métodos genéricos son aquellos que se escriben con una única declaración de método y se pueden llamar con argumentos de diferentes tipos. El compilador asegurará la exactitud del tipo que se utilice. Estas son algunas de las propiedades de los métodos genéricos:

  • Los métodos genéricos tienen un parámetro de tipo (el operador de diamante que encierra el tipo) antes del tipo de retorno de la declaración del método
  • Los parámetros de tipo se pueden limitar (los límites se explican más adelante en el artículo)
  • Los métodos genéricos pueden tener diferentes parámetros de tipo separados por comas en la firma del método
  • El cuerpo del método para un método genérico es como un método normal

Un ejemplo de cómo definir un método genérico para convertir una matriz en una lista:

public  List fromArrayToList(T[] a) { return Arrays.stream(a).collect(Collectors.toList()); }

En el ejemplo anterior, el en la firma del método implica que el método se ocupará de tipo genérico T . Esto es necesario incluso si el método devuelve vacío.

Como se mencionó anteriormente, el método puede tratar con más de un tipo genérico, donde este es el caso, todos los tipos genéricos deben agregarse a la firma del método, por ejemplo, si queremos modificar el método anterior para tratar con el tipo T y el tipo G , debería escribirse así:

public static  List fromArrayToList(T[] a, Function mapperFunction) { return Arrays.stream(a) .map(mapperFunction) .collect(Collectors.toList()); }

Estamos pasando una función que convierte una matriz con los elementos de tipo T a una lista con elementos de tipo G. Un ejemplo sería convertir Integer a su representación de cadena :

@Test public void givenArrayOfIntegers_thanListOfStringReturnedOK() { Integer[] intArray = {1, 2, 3, 4, 5}; List stringList = Generics.fromArrayToList(intArray, Object::toString); assertThat(stringList, hasItems("1", "2", "3", "4", "5")); }

Vale la pena señalar que la recomendación de Oracle es usar una letra mayúscula para representar un tipo genérico y elegir una letra más descriptiva para representar tipos formales, por ejemplo, en las colecciones de Java, se usa T para el tipo, K para la clave, V para el valor.

3.1. Genéricos limitados

Como se mencionó anteriormente, los parámetros de tipo se pueden limitar. Delimitado significa " restringido ", podemos restringir los tipos que pueden ser aceptados por un método.

Por ejemplo, podemos especificar que un método acepta un tipo y todas sus subclases (límite superior) o un tipo y todas sus superclases (límite inferior).

Para declarar un tipo acotado superior usamos la palabra clave se extiende después del tipo seguida por el límite superior que queremos usar. Por ejemplo:

public  List fromArrayToList(T[] a) { ... } 

La palabra clave extiende se usa aquí para significar que el tipo T extiende el límite superior en el caso de una clase o implementa un límite superior en el caso de una interfaz.

3.2. Múltiples límites

Un tipo también puede tener varios límites superiores de la siguiente manera:

Si uno de los tipos que se extienden por T es una clase (es decir, Número ), debe colocarse en primer lugar en la lista de límites. De lo contrario, provocará un error en tiempo de compilación.

4. Uso de comodines con genéricos

Los comodines están representados por el signo de interrogación en Java “ ? ”Y se utilizan para referirse a un tipo desconocido. Los comodines son particularmente útiles cuando se usan genéricos y se pueden usar como un tipo de parámetro, pero primero hay una nota importante a considerar.

Se sabe que Object es el supertipo de todas las clases de Java, sin embargo, una colección de Object no es el supertipo de ninguna colección.

Por ejemplo, una Lista no es el supertipo de Lista y asignar una variable de tipo Lista a una variable de tipo Lista provocará un error de compilación. Esto es para evitar posibles conflictos que pueden ocurrir si agregamos tipos heterogéneos a la misma colección.

La misma regla se aplica a cualquier colección de un tipo y sus subtipos. Considere este ejemplo:

public static void paintAllBuildings(List buildings) { buildings.forEach(Building::paint); }

if we imagine a subtype of Building, for example, a House, we can't use this method with a list of House, even though House is a subtype of Building. If we need to use this method with type Building and all its subtypes, then the bounded wildcard can do the magic:

public static void paintAllBuildings(List buildings) { ... } 

Now, this method will work with type Building and all its subtypes. This is called an upper bounded wildcard where type Building is the upper bound.

Wildcards can also be specified with a lower bound, where the unknown type has to be a supertype of the specified type. Lower bounds can be specified using the super keyword followed by the specific type, for example, means unknown type that is a superclass of T (= T and all its parents).

5. Type Erasure

Generics were added to Java to ensure type safety and to ensure that generics wouldn'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 it with their bounds or with Object if the type parameter is unbounded. Thus the bytecode after compilation contains only normal classes, interfaces and methods thus ensuring that no new types are produced. Proper casting is applied as well to the Object type at compile time.

This is an example of type erasure:

public  List genericMethod(List list) { return list.stream().collect(Collectors.toList()); } 

With type erasure, the unbounded type T is replaced with Object as follows:

// for illustration public List withErasure(List 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, then the type will be replaced by the bound at compile time:

public  void genericMethod(T t) { ... } 

would change after compilation:

public void genericMethod(Building t) { ... }

6. Generics and Primitive Data Types

A restriction of generics in Java is that the type parameter cannot be a primitive type.

For example, the following doesn't compile:

List list = new ArrayList(); list.add(17);

To understand why primitive data types don't work, let's remember that generics are a compile-time feature, meaning the type parameter is erased and all generic types are implemented as type Object.

As an example, let's look at the add method of a list:

List list = new ArrayList(); list.add(17);

The signature of the add method is:

boolean add(E e);

And will be compiled to:

boolean add(Object e);

Therefore, type parameters must be convertible to Object. Since primitive types don't extend Object, we can't use them as type parameters.

However, Java provides boxed types for primitives, along with autoboxing and unboxing to unwrap them:

Integer a = 17; int b = a; 

So, if we want to create a list which can hold integers, we can use the wrapper:

List list = new ArrayList(); list.add(17); int first = list.get(0); 

The compiled code will be the equivalent of:

List list = new ArrayList(); list.add(Integer.valueOf(17)); int first = ((Integer) list.get(0)).intValue(); 

Future versions of Java might allow primitive data types for generics. Project Valhalla aims at improving the way generics are handled. The idea is to implement generics specialization as described in JEP 218.

7. Conclusion

Java Generics es una poderosa adición al lenguaje Java ya que hace que el trabajo del programador sea más fácil y menos propenso a errores. Los genéricos imponen la corrección de tipos en el momento de la compilación y, lo más importante, permiten la implementación de algoritmos genéricos sin causar ninguna sobrecarga adicional a nuestras aplicaciones.

El código fuente que acompaña al artículo está disponible en GitHub.