Records en Java
En esta lección
Muchas clases de un programa solo sirven para llevar datos de un sitio a otro: una ruta con su nombre y sus kilómetros, un punto con dos coordenadas, un alumno con su nota. Escribirlas a mano exige atributos, constructor, getters, equals, hashCode y toString: unas treinta líneas para guardar tres datos. Desde Java 16 existe una forma mucho más corta, el record. En esta lección aprenderás a declararlo, a validar sus datos, a añadirle métodos, a entender qué te da gratis y cuándo conviene usar un record en lugar de una clase.
En encapsulación ya viste una primera toma de contacto. Aquí vamos a fondo, y todo el código está probado en Java 21.
Tu primer record
public record Ruta(String nombre, double km, int desnivel) { }
Esa única línea declara una clase completa. Lo que va entre paréntesis son los componentes del record: los datos que guarda. Java genera automáticamente:
- Un atributo
private finalpor cada componente. - Un constructor que recibe todos los componentes en ese orden (el constructor canónico).
- Un método de acceso por componente, con el mismo nombre que el componente:
nombre(),km(),desnivel(). Sin prefijoget. - Los métodos
toString(),equals()yhashCode().
Ruta r = new Ruta("Peña Cabarga", 8.5, 420);
System.out.println(r.nombre()); // Peña Cabarga
System.out.println(r.km()); // 8.5
System.out.println(r); // Ruta[nombre=Peña Cabarga, km=8.5, desnivel=420]
Observa el toString(): muestra el nombre del record y todos sus componentes, algo muy cómodo para depurar sin escribir nada. Los records suelen declararse en su propio archivo (Ruta.java), pero también puedes ponerlos dentro de otra clase o incluso dentro de un método, como verás más adelante.
equals y hashCode por valor
En una clase normal, dos objetos con los mismos datos no son iguales salvo que escribas equals (lo explica la clase Object). En un record, la igualdad es por valor: dos records son iguales si todos sus componentes lo son.
Ruta a = new Ruta("Peña Cabarga", 8.5, 420);
Ruta b = new Ruta("Peña Cabarga", 8.5, 420);
System.out.println(a.equals(b)); // true
System.out.println(a == b); // false
System.out.println(a.hashCode() == b.hashCode()); // true
== sigue comparando si son el mismo objeto, como siempre; equals compara el contenido. Y como hashCode es coherente con equals, los records funcionan sin problemas como claves de un HashMap o elementos de un HashSet.
Los records son inmutables
Los componentes son final: una vez creado el record, no se pueden cambiar. No existen los setters.
Ruta r = new Ruta("Peña Cabarga", 8.5, 420);
r.km = 9; // error: no se puede asignar un campo final
r.setKm(9); // error: el método no existe
Para “modificar” un dato creas otro record con el valor nuevo. Es habitual añadir un método que lo haga:
record Punto(int x, int y) {
Punto desplazar(int dx, int dy) {
return new Punto(x + dx, y + dy);
}
}
Punto p = new Punto(3, 4);
System.out.println(p.desplazar(1, 1)); // Punto[x=4, y=5]
System.out.println(p); // Punto[x=3, y=4]
El original no cambia. La inmutabilidad hace los programas más fáciles de razonar: nadie puede alterar un dato “por detrás”, y puedes pasar un record a cualquier parte, incluso a varios hilos, sin miedo.
Constructor compacto: validar los datos
Un record puede tener un constructor compacto: se escribe con el nombre del record y sin paréntesis. Sirve para validar o normalizar los datos antes de guardarlos. Las asignaciones a los atributos las hace Java por ti al final del constructor.
record Punto(int x, int y) {
Punto {
if (x < 0 || y < 0) {
throw new IllegalArgumentException("Coordenadas negativas: " + x + ", " + y);
}
}
}
try {
new Punto(-1, 2);
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage()); // Coordenadas negativas: -1, 2
}
Así, es imposible tener un Punto inválido en el programa, igual que con la validación de un setter en una clase normal pero con mucho menos código.
Normalizar valores
Dentro del constructor compacto puedes reasignar los parámetros (no los atributos) antes de que Java los guarde:
record Socio(String nombre, String email) {
Socio {
nombre = nombre.strip();
email = email.toLowerCase();
if (nombre.isEmpty()) {
throw new IllegalArgumentException("Nombre vacío");
}
}
}
System.out.println(new Socio(" Lucía ", "LUCIA@Correo.ES"));
// Socio[nombre=Lucía, email=lucia@correo.es]
Nota: dentro del constructor compacto no escribas
this.nombre = nombre: da error de compilación. Solo modificas el parámetro; Java hace la asignación final.
También puedes escribir el constructor canónico completo, con paréntesis y this.x = x, pero casi nunca compensa: el compacto hace lo mismo con menos ruido.
Constructores y métodos extra
Además del canónico, puedes añadir otros constructores, siempre que lo primero que hagan sea llamar a otro con this(...):
record Punto(int x, int y) {
Punto(int lado) {
this(lado, lado); // punto en la diagonal
}
}
System.out.println(new Punto(5)); // Punto[x=5, y=5]
Los records admiten métodos de instancia, métodos static y constantes static. Lo que no admiten son atributos de instancia adicionales: todo el estado sale de la cabecera.
record Punto(int x, int y) {
static final Punto ORIGEN = new Punto(0, 0);
double distanciaAlOrigen() {
return Math.sqrt(x * x + y * y);
}
static Punto de(String texto) { // "7, 2"
String[] trozos = texto.split(",");
return new Punto(Integer.parseInt(trozos[0].trim()),
Integer.parseInt(trozos[1].trim()));
}
}
System.out.println(new Punto(3, 4).distanciaAlOrigen()); // 5.0
System.out.println(Punto.ORIGEN); // Punto[x=0, y=0]
System.out.println(Punto.de("7, 2")); // Punto[x=7, y=2]
Fíjate en que dentro de un método puedes usar x e y directamente (son los atributos) o x() e y() (los accesores). Es indiferente.
Cambiar lo que se genera solo
Puedes sobrescribir cualquier método automático. Lo más habitual es el toString:
record Precio(double importe, String moneda) {
@Override
public String toString() {
return String.format("%.2f %s", importe, moneda);
}
}
System.out.println(new Precio(12.5, "EUR")); // 12.50 EUR
Si sobrescribes un accesor, debe ser public y devolver el mismo tipo. Úsalo con cuidado: quien lea ruta.nombre() espera el dato guardado, no uno transformado.
Records, interfaces y genéricos
Un record no puede extender otra clase (ya hereda de java.lang.Record) y es implícitamente final, pero sí puede implementar interfaces y ser genérico:
interface Figura { double area(); }
record Cuadrado(double lado) implements Figura {
public double area() { return lado * lado; }
}
record Circulo(double radio) implements Figura {
public double area() { return Math.PI * radio * radio; }
}
record Par<A, B>(A primero, B segundo) { }
Par<String, Integer> par = new Par<>("edad", 30);
System.out.println(par); // Par[primero=edad, segundo=30]
También implementan Comparable sin problema: es lo que se hace en Comparable y Comparator.
Desestructurar un record
Java moderno permite “abrir” un record con instanceof y con switch, lo que se llama patrones de record:
Object o = new Punto(1, 2);
if (o instanceof Punto(int x, int y)) {
System.out.println("x=" + x + " y=" + y); // x=1 y=2
}
Figura f = new Circulo(1);
String texto = switch (f) {
case Cuadrado c -> "Cuadrado de lado " + c.lado();
case Circulo c -> "Círculo de radio " + c.radio();
default -> "Otra figura";
};
System.out.println(texto); // Círculo de radio 1.0
Para que no haga falta default, la interfaz tendría que ser sealed. Es un tema avanzado que no necesitas todavía.
Cuidado con los componentes mutables
El record es inmutable, pero lo que guarda dentro puede no serlo. Si un componente es una lista o un array, el record guarda la referencia y alguien puede modificar el contenido desde fuera:
record Equipo(String nombre, List<String> miembros) { }
List<String> lista = new ArrayList<>(List.of("Ana", "Luis"));
Equipo e = new Equipo("Norte", lista);
lista.add("Pablo");
System.out.println(e.miembros()); // [Ana, Luis, Pablo] (¡el equipo cambió!)
La solución es hacer una copia defensiva en el constructor compacto con List.copyOf, que además crea una lista que no se puede modificar:
record Equipo(String nombre, List<String> miembros) {
Equipo {
miembros = List.copyOf(miembros);
}
}
List<String> lista = new ArrayList<>(List.of("Ana", "Luis"));
Equipo e = new Equipo("Norte", lista);
lista.add("Pablo");
System.out.println(e.miembros()); // [Ana, Luis]
e.miembros().add("X"); // UnsupportedOperationException
Con los arrays hay un segundo problema: equals compara arrays por referencia, no por contenido, así que dos records con arrays idénticos no serían iguales. Si puedes, usa una List en lugar de un array.
Records en colecciones
Como equals y hashCode van por valor, un record es una clave perfecta para un HashMap o un HashSet, sin escribir nada más:
Set<Punto> visitados = new HashSet<>();
visitados.add(new Punto(1, 1));
visitados.add(new Punto(1, 1)); // mismo valor: no se duplica
visitados.add(new Punto(2, 1));
System.out.println(visitados.size()); // 2
Map<Punto, String> tiendas = new HashMap<>();
tiendas.put(new Punto(1, 1), "panadería");
System.out.println(tiendas.get(new Punto(1, 1))); // panadería
Con una clase normal sin equals ni hashCode, tiendas.get(new Punto(1, 1)) devolvería null, porque sería otro objeto distinto. Para saber cómo funcionan estas estructuras, consulta HashMap y HashSet.
Records y streams
Los records y los streams se llevan de maravilla: los accesores se pasan como referencias a método (Alumno::nota) y los records pequeños sirven para devolver resultados intermedios.
record Alumno(String nombre, String grupo, double nota) { }
List<Alumno> alumnos = List.of(
new Alumno("Ana", "A", 8.5),
new Alumno("Luis", "B", 6.0),
new Alumno("Eva", "A", 9.5),
new Alumno("Mario", "B", 4.5));
// aprobados
List<String> aprobados = alumnos.stream()
.filter(a -> a.nota() >= 5)
.map(Alumno::nombre)
.toList();
System.out.println(aprobados); // [Ana, Luis, Eva]
// ordenados por nota, de mayor a menor
List<String> ranking = alumnos.stream()
.sorted(Comparator.comparingDouble(Alumno::nota).reversed())
.map(Alumno::nombre)
.toList();
System.out.println(ranking); // [Eva, Ana, Luis, Mario]
// nota media por grupo
Map<String, Double> medias = alumnos.stream()
.collect(Collectors.groupingBy(Alumno::grupo, TreeMap::new,
Collectors.averagingDouble(Alumno::nota)));
System.out.println(medias); // {A=9.0, B=5.25}
Necesitas import java.util.*; e import java.util.stream.*;.
Records locales
Dentro de un método puedes declarar un record solo para ese método, ideal para devolver dos valores o agrupar un resultado intermedio sin crear un archivo nuevo:
record Resumen(String grupo, long total) { }
var resumenes = alumnos.stream()
.collect(Collectors.groupingBy(Alumno::grupo, TreeMap::new, Collectors.counting()))
.entrySet().stream()
.map(e -> new Resumen(e.getKey(), e.getValue()))
.toList();
System.out.println(resumenes);
// [Resumen[grupo=A, total=2], Resumen[grupo=B, total=2]]
Record o clase: cómo decidir
| Pregunta | Record | Clase |
|---|---|---|
| ¿Solo agrupa datos? | Sí | |
| ¿Los datos no cambian tras crearse? | Sí | |
| ¿Dos objetos con los mismos datos son “el mismo”? | Sí | |
| ¿Hay estado que cambia (saldo, contador, estado)? | Sí | |
| ¿Necesitas herencia de otra clase? | Sí | |
| ¿Hay lógica compleja o atributos calculados guardados? | Sí |
Casos típicos de record: un punto, una coordenada, una dirección, un resultado de búsqueda, un DTO (los datos que viajan entre capas o desde una API), una clave compuesta para un mapa. Casos típicos de clase: una cuenta bancaria, un carrito de la compra, un personaje de un juego.
Tampoco vale para clases que usa una librería que exige constructor vacío y setters (algunos marcos antiguos y ciertas herramientas de bases de datos): ahí necesitas una clase tradicional.
Errores frecuentes
- Intentar modificar un componente. No hay setters ni se puede asignar: crea un record nuevo con el valor cambiado.
- Escribir
this.x = xen un constructor compacto. No compila. En el compacto solo se reasignan los parámetros. - Llamar al accesor con
get. Esr.nombre(), nor.getNombre(). - Olvidar los componentes mutables. Una lista o un array dentro del record se puede cambiar desde fuera. Haz una copia defensiva con
List.copyOf. - Añadir atributos de instancia. Un record no los admite; si necesitas uno extra, o va como componente o usas una clase.
- Esperar herencia. Un record no puede extender una clase ni ser extendido. Sí puede implementar interfaces.
- Usar un record con arrays y comparar con
equals. Los arrays se comparan por referencia: dos records con arrays iguales no son iguales.
Resumen
| Concepto | Qué hace | Ejemplo |
|---|---|---|
record | Clase de datos inmutable en una línea | record Punto(int x, int y) {} |
| Componentes | Datos de la cabecera, atributos private final | (String nombre, int edad) |
| Accesores | Un método por componente, sin get | p.x() |
| Automáticos | toString, equals y hashCode por valor | Punto[x=1, y=2] |
| Constructor compacto | Valida o normaliza antes de guardar | Punto { if (x < 0) throw ... } |
Métodos y static | Permitidos; sin atributos de instancia extra | double distancia() |
| Interfaces y genéricos | Sí; herencia de clases, no | record A() implements B |
| Componentes mutables | Copia defensiva | List.copyOf(lista) |
| Colecciones y streams | Claves y elementos naturales | Alumno::nota |
En la siguiente lección aprenderás a controlar los fallos del programa con las excepciones.
Pon a prueba lo que has aprendido
¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.