fbpx

Genéricos en JAVA

Imaginemos un escenario en el que queremos crear una lista en Java para almacenar Entero.

Podríamos intentar escribir lo siguiente

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 devuelven.

El compilador requerirá un casting explícito:

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

Ningún contrato podría garantizar que el tipo de retorno de la lista es un Integer. La lista definida podría contener cualquier objeto. Sólo sabemos que estamos recuperando una lista inspeccionando el contexto. Cuando se miran los tipos, sólo se puede garantizar que es un Object y por lo tanto se requiere un cast explícito para asegurar que el tipo es seguro.

Este cast puede ser molesto – sabemos que el tipo de dato en esta lista es un Integer. El molde también está desordenando nuestro código. Puede causar errores en 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 utilizar tipos específicos y el compilador garantizara la corrección de dichos tipos. Esta es la idea central detrás de los genéricos.

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

List lista = new LinkedList<>();

Añadiendo el operador de diamante <> que contiene el tipo, restringimos la especialización de esta lista sólo al tipo Entero. En otras palabras, especificamos el tipo que contiene la lista. El compilador puede imponer el tipo en tiempo de compilación.

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

Métodos genéricos

Escribimos métodos genéricos con una única declaración de método, y podemos llamarlos con argumentos de distintos tipos. El compilador garantizará la corrección del tipo que utilicemos.

Estas son algunas propiedades de los métodos genéricos:

  • Los métodos genéricos tienen un parámetro de tipo (el operador rombo que encierra el tipo) antes del tipo de retorno de la declaración del método.
  • Los parámetros de tipo pueden estar acotados (explicaremos los límites más adelante en este 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 de un método genérico es igual que el de un método normal.
  • He aquí un ejemplo de definición de un método genérico para convertir un array en una lista:
public <T> List<T> arrayAList(T[] a) {   
    return Arrays.stream(a).collect(Collectors.toList());
}

El <T> en la firma del método implica que el método tratará con el tipo genérico T. Esto es necesario incluso si el método devuelve void.

Como se ha mencionado, el método puede tratar con más de un tipo genérico. En este caso, debemos añadir todos los tipos genéricos a la firma del método.

Así es como modificaríamos el método anterior para tratar con el tipo T y el tipo G:

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

Estamos pasando una función que convierte un array con los elementos de tipo T a lista con elementos de tipo G.

Extends en genéricos

Recuerde que los parámetros de tipo pueden ser limitados. Limitado significa “restringido”, y podemos restringir los tipos que acepta un método.

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

Para declarar un tipo con límite superior, utilizamos la palabra clave extends después del tipo, seguida del límite superior que queremos utilizar:

public <T extends Number> List<T> arrayAList(T[] a) {
    ...
}
Utilizamos la palabra clave extends 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.

Multiples extensiones

Un tipo también puede tener varios límites superiores:

<T extends Number & Comparable>

Si uno de los tipos que son extendidos por T es una clase (por ejemplo, Number), tenemos que ponerlo primero en la lista de límites. De lo contrario, provocará un error de compilación.

Uso de comodines con genéricos

Los comodines están representados por el signo de interrogación ? en Java, y los usamos para referirnos a un tipo desconocido. Los comodines son particularmente útiles con genéricos y pueden usarse como tipo de parámetro.

Pero primero hay que considerar una nota importante. Sabemos que Object es el supertipo de todas las clases de Java. Sin embargo, una colección de Objetos no es el supertipo de ninguna colección.

Por ejemplo, List<Object> no es el supertipo de List<String> y asignar una variable de tipo List<Object> a una variable de tipo List<String> 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.

Por ejemplo:

public static void moverPersonajes(List<Personaje> personajes) {
    personajes.forEach(Building::mover);
}

Si imaginamos un subtipo de Personaje, como un Héroe, no podemos usar este método con una lista de Personaje, aunque Héroe sea un subtipo de Personaje.

Si necesitamos usar este método con el tipo Personaje y todos sus subtipos, el comodín nos dará la solución:

public static void moverPersonajes(List<? extends Building> personajes) {
    ...
}

Ahora este método funcionará con el tipo Personaje y todos sus subtipos. Esto se denomina comodín de límite superior, donde el tipo Personaje es el límite superior.

También podemos especificar comodines con un límite inferior, donde el tipo desconocido tiene que ser un supertipo del tipo especificado. Los límites inferiores se pueden especificar utilizando la palabra clave super seguida del tipo específico. Por ejemplo <? super T>, significa tipo desconocido que es una superclase de T (= T y todos sus padres).

Tipo de borrado

Se agregaron genéricos a Java para garantizar la seguridad de tipos. Y para garantizar que los genéricos no causen gastos generales en tiempo de ejecución, el compilador aplica un proceso llamado borrado de tipos en los genéricos en tiempo de compilación.

El borrado de tipo elimina todos los parámetros de tipo y los reemplaza con sus límites o con Objeto si el parámetro de tipo no tiene límites. De esta manera, el código de bytes después de la compilación contiene solo clases, interfaces y métodos normales, lo que garantiza que no se produzcan nuevos tipos. También se aplica la conversión adecuada al tipo de objeto en el momento de la compilación.

Este es un ejemplo de borrado de tipo:

public <T> List<T> metodoGenerico(List<T> list) {
    return list.stream().collect(Collectors.toList());
}

Con el borrado de tipo, el tipo T ilimitado se reemplaza por Objeto:

// con borrado
public List<Object> conBorrado(List<Object> list) {
    return list.stream().collect(Collectors.toList());
}

// que sera en la practica a
public List conBorrado(List list) {
    return list.stream().collect(Collectors.toList());
}

Si el tipo está limitado, el tipo será reemplazado por el límite en el momento de la compilación:

public <T extends Building> void metodoGenerico(T t) {
    ...
}

y cambiaría después de la compilación:

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