Java Records en Profundidad
Un análisis detallado de los records de Java: constructores canónicos y compactos, validación y casos de uso.
Un record es la forma que tiene Java de declarar una clase cuyo único propósito es transportar datos. Introducido como vista previa en Java 14 y finalizado en Java 16, un record elimina el habitual código repetitivo — campos private final, un constructor, accesores, equals, hashCode y toString — en una sola línea de encabezado. El capítulo anterior sobre records mostró la sintaxis básica; este va más a fondo en cómo se comportan realmente los records: sus constructores canónicos y compactos, cómo hacen cumplir invariantes, qué garantías de inmutabilidad ofrecen y dónde encajan (y dónde no).
Lo que el compilador genera por ti
Cuando escribes record Point(int x, int y) {}, el compilador emite una clase final con dos campos private final, un constructor público que acepta ambos, métodos accesores públicos con el mismo nombre que los componentes (x(), y() — sin prefijo get), y métodos equals, hashCode y toString basados en valor.
record Point(int x, int y) {}
// Equivalent to (roughly) hand-writing:
// final class Point {
// private final int x;
// private final int y;
// Point(int x, int y) { this.x = x; this.y = y; }
// int x() { return x; }
// int y() { return y; }
// public boolean equals(Object o) { ... compares x and y ... }
// public int hashCode() { ... derived from x and y ... }
// public String toString() { return "Point[x=" + x + ", y=" + y + "]"; }
// }Los x e y del encabezado son los componentes del record. Los miembros generados por el compilador se derivan completamente de ellos, en el orden de declaración.
Constructores canónicos y compactos
Cada record tiene un constructor canónico cuyos parámetros coinciden con los componentes. Rara vez se escribe completo — en su lugar se usa el constructor compacto, que omite la lista de parámetros y las asignaciones finales this.field = field. El compilador ejecuta tu código primero y luego asigna los parámetros (posiblemente modificados) a los campos. Es el lugar natural para la validación y la normalización.
record Range(int low, int high) {
Range { // compact constructor — no (int low, int high)
if (low > high) {
throw new IllegalArgumentException("low must be <= high");
}
low = Math.max(low, 0); // reassigning the parameter normalizes the field
}
}Si alguna vez necesitas la forma canónica explícita (por ejemplo, para copiar defensivamente un componente mutable), escribe la firma completa y haz las asignaciones tú mismo:
record Tags(String name, List<String> values) {
Tags(String name, List<String> values) { // explicit canonical constructor
this.name = name;
this.values = List.copyOf(values); // defensive, unmodifiable copy
}
}Inmutabilidad y lo que los records no son
Los campos de un record son final, por lo que la referencia que contiene cada componente nunca cambia tras la construcción. Eso hace que los records sean superficialmente inmutables. Pero la inmutabilidad se detiene en la referencia: si un componente apunta a un objeto mutable (como un ArrayList), quienes compartan ese objeto pueden seguir mutando su contenido. Las copias defensivas en el constructor canónico cierran esa brecha.
| Propiedad | Records | Clases regulares |
|---|---|---|
| Campos | siempre private final | a tu elección |
| Clase | implícitamente final | extensible salvo que sea final |
| Superclase | siempre java.lang.Record | cualquiera (por defecto Object) |
| Accesores | generados automáticamente, sin prefijo get | escritos a mano |
equals/hashCode | basados en valor, generados | por identidad por defecto |
| Setters | ninguno — inmutable | permitidos |
Como un record siempre extiende java.lang.Record, no puede extender otra clase. Sí puede implementar interfaces, declarar miembros estáticos y añadir métodos de instancia.
Añadir comportamiento, miembros estáticos y fábricas
Un record sigue siendo una clase. Puedes añadirle métodos adicionales, métodos de fábrica estáticos, campos estáticos e incluso tipos anidados. Los componentes definen el estado; todo lo demás es Java ordinario.
record Money(String currency, long cents) {
static Money of(String currency, long cents) { // static factory
return new Money(currency, cents);
}
Money plus(Money other) { // derived behavior
if (!currency.equals(other.currency)) {
throw new IllegalArgumentException("currency mismatch");
}
return new Money(currency, cents + other.cents); // returns a new value
}
}Los records también se combinan de forma natural con los tipos sellados y el reconocimiento de patrones, modelando conjuntos cerrados de formas de datos — la base del diseño de datos de estilo algebraico en el Java moderno. Una interfaz sellada fija el conjunto de implementaciones de record permitidas, y un switch sobre esos records puede deconstruir cada uno por sus componentes en una sola expresión.
Un ejemplo completo: records de principio a fin
Este programa ejercita los miembros generados de un record, demuestra las propiedades de inmutabilidad y de clase mediante reflexión, hace cumplir un invariante en un constructor compacto, lista los componentes del record en el orden de declaración y muestra los records funcionando con colecciones y comportamiento añadido.
Lo que se puede extraer de la ejecución:
- El
Pointpara el que nunca escribiste un cuerpo imprimió igualmentePoint[x=3, y=4], respondió aa.x()e informóequals by value: truecon códigos hash coincidentes — el compilador generótoString, accesores,equalsyhashCodebasados en valor a partir de los dos componentes únicamente. - La reflexión confirmó el contrato que garantiza el lenguaje:
is final class : true(los records no pueden ser subclasificados) eis a record : true(cada record extiendejava.lang.Record), razón por la que no hay setters y los campos son inmutables. - La llamada
Range(9, 2)fue rechazada conlow must be <= high. El constructor compacto se ejecutó antes de que se asignaran los campos, por lo que un record nunca se construye en un estado inválido — la validación pertenece ahí, no en una comprobación de fábrica separada. getRecordComponents()devolvió los componentes en el orden de declaración comolow:int high:int, lo que muestra que la estructura de un record es introspectable mediante reflexión — la base para las bibliotecas de serialización y los frameworks que mapean records automáticamente.Money.of("USD", 500).plus(Money.of("USD", 250))produjoUSD 750, ydistinct()redujo dos valores idénticosPoint(0,0)dejando2— los records se comportan como valores propios en todas partes, incluyendo streams y conjuntos, precisamente porque suequals/hashCodecompara contenidos.
Cuándo usar un record (y cuándo no)
Opta por un record cuando el tipo está definido por sus datos y esos datos no cambian tras la construcción:
- DTOs y payloads de solicitud/respuesta de API.
- Claves de mapas y elementos de conjuntos (
equals/hashCodebasados en valor vienen de serie). - Tipos de retorno que agrupan varios valores, sustituyendo tuplas desechables o parámetros de salida.
- Las "hojas" de una jerarquía sellada que se desestructura con reconocimiento de patrones.
Prefiere una clase regular cuando:
- El objeto tiene estado mutable o un ciclo de vida (entidades, constructores, servicios).
- Necesitas extender otra clase — los records solo pueden implementar interfaces.
- La identidad del objeto importa más que su contenido (quieres igualdad por referencia).
Un error frecuente: el accesor de un record devuelve la referencia almacenada tal cual. Si un componente es de tipo mutable (un List, un array, un Date), cópialo defensivamente en el constructor canónico — como hace el ejemplo Tags con List.copyOf — de lo contrario, los llamadores pueden mutar el estado "inmutable" del record a través de la referencia que pasaron.