🧬 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:
- Evitar casting innecesario.
- Detectar errores antes de ejecutar el programa.
- 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:
IntegerDoubleFloatLong
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
Numbero una clase hija deNumber.
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
Numbero hijo deNumber.
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
Integero padre deInteger.
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
Numbero una clase hija.
Un wildcard:
List<?>
significa:
Una lista de algún tipo desconocido.