W3docs

Introducción a los genéricos de Java

Por qué existen los genéricos de Java — seguridad de tipos, reutilización de código y eliminación de casts en colecciones y API.

Introducción a los genéricos de Java

Los genéricos son la característica que permite que una clase, interfaz o método trabaje sobre un tipo no especificado, y que luego el compilador fije ese tipo en el lugar donde lo usa. Una List<String> es una lista de cadenas, el compilador lo sabe, y cualquier intento de meter una Date en ella se rechaza antes de que el programa siquiera se ejecute. Antes de que llegaran los genéricos en Java 5, esa misma lista era una List de Object, y cada lectura de ella necesitaba un cast escrito a mano que podía o no tener éxito en tiempo de ejecución. Los genéricos convirtieron esa apuesta en tiempo de ejecución en una comprobación en tiempo de compilación, y casi toda API Java moderna está moldeada por ellos.

El problema que resuelven los genéricos

Para ver por qué existen los genéricos, imagine un Java sin ellos. Un contenedor que guarda cosas arbitrarias tiene que declarar su contenido como Object:

// Pre-Java-5 style — what the standard library actually looked like.
List names = new ArrayList();
names.add("Ada");
names.add("Linus");

String first = (String) names.get(0);   // cast required, never checked by the compiler

Dos problemas. Primero, el cast es ruido — cada lectura del contenedor necesita uno. Segundo, y peor, nada impide que alguien meta una Date en esa misma lista:

names.add(new java.util.Date());        // compiler is fine with this
String oops = (String) names.get(2);    // ClassCastException at runtime

El error aparece en la lectura, lejos de la escritura. El cast miente — dice «esto es una String», y la JVM lo descubre solo cuando ya es demasiado tarde para darle un marco de pila útil cerca del fallo real.

Los genéricos arreglan ambos:

List<String> names = new ArrayList<>();
names.add("Ada");
names.add("Linus");
names.add(new Date());        // ❌ compile error — won't even build

String first = names.get(0);  // no cast — the compiler already knows it's a String

Los corchetes angulares <String> son el parámetro de tipo. Le dicen al compilador «esta lista contiene String», y desde ese momento cada add y get se comprueba contra esa promesa.

Tres cosas que obtiene gratis

Los genéricos le compran tres beneficios concretos, y son la razón por la que cada colección, stream y optional del JDK moderno es genérico:

  • Comprobaciones más fuertes en tiempo de compilación. La inserción del tipo equivocado de arriba se atrapa en el build, no en producción. Toda una clase de ClassCastException simplemente deja de ocurrir.
  • No más casts. Leer de un Map<String, User> le da un User, no un Object que deba castear. Menos ruido sintáctico, menos que leer, menos que mantener.
  • Reutilización de código sin copiar y pegar. Una sola clase List<E> funciona para cada tipo de elemento. Antes de los genéricos, la biblioteca estándar o bien aceptaba Object en todas partes, o bien entregaba una StringList, IntList, DateList, etc. Ahora escribe una clase y deja que el llamador la parametrice.

Ese último punto es la mayor victoria arquitectónica. Los genéricos son cómo escribe un contenedor, un algoritmo o una forma de callback una sola vez y hace que se aplique a cada tipo que el llamador pudiera pasar.

Una primera clase genérica

La convención es nombrar un parámetro de tipo con una sola letra mayúscula — T para un «tipo» genérico, E para «element» de una colección, K/V para «key» y «value» de un map, R para «return». Aquí la clase genérica más simple posible — un par de dos cosas del mismo tipo:

public class Pair<T> {
  private final T first;
  private final T second;

  public Pair(T first, T second) {
    this.first  = first;
    this.second = second;
  }

  public T first()  { return first; }
  public T second() { return second; }
}

El <T> tras el nombre de la clase introduce el parámetro de tipo. A partir de ahí, T es utilizable dentro de la clase en cualquier lugar donde pudiera ir un tipo normal. El llamador elige T cuando crea el objeto:

Pair<String>  names   = new Pair<>("Ada", "Grace");
Pair<Integer> scores  = new Pair<>(100, 87);
String n1 = names.first();      // already a String, no cast
int s1    = scores.first();     // auto-unboxed from Integer

El <> vacío a la derecha (el operador diamante, Java 7+) le dice al compilador que infiera el tipo desde la declaración de la izquierda — casi nunca tiene que repetir el argumento de tipo.

Qué se parametriza y qué no

Un parámetro de tipo puede ocupar el lugar de:

  • El tipo de un campo (private T value;)
  • Un tipo de parámetro o de retorno de un método (public T get() { ... }, void put(T value))
  • El tipo de elemento de un arreglo de ese tipo (T[] items — con algunas salvedades)

Un parámetro de tipo no puede ocupar el lugar de:

  • Un primitivo (Pair<int> es ilegal — use Pair<Integer> y deje que el autoboxing haga el trabajo)
  • El parámetro de tipo de un campo estático o un método estático (el parámetro pertenece a una instancia, no a la clase en sí)
  • El objetivo de new T() o instanceof T — Java borra los genéricos en tiempo de ejecución, así que el programa no tiene un T que construir o contra el que comprobar

La lista completa de «cosas que no puede hacer» tiene su propio capítulo al final de esta parte — Restricciones de los genéricos de Java — una vez que hayamos cubierto suficiente maquinaria para que las reglas tengan sentido.

Un ejemplo resuelto: seguridad de tipos vs. tipos crudos, lado a lado

El programa de abajo construye el mismo contenedor dos veces — una vez como una List cruda (la forma anterior a los genéricos) y otra como una List<String>. Ambas compilan; solo la parametrizada es segura.

java— editable, runs on the server

La versión cruda se cae a mitad de la iteración porque el bucle confió en un cast en el que no tenía por qué confiar. La versión genérica hizo el mismo error irrepresentable — el mal add(42) ni siquiera compila. Ese desplazamiento del tiempo de ejecución al tiempo de compilación es toda la razón por la que existen los genéricos.

Qué cubre esta parte del libro

Los capítulos restantes de esta parte desarman los genéricos una pieza a la vez:

  • Clases genéricas — el parámetro de tipo a nivel de clase que acaba de ver, con más profundidad.
  • Métodos genéricos — métodos que introducen su propio parámetro de tipo, independiente de la clase.
  • Interfaces genéricas — diseñar contratos de API parametrizados sobre un tipo.
  • Parámetros de tipo acotados — decir «T debe extender Number» para poder llamar métodos sobre T.
  • Comodines (wildcards)? extends T, ? super T y la regla PECS que decide cuándo usar cada uno.
  • Borrado de tipos — cómo implementa la JVM los genéricos bajo el capó, y por qué algunas cosas que esperaría que funcionaran no lo hacen.
  • Restricciones — el catálogo de cosas que el lenguaje se niega a dejarle hacer, con las razones detrás de cada una.

Léalos en orden — cada capítulo da por supuestos los anteriores.

Qué sigue

Comience por la forma más común — una clase cuyos campos y métodos están parametrizados sobre un tipo que el llamador elige. Continúe con Clases genéricas de Java.

Practice

Práctica

A method declares `public static List getNames() { ... }` (no type parameter on the list). The caller writes `String first = getNames().get(0);`. Why does the compiler warn — and what's the danger if you ignore the warning?