Blog

Records en Java: clases de datos sin código repetitivo

Un record de Java declara en una línea una pequeña clase de datos inmutable, y el compilador escribe su constructor, sus métodos de acceso, equals, hashCode y toString. Aprende qué te da un record, qué rechaza y dónde se queda corto.

Un record es una clase cuyo único trabajo es llevar unos pocos valores. Listas los valores una vez, y Java escribe por ti el constructor, los métodos de acceso, equals, hashCode y toString. Hoy, la mayoría de las clases de datos pequeñas en código Java moderno son records.

Este post cubre qué genera un record, los constructores compactos para validar, qué se niegan a hacer los records, qué siguen permitiendo, la trampa de la inmutabilidad superficial, los métodos “wither”, cuándo un record es la elección equivocada y por qué los records son buenas claves de mapa. Cada programa de abajo se ejecutó en Java 25, y su salida está copiada de esa ejecución. Para ejecutar uno tú mismo, guárdalo como Main.java y ejecuta java Main.java.

El problema del código repetitivo

Una clase que solo guarda dos valores lleva una cantidad sorprendente de código en Java simple. La parte sobre clases y objetos construyó a mano una clase inmutable Money. Tenía dos campos private final, un constructor y un toString. Y ni siquiera estaba terminada. Para ser útil también necesitaba métodos de acceso para leer los campos, además de equals y hashCode, para que dos objetos Money con 12.50 EUR cuenten como iguales. Son unas 40 líneas para dos valores, y cada línea es un lugar para un error de tipeo.

Aquí está lo mismo como record:

record Money(long cents, String currency) {}

void main() {
    var price = new Money(1250, "EUR");
    var samePrice = new Money(1250, "EUR");
    var other = new Money(999, "EUR");

    IO.println(price);
    IO.println(price.cents() + " " + price.currency());
    IO.println("equals:   " + price.equals(samePrice));
    IO.println("hashCode: " + (price.hashCode() == samePrice.hashCode()));
    IO.println("==:       " + (price == samePrice));
    IO.println("other:    " + price.equals(other));
}

Imprime:

Money[cents=1250, currency=EUR]
1250 EUR
equals:   true
hashCode: true
==:       false
other:    false

La parte entre paréntesis, (long cents, String currency), es el encabezado del record, y cada entrada es un componente. A partir de esa sola línea, el compilador escribió:

  • Un campo private final por cada componente.
  • Un constructor canónico, Money(long cents, String currency), que recibe los componentes en orden y los guarda.
  • Un método de acceso (accessor) por cada componente. Se llama cents(), no getCents(). Los records no siguen la vieja convención de nombres de JavaBeans.
  • toString, que imprime el nombre del record y cada componente.
  • equals y hashCode, basados en los valores de los componentes.

price y samePrice son dos objetos separados, así que == es false. Pero equals compara los valores, y los valores coinciden, así que es true. Sus hash codes también coinciden, como debe ser.

Los records pasaron a ser una funcionalidad definitiva en Java 16. Lo verificamos: compilar un record con javac --release 15 falla con records are not supported in -source 15, y el mismo archivo compila con --release 16.

Los métodos de acceso son métodos de verdad

Puedes listar los componentes de un record en tiempo de ejecución, en el orden en que los declara el encabezado:

record Money(long cents, String currency) {}

void main() {
    IO.println(Money.class.isRecord());
    for (var component : Money.class.getRecordComponents()) {
        IO.println(component.getType() + " " + component.getName());
    }
    IO.println(java.lang.reflect.Modifier.isFinal(Money.class.getModifiers()));
}

Imprime:

true
long cents
class java.lang.String currency
true

La última línea dice que la clase del record es final. Nada puede extender un record, y eso vuelve más adelante en este post.

Explicado como si tuvieras diez años

Un record es un formulario lleno. Las preguntas vienen impresas en el formulario: “nombre”, “edad”, “color favorito”. Cuando creas un record, llenas todas las respuestas a la vez, con bolígrafo.

Cualquiera puede leer las respuestas. Nadie puede borrar una y escribir otra. Si quieres una respuesta distinta, llenas un formulario nuevo.

Y dos formularios con las mismas respuestas son el mismo formulario, para cualquiera que los revise. No importa que sean dos hojas de papel separadas.

La versión precisa

Una declaración de record record R(T1 c1, T2 c2) {} crea una clase final que extiende java.lang.Record. Por cada componente declara un campo private final y un método de acceso público con el nombre del componente. Recibe un constructor canónico con la misma lista de parámetros que el encabezado. También recibe equals, hashCode y toString, todos calculados a partir de los componentes.

equals devuelve true cuando el otro objeto es de la misma clase record y cada par de componentes es igual. Los componentes primitivos se comparan por valor, y los de referencia con Objects.equals. hashCode combina los hash codes de los componentes, así que records iguales siempre tienen hash codes iguales. Puedes escribir cualquiera de estos miembros tú mismo, y entonces el compilador usa el tuyo.

Dónde falla la analogía: las respuestas de un formulario están escritas en el papel, pero los componentes de referencia de un record no. Un componente de tipo List guarda una referencia a una lista que vive en otro lugar, y esa lista todavía puede cambiar. La sección sobre la inmutabilidad superficial muestra exactamente cómo.

Los constructores compactos validan y normalizan

El constructor canónico de un record se puede escribir en una forma corta, llamada constructor compacto, que no tiene lista de parámetros. Escribes solo las revisiones, y el compilador asigna los campos al final:

record Money(long cents, String currency) {
    Money {
        if (cents < 0) {
            throw new IllegalArgumentException("negative amount: " + cents);
        }
        if (currency.length() != 3) {
            throw new IllegalArgumentException("bad currency code: " + currency);
        }
        currency = currency.toUpperCase();
    }
}

void main() {
    IO.println(new Money(1250, "eur"));
    IO.println(new Money(-5, "EUR"));
}

Imprime y se detiene:

Money[cents=1250, currency=EUR]
Exception in thread "main" java.lang.IllegalArgumentException: negative amount: -5

Dentro del constructor compacto, cents y currency son los parámetros del constructor, no los campos. La línea currency = currency.toUpperCase() cambia el parámetro. Cuando el cuerpo termina, Java copia cada parámetro en su campo. Por eso "eur" se guardó como "EUR", y -5 nunca llegó a formar parte de un Money.

Por eso no puedes escribir this.cents = cents en un constructor compacto. Lo intentamos, y la compilación falló con cannot assign a value to final variable cents. El compilador hace esa asignación por su cuenta, después de tu código, y un campo final solo se puede asignar una vez.

Los constructores extra deben delegar

Un record puede tener otros constructores, siempre que cada uno termine llamando al constructor canónico con this(...):

record Money(long cents, String currency) {
    Money {
        if (cents < 0) {
            throw new IllegalArgumentException("negative amount: " + cents);
        }
        currency = currency.toUpperCase();
    }

    Money(long cents) {
        this(cents, "EUR");
    }

    Money(String text) {
        String[] parts = text.split(" ");
        this(Long.parseLong(parts[0]), parts[1]);
    }
}

void main() {
    IO.println(new Money(300));
    IO.println(new Money("450 usd"));
}

Imprime:

Money[cents=300, currency=EUR]
Money[cents=450, currency=USD]

Los dos constructores extra pasaron por el constructor compacto, así que "usd" se pasó a mayúsculas y la revisión de negativos sigue aplicando. Las reglas viven en un solo lugar.

El constructor Money(String text) ejecuta dos sentencias antes de this(...). Eso está permitido desde los cuerpos de constructor flexibles de Java 25, que cubre la parte sobre clases y objetos. Si un constructor extra intenta asignar los campos por su cuenta en vez de llamar a this(...), javac lo rechaza con constructor is not canonical, so it must invoke another constructor of class Money.

Lo que los records no pueden hacer

Un record cambia flexibilidad por garantías, y el compilador hace cumplir ese trato. Cada regla de abajo existe para que el encabezado siga siendo la descripción completa de los datos del record.

No hay campos de instancia extra

Todo el estado de un record está en su encabezado. No puedes agregar un campo escondido a un lado:

record Money(long cents, String currency) {
    private int timesPrinted;
}

void main() {
    IO.println(new Money(1250, "EUR"));
}

La compilación falla con:

Main.java:2: error: field declaration must be static
    private int timesPrinted;
                ^

javac agrega una pista debajo: (consider replacing field with record component). Si equals y toString se generan a partir del encabezado, un campo fuera del encabezado serían datos que ignoran en silencio.

No hay extends

Un record ya extiende java.lang.Record, así que no puede extender nada más. El error que obtienes es una sorpresa:

class Amount {
    long cents;
}

record Money(long cents, String currency) extends Amount {}

void main() {
    IO.println(new Money(1250, "EUR"));
}

La compilación falla con:

Main.java:5: error: '{' expected
record Money(long cents, String currency) extends Amount {}
                                         ^

Es un error de sintaxis, no una explicación amable. La gramática de un record simplemente no tiene lugar para extends, así que el parser espera que el cuerpo empiece justo después del encabezado. Si ves '{' expected apuntando a un record, busca un extends. También funciona al revés: una clase que intenta extender Money falla con cannot inherit from final Money.

No hay setters

Cada campo es final, así que un método que asigna uno no compila:

record Money(long cents, String currency) {
    void setCents(long cents) {
        this.cents = cents;
    }
}

void main() {
    var price = new Money(1250, "EUR");
    price.setCents(0);
}

La compilación falla con:

Main.java:3: error: cannot assign a value to final variable cents
        this.cents = cents;
            ^

Si quieres un monto distinto, creas un Money distinto. La sección sobre withers muestra la forma habitual de hacerlo.

Lo que los records sí pueden hacer

Aparte de esos límites, un record es una clase normal. Puede tener campos y métodos estáticos, métodos de instancia, y puede implementar interfaces:

interface Priced {
    Money price();
}

record Money(long cents, String currency) implements Comparable<Money> {
    static final String DEFAULT_CURRENCY = "EUR";

    static Money zero() {
        return new Money(0, DEFAULT_CURRENCY);
    }

    static Money euros(long whole, long cents) {
        return new Money(whole * 100 + cents, "EUR");
    }

    Money plus(Money other) {
        if (!currency.equals(other.currency)) {
            throw new IllegalArgumentException("currency mismatch");
        }
        return new Money(cents + other.cents, currency);
    }

    @Override
    public int compareTo(Money other) {
        return Long.compare(cents, other.cents);
    }

    @Override
    public String toString() {
        return String.format("%d.%02d %s", cents / 100, cents % 100, currency);
    }
}

record Item(String name, Money price) implements Priced {}

void main() {
    var items = List.of(
        new Item("tea", Money.euros(3, 0)),
        new Item("cake", Money.euros(4, 50)),
        new Item("water", Money.euros(1, 20)));

    var total = Money.zero();
    for (Priced item : items) {
        total = total.plus(item.price());
    }
    IO.println("total: " + total);

    var cheapest = items.stream().map(Item::price).min(Money::compareTo).orElseThrow();
    IO.println("cheapest: " + cheapest);
    IO.println(items.get(0));
}

Imprime:

total: 8.70 EUR
cheapest: 1.20 EUR
Item[name=tea, price=3.00 EUR]

Vale la pena notar algunas cosas:

  • Money.zero() y Money.euros(...) son métodos factory estáticos. Llaman a new Money(...) desde un método estático. En un archivo fuente compacto, la parte sobre clases y objetos se topó con un error de compilación al hacer eso con una clase normal, porque la clase estaba anidada dentro de la clase oculta Main. Los records son implícitamente static cuando están anidados, así que el factory simplemente funciona.
  • Item implementa Priced sin escribir price(). La interfaz pide un método llamado price() que devuelva Money, y el método de acceso generado del record es exactamente ese método.
  • Reemplazamos toString. Ahora Money se imprime como 3.00 EUR, y el toString generado de Item lo usó para su componente price.
  • Dentro del record, other.currency lee el campo directamente. También puedes llamar a other.currency(). Las dos formas funcionan.

Records locales y records genéricos

Puedes declarar un record dentro de un método, lo que resulta práctico para un grupo de valores de vida corta. Un record también puede recibir parámetros de tipo:

record Pair<A, B>(A first, B second) {
    <C> Pair<A, C> withSecond(C newSecond) {
        return new Pair<>(first, newSecond);
    }
}

void main() {
    record Score(String player, int points) {}

    var scores = List.of(new Score("Ana", 15), new Score("Ben", 22), new Score("Caro", 9));
    var best = scores.stream().max((a, b) -> Integer.compare(a.points(), b.points())).orElseThrow();
    IO.println("best: " + best);

    var pair = new Pair<>("Ana", 15);
    Pair<String, String> labelled = pair.withSecond("fifteen");
    IO.println(pair + " " + pair.first().length());
    IO.println(labelled);
    IO.println(new Pair<>("x", 1).equals(new Pair<>("x", 1)));
}

Imprime:

best: Score[player=Ben, points=22]
Pair[first=Ana, second=15] 3
Pair[first=Ana, second=fifteen]
true

Score existe solo dentro de main, y nada de afuera puede verlo. Un record local también es implícitamente static, así que no puede leer las variables locales del método. Intentamos leer una variable local currency desde un método dentro de un record local, y javac dijo non-static variable currency cannot be referenced from a static context. En su lugar, pasa el valor como componente.

Pair<A, B> funciona como cualquier clase genérica. pair.first() devuelve un String, así que .length() no necesita cast e imprime 3. withSecond devuelve un Pair con un segundo tipo distinto, y el compilador también lo sigue.

Los records también son hojas naturales para las sealed interfaces, y un switch puede desarmarlos con record patterns. La parte sobre sealed types y pattern matching cubre eso.

Inmutabilidad superficial: un record puede guardar una lista que cambia

Los campos de un record son final, pero final solo fija la referencia, no el objeto al que apunta. Aquí hay un bug que parece imposible en un record “inmutable”:

record Order(String id, List<String> items) {}

void main() {
    var basket = new ArrayList<String>();
    basket.add("tea");
    var order = new Order("A1", basket);
    IO.println("placed:  " + order);

    basket.add("cake");
    IO.println("later:   " + order);

    order.items().clear();
    IO.println("cleared: " + order);
}

Imprime:

placed:  Order[id=A1, items=[tea]]
later:   Order[id=A1, items=[tea, cake]]
cleared: Order[id=A1, items=[]]

El pedido cambió dos veces, y ninguna línea de código tocó order directamente. El campo items del record apunta al mismo ArrayList que basket. Agregar a basket cambió el pedido. Y order.items() entregó esa misma lista, así que cualquiera que lea el pedido puede vaciarla.

Eso es inmutabilidad superficial (shallow immutability). No se puede hacer que el record apunte a otra lista, pero la lista en sí es tan mutable como siempre. Lo mismo pasa con los arrays, StringBuilder y cualquier clase mutable que pongas en un componente.

Arréglalo con List.copyOf en el constructor compacto

Copia la lista cuando se crea el record, en una lista que nadie pueda cambiar:

record Order(String id, List<String> items) {
    Order {
        items = List.copyOf(items);
    }
}

void main() {
    var basket = new ArrayList<String>();
    basket.add("tea");
    var order = new Order("A1", basket);

    basket.add("cake");
    IO.println("after changing basket: " + order);

    order.items().add("biscuits");
    IO.println("never printed");
}

Imprime y se detiene:

after changing basket: Order[id=A1, items=[tea]]
Exception in thread "main" java.lang.UnsupportedOperationException

Pasaron dos cosas. List.copyOf creó una lista nueva, así que los cambios posteriores a basket ya no llegan al pedido. Y esa lista nueva no se puede modificar, así que order.items().add(...) lanza una excepción en vez de cambiarla. La excepción no tiene mensaje, lo que puede confundir la primera vez que la ves.

List.copyOf también rechaza elementos null: le pasamos una lista que contenía null y obtuvimos una NullPointerException del constructor. Normalmente eso es lo que quieres de un pedido. Set.copyOf y Map.copyOf hacen lo mismo con sets y mapas.

Withers: cambiar un componente

Un record no tiene setters, así que “cambiar el monto” significa “crear un record nuevo con otro monto”. Java no escribe ese método por ti, así que el patrón habitual es escribir a mano un método with:

record Money(long cents, String currency) {
    Money withCents(long newCents) {
        return new Money(newCents, currency);
    }

    Money withCurrency(String newCurrency) {
        return new Money(cents, newCurrency);
    }
}

void main() {
    var price = new Money(1250, "EUR");
    var discounted = price.withCents(1000);
    var inDollars = discounted.withCurrency("USD");

    IO.println(price);
    IO.println(discounted);
    IO.println(inDollars);
}

Imprime:

Money[cents=1250, currency=EUR]
Money[cents=1000, currency=EUR]
Money[cents=1000, currency=USD]

price queda intacto. Cada wither llama al constructor canónico, así que las revisiones del constructor compacto se ejecutan de nuevo para el valor nuevo. No puedes colar un monto negativo a través de un wither.

Algunos lenguajes tienen una sintaxis integrada para esto. Java 25 no. Probamos la forma propuesta price with { cents = 0; }, con y sin --enable-preview, y javac la rechazó las dos veces con not a statement. Por ahora, escribe los withers que necesites, y solo esos.

Cuándo no usar un record

Un record es la herramienta correcta cuando dos objetos con los mismos valores deben contar como la misma cosa. Algunos objetos no son así.

Una entidad tiene una identidad que se mantiene mientras sus datos cambian. Una cuenta bancaria es el caso clásico. La cuenta 42 sigue siendo la cuenta 42 después de un depósito, y dos cuentas distintas que tienen 100 EUR cada una no son la misma cuenta. Un record se equivoca en las dos cosas: no puede cambiar, y dice que valores iguales significan objetos iguales. Escribe una clase, como BankAccount en la parte sobre clases y objetos, con campos privados y métodos que protejan las reglas.

El otro caso que no encaja es un bean mutable. Frameworks como JPA (el estándar de Java para guardar objetos en una base de datos) esperan un constructor sin argumentos, setters y campos que se puedan llenar después de que el objeto existe. Un record no tiene nada de eso. Usa records para los datos que pasas de un lado a otro, como cuerpos de peticiones, resultados de consultas y mensajes, y deja las clases del framework para lo que el framework administra.

Records como claves de mapa y en sets

Una buena clave de mapa tiene que mantener estables su equals y su hashCode, y los records te dan exactamente eso. Un HashMap encuentra una clave por su hash code, y luego confirma la coincidencia con equals. Así que una búsqueda funciona con cualquier objeto que sea igual a la clave guardada, no solo con el mismo objeto.

Aquí hay un plano de asientos con un record como clave, junto al mismo mapa con una clase normal como clave, que no sobrescribe equals:

record Seat(char row, int number) {}

class PlainSeat {
    final char row;
    final int number;

    PlainSeat(char row, int number) {
        this.row = row;
        this.number = number;
    }
}

void main() {
    var bookings = new HashMap<Seat, String>();
    bookings.put(new Seat('B', 7), "Ana");
    bookings.put(new Seat('B', 8), "Ben");
    IO.println("record key B7:  " + bookings.get(new Seat('B', 7)));
    IO.println("contains B8:    " + bookings.containsKey(new Seat('B', 8)));

    var plainBookings = new HashMap<PlainSeat, String>();
    plainBookings.put(new PlainSeat('B', 7), "Ana");
    IO.println("plain key B7:   " + plainBookings.get(new PlainSeat('B', 7)));

    var taken = new HashSet<Seat>();
    taken.add(new Seat('C', 1));
    taken.add(new Seat('C', 1));
    IO.println("seats in set:   " + taken.size());
}

Imprime:

record key B7:  Ana
contains B8:    true
plain key B7:   null
seats in set:   1

new Seat('B', 7) en la búsqueda es un objeto recién creado, pero es igual a la clave guardada antes, así que el mapa encuentra a Ana. La búsqueda con PlainSeat devuelve null, porque una clase sin su propio equals solo coincide con el mismo objeto exacto. Agregar Seat('C', 1) dos veces a un set conserva uno, por la misma razón.

Dos advertencias. Primero, una clave record es tan estable como sus componentes. Si una clave guarda una List mutable y la lista cambia, su hash code cambia, y el mapa ya no puede encontrarla. El arreglo con List.copyOf de arriba lo evita. Segundo, un componente array se compara por referencia, no por contenido, porque los arrays no sobrescriben equals. Lo verificamos: dos records con arrays String[] {"a"} separados no son iguales, y su toString muestra [Ljava.lang.String;@ y un hash en vez del contenido. Usa una List en un record que pienses comparar.

Qué recordar

  • record Money(long cents, String currency) {} te da campos private final, un constructor canónico, métodos de acceso llamados cents() y currency(), y equals, hashCode y toString basados en los valores. Los records son definitivos desde Java 16.
  • Pon la validación y la normalización en un constructor compacto. Asigna a los parámetros, y el compilador los guarda en los campos. Los constructores extra deben llamar a this(...).
  • Un record no puede declarar campos de instancia fuera de su encabezado, no puede usar extends y no puede asignar sus campos después de construirse.
  • Un record puede tener métodos factory estáticos, métodos de instancia, interfaces y parámetros de tipo, y se puede declarar localmente dentro de un método.
  • Los records son superficialmente inmutables. Copia los componentes mutables con List.copyOf en el constructor compacto.
  • Para cambiar un componente, escribe un método withX que devuelva un record nuevo. Java 25 no tiene sintaxis integrada para eso.
  • Usa records para valores, y clases para entidades cuyo estado cambia. La igualdad por valor hace que los records sean claves de mapa y elementos de set confiables.

Un record es para datos que quedan descritos por completo por sus valores, y Java escribe el resto.

¿Qué tan útil te resultó este post?

¡Haz clic en un corazón para calificar!

Calificación promedio 0 / 5. Total de votos: 0

Todavía no hay votos. Sé el primero en calificar este post.