Java Records em Profundidade
Uma análise aprofundada dos records Java — construtores canônicos e compactos, validação e casos de uso.
Um record é a forma do Java de declarar uma classe cuja única função é carregar dados. Introduzido como prévia no Java 14 e finalizado no Java 16, um record elimina o boilerplate usual — campos private final, um construtor, acessores, equals, hashCode e toString — em uma única linha de cabeçalho. O capítulo anterior sobre records mostrou a sintaxe básica; este vai mais fundo em como os records realmente se comportam: seus construtores canônicos e compactos, como eles impõem invariantes, quais garantias de imutabilidade você obtém e onde eles se encaixam (e onde não se encaixam).
O que o compilador gera para você
Quando você escreve record Point(int x, int y) {}, o compilador emite uma classe final com dois campos private final, um construtor público que recebe ambos, métodos acessores públicos nomeados exatamente como os componentes (x(), y() — sem prefixo get), e equals, hashCode e toString baseados em 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 + "]"; }
// }O x e o y no cabeçalho são os componentes do record. Os membros gerados pelo compilador são derivados inteiramente deles, na ordem de declaração.
Construtores canônicos e compactos
Todo record tem um construtor canônico cujos parâmetros correspondem aos componentes. Você raramente o escreve por completo — em vez disso, usa o construtor compacto, que omite a lista de parâmetros e as atribuições finais this.field = field. O compilador executa seu código primeiro e depois atribui os parâmetros (possivelmente modificados) aos campos. É o lugar natural para validação e normalização.
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
}
}Se você precisar da forma canônica explícita (por exemplo, para copiar defensivamente um componente mutável), escreva a assinatura completa e faça as atribuições você mesmo:
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
}
}Imutabilidade e o que records não são
Os campos de um record são final, portanto a referência que cada componente mantém nunca muda após a construção. Isso torna os records superficialmente imutáveis. Mas a imutabilidade para na referência: se um componente aponta para um objeto mutável (como um ArrayList), quem compartilha esse objeto ainda pode mutar seu conteúdo. Cópias defensivas no construtor canônico fecham essa brecha.
| Propriedade | Records | Classes comuns |
|---|---|---|
| Campos | sempre private final | sua escolha |
| Classe | implicitamente final | extensível a menos que seja final |
| Superclasse | sempre java.lang.Record | qualquer uma (padrão Object) |
| Acessores | gerados automaticamente, sem prefixo get | escritos à mão |
equals/hashCode | baseados em valor, gerados | por identidade por padrão |
| Setters | nenhum — imutável | permitidos |
Como um record sempre estende java.lang.Record, ele não pode estender outra classe. Ele ainda pode implementar interfaces, declarar membros estáticos e adicionar métodos de instância.
Adicionando comportamento, membros estáticos e fábricas
Um record ainda é uma classe. Você pode adicionar métodos extras, métodos de fábrica estáticos, campos estáticos e até tipos aninhados. Os componentes definem o estado; todo o resto é Java comum.
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
}
}Records também se combinam naturalmente com tipos sealed e pattern matching, modelando conjuntos fechados de formas de dados — a base do design de dados no estilo algébrico no Java moderno. Uma interface sealed fixa o conjunto de implementações de record permitidas, e um switch sobre esses records pode desconstruir cada um por seus componentes em uma única expressão.
Um exemplo completo: records de ponta a ponta
Este programa exercita os membros gerados de um record, comprova as propriedades de imutabilidade e de classe via reflexão, impõe um invariante em um construtor compacto, lista os componentes do record na ordem de declaração e mostra records funcionando com coleções e comportamento adicionado.
O que extrair da execução:
- O
Pointpara o qual você nunca escreveu um corpo ainda imprimiuPoint[x=3, y=4], respondeu aoa.x()e reportouequals by value: truecom hash codes iguais — o compilador geroutoString, acessores,equalsehashCodebaseados em valor apenas a partir dos dois componentes. - A reflexão confirmou o contrato que a linguagem garante:
is final class : true(records não podem ser subclassificados) eis a record : true(todo record estendejava.lang.Record), razão pela qual não existem setters e os campos são imutáveis. - A chamada
Range(9, 2)foi rejeitada comlow must be <= high. O construtor compacto executou antes que os campos fossem atribuídos, portanto um record nunca é construído em um estado inválido — a validação pertence ali, não em uma verificação de fábrica separada. getRecordComponents()retornou os componentes na ordem de declaração comolow:int high:int, mostrando que a estrutura de um record é introspectável por reflexão — a base para bibliotecas de serialização e frameworks que mapeiam records automaticamente.Money.of("USD", 500).plus(Money.of("USD", 250))produziuUSD 750, edistinct()colapsou dois valores idênticosPoint(0,0)deixando2— records se comportam como valores adequados em qualquer lugar, incluindo streams e sets, precisamente porque seuequals/hashCodecompara conteúdos.
Quando usar um record (e quando não usar)
Recorra a um record quando o tipo é definido pelos seus dados e esses dados não mudam após a construção:
- DTOs e payloads de requisição/resposta de API.
- Chaves de mapas e elementos de conjuntos (
equals/hashCodebaseados em valor vêm gratuitamente). - Tipos de retorno que agrupam vários valores, substituindo tuplas descartáveis ou parâmetros de saída.
- As "folhas" de uma hierarquia sealed que você desconstrói com pattern matching.
Prefira uma classe comum quando:
- O objeto tem estado mutável ou um ciclo de vida (entidades, builders, serviços).
- Você precisa estender outra classe — records só podem implementar interfaces.
- A identidade do objeto importa mais do que seu conteúdo (você quer igualdade por referência).
Uma armadilha comum: o acessor de um record retorna a referência armazenada como está. Se um componente é de um tipo mutável (uma List, um array, um Date), copie-o defensivamente no construtor canônico — como o exemplo Tags acima faz com List.copyOf — caso contrário, quem chamar pode mutar o estado "imutável" do record através da referência que passou.