Tutorial sobre los tipos genéricos de Java

Escrito por el , actualizado el .
java planeta-codigo programacion
Enlace permanente Comentarios

Hace ya más de una década que en Java 5 se introdujeron los generics para dotar al lenguaje de una mejor comprobación de tipos en tiempo de compilación y al mismo tiempo eliminar los cast que hasta entonces eran necesarios al usar las colecciones. Dada la lentitud de adopción que suele haber en la plataforma Java en los grandes entornos empresariales puede que aún no los hayamos usado extensamente o tengamos alguna duda en su uso. Hay unos cuantos conceptos sobre los generics que son convenientes conocer.

Java

Los generics fueron introducidos en la versión 5 de Java en 2004 junto con otras muchas novedades suponiendo en su historia una de las mayores modificaciones o al mismo nivel de las novedades introducidas con Java 8 más recientemente al lenguaje Java. Los generics son importantes ya que permiten al compilador informar de muchos errores de compilación que hasta el momento solo se descubrirían en tiempo de ejecución, al mismo tiempo permiten eliminar los cast simplificando, reduciendo la repetición y aumentando la legibilidad el código. Los errores por cast inválido son especialmente problemáticos de debuggear ya que el error se suele producir en un sitio alejado del de la causa.

Los generics permiten usar tipos para parametrizar las clases, interfaces y métodos al definirlas. Los beneficios son:

  • Comprobación de tipos más fuerte en tiempo de compilación.
  • Eliminación de casts aumentando la legibilidad del código.
  • Posibilidad de implementar algoritmos genéricos, con tipado seguro.

Un tipo usando generics tiene el siguiente aspecto, por ejemplo usando una clase Box contenedor de una referencia a un tipo no determinado en la definición de la clase pero que lo será en su uso. Una clase genérica puede tener múltiples argumentos de tipos y los argumentos pueden ser a su vez tipos genéricos. Después del nombre de la clase se puede indicar la lista de parámetros de tipos con el formato \<T1, T2, T3, ...\>.

1
2
3
4
5
6
7
public class Box<T> {

  private T t;

  public T get() { return t; }
  public void set(T t) { this.t = t; }
}
Box.java
1
2
3
4
public interface Pair<K, V> {
  public K getKey();
  public V getValue();
}
Pair.java
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
public class OrderedPair<K, V> implements Pair<K, V> {

  private K key;
  private V value;

  public OrderedPair(K key, V value) {
    this.key = key;
    this.value = value;
  }

  public K getKey() { return key; }
  public V getValue() { return value; }
}
OrderedPair.java

Según las convenciones los nombres de los parámetros de tipo usados comúnmente son los siguientes:

  • E: elemento de una colección.
  • K: clave.
  • N: número.
  • T: tipo.
  • V: valor.
  • S, U, V etc: para segundos, terceros y cuartos tipos.

En el momento de la instanciación de un tipo genérico indicaremos el argumento para el tipo, en este caso Box contendrá una referencia a un tipo Integer. Con Java 7 se puede usar el operador diamond y el compilador inferirá el tipo según su definición para mayor claridad en el código. Podemos usar cualquiera de esta dos maneras prefiriendo usar el operador diamond por ser más clara.

1
2
3
Box<Integer> integerBox1 = new Box<Integer>();
Box<Integer> integerBox2 = new Box<>();
OrderedPair<String, Integer> p1 = new OrderedPair<>("Even", 8);
Instantation.java

Para mantener la compatibilidad con versiones anteriores a Java 5 los tipos genéricos que al usarse no indican argumentos de tipo se denominan raw. El compilador indicará una advertencia como un uso potencialmente peligroso ya que no podrá validar los tipos.

1
2
Box rawBox = new Box();

Raw.java

Además de las clases los métodos también pueden tener su propia definición de tipos genéricos.

1
2
3
public static <K, V> boolean compare(Pair<K, V> p1, Pair<K, V> p2) {
  return p1.getKey().equals(p2.getKey()) && p1.getValue().equals(p2.getValue());
}
Method.java

La sintaxis completa de uso sería:

1
2
3
Pair<Integer, String> p1 = new OrderedPair<>(1, "apple");
Pair<Integer, String> p2 = new OrderedPair<>(2, "pear");
boolean same = Util.<Integer, String>compare(p1, p2);
MethodUsage.java

Aunque puede abreviarse ya que el compilador puede inferir los tipos:

1
2
boolean same = Util.compare(p1, p2);

MethodUsageInference.java

A veces querremos limitar los tipos que pueden ser usados empleando lo que se denomina bounded type. Con \<U extends Number\> el tipo U debe extender la clase Number.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class BoxBounds<T> {

  private T t;

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

  public T get() {
    return t;
  }

  public <U extends Number> void inspect(U u){
    System.out.println("T: " + t.getClass().getName());
    System.out.println("U: " + u.getClass().getName());
  }

  public static void main(String[] args) {
    Box<Integer> integerBox = new Box<Integer>();
    integerBox.set(new Integer(10));
    integerBox.inspect("some text"); // error: this is still String!
  }
}
BoxBounds.java

Una clase puede tener múltiples limitaciones, si una es una clase debe ser la primera y el resto de argumentos interfaces.

1
2
3
4
5
6
7
<T extends B1 & B2 & B3>

Class A { /* ... */ }
interface B { /* ... */ }
interface C { /* ... */ }

class D <T extends A & B & C> { /* ... */ }
Bounds.java

En Java un tipo puede ser asignado a otro mientras el primero sea compatible con el segundo, es decir tengan una «relación es un». Una referencia de Object puede referenciar una instancia de Integer (un Integer es un Object).

1
2
3
Object object = new Object();
Integer integer = new Integer(10);
object = integer;
IsA.java

Sin embargo, en el caso de los generics, ¿una referencia de Box<Number> puede aceptar una instancia Box<Integer> or Box<Double> aun siendo Integer y Double subtipos de Number?. La respuesta es no, ya que Box<Integer> y Box<Double> en Java no son subtipos de Box<Number>. La jerarquía de tipos es la siguiente:

Los tipos genéricos pueden extenderse o implementarse y mientras no se cambie el tipo del argumento la «relación es un» se preserva. De modo que ArrayList<String> es un subtipo de List<String> que a su vez es un subtipo de Collection<String>.

1
2
3
4
5
6
7
8
interface PayloadList<E,P> extends List<E> {
  void setPayload(int index, P val);
  ...
}

PayloadList<String,String>
PayloadList<String,Integer>
PayloadList<String,Exception>
PayloadList.java

En los generics un parámetro para un tipo ? se denomina wildcard siendo este un tipo desconocido. Son usados para reducir las restricciones de un tipo de modo que un método pueda funcionar con una lista de List<Integer>, List<Double> y List<Number>. El término List<Number> es más restrictivo que List<? extends Number> porque el primero solo acepta una lista de Number y el segundo una lista de Number o de sus subtipos. List<? extends Number> es un upper bounded wildcard.

1
2
public static void process(List<? extends Number> list) { /* ... */ }

BoundedWildcard.java

Se puede definir una lista de un tipo desconocido, List<?>, en casos en los que:

  • La funcionalidad se puede implementar usando un tipo Object.
  • Cuando el código usa métodos que no dependen del tipo de parámetro. Por ejemplo, List.size o List.clear.

Digamos que queremos definir un método que inserte objetos Integer en un List. Para mayor flexibilidad queremos que ese método pueda trabajar con cualquier tipo de lista que permita contener Integer, ya sea List<Integer>, List<Number> y List<Object>. Lo podemos conseguir definiendo List<? super Integer> que se conoce como Lower Bounded Wildcard.

Las clases genéricas no tienen relación alguna aunque sus tipos los tengan, pero usando wildcards podemos crearlas.

1
2
List<? extends Integer> intList = new ArrayList<>();
List<? extends Number>  numList = intList;
WildcardList.java

Uno de las mayores confusiones al usar generics es cuando usar upper bounded wildcards o cuando usar lower bounded wildcards. Podemos usar las siguientes reglas:

  • Una variable generic que se usa como fuente de datos (in), por ejemplo src en <U> copy(List<? extends U> src, List<? super U> dest) se define usando upper bounded wildcard con la palabra clave extends. De modo que la lista del parámetro src pueda ser una lista de un tipo U o de un subtipo de U.
  • Un variable generic que se usa como destino de datos (out), por ejemplo dest en <U> copy(List<? extends U> src, List<? super U> dest) se define usando lower bounded wildcard con la palabra clave super. De modo que la lista del parámetro dest pueda ser una lista de un tipo U o de un supertipo de U.
  • En caso de que la variable pueda ser usando mediante métodos definidos en la clase Object se recomienda usar un unbounded wildcard (?).
  • En caso de que la variable se necesite usar como fuente de datos y como destino (in y out) no usar wildcard.

Los generics son un mecanismo para proporcionar comprobaciones en tiempo de compilación, sin embargo, el compilador aplica type erasure que implica:

  • Reemplazar todos los tipos con sus bounds o por Object si son unbounded.
  • Insertar casts para preservar el tipado seguro.
  • Generar métodos puente para preservar el polimorfismo en generics en los que son extendidos.

Un tipo non reifiable son aquellos cuya información de tipo ha sido eliminada en tiempo de compilación por el type erasure, para la JVM no hay ninguna diferencia en tiempo de ejecución entre List<String> y List<Number>. No se crean nuevas clases para los tipos parametrizados de modo que no hay ninguna penalización en tiempo de ejecución. Una clase genérica al compilarla se transforma aplicando type erasure:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// TypeErasure to Object
public class Node<T> {

  private T data;
  private Node<T> next;

  public Node(T data, Node<T> next) }
    this.data = data;
    this.next = next;
  }

  public T getData() { return data; }
  // ...
}

// Node type erased
public class Node {

  private Object data;
  private Node next;

  public Node(Object data, Node next) {
    this.data = data;
    this.next = next;
  }

  public Object getData() { return data; }
  // ...
}

// TypeErasure to Comparable
public class Node<T extends Comparable<T>> {

  private T data;
  private Node<T> next;

  public Node(T data, Node<T> next) {
    this.data = data;
    this.next = next;
  }

  public T getData() { return data; }
  // ...
}

// Node type erased
public class Node {

  private Comparable data;
  private Node next;

  public Node(Comparable data, Node next) {
    this.data = data;
    this.next = next;
  }

  public Comparable getData() { return data; }
  // ...
}
TypeErasure.java

Los generics tiene algunas restricciones:

  • No se pueden instanciar tipos genéricos con tipos primitivos.
  • No se pueden crear instancias de los parámetros de tipo.
  • No se pueden declarar campos static cuyos tipos son parámetros de tipo.
  • No se pueden usar casts o instanceof con tipos parametrizados.
  • No se pueden crear arrays de tipos parametrizados.
  • No se pueden crear, capturar o lanzar tipos parametrizados que extiendan de Throwable.
  • No se puede sobrecargar un método que tengan la misma firma que otro después del type erasure.

Este artículo es gran medida una traducción del tutorial de Java sobre Generics, que también es recomendable echarle un vistazo incluso leerlo varias veces por la cantidad de información que contiene, en algunos puntos todo lo comentado en este artículo está explicado de forma más extensa.

Para profundizar más en este importante tema de genéricos de Java tenemos a nuestra disposición varios libros, alguno como Java Generics and Collections dedicado en gran parte a él, no importa que se un libro del 2006 ya que desde entonces los genéricos no han tenido grandes cambios y su contenido sigue siendo válido. Los generics de Java no son perfectos, por el type erasure y ser non reifiables, pero tampoco débiles y hay buenos motivos para que sean así como se dice en el libro.

A pesar de los generics y el compilador es posible poner en un String en un HashSet<Integer> usando el tipo raw de HashSet, cosa que se denomina Heap Pollution y que provoca excepciones ClassCastException en tiempo de ejecución. Usando colecciones envueltas por los métodos Collections.checkedSet, checkedList y checkedMap evitaremos el Heap Pollution produciendo una excepción no en el momento de extraer el objeto de la colección sino en el momento de insertarlo.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Set<Integer> set = new HashSet<>();
Set rawSet = set;
rawSet.add("heap pollution!"); // heap pollution
set.stream().forEach(System.out::println); // ClassCastException

//
Set<Integer> set = new HashSet<>();
set = Collections.checkedSet(set, Integer.class);
Set rawSet = set;
rawSet.add("exception!");  // ClassCastException, no heap pollution
set.stream().forEach(System.out::println);
HeapPollution.java

En resumen, los genéricos en Java son un añadido muy útil al lenguaje.


Comparte el artículo: