Blog

Records em Java: classes de dados sem código repetitivo

Um record Java declara uma pequena classe de dados imutável numa linha, e o compilador escreve o construtor, os accessors, equals, hashCode e toString. Veja o que os records dão, o que recusam e onde ficam devendo.

Um record é uma classe cujo único trabalho é carregar alguns valores. Você lista os valores uma vez, e o Java escreve o construtor, os accessors, equals, hashCode e toString para você. Hoje, a maioria das classes de dados pequenas em código Java moderno é record.

Este post cobre o que um record gera, construtores compactos para validação, o que os records se recusam a fazer, o que eles ainda permitem, a armadilha da imutabilidade rasa, métodos “wither”, quando um record é a escolha errada e por que records são boas chaves de map. Todo programa abaixo rodou no Java 25, e a saída foi colada da execução. Para rodar um você mesmo, salve como Main.java e rode java Main.java.

O problema do código repetitivo

Uma classe que só guarda dois valores pede uma quantidade surpreendente de código em Java puro. A parte sobre classes e objetos montou à mão uma classe imutável Money. Ela tinha dois campos private final, um construtor e um toString. E nem assim estava pronta. Para ser útil, ela ainda precisava de métodos accessor para ler os campos, mais equals e hashCode para que dois objetos Money com 12.50 EUR contem como iguais. São umas 40 linhas para dois valores, e cada linha é um lugar para um erro de digitação.

Aqui está a mesma coisa 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));
}

Ele imprime:

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

A parte entre parênteses, (long cents, String currency), é o cabeçalho do record, e cada item é um componente. A partir dessa única linha, o compilador escreveu:

  • Um campo private final para cada componente.
  • Um construtor canônico, Money(long cents, String currency), que recebe os componentes em ordem e os guarda.
  • Um accessor para cada componente. Ele se chama cents(), não getCents(). Records não seguem a velha nomenclatura do JavaBeans.
  • toString, que imprime o nome do record e todos os componentes.
  • equals e hashCode, baseados nos valores dos componentes.

price e samePrice são dois objetos separados, então ==false. Mas equals compara os valores, e os valores batem, então dá true. Os hash codes também batem, como precisam bater.

Records viraram recurso final no Java 16. Nós conferimos: compilar um record com javac --release 15 falha com records are not supported in -source 15, e o mesmo arquivo compila com --release 16.

Os accessors são métodos de verdade

Você pode listar os componentes de um record em tempo de execução, na ordem em que o cabeçalho os declara:

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()));
}

Ele imprime:

true
long cents
class java.lang.String currency
true

A última linha diz que a classe do record é final. Nada pode estender um record, e isso volta mais adiante neste post.

Explicado como se você tivesse dez anos

Um record é um formulário preenchido. As perguntas vêm impressas no formulário: “nome”, “idade”, “cor favorita”. Quando você cria um record, preenche todas as respostas de uma vez, à caneta.

Qualquer um pode ler as respostas. Ninguém pode apagar uma e escrever outra. Se você quer uma resposta diferente, preenche um formulário novo.

E dois formulários com as mesmas respostas são o mesmo formulário, para quem estiver conferindo. Não importa que sejam duas folhas de papel separadas.

A versão precisa

Uma declaração de record record R(T1 c1, T2 c2) {} cria uma classe final que estende java.lang.Record. Para cada componente, ela declara um campo private final e um método accessor público com o nome do componente. Ela ganha um construtor canônico com a mesma lista de parâmetros do cabeçalho. Ganha também equals, hashCode e toString, todos calculados a partir dos componentes.

equals devolve true quando o outro objeto é da mesma classe de record e cada par de componentes é igual. Componentes primitivos são comparados por valor, e componentes de referência com Objects.equals. hashCode combina os hash codes dos componentes, então records iguais sempre têm hash codes iguais. Você pode escrever qualquer um desses membros, e aí o compilador usa o seu.

Onde a analogia falha: as respostas de um formulário estão escritas no papel, mas os componentes de referência de um record não estão. Um componente do tipo List guarda uma referência para uma lista que mora em outro lugar, e essa lista ainda pode mudar. A seção sobre imutabilidade rasa mostra exatamente como.

Construtores compactos validam e normalizam

O construtor canônico de um record pode ser escrito numa forma curta, chamada construtor compacto, que não tem lista de parâmetros. Você escreve só as verificações, e o compilador atribui os campos no 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"));
}

Ele imprime e para:

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

Dentro do construtor compacto, cents e currency são os parâmetros do construtor, não os campos. A linha currency = currency.toUpperCase() muda o parâmetro. Quando o corpo termina, o Java copia cada parâmetro para o seu campo. Por isso "eur" foi guardado como "EUR", e -5 nunca chegou a virar um Money.

É por isso que você não pode escrever this.cents = cents num construtor compacto. Nós tentamos, e o build falhou com cannot assign a value to final variable cents. O próprio compilador faz essa atribuição, depois do seu código, e um campo final só pode ser atribuído uma vez.

Construtores extras precisam delegar

Um record pode ter outros construtores, desde que cada um acabe chamando o construtor canônico com 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"));
}

Ele imprime:

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

Os dois construtores extras passaram pelo construtor compacto, então "usd" virou maiúsculo e a verificação de valor negativo continua valendo. As regras moram num lugar só.

O construtor Money(String text) roda duas instruções antes de this(...). Isso é permitido desde os flexible constructor bodies do Java 25, que a parte sobre classes e objetos explica. Se um construtor extra tenta definir os campos por conta própria em vez de chamar this(...), o javac recusa com constructor is not canonical, so it must invoke another constructor of class Money.

O que os records não podem fazer

Um record troca flexibilidade por garantias, e o compilador faz valer a troca. Cada regra abaixo existe para que o cabeçalho continue sendo a descrição completa dos dados do record.

Nada de campos de instância extras

Todo o estado de um record está no cabeçalho. Você não pode acrescentar um campo escondido por fora:

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

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

O build falha com:

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

O javac acrescenta uma dica embaixo: (consider replacing field with record component). Se equals e toString são gerados a partir do cabeçalho, um campo fora do cabeçalho seria um dado que eles ignoram em silêncio.

Nada de extends

Um record já estende java.lang.Record, então não pode estender mais nada. O erro que você recebe é uma surpresa:

class Amount {
    long cents;
}

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

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

O build falha com:

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

Isso é um erro de sintaxe, não uma explicação amigável. A gramática de um record simplesmente não tem lugar para extends, então o parser espera que o corpo comece logo depois do cabeçalho. Se você vir '{' expected apontando para um record, procure um extends. Funciona no sentido contrário também: uma classe que tenta estender Money falha com cannot inherit from final Money.

Nada de setters

Todo campo é final, então um método que atribui um deles não 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);
}

O build falha com:

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

Se você quer um valor diferente, cria um Money diferente. A seção sobre withers mostra o jeito comum de fazer isso.

O que os records podem fazer

Fora esses limites, um record é uma classe normal. Ele pode ter campos e métodos estáticos, métodos de instância, e pode 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));
}

Ele imprime:

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

Vale reparar em algumas coisas:

  • Money.zero() e Money.euros(...) são static factories. Elas chamam new Money(...) a partir de um método estático. Num arquivo-fonte compacto, a parte sobre classes e objetos esbarrou num erro de compilação fazendo isso com uma classe comum, porque a classe estava aninhada dentro da classe escondida Main. Records aninhados são implicitamente static, então a factory simplesmente funciona.
  • Item implementa Priced sem escrever price(). A interface pede um método chamado price() que devolve Money, e o accessor gerado pelo record é exatamente esse método.
  • Nós substituímos toString. Agora Money imprime como 3.00 EUR, e o toString gerado de Item usou isso para o componente price.
  • Dentro do record, other.currency lê o campo diretamente. Você também pode chamar other.currency(). Os dois funcionam.

Records locais e records genéricos

Você pode declarar um record dentro de um método, o que ajuda com um grupo de valores de vida curta. Um record também pode receber 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)));
}

Ele imprime:

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

Score só existe dentro de main, e nada de fora o enxerga. Um record local também é implicitamente static, então não pode ler as variáveis locais do método. Nós tentamos ler uma variável local currency a partir de um método dentro de um record local, e o javac disse non-static variable currency cannot be referenced from a static context. Passe o valor como componente.

Pair<A, B> funciona como qualquer classe genérica. pair.first() devolve uma String, então .length() não precisa de cast e imprime 3. withSecond devolve um Pair com outro tipo no segundo componente, e o compilador acompanha isso também.

Records também são folhas naturais para sealed interfaces, e um switch consegue desmontá-los com record patterns. A parte sobre sealed types e pattern matching trata disso.

Imutabilidade rasa: um record pode guardar uma lista que muda

Os campos de um record são final, mas final só fixa a referência, não o objeto para o qual ela aponta. Aqui está um bug que parece impossível num record “imutável”:

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);
}

Ele imprime:

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

O pedido mudou duas vezes, e nenhuma linha de código mexeu em order diretamente. O campo items do record aponta para o mesmo ArrayList que basket. Adicionar em basket mudou o pedido. E order.items() entregou essa mesma lista, então qualquer um que lê o pedido pode esvaziá-la.

Isso é imutabilidade rasa. O record não pode passar a apontar para outra lista, mas a lista em si continua tão mutável quanto antes. O mesmo vale para arrays, StringBuilder e qualquer classe mutável que você coloque num componente.

Corrija com List.copyOf no construtor compacto

Copie a lista quando o record é criado, para uma lista que ninguém pode mudar:

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");
}

Ele imprime e para:

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

Aconteceram duas coisas. List.copyOf criou uma lista nova, então mudanças posteriores em basket não chegam mais ao pedido. E essa lista nova não pode ser modificada, então order.items().add(...) lança uma exceção em vez de mudá-la. A exceção não tem mensagem, o que pode confundir na primeira vez que você a vê.

List.copyOf também rejeita elementos null: passamos uma lista contendo null e recebemos uma NullPointerException do construtor. Normalmente é isso que você quer de um pedido. Set.copyOf e Map.copyOf fazem o mesmo para sets e maps.

Withers: mudando um componente

Um record não tem setters, então “mudar o valor” quer dizer “criar um record novo com outro valor”. O Java não escreve esse método para você, então o padrão comum é escrever um método with à mão:

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);
}

Ele imprime:

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

price fica intacto. Cada wither chama o construtor canônico, então as verificações do construtor compacto rodam de novo para o valor novo. Você não consegue enfiar um valor negativo por um wither.

Algumas linguagens têm uma sintaxe pronta para isso. O Java 25 não tem. Nós tentamos a forma proposta price with { cents = 0; }, com e sem --enable-preview, e o javac rejeitou as duas vezes com not a statement. Por enquanto, escreva os withers de que você precisa, e só esses.

Quando não usar um record

Um record é a ferramenta certa quando dois objetos com os mesmos valores devem contar como a mesma coisa. Alguns objetos não são assim.

Uma entidade tem uma identidade que continua a mesma enquanto os dados mudam. Uma conta bancária é o caso clássico. A conta 42 continua sendo a conta 42 depois de um depósito, e duas contas diferentes que têm 100 EUR cada não são a mesma conta. Um record erra nas duas coisas: ele não pode mudar, e diz que valores iguais significam objetos iguais. Escreva uma classe, como a BankAccount da parte sobre classes e objetos, com campos privados e métodos que protegem as regras.

O outro caso que não encaixa é um bean mutável. Frameworks como o JPA (o padrão do Java para guardar objetos num banco de dados) esperam um construtor sem argumentos, setters e campos que possam ser preenchidos depois que o objeto existe. Um record não tem nada disso. Use records para os dados que você passa de um lado para outro, como corpos de requisição, resultados de consulta e mensagens, e deixe as classes do framework para o que o framework gerencia.

Records como chaves de map e em sets

Uma boa chave de map precisa manter equals e hashCode estáveis, e os records dão exatamente isso. Um HashMap encontra uma chave pelo hash code e depois confirma com equals. Então uma busca funciona com qualquer objeto igual à chave guardada, não só com o mesmo objeto.

Aqui está um mapa de assentos com um record como chave, ao lado do mesmo map com uma classe comum como chave, que não sobrescreve 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());
}

Ele imprime:

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

new Seat('B', 7) na busca é um objeto novinho, mas é igual à chave guardada antes, então o map encontra a Ana. A busca com PlainSeat devolve null, porque uma classe sem o próprio equals só casa com o mesmíssimo objeto. Adicionar Seat('C', 1) duas vezes a um set mantém um só, pelo mesmo motivo.

Dois cuidados. Primeiro, uma chave record só é tão estável quanto os seus componentes. Se uma chave guarda uma List mutável e a lista muda, o hash code muda, e o map não consegue mais encontrá-la. A correção com List.copyOf acima evita isso. Segundo, um componente array é comparado por referência, não pelo conteúdo, porque arrays não sobrescrevem equals. Nós conferimos: dois records com arrays String[] {"a"} separados não são iguais, e o toString deles mostra [Ljava.lang.String;@ e um hash em vez do conteúdo. Use uma List num record que você pretende comparar.

O que lembrar

  • record Money(long cents, String currency) {} dá campos private final, um construtor canônico, accessors chamados cents() e currency(), e equals, hashCode e toString baseados nos valores. Records são recurso final desde o Java 16.
  • Coloque validação e normalização num construtor compacto. Atribua aos parâmetros, e o compilador os guarda nos campos. Construtores extras precisam chamar this(...).
  • Um record não pode declarar campos de instância fora do cabeçalho, não pode usar extends e não pode atribuir os campos depois da construção.
  • Um record pode ter static factories, métodos de instância, interfaces, parâmetros de tipo, e pode ser declarado localmente dentro de um método.
  • Records são imutáveis de forma rasa. Copie componentes mutáveis com List.copyOf no construtor compacto.
  • Para mudar um componente, escreva um método withX que devolve um record novo. O Java 25 não tem sintaxe pronta para isso.
  • Use records para valores, e classes para entidades cujo estado muda. A igualdade por valor torna os records chaves de map e elementos de set confiáveis.

Um record é para dados totalmente descritos pelos seus valores, e o Java escreve o resto.

Quanto este post te ajudou?

Clique em um coração para avaliar!

Média das avaliações 0 / 5. Total de votos: 0

Nenhum voto até agora. Seja o primeiro a avaliar este post.