Saltar al contenido
genericos.java · devschool

Genéricos en Java

Lección 30 de 39 · 13 min de lectura · Actualizado el

En esta lección
  1. El problema: una caja para todo
  2. Clases genéricas
  3. Métodos genéricos
  4. Tipos acotados: <T extends ...>
  5. Interfaces genéricas
  6. Comodines: ?, ? extends y ? super
  7. Borrado de tipos: lo que no se puede hacer
  8. Errores frecuentes
  9. Resumen

Llevas varias lecciones escribiendo List<String>, Map<String, Integer> o ArrayDeque<Character>. Esos < > son los genéricos: la forma que tiene Java de escribir una clase o un método una sola vez y que funcione con cualquier tipo, sin perder la comprobación del compilador. En esta lección aprenderás a crear tus propias clases, records, interfaces y métodos genéricos, a limitar qué tipos aceptan, a usar los comodines ?, extends y super, y qué cosas no se pueden hacer con ellos.

El problema: una caja para todo

Imagina que en una tienda de montaña quieres una clase que guarde un objeto cualquiera: un producto, un precio, un código… Sin genéricos, la única forma es usar Object, el padre de todas las clases (lo viste en la clase Object):

static class CajaVieja {
    private Object contenido;
    public void guardar(Object o) { contenido = o; }
    public Object sacar() { return contenido; }
}

Funciona, pero tiene dos problemas:

CajaVieja caja = new CajaVieja();
caja.guardar("Mochila de 30 litros");
String texto = (String) caja.sacar();   // 1. hay que hacer un cast
System.out.println(texto);              // Mochila de 30 litros

caja.guardar(42);                       // el compilador no protesta
String otro = (String) caja.sacar();    // 2. error, pero en ejecución
// Exception in thread "main" java.lang.ClassCastException:
//   class java.lang.Integer cannot be cast to class java.lang.String
  1. Cada vez que sacas algo tienes que hacer un cast (conversión de tipo) a mano.
  2. Si te equivocas de tipo, el error no aparece al compilar, sino cuando el programa ya está funcionando, quizá delante del cliente.

Así eran las colecciones de Java antes de la versión 5. Los genéricos llegaron para resolver justo esto.

Clases genéricas

Una clase genérica declara uno o varios parámetros de tipo entre < > justo después de su nombre. Dentro de la clase usas ese parámetro como si fuera un tipo normal:

static class Caja<T> {
    private T contenido;

    public void guardar(T contenido) { this.contenido = contenido; }
    public T sacar() { return contenido; }
    public boolean estaVacia() { return contenido == null; }
}

T es un hueco que se rellena cuando alguien usa la clase:

Caja<String> cajaTexto = new Caja<>();
cajaTexto.guardar("Mochila de 30 litros");
String t = cajaTexto.sacar();           // sin cast
System.out.println(t.toUpperCase());    // MOCHILA DE 30 LITROS

Caja<Integer> cajaNumero = new Caja<>();
System.out.println(cajaNumero.estaVacia()); // true
cajaNumero.guardar(7);
int n = cajaNumero.sacar();             // unboxing automático a int
System.out.println(n * 2);              // 14

cajaTexto.guardar(42);                  // error de compilación

Ahora el compilador sabe qué hay en cada caja: no hacen falta casts y el error de tipos se detecta antes de ejecutar. Es el mismo código para todos los tipos; solo cambia lo que pones entre < >.

Recuerda dos detalles que ya viste en ArrayList:

  • new Caja<>(): el operador diamante <> deduce el tipo de la izquierda.
  • Los parámetros de tipo solo admiten objetos. No existe Caja<int>; usa Caja<Integer>.

Nombres de los parámetros de tipo

Por convención se usa una sola letra mayúscula, para distinguirlos de las clases reales:

LetraSignificado habitualEjemplo en Java
TTipo (type)Optional<T>
EElemento de una colecciónList<E>, Set<E>
K, VClave (key) y valor (value)Map<K, V>
RResultadoFunction<T, R>
A, B, U…Segundo, tercer tipoBiFunction<T, U, R>

Varios parámetros: un Par con record

Una clase puede tener tantos parámetros como necesite, separados por comas. Los records también pueden ser genéricos, y para guardar dos valores juntos son perfectos:

record Par<A, B>(A primero, B segundo) {}
Par<String, Integer> alumno = new Par<>("Lucía", 8);
System.out.println(alumno);              // Par[primero=Lucía, segundo=8]
System.out.println(alumno.primero() + " ha sacado un " + alumno.segundo());
// Lucía ha sacado un 8

var ruta = new Par<>("Picos de Europa", 12.5);  // Java deduce Par<String, Double>
System.out.println(ruta.segundo() + 0.5);       // 13.0

Con var y el diamante a la vez, Java deduce los tipos a partir de los argumentos del constructor.

Métodos genéricos

A veces no necesitas una clase entera, solo un método que funcione con cualquier tipo. Un método genérico declara su parámetro de tipo antes del tipo de retorno:

static <T> T ultimo(List<T> lista) {
    return lista.get(lista.size() - 1);
}

static <T> List<T> repetir(T valor, int veces) {
    List<T> resultado = new ArrayList<>();
    for (int i = 0; i < veces; i++) {
        resultado.add(valor);
    }
    return resultado;
}

Ese <T> del principio dice: “este método trabaja con un tipo T que decidirá quien lo llame”. Al usarlo no tienes que indicar nada; Java lo infiere (deduce) de los argumentos:

System.out.println(ultimo(List.of("lunes", "martes", "miércoles"))); // miércoles
System.out.println(ultimo(List.of(3, 1, 4)));                        // 4
System.out.println(repetir("hola", 3));                              // [hola, hola, hola]

En casos raros en que la inferencia no basta, puedes escribir el tipo a mano delante del nombre del método: Genericos.<Double>repetir(1.5, 2). Casi nunca hace falta.

Muchos métodos que ya conoces son genéricos: List.of, Collections.max, Optional.of, Arrays.asList…

Tipos acotados: <T extends ...>

Intenta escribir un método que devuelva el mayor elemento de una lista:

static <T> T maximo(List<T> lista) {
    T mayor = lista.get(0);
    for (T elemento : lista) {
        if (elemento.compareTo(mayor) > 0) {   // error: T no tiene compareTo
            mayor = elemento;
        }
    }
    return mayor;
}

No compila. Para Java, T puede ser cualquier cosa, y no todas las clases tienen compareTo. La solución es acotar el tipo: decir que T tiene que ser una clase que implemente Comparable:

static <T extends Comparable<T>> T maximo(List<T> lista) {
    T mayor = lista.get(0);
    for (T elemento : lista) {
        if (elemento.compareTo(mayor) > 0) {
            mayor = elemento;
        }
    }
    return mayor;
}
System.out.println(maximo(List.of(4, 9, 2)));             // 9
System.out.println(maximo(List.of("pera", "uva", "kiwi"))); // uva

<T extends X> significa “T es X o un subtipo de X”. En genéricos se usa la palabra extends tanto para clases como para interfaces. Gracias a la cota, dentro del método puedes llamar a cualquier método de X. Comparable lo estudiarás a fondo en la siguiente lección.

Otro ejemplo: sumar una lista de números, sean Integer, Double o Long. Todos heredan de Number, que tiene el método doubleValue():

static <T extends Number> double sumar(List<T> numeros) {
    double total = 0;
    for (T n : numeros) {
        total += n.doubleValue();
    }
    return total;
}
System.out.println(sumar(List.of(1, 2, 3)));      // 6.0
System.out.println(sumar(List.of(2.5, 0.25)));    // 2.75
sumar(List.of("uno", "dos"));                     // error: String no es un Number

Interfaces genéricas

Las interfaces también pueden tener parámetros de tipo. Es muy habitual en aplicaciones reales tener un repositorio: un objeto que guarda y busca entidades de un tipo. Con genéricos lo defines una vez para todos:

interface Repositorio<T, ID> {
    void guardar(T elemento);
    Optional<T> buscar(ID id);
    List<T> todos();
}

Al implementarla, eliges los tipos concretos. Para una biblioteca, los libros se identifican por su ISBN, que es un String:

record Libro(String isbn, String titulo) {}

static class RepositorioLibros implements Repositorio<Libro, String> {
    private final Map<String, Libro> libros = new LinkedHashMap<>();

    @Override
    public void guardar(Libro libro) { libros.put(libro.isbn(), libro); }

    @Override
    public Optional<Libro> buscar(String isbn) { return Optional.ofNullable(libros.get(isbn)); }

    @Override
    public List<Libro> todos() { return new ArrayList<>(libros.values()); }
}
Repositorio<Libro, String> repo = new RepositorioLibros();
repo.guardar(new Libro("978-1", "Cien años de soledad"));
repo.guardar(new Libro("978-2", "La sombra del viento"));

System.out.println(repo.buscar("978-2").map(Libro::titulo).orElse("No está")); // La sombra del viento
System.out.println(repo.buscar("000").map(Libro::titulo).orElse("No está"));   // No está
System.out.println(repo.todos().size());                                       // 2

Mañana puedes crear RepositorioSocios implements Repositorio<Socio, Integer> y el resto del programa sabrá usarlo igual. Si Optional te suena nuevo, repásalo en Optional.

Comodines: ?, ? extends y ? super

Una sorpresa: List<Integer> no es una List<Number>

Integer hereda de Number, así que parece lógico que una lista de enteros sirva donde se espera una lista de números. Pues no:

List<Integer> enteros = List.of(1, 2, 3);
List<Number> numeros = enteros;   // error: List<Integer> cannot be converted to List<Number>

El motivo es la seguridad. Si se permitiera, podrías hacer numeros.add(3.14) y habrías metido un Double en una lista que, por otro nombre, solo debía tener Integer. Por eso, entre genéricos no hay herencia aunque la haya entre los tipos de dentro.

Para escribir métodos flexibles existen los comodines (wildcards), que se escriben con ?.

List<?>: de cualquier tipo

? solo significa “una lista de algo, no sé de qué”. Sirve cuando solo vas a leer los elementos como Object:

static void imprimirTodo(List<?> lista) {
    for (Object o : lista) {
        System.out.print(o + " ");
    }
    System.out.println();
}
imprimirTodo(enteros);          // 1 2 3
imprimirTodo(List.of("a", "b")); // a b

? extends T: para leer

List<? extends Number> acepta una lista de Number o de cualquier subclase: List<Integer>, List<Double>… Puedes leer de ella sabiendo que cada elemento es, como mínimo, un Number:

static double sumaFlexible(List<? extends Number> numeros) {
    double total = 0;
    for (Number n : numeros) {
        total += n.doubleValue();
    }
    return total;
}
System.out.println(sumaFlexible(enteros));            // 6.0
System.out.println(sumaFlexible(List.of(1.5, 2.5)));  // 4.0

Pero no puedes añadir nada (salvo null): Java no sabe si la lista real es de Integer o de Double, así que numeros.add(5) no compila.

? super T: para escribir

List<? super Integer> acepta una lista de Integer o de cualquier superclase: List<Number>, List<Object>. En todas ellas cabe un Integer, así que puedes añadir enteros sin miedo:

static void rellenarConCeros(List<? super Integer> destino, int cantidad) {
    for (int i = 0; i < cantidad; i++) {
        destino.add(0);
    }
}
List<Number> destino = new ArrayList<>();
rellenarConCeros(destino, 3);
System.out.println(destino);   // [0, 0, 0]

List<Object> objetos = new ArrayList<>(List.of("x"));
rellenarConCeros(objetos, 2);
System.out.println(objetos);   // [x, 0, 0]

Al leer de una List<? super Integer> solo obtienes Object, porque no sabes qué superclase es.

La regla para acordarte

En inglés se resume como PECS: Producer Extends, Consumer Super.

Si la colección…UsaPuedes
te da datos (la lees)? extends Tleer como T, no añadir
recibe datos (escribes en ella)? super Tañadir T, leer solo como Object
ni una cosa ni otra, o te da igual el tipo?leer como Object
la lees y la escribesT sin comodíntodo

Lo verás en la API de Java: Collections.copy(List<? super T> destino, List<? extends T> origen) copia de una lista que produce a otra que consume. Los comodines se usan sobre todo en parámetros de métodos; en variables y atributos normalmente basta con el tipo concreto.

Borrado de tipos: lo que no se puede hacer

Los genéricos existen solo para el compilador. Una vez comprobados los tipos, Java los elimina del código compilado y los sustituye por Object (o por la cota, si la hay). Esto se llama borrado de tipos (type erasure) y se hizo así para que el código nuevo siguiera funcionando con el antiguo.

Puedes comprobarlo:

List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
System.out.println(a.getClass() == b.getClass()); // true
System.out.println(a.getClass().getName());       // java.util.ArrayList

En ejecución las dos son simplemente un ArrayList. Por eso hay cosas que no compilan dentro de una clase genérica Caja<T>:

T crear() { return new T(); }          // error: no se sabe qué clase crear
T[] array = new T[10];                 // error: generic array creation
static T compartido;                   // error: T no existe en contexto static
boolean b = o instanceof List<String>; // error: en ejecución no hay <String>
  • new T(): en ejecución T ya no existe. Si necesitas crear objetos, recibe un Supplier<T> (una lambda que los crea).
  • new T[]: usa una List<T> en lugar de un array.
  • Atributos static de tipo T: lo estático es compartido por todas las Caja<String>, Caja<Integer>…, así que no puede depender de T.
  • instanceof List<String>: solo puedes preguntar instanceof List<?>.

Tipos crudos en código antiguo

Verás en código antiguo colecciones sin < >, llamadas tipos crudos (raw types):

List lista = new ArrayList();   // tipo crudo: no lo hagas
lista.add("hola");
lista.add(3);
String s = (String) lista.get(1); // ClassCastException en ejecución

Compila con un aviso (unchecked call), pero vuelves a todos los problemas de la CajaVieja. Escribe siempre el tipo entre < >.

Errores frecuentes

  • Usar primitivos como parámetro de tipo. Caja<int> o List<double> no compilan; usa Integer, Double, etc.
  • Olvidar el <T> en un método genérico. static T ultimo(List<T> l) da error “cannot find symbol T”; el <T> va antes del tipo de retorno.
  • Llamar a métodos de T sin acotarlo. Si necesitas compareTo o doubleValue, añade extends Comparable<T> o extends Number.
  • Pensar que List<Integer> es una List<Number>. No lo es; usa List<? extends Number> en el parámetro.
  • Intentar añadir a una List<? extends X>. Es de solo lectura; para escribir, ? super X.
  • Intentar new T() o new T[n]. El borrado de tipos lo impide; usa un Supplier<T> o una List<T>.
  • Usar tipos crudos. List sin < > desactiva la comprobación y trae de vuelta los ClassCastException.

Resumen

ConceptoEjemplo
Clase genéricaclass Caja<T> { private T contenido; }
Record genéricorecord Par<A, B>(A primero, B segundo) {}
Usar con diamanteCaja<String> c = new Caja<>();
Interfaz genéricainterface Repositorio<T, ID> { ... }
Método genéricostatic <T> T ultimo(List<T> lista)
Tipo acotado<T extends Comparable<T>>, <T extends Number>
Comodín libreList<?>
Comodín para leerList<? extends Number>
Comodín para escribirList<? super Integer>
Borrado de tiposNo se puede new T(), new T[], static T, instanceof List<String>

En la siguiente lección usarás un tipo genérico muy importante, Comparable<T>, y su compañero Comparator<T> para ordenar tus propios objetos: Comparable y Comparator.

Pon a prueba lo que has aprendido

[Java] ¿Qué ocurre con esta línea?
List<Integer> enteros = List.of(1, 2, 3);
List<Number> numeros = enteros;

[Java] En static <T> T maximo(List<T> lista), la línea elemento.compareTo(mayor) no compila. ¿Cuál es la solución?

[Java] ¿Qué ocurre al compilar la última línea?
List<? extends Number> lista = new ArrayList<Integer>();
lista.add(5);

[Java] ¿Qué imprime este código y por qué?
List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
System.out.println(a.getClass() == b.getClass());

¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.