Saltar a contenido

🧬 Genéricos en Java

Los genéricos permiten escribir clases, interfaces y métodos que trabajan con un tipo de dato que se decide más tarde.

Dicho de forma muy simple:

Un genérico es como dejar un hueco para el tipo de dato.

Por ejemplo:

ArrayList<String> nombres = new ArrayList<>();

Aquí estamos diciendo:

Esta lista solo va a guardar String.

El tipo String está escrito entre los símbolos < > (operador diamante).

<String>

A eso lo llamamos tipo genérico, tipo parametrizado o parámetro de tipo.


❌ El problema antes de los genéricos

Antes de Java 5, las colecciones no indicaban qué tipo de dato guardaban.

import java.util.ArrayList;

public class Ejemplo {
    public static void main(String[] args) {
        ArrayList lista = new ArrayList();

        lista.add("Hola");
        lista.add("Mundo");

        String texto = (String) lista.get(0);

        System.out.println(texto);
    }
}

Como no se indicaba el tipo, Java trataba los elementos como Object.

Por eso era necesario hacer un casting:

String texto = (String) lista.get(0);

El problema es que la lista podía aceptar cualquier cosa:

ArrayList lista = new ArrayList();

lista.add("Hola");
lista.add(25);
lista.add(true);

Java permitía mezclar tipos porque todo era tratado como Object.


💥 Error en tiempo de ejecución

El siguiente código compila:

import java.util.ArrayList;

public class Ejemplo {
    public static void main(String[] args) {
        ArrayList lista = new ArrayList();

        lista.add(25);

        String texto = (String) lista.get(0);

        System.out.println(texto);
    }
}

Pero al ejecutarlo se produce un error:

ClassCastException

¿Por qué?

Porque hemos metido un Integer, pero después intentamos tratarlo como si fuera un String.

String texto = (String) lista.get(0);

Java no puede convertir un Integer en un String.


✅ Solución con genéricos

Con genéricos indicamos desde el principio qué tipo de dato puede guardar la colección.

import java.util.ArrayList;

public class Ejemplo {
    public static void main(String[] args) {
        ArrayList<String> lista = new ArrayList<>();

        lista.add("Hola");
        lista.add("Mundo");

        String texto = lista.get(0);

        System.out.println(texto);
    }
}

Ahora Java sabe que la lista guarda String.

Por tanto:

String texto = lista.get(0);

ya no necesita casting.


🧱 Qué nos aportan los genéricos

Los genéricos sirven principalmente para tres cosas:

  1. Evitar casting innecesario.
  2. Detectar errores antes de ejecutar el programa.
  3. Reutilizar código con distintos tipos de datos.

Ejemplo:

ArrayList<String> nombres = new ArrayList<>();

nombres.add("Ana");
nombres.add("Luis");

Esto es correcto.

Pero esto no compila:

nombres.add(25);

Java detecta el error antes de ejecutar el programa.


🧪 Comparación rápida

Sin genéricos

ArrayList lista = new ArrayList();

lista.add("Ana");

String nombre = (String) lista.get(0);

Problemas:

  • Hay que hacer casting.
  • Se pueden mezclar tipos.
  • Algunos errores aparecen al ejecutar.

Con genéricos

ArrayList<String> lista = new ArrayList<>();

lista.add("Ana");

String nombre = lista.get(0);

Ventajas:

  • No hay casting.
  • La lista solo acepta String.
  • Los errores se detectan al compilar.

📦 Genéricos en colecciones

Ya hemos usado genéricos muchas veces sin darnos cuenta.

ArrayList<String> nombres = new ArrayList<>();
HashSet<Integer> numeros = new HashSet<>();
HashMap<String, Double> precios = new HashMap<>();

En cada caso, los genéricos indican qué tipos se usan.


📋 Ejemplo con ArrayList

public class Ejemplo {
    public static void main(String[] args) {
        ArrayList<String> nombres = new ArrayList<>();

        nombres.add("Ana");
        nombres.add("Luis");
        nombres.add("Marta");

        for (String nombre : nombres) {
            System.out.println(nombre.toUpperCase());
        }
    }
}

Como Java sabe que la lista contiene String, podemos usar directamente métodos de String:

nombre.toUpperCase()

🗺️ Ejemplo con HashMap

public class Ejemplo {
    public static void main(String[] args) {
        HashMap<String, Integer> edades = new HashMap<>();

        edades.put("Ana", 20);
        edades.put("Luis", 25);

        Integer edadAna = edades.get("Ana");

        System.out.println(edadAna);
    }
}

En este caso:

HashMap<String, Integer>

significa:

  • La clave es de tipo String.
  • El valor es de tipo Integer.

Es decir:

HashMap<Clave, Valor>

⚠️ No se pueden usar tipos primitivos

En los genéricos NO se pueden usar tipos primitivos.

Esto es incorrecto:

ArrayList<int> numeros = new ArrayList<>();

En su lugar usamos las clases envoltorio:

ArrayList<Integer> numeros = new ArrayList<>();
ArrayList<Double> precios = new ArrayList<>();
ArrayList<Boolean> respuestas = new ArrayList<>();
ArrayList<Character> letras = new ArrayList<>();

Tabla de equivalencias:

Tipo primitivo Clase envoltorio
int Integer
double Double
boolean Boolean
char Character
long Long
float Float
byte Byte
short Short

💎 Operador diamante <>

Antes se escribía así:

ArrayList<String> nombres = new ArrayList<String>();

Desde Java 7 se puede simplificar usando el operador diamante:

ArrayList<String> nombres = new ArrayList<>();

Java deduce el tipo a partir de la parte izquierda.

Es decir, si escribimos:

ArrayList<String> nombres

Java ya sabe que en la parte derecha también debe ser un ArrayList<String>.


🧠 Cuidado con var

Desde Java 10 existe var, que permite inferir el tipo de una variable local.

var nombres = new ArrayList<String>();

Java deduce que nombres es un ArrayList<String>.

Pero cuidado con esto:

var nombres = new ArrayList<>();

En este caso Java no tiene suficiente información concreta y puede inferir un tipo demasiado general.

Por eso, al aprender genéricos, es mejor escribir el tipo claramente:

ArrayList<String> nombres = new ArrayList<>();

Mucho más claro.


🏗️ Crear nuestra propia clase genérica

También podemos crear nuestras propias clases genéricas.

Imaginemos una caja que puede guardar un dato.

Podríamos hacer una caja para String:

public class CajaString {
    private String contenido;

    public void guardar(String contenido) {
        this.contenido = contenido;
    }

    public String obtener() {
        return contenido;
    }
}

Y otra para Integer:

public class CajaInteger {
    private Integer contenido;

    public void guardar(Integer contenido) {
        this.contenido = contenido;
    }

    public Integer obtener() {
        return contenido;
    }
}

Esto es repetitivo.

La solución es crear una clase genérica:

public class Caja<T> {

    private T contenido;

    public void guardar(T contenido) {
        this.contenido = contenido;
    }

    public T obtener() {
        return contenido;
    }
}

La letra T representa un tipo que todavía no conocemos.


🔤 Qué significa T

En esta clase:

public class Caja<T> {
    private T contenido;
}

T es una variable de tipo.

No es una clase real llamada T.

Es un hueco que se rellenará cuando creemos el objeto.

Caja<String> cajaTexto = new Caja<>();
Caja<Integer> cajaNumero = new Caja<>();

En el primer caso, T será String.

En el segundo caso, T será Integer.


📦 Usar una clase genérica

public class Main {
    public static void main(String[] args) {
        Caja<String> cajaTexto = new Caja<>();

        cajaTexto.guardar("Hola");

        String texto = cajaTexto.obtener();

        System.out.println(texto);
    }
}

También podemos usarla con números:

public class Main {
    public static void main(String[] args) {
        Caja<Integer> cajaNumero = new Caja<>();

        cajaNumero.guardar(100);

        Integer numero = cajaNumero.obtener();

        System.out.println(numero);
    }
}

La misma clase Caja<T> sirve para distintos tipos.


🧩 Ejemplo completo de clase genérica

public class Caja<T> {

    private T contenido;

    public Caja(T contenido) {
        this.contenido = contenido;
    }

    public T getContenido() {
        return contenido;
    }

    public void setContenido(T contenido) {
        this.contenido = contenido;
    }

    @Override
    public String toString() {
        return "Caja{" +
                "contenido=" + contenido +
                '}';
    }
}

Uso:

public class Main {
    public static void main(String[] args) {
        Caja<String> caja1 = new Caja<>("Libro");
        Caja<Double> caja2 = new Caja<>(19.99);

        System.out.println(caja1);
        System.out.println(caja2);
    }
}

Salida:

Caja{contenido=Libro}
Caja{contenido=19.99}

🧾 Convenciones de nombres

Por convenio se suelen usar letras mayúsculas para los tipos genéricos.

Letra Significado habitual
T Type, un tipo cualquiera
E Element, elemento de una colección
K Key, clave de un mapa
V Value, valor de un mapa
N Number, número
R Result, resultado
S, U Segundo o tercer tipo genérico

Ejemplos:

public class Caja<T> { }
public interface List<E> { }
public interface Map<K, V> { }

🧰 Clases con varios tipos genéricos

Una clase puede tener más de un tipo genérico.

public class Par<K, V> {

    private K clave;
    private V valor;

    public Par(K clave, V valor) {
        this.clave = clave;
        this.valor = valor;
    }

    public K getClave() {
        return clave;
    }

    public V getValor() {
        return valor;
    }
}

Uso:

public class Main {
    public static void main(String[] args) {
        Par<String, Integer> edad = new Par<>("Ana", 20);

        String nombre = edad.getClave();
        Integer anios = edad.getValor();

        System.out.println(nombre + " tiene " + anios + " años");
    }
}

Salida:

Ana tiene 20 años

🧪 Métodos genéricos

No solo las clases pueden ser genéricas.

También podemos crear métodos genéricos.

Ejemplo:

public static <T> void mostrar(T dato) {
    System.out.println(dato);
}

Uso:

public class Main {

    public static <T> void mostrar(T dato) {
        System.out.println(dato);
    }

    public static void main(String[] args) {
        mostrar("Hola");
        mostrar(25);
        mostrar(3.14);
    }
}

Salida:

Hola
25
3.14

🔍 Dónde se coloca <T> en un método

En un método genérico, el tipo genérico se declara antes del tipo de retorno.

public static <T> void mostrar(T dato)

Partes:

public static <T> void mostrar(T dato)
              --- ----        ------
               |    |            |
               |    |            dato de tipo T
               |    no devuelve nada
               declaración del genérico

Otro ejemplo:

public static <T> T devolverPrimero(T a, T b) {
    return a;
}

Aquí el método recibe dos valores de tipo T y devuelve un valor de tipo T.


🧠 Método genérico con arrays

public class Utilidades {

    public static <T> void imprimirArray(T[] array) {
        for (T elemento : array) {
            System.out.println(elemento);
        }
    }
}

Uso:

public class Main {
    public static void main(String[] args) {
        String[] nombres = {"Ana", "Luis", "Marta"};
        Integer[] numeros = {1, 2, 3};

        Utilidades.imprimirArray(nombres);
        Utilidades.imprimirArray(numeros);
    }
}

El mismo método sirve para arrays de String, Integer, Double, etc.


🧱 Método genérico que devuelve un valor

public static <T> T obtenerPrimero(List<T> lista) {
    return lista.get(0);
}

Uso:

import java.util.List;

public class Main {

    public static <T> T obtenerPrimero(List<T> lista) {
        return lista.get(0);
    }

    public static void main(String[] args) {
        List<String> nombres = List.of("Ana", "Luis");
        List<Integer> numeros = List.of(10, 20);

        String nombre = obtenerPrimero(nombres);
        Integer numero = obtenerPrimero(numeros);

        System.out.println(nombre);
        System.out.println(numero);
    }
}

Salida:

Ana
10

🔒 Genéricos con restricciones

A veces no queremos aceptar cualquier tipo.

Por ejemplo, imaginemos este método:

public static <T> double sumar(T a, T b) {
    return a.doubleValue() + b.doubleValue();
}

Esto no compila.

¿Por qué?

Porque Java no sabe si T tiene el método doubleValue().

Si T fuera String, no tendría sentido.

La solución es limitar el tipo:

public static <T extends Number> double sumar(T a, T b) {
    return a.doubleValue() + b.doubleValue();
}

Ahora T solo puede ser Number o una subclase de Number.

Por ejemplo:

  • Integer
  • Double
  • Float
  • Long

Uso:

public class Main {

    public static <T extends Number> double sumar(T a, T b) {
        return a.doubleValue() + b.doubleValue();
    }

    public static void main(String[] args) {
        System.out.println(sumar(10, 20));
        System.out.println(sumar(5.5, 2.5));
    }
}

Salida:

30.0
8.0

Esto no compila:

sumar("Hola", "Mundo");

🧬 Qué significa extends en genéricos

En genéricos, extends significa:

Este tipo debe ser esa clase o una subclase.

Al poner extends en un genérico lo que haces es acotar el tipo, se conoce como bounded type.

<T extends Number>

Significa:

T debe ser Number o una clase hija de Number.

Aunque se use la palabra extends, también se usa para interfaces.

<T extends Comparable<T>>

Esto significa:

T debe implementar Comparable<T>.

No se escribe implements en los genéricos.

Se escribe siempre extends.


🧮 Ejemplo con Comparable

Queremos crear un método que devuelva el mayor de dos valores.

public static <T> T mayor(T a, T b) {
    if (a.compareTo(b) >= 0) {
        return a;
    } else {
        return b;
    }
}

Esto no compila porque Java no sabe si T tiene compareTo.

Solución:

public static <T extends Comparable<T>> T mayor(T a, T b) {
    if (a.compareTo(b) >= 0) {
        return a;
    } else {
        return b;
    }
}

Uso:

public class Main {

    //Puedes probarlo con cualquier clase que implemente Comparable.
    public static <T extends Comparable<T>> T mayor(T a, T b) {
        if (a.compareTo(b) >= 0) {
            return a;
        } else {
            return b;
        }
    }

    public static void main(String[] args) {
        System.out.println(mayor(10, 20));
        System.out.println(mayor("Ana", "Luis"));
    }
}

Salida:

20
Luis

🧷 Múltiples restricciones

Un tipo genérico puede tener varias restricciones.

<T extends Number & Comparable<T>>

Significa:

T debe ser un número y además debe poder compararse.

Si hay una clase y varias interfaces, la clase debe ir primero.

Correcto:

<T extends Number & Comparable<T>>

Incorrecto:

<T extends Comparable<T> & Number>

🧺 Interfaces genéricas

Las interfaces también pueden ser genéricas.

public interface Repositorio<T> {
    void guardar(T elemento);
    T buscarPorId(int id);
}

Una clase puede implementar esa interfaz indicando el tipo concreto.

public class RepositorioProductos implements Repositorio<Producto> {

    @Override
    public void guardar(Producto producto) {
        System.out.println("Guardando producto: " + producto.getNombre());
    }

    @Override
    public Producto buscarPorId(int id) {
        return new Producto("Producto " + id);
    }
}

También se puede mantener genérica:

public class RepositorioMemoria<T> implements Repositorio<T> {

    private ArrayList<T> elementos = new ArrayList<>();

    @Override
    public void guardar(T elemento) {
        elementos.add(elemento);
    }

    @Override
    public T buscarPorId(int id) {
        return elementos.get(id);
    }
}

🧑‍💻 Ejemplo práctico con objetos

Clase Producto:

public class Producto {

    private String nombre;
    private double precio;

    public Producto(String nombre, double precio) {
        this.nombre = nombre;
        this.precio = precio;
    }

    public String getNombre() {
        return nombre;
    }

    public double getPrecio() {
        return precio;
    }

    @Override
    public String toString() {
        return nombre + " - " + precio + "€";
    }
}

Clase genérica Inventario<T>:

import java.util.ArrayList;

public class Inventario<T> {

    private ArrayList<T> elementos = new ArrayList<>();

    public void agregar(T elemento) {
        elementos.add(elemento);
    }

    public T obtener(int posicion) {
        return elementos.get(posicion);
    }

    public int cantidad() {
        return elementos.size();
    }

    public void mostrar() {
        for (T elemento : elementos) {
            System.out.println(elemento);
        }
    }
}

Uso:

public class Main {
    public static void main(String[] args) {
        Inventario<Producto> inventario = new Inventario<>();

        inventario.agregar(new Producto("Ratón", 12.99));
        inventario.agregar(new Producto("Teclado", 29.99));

        inventario.mostrar();
    }
}

Salida:

Ratón - 12.99
Teclado - 29.99

❓ Comodines o wildcards

A veces veremos el símbolo ? wildcard (comodín).

List<?>
void printList(List<?> lista)

El símbolo ? significa:

Una lista de algún tipo, pero no sé exactamente de cuál.

Ejemplo:

public static void mostrarLista(List<?> lista) {
    for (Object elemento : lista) {
        System.out.println(elemento);
    }
}

Uso:

List<String> nombres = List.of("Ana", "Luis");
List<Integer> numeros = List.of(1, 2, 3);

mostrarLista(nombres);
mostrarLista(numeros);

El método acepta listas de cualquier tipo.


🧠 ¿Para qué sirve List<?>?

List<?> sí puede apuntar a listas de distintos tipos.

List<String> nombres = List.of("Ana", "Luis");
List<Integer> numeros = List.of(1, 2, 3);

List<?> lista;

lista = nombres;
lista = numeros;

Pero tiene una limitación importante:

lista.add("Hola"); // Error

No podemos añadir elementos porque Java no sabe cuál es el tipo real de la lista.

Sí podemos leer:

Object elemento = lista.get(0);

Cuando usamos ?, Java sabe que hay elementos, pero no sabe de qué tipo exacto son.


⬆️ Wildcard con extends

List<? extends Number>

Significa:

Una lista de algún tipo que es Number o hijo de Number.

Puede aceptar:

List<Integer>
List<Double>
List<Float>

Ejemplo:

public static double sumarLista(List<? extends Number> numeros) {
    double suma = 0;

    for (Number n : numeros) {
        suma += n.doubleValue();
    }

    return suma;
}

Uso:

List<Integer> enteros = List.of(1, 2, 3);
List<Double> decimales = List.of(1.5, 2.5);

System.out.println(sumarLista(enteros));
System.out.println(sumarLista(decimales));

Con extends normalmente podemos leer, pero no añadir elementos.

List<? extends Number> lista = new ArrayList<Integer>();

Number n = lista.get(0); // Sí se puede leer

lista.add(10); // Error

⬇️ Wildcard con super

List<? super Integer>

Significa:

Una lista de algún tipo que es Integer o padre de Integer.

Puede aceptar:

List<Integer>
List<Number>
List<Object>

Con super normalmente podemos añadir valores de tipo Integer.

public static void agregarNumeros(List<? super Integer> lista) {
    lista.add(1);
    lista.add(2);
    lista.add(3);
}

Pero al leer, solo tenemos garantizado que obtenemos un Object:

Object elemento = lista.get(0);

📌 Regla PECS

Hay una regla famosa para recordar cuándo usar extends y cuándo usar super.

PECS: Producer Extends, Consumer Super

Traducido de forma simple:

  • Si la estructura produce datos para leerlos: usa extends.
  • Si la estructura consume datos porque vas a añadirlos: usa super.

Ejemplo de lectura:

public static double sumar(List<? extends Number> lista) {
    double suma = 0;

    for (Number n : lista) {
        suma += n.doubleValue();
    }

    return suma;
}

Ejemplo de escritura:

public static void rellenar(List<? super Integer> lista) {
    lista.add(1);
    lista.add(2);
    lista.add(3);
}

Para este curso no hace falta dominar PECS al máximo, pero sí conviene entender la idea.


🧯

🧨 Los genéricos solo existen en compilación

Java usa algo llamado type erasure.

Significa que los genéricos sirven para comprobar tipos durante la compilación, pero en tiempo de ejecución se borran en gran parte.

Por eso no podemos hacer algunas cosas.

Por ejemplo, no podemos crear arrays genéricos directamente:

T[] datos = new T[10]; // Error

Tampoco podemos preguntar directamente por un tipo genérico con instanceof:

if (objeto instanceof ArrayList<String>) { // Error
}

Sí podemos hacer esto:

if (objeto instanceof ArrayList<?>) {
    System.out.println("Es un ArrayList");
}

🧬 Genéricos y record

Los record también pueden ser genéricos.

public record Par<T, U>(T primero, U segundo) {
}

Uso:

public class Main {
    public static void main(String[] args) {
        Par<String, Integer> dato = new Par<>("Ana", 20);

        System.out.println(dato.primero());
        System.out.println(dato.segundo());
    }
}

Salida:

Ana
20

Esto es útil para crear estructuras pequeñas e inmutables.


🧩 Genéricos y sealed

También podemos usar genéricos con clases e interfaces sealed.

public sealed interface Resultado<T>
        permits Exito, Error {
}
public final class Exito<T> implements Resultado<T> {

    private T valor;

    public Exito(T valor) {
        this.valor = valor;
    }

    public T getValor() {
        return valor;
    }
}
public final class Error<T> implements Resultado<T> {

    private String mensaje;

    public Error(String mensaje) {
        this.mensaje = mensaje;
    }

    public String getMensaje() {
        return mensaje;
    }
}

Ejemplo de uso:

Resultado<String> resultado = new Exito<>("Operación correcta");

No es necesario profundizar mucho en esto, pero es bueno saber que los genéricos se combinan con características modernas de Java.


🧪 Genéricos y métodos static

Una clase genérica puede tener métodos estáticos, pero hay que tener cuidado.

Esto no compila:

public class Caja<T> {

    public static void mostrar(T dato) { // Error
        System.out.println(dato);
    }
}

¿Por qué?

Porque T pertenece a cada objeto de Caja<T>.

Pero un método static pertenece a la clase, no a un objeto concreto.

La solución es declarar el genérico en el propio método:

public class Caja<T> {

    public static <U> void mostrar(U dato) {
        System.out.println(dato);
    }
}

Aquí usamos U para dejar claro que es un genérico propio del método estático.


🚫 Limitaciones importantes

Con genéricos no podemos hacer algunas cosas.

No podemos usar tipos primitivos

Caja<int> caja = new Caja<>(); // Error

Usamos:

Caja<Integer> caja = new Caja<>();

No podemos crear objetos de tipo T

public class Caja<T> {
    private T dato = new T(); // Error
}

Java no sabe qué constructor tendría que llamar.

No podemos crear arrays de T

T[] datos = new T[10]; // Error

No podemos usar instanceof con el tipo exacto

if (lista instanceof ArrayList<String>) { // Error
}

Sí se puede:

if (lista instanceof ArrayList<?>) {
    System.out.println("Es un ArrayList");
}

🧠 Errores típicos

Error 1: olvidar el tipo genérico

ArrayList lista = new ArrayList();

Mejor:

ArrayList<String> lista = new ArrayList<>();

Error 2: pensar que List<String> es un List<Object>

List<String> nombres = new ArrayList<>();
List<Object> objetos = nombres; // Error

Error 3: usar primitivos

ArrayList<int> numeros = new ArrayList<>(); // Error

Mejor:

ArrayList<Integer> numeros = new ArrayList<>();

Error 4: declarar mal un método genérico

Incorrecto:

public static T primero(List<T> lista) {
    return lista.get(0);
}

Correcto:

public static <T> T primero(List<T> lista) {
    return lista.get(0);
}

El <T> antes del tipo de retorno es obligatorio si T no está declarado en la clase.


🧭 Cuándo usar genéricos

Usamos genéricos cuando queremos que una clase o método pueda trabajar con distintos tipos de datos, pero manteniendo seguridad.

Por ejemplo:

Caja<String>
Caja<Integer>
Caja<Producto>

O métodos:

mostrar("Hola");
mostrar(25);
mostrar(new Producto("Ratón", 12.99));

No usamos genéricos si la clase solo tiene sentido para un tipo concreto.

Por ejemplo, si una clase siempre trabaja con productos, no hace falta hacerla genérica:

public class GestorProductos {
    private ArrayList<Producto> productos;
}

🧑‍🏫 Resumen rápido

Los genéricos permiten escribir código flexible y seguro.

ArrayList<String> nombres = new ArrayList<>();

Esto significa:

Esta lista solo guarda String.

Una clase genérica:

public class Caja<T> {
    private T contenido;
}

significa:

Esta caja puede guardar cualquier tipo, pero cuando la usemos deberemos indicar cuál.

Un método genérico:

public static <T> void mostrar(T dato) {
    System.out.println(dato);
}

significa:

Este método puede recibir datos de distintos tipos.

Una restricción:

<T extends Number>

significa:

T solo puede ser Number o una clase hija.

Un wildcard:

List<?>

significa:

Una lista de algún tipo desconocido.