Programación en Java

Genéricos en Java: parámetros de tipo, cotas, wildcards y borrado de tipos

Intermedio8 min de lecturaActualizado

Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés

Los genéricos te permiten escribir una clase o un método una sola vez y usarlo de forma segura con muchos tipos, trasladando los errores de cast del tiempo de ejecución al de compilación. Esta guía explica cómo declarar y usar parámetros de tipo, cómo las cotas y los wildcards expresan lo que necesita un método, por qué una List<Dog> no es una List<Animal>, qué permite y qué impide el borrado de tipos, y qué patrones idiomáticos —PECS, métodos genéricos, type tokens— hacen legible el código genérico.

Lo que aprenderás

  • Declarar clases, interfaces y métodos genéricos con parámetros de tipo significativos
  • Aplicar cotas superiores e inferiores y la regla PECS a los parámetros con wildcard
  • Explicar la invarianza y por qué los arrays se comportan de forma distinta a las colecciones genéricas
  • Describir el borrado de tipos y sortear sus límites con class tokens y métodos auxiliares
  • Reconocer los raw types y los warnings unchecked como bugs en potencia

Antes de que los genéricos llegaran en Java 5, una List contenía Object, cada lectura necesitaba un cast y un cast equivocado aparecía como una ClassCastException lejos del código que lo provocaba. Los genéricos trasladan esa comprobación al compilador: una List<String> solo puede contener cadenas y get devuelve un String sin cast. La mecánica es sencilla hasta que te topas con los wildcards y el borrado de tipos, que es donde la mayoría de los desarrolladores deja de entender lo que escribe. Esta guía te lleva a través de ambos. Los ejemplos usan Java 21.

Clases e interfaces genéricas

Un parámetro de tipo es un marcador de posición para un tipo, declarado entre corchetes angulares después del nombre de la clase y usado dentro de ella en cualquier lugar donde podría aparecer un tipo:

public final class Pair<A, B> {
    private final A first;
    private final B second;

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

    public A first()  { return first; }
    public B second() { return second; }

    public <C> Pair<A, C> withSecond(C newSecond) {      // generic method inside a generic class
        return new Pair<>(first, newSecond);
    }
}

Pair<String, Integer> p = new Pair<>("age", 42);
Integer age = p.second();                                 // no cast
Pair<String, Boolean> q = p.withSecond(true);

El diamante <> de new Pair<>(...) le pide al compilador que infiera los argumentos a partir del tipo de destino, así que solo los escribes una vez. Por convención, los parámetros de tipo son una sola letra mayúscula: T para un tipo, E para un elemento, K/V para clave y valor, R para un resultado. Los nombres más largos, como Request, son válidos y a veces más claros en firmas complejas.

Los parámetros de tipo deben ser tipos por referencia. List<int> no compila; escribes List<Integer> y pagas el coste del boxing, que es la razón de que la API de streams tenga IntStream y de que java.util.function incluya IntPredicate, ToLongFunction y otras especializaciones para primitivos.

Las interfaces genéricas funcionan igual. Comparable<T>, Iterable<T>, Function<T, R> y Map<K, V> son las que más a menudo implementarás o recibirás como parámetro, y una clase puede fijar el parámetro al implementarla —class Version implements Comparable<Version>—, que es lo que permite que compareTo reciba un Version en lugar de un Object.

Métodos genéricos

Un método puede introducir su propio parámetro de tipo, declarado antes del tipo de retorno. Es la opción adecuada para utilidades estáticas y para cualquier método cuya relación entre tipos no dependa de la clase que lo contiene:

public static <T> List<T> repeat(T value, int times) {
    List<T> result = new ArrayList<>(times);
    for (int i = 0; i < times; i++) result.add(value);
    return result;
}

public static <T, R> List<R> mapAll(List<T> input, Function<? super T, ? extends R> fn) {
    List<R> out = new ArrayList<>(input.size());
    for (T item : input) out.add(fn.apply(item));
    return out;
}

List<String> xs = repeat("x", 3);                    // T inferred as String
List<Integer> lengths = mapAll(xs, String::length);  // T = String, R = Integer

La inferencia casi siempre encuentra los argumentos. Cuando falla —normalmente con una colección vacía o con una llamada encadenada cuyo tipo de destino se desconoce—, puedes indicarlos explícitamente con un type witness: Collections.<String>emptyList().

Parámetros de tipo acotados

Dentro del método, un T sin cota equivale en la práctica a Object: puedes guardarlo y devolverlo, pero no invocar nada sobre él. Una cota restringe el parámetro y habilita los métodos de la cota:

public static <T extends Comparable<? super T>> T max(Collection<? extends T> items) {
    Iterator<? extends T> it = items.iterator();
    T best = it.next();
    while (it.hasNext()) {
        T candidate = it.next();
        if (candidate.compareTo(best) > 0) best = candidate;   // compareTo is available
    }
    return best;
}

T extends Comparable<? super T> se lee como «T se puede comparar consigo mismo o con un supertipo suyo». El ? super T importa: si Employee implements Comparable<Employee> y Manager extends Employee, entonces max(List<Manager>) compila porque Manager es comparable a través del Comparable<Employee> heredado. Con una cota Comparable<T> a secas, la llamada solo compilaría infiriendo T como Employee, de modo que max te devolvería un Employee y volverías a necesitar un cast. Las cotas múltiples usan &: <T extends Number & Comparable<T>>, con una clase como máximo y cualquier número de interfaces.

La palabra clave es siempre extends, incluso para interfaces; en una cota no existe implements.

La invarianza y por qué los arrays son diferentes

Los tipos genéricos son invariantes: List<Manager> no es un subtipo de List<Employee>, aunque Manager sea un subtipo de Employee. El motivo es la solidez del sistema de tipos. Si se permitiera la asignación:

List<Manager> managers = new ArrayList<>();
List<Employee> employees = managers;     // hypothetically allowed
employees.add(new Contractor());         // legal on a List<Employee>...
Manager m = managers.get(0);             // ...and now this explodes

Los arrays tomaron la decisión contraria: Manager[] sí es un subtipo de Employee[] (covarianza), y la JVM comprueba cada escritura en tiempo de ejecución y lanza ArrayStoreException si se guarda un Contractor en lo que en realidad es un Manager[]. Los genéricos trasladaron la comprobación al tiempo de compilación y, para lograrlo, renunciaron a la covarianza. Por eso tampoco puedes crear un array genérico —new T[10] y new List<String>[3] no son válidos—: la comprobación en tiempo de ejecución necesitaría un tipo que el borrado ya ha eliminado.

Wildcards y la regla PECS

La invarianza es segura pero restrictiva: un método printAll(List<Employee>) rechazaría una List<Manager>. Los wildcards devuelven la flexibilidad en el lado del uso, indicando lo que el método necesita.

  • List<? extends Employee>: una lista de Employee o de algún subtipo. Puedes leer los elementos como Employee. No puedes añadir nada salvo null, porque el tipo real de los elementos podría ser Manager y tú podrías tener un Contractor.
  • List<? super Manager>: una lista de Manager o de algún supertipo. Puedes añadir un Manager (encaja sea cual sea el tipo real). Al leer obtienes Object, porque el tipo real podría ser Employee u Object.
  • List<?>: una lista de algo. Se lee como Object y no se añade nada.

La regla mnemotécnica es PECS: Producer Extends, Consumer Super. Si el parámetro produce valores que lees, usa extends; si consume valores que introduces, usa super; si hace ambas cosas, usa el tipo exacto.

// Copy from a producer into a consumer
public static <T> void copy(List<? super T> dest, List<? extends T> src) {
    for (T item : src) dest.add(item);
}

List<Employee> staff = new ArrayList<>();
List<Manager> mgrs = List.of(new Manager("Ada"), new Manager("Grace"));
copy(staff, mgrs);                        // dest is a super of Manager, src produces Managers

Las propias firmas del JDK siguen esta regla: Collections.copy(List<? super T> dest, List<? extends T> src), Stream.map(Function<? super T, ? extends R>), Collection.addAll(Collection<? extends E>). Cuando veas ? super T en un Comparator o un Consumer, es PECS en acción: un Comparator<Object> puede comparar empleados, así que sort(List<T>, Comparator<? super T>) lo acepta.

Los wildcards van en los parámetros de los métodos. Los tipos de retorno deben ser concretos: un método que devuelve List<? extends Shape> obliga a cada llamante a lidiar con el wildcard sin ningún beneficio.

Borrado de tipos

Los genéricos solo existen en tiempo de compilación. Tras la comprobación, el compilador borra los parámetros de tipo: un T sin cota se convierte en Object, un T extends Number acotado se convierte en Number, y se insertan casts en los puntos de llamada. List<String> y List<Integer> son la misma clase en tiempo de ejecución. Fue el precio de mantener la compatibilidad con el bytecode anterior a los genéricos, y tiene consecuencias:

No puedes escribirPorque
new T()No hay ninguna clase que instanciar en tiempo de ejecución
new T[n]Un array necesita un tipo de elemento en tiempo de ejecución
obj instanceof List<String>El parámetro ya no existe; instanceof List<?> sí está permitido
catch (T e)Los tipos de excepción deben ser reificables
Sobrecargas f(List<String>) y f(List<Integer>)Tienen la misma firma una vez borrada
static T fieldLos miembros estáticos se comparten entre todas las parametrizaciones

La solución habitual para «necesito la clase en tiempo de ejecución» es un type token: pasar Class<T> de forma explícita.

public static <T> T firstOfType(List<?> items, Class<T> type) {
    for (Object item : items) {
        if (type.isInstance(item)) return type.cast(item);   // safe, checked cast
    }
    return null;
}

Integer n = firstOfType(List.of("a", 7, 2.0), Integer.class);   // 7

Class<T>.cast e isInstance te dan un cast comprobado sin warning unchecked. Frameworks como Jackson y Spring usan este patrón y lo amplían con subclases de ParameterizedTypeReference/TypeReference para capturar un tipo completo como List<Foo> mediante reflexión sobre una subclase anónima, el único lugar donde el borrado deja intacta la firma completa.

El borrado también introduce los métodos puente (bridge methods): cuando Version implements Comparable<Version>, el compilador genera un compareTo(Object) oculto que hace el cast y delega, para que el código que llama a través de la interfaz borrada siga funcionando. Solo te fijarás en ellos en las stack traces y al usar reflexión.

Raw types y warnings unchecked

Un raw type es un tipo genérico usado sin argumentos: List list = new ArrayList();. Existe únicamente para que compile el código anterior a Java 5, y usarlo desactiva las comprobaciones de tipos en todo lo que lo toca. Cada raw type en un código base moderno es una ClassCastException latente.

Un warning unchecked significa que el compilador no pudo demostrar que un cast o una llamada sean seguros en cuanto a tipos; lo típico es un cast a un tipo parametrizado, como (List<String>) obj. Nunca lo silencies con @SuppressWarnings("unchecked") sin un comentario que demuestre por qué es seguro, y coloca la anotación en el ámbito más pequeño posible (una variable local, no la clase). El patrón Class<T>.cast que vimos antes evita el warning por completo en los casts a un único tipo.

Un caso concreto y frecuente: los varargs genéricos. List<String>... lists crea un array de un tipo genérico, y el compilador emite un warning en cada punto de llamada. Si el método solo lee el array, anótalo con @SafeVarargs (permitido en métodos static, final y private) y el warning desaparece de los puntos de llamada.

Cotas recursivas y builders fluidos

<T extends Comparable<T>> es una cota de tipo recursiva: el parámetro aparece en su propia cota. El mismo truco permite que una clase base de builder devuelva el tipo concreto de la subclase desde sus métodos fluidos:

abstract class Builder<B extends Builder<B>> {
    protected String name;
    public B name(String name) { this.name = name; return self(); }
    protected abstract B self();
}

final class UserBuilder extends Builder<UserBuilder> {
    private String email;
    public UserBuilder email(String email) { this.email = email; return this; }
    @Override protected UserBuilder self() { return this; }
    public User build() { return new User(name, email); }
}

User u = new UserBuilder().name("Ada").email("[email protected]").build();
// Without the recursive bound, name() would return Builder and email() would not exist

Úsalo con moderación: es potente pero difícil de leer, y muchas veces un único builder concreto sirve mejor.

Errores comunes

Raw types. new ArrayList() o Map map tiran por la borda las comprobaciones. Añade los argumentos de tipo; usa el diamante para escribirlos una sola vez.

Devolver wildcards. List<? extends Number> compute() traslada la complejidad a cada llamante. Devuelve List<Number> o un tipo concreto.

Wildcards en ambos lados. void merge(List<? extends T> a, List<? extends T> b) no puede añadir nada de b en a. Cuando un parámetro se lee y se escribe, usa el parámetro de tipo sin más.

Suponer que List<String> sobrevive hasta el tiempo de ejecución. La deserialización, la reflexión e instanceof solo ven List. Usa type tokens o conversiones explícitas.

Mezclar arrays y genéricos. Los campos T[], new ArrayList<String>[n] y List<String>[] producen warnings o errores. Prefiere List<List<String>>.

Abusar de Object para evitar los genéricos. Un método que recibe Object y hace cast dentro es el mundo anterior a los genéricos con pasos de más. Introduce un parámetro de tipo.

Acotar solo con Comparable<T>. Rechaza las subclases de una clase comparable. Usa Comparable<? super T>.

Puntos clave

  • Los parámetros de tipo trasladan los fallos de cast al tiempo de compilación; decláralos en clases, interfaces o métodos concretos, y deja que el diamante y la inferencia completen los argumentos.
  • Las cotas (T extends X) permiten invocar los métodos de la cota; Comparable<? super T> es la cota correcta para elementos ordenables.
  • Los tipos genéricos son invariantes. Usa ? extends en los parámetros de los que lees y ? super en los que escribes (PECS); devuelve tipos concretos.
  • El borrado elimina los argumentos de tipo en tiempo de ejecución, así que new T(), los arrays genéricos e instanceof List<String> son imposibles; pasa un token Class<T> cuando necesites el tipo.
  • Los raw types y los warnings unchecked sin resolver son bugs en potencia; corrígelos en lugar de silenciarlos.
  • Los arrays son covariantes y se comprueban en tiempo de ejecución; las colecciones son invariantes y se comprueban en tiempo de compilación. En código genérico, prefiere las colecciones.

Fuentes y lecturas recomendadas

Preguntas frecuentes

¿Por qué no puedo asignar una List<Dog> a una List<Animal>?
Porque los tipos genéricos son invariantes. Si se permitiera la asignación, el código que tiene la List<Animal> podría añadir un Cat a lo que en realidad es una lista de perros, y el fallo aparecería más tarde como una ClassCastException en algún punto sin relación. Usa List<? extends Animal> cuando solo necesites leer animales de la lista.
¿Qué significa «borrado de tipos» (type erasure)?
El compilador comprueba los tipos genéricos y después los elimina del bytecode: sustituye los parámetros de tipo por su cota (normalmente Object) e inserta casts donde hacen falta. En tiempo de ejecución, una List<String> y una List<Integer> son la misma clase. Por eso no puedes escribir new T(), T[] ni instanceof List<String>.
¿Qué diferencia hay entre List<Object> y List<?>?
List<Object> es una lista que puede contener cualquier objeto y a la que puedes añadir cualquier cosa; solo se le puede asignar una List<Object>. List<?> es una lista de algún tipo concreto desconocido: se le puede asignar cualquier List<X>, pero solo puedes añadirle null, porque el compilador no sabe qué es X.
¿Debo usar T extends Comparable<T> o T extends Comparable<? super T>?
La segunda forma es más flexible. Si una clase Employee implementa Comparable<Employee> y Manager extiende Employee, Manager es comparable a través de la implementación de su superclase. Comparable<? super T> lo acepta; Comparable<T> a secas rechaza una List<Manager>.