Pular para o conteúdo

Trilha de Kotlin · parte 12 de 59

data class, enum e value class

Aula da fase 2 da trilha de Kotlin: Quando usar `data class` para valores, o que ela gera por baixo, e como `value class` evita confundir `UserId` com `OrderId` sem custo extra?

intermediárioPublicada em · 12 min de leitura

Tamanho da fonte:
Largura da coluna:
Tom:
Espaço entre linhas:
Fonte:

data class, enum e value class

Pense numa etiqueta de caixa de correio com o nome do morador. Se a etiqueta diz “Ana, apartamento 12” e outra diz “Ana, apartamento 12”, a carta vai para o mesmo lugar, mesmo que tenham sido impressas em dias diferentes. Dois valores são iguais quando o conteúdo é igual, e não quando são o mesmo papel. Essa ideia de igualdade por valor é o que separa uma data class de uma classe comum, e ela tem consequências que aparecem em produção, principalmente quando o objeto entra num banco de dados ou num conjunto.

Esta aula cobre três construções que parecem parecidas e servem para coisas diferentes. A data class modela um valor que transporta dados e ganha igualdade automática. A enum class modela um conjunto fechado de constantes. A value class envolve um tipo simples, como um Long, num tipo próprio, para que UserId e OrderId não possam ser trocados por engano no código. A trilha pede três respostas: quando usar data class para valores, o que ela gera por baixo e como a value class evita confundir identificadores sem custo extra.

Os exemplos desta aula foram compilados e executados com o kotlinc 2.1.21. Onde a saída do compilador ou do bytecode importa, ela está transcrita como apareceu.

1. data class: um valor com igualdade automática

Em Java, um objeto que transporta dados costuma exigir uma classe inteira só para equals, hashCode, toString e getters. São linhas repetitivas que ninguém quer escrever e que quebram em silêncio quando alguém adiciona um campo e esquece de incluí-lo na comparação. Java 16 trouxe os records para atacar exatamente esse problema, e o Kotlin resolveu o mesmo problema antes, com a data class.

O uso típico é um objeto que representa um valor e não uma entidade com ciclo de vida. Pense em um dinheiro, que é um valor de centavos e uma moeda, ou em um endereço, uma faixa de datas ou o corpo de uma resposta HTTP. Dois objetos com os mesmos campos devem ser considerados o mesmo valor, e a data class declara essa intenção:

data class Dinheiro(val centavos: Long, val moeda: String)

Com essa única linha, o compilador gera os métodos que a classe precisa para se comportar como valor. Rodando um teste simples, a saída foi esta:

a == b: true
hashCode iguais: true
Dinheiro(centavos=1000, moeda=BRL)
Dinheiro(centavos=2500, moeda=BRL)
componentes: 1000 BRL

Os dois objetos foram criados separadamente, mas a == b é verdadeiro porque equals compara os campos. hashCode é coerente com equals, o que é um requisito para que a classe funcione dentro de HashMap e HashSet. toString mostra os campos com nome, o que ajuda muito no log. copy cria uma nova instância com alguns campos trocados, e a desestruturação val (valor, moeda) = a usa as funções component1 e component2 que o compilador também gera.

A data class precisa de pelo menos um parâmetro no construtor primário. Uma declaração como data class Vazio() é recusada com a mensagem data class must have at least one primary constructor parameter. Além disso, uma data class não pode ser abstract, open, sealed ou inner, porque a geração automática de igualdade e de cópia não combina com herança.

2. o que a data class gera e o que ela não gera

Vale separar com precisão o que o compilador gera. Para cada propriedade declarada no construtor primário, a data class produz equals, hashCode, toString, copy e um par de funções componentN para a desestruturação. Propriedades declaradas no corpo da classe não entram em nada disso, e esse é um detalhe que costuma surpreender. Se você declara val criadoEm = Instant.now() dentro das chaves, ela fica fora do equals.

Também não entram na geração regras de negócio. Uma data class não valida se o centavos é positivo, não impede que a moeda seja uma string vazia e não define nenhum comportamento além da igualdade e da cópia. Se você precisa de validação, ela vai num bloco init, como em qualquer classe.

O uso mais perigoso aparece quando a data class é mutável. Um var dentro de uma classe com hashCode baseado nesse campo quebra o contrato de um conjunto que a contenha. Veja o teste:

data class Tag(var nome: String)

val conjunto = hashSetOf(Tag("java"))
val tag = conjunto.first()
tag.nome = "kotlin"
println("contains depois de mutar: ${conjunto.contains(tag)}")
println("tamanho: ${conjunto.size}")

A saída real foi contains depois de mutar: false e tamanho: 1. O elemento continua no conjunto, mas no balde errado, porque o hashCode mudou depois que ele foi inserido. O contains procura pelo hash atual e não encontra. Esse é o motivo pelo qual a regra prática é tornar data class de valor imutável, usando val em todas as propriedades.

O que a data class gera. data class Dinheiro → equals(); data class Dinheiro → hashCode(); data class Dinheiro → toString(); data class Dinheiro → copy()…
figura 1 · O que a data class gera

O diagrama separa o que o compilador produz do que continua sendo sua responsabilidade. A data class no topo alimenta os cinco métodos gerados, todos na faixa cinza, e o retângulo vermelho à direita lembra que validação e regra de negócio ficam fora dessa lista. A seta tracejada até ele é intencional: é a parte que o compilador não faz por você.

Essa mesma lógica explica por que a trilha de Kotlin alerta contra usar data class como entidade de banco. Uma entidade tem identidade própria, e duas linhas com os mesmos campos podem ser pedidos diferentes. Se equals compara todos os campos, o JPA pode se confundir, e o toString gerado pode disparar carregamento de relacionamentos. Essa discussão ganha corpo na aula de JPA da fase 6, mas a regra já vale aqui: data class é para valores, e entidades pedem uma classe comum com identidade explícita.

3. enum class: um conjunto fechado de constantes

Um enum representa um conjunto fechado de possibilidades, como os dias da semana ou os status de um chamado. Em Java, o enum já existe há muito tempo, e o Kotlin o mantém com a mesma ideia. A diferença está no que a constante pode carregar. Uma constante de enum pode ter propriedades e métodos próprios, e todas as constantes compartilham o mesmo tipo.

enum class Status(val rotulo: String) {
    ABERTO("aberto"),
    FECHADO("fechado")
}

println(Status.entries.map { it.rotulo })

A saída foi [aberto, fechado]. A propriedade rotulo fica junto da constante, o que evita um switch espalhado pelo código para traduzir cada valor. A propriedade entries lista as constantes do enum. A documentação oficial marca como recomendada a função enumEntries<T>(), que devolve sempre a mesma lista, e não diz desde qual versão entries substitui values().

Um enum serve bem quando todas as possibilidades têm o mesmo formato. Se cada estado precisa carregar dados diferentes, como o motivo de uma recusa de pagamento que não existe no estado de aprovação, o enum deixa de ser a ferramenta certa, e a aula sealed e when exaustivo mostra por que a sealed resolve esse caso. Antes de decidir, pergunte se cada possibilidade tem os mesmos campos. Se a resposta for sim, use enum. Se não, use sealed.

Pergunta de entrevistaO que uma data class gera automaticamente, e o que ela não gera?

Gera equals e hashCode com base nas propriedades do construtor, um toString legível, copy para criar uma cópia com alterações, e componentN para desestruturação. Não gera nenhuma identidade: duas instâncias com os mesmos valores são iguais, o que é o oposto do que uma entidade com identidade precisa. Também não torna o objeto imutável sozinha, isso depende de usar val. E não pode ser herdada, porque a classe é final.

4. value class: um tipo próprio sem custo de caixa

Agora o problema que a value class resolve. Suponha que um serviço tenha duas funções: buscarUsuario(id: Long) e cancelarPedido(id: Long). O compilador aceita qualquer Long em ambas, e nada impede de passar o id de um pedido para a função de usuário. O erro só aparece no banco, como um registro não encontrado ou, pior, como a operação feita no registro errado.

Uma value class cria um tipo novo em volta de um valor simples:

@JvmInline
value class UserId(val valor: Long) {
    init { require(valor > 0) { "id deve ser positivo" } }
}

@JvmInline
value class OrderId(val valor: Long)

fun buscarUsuario(id: UserId): String = "usuario ${id.valor}"

Agora buscarUsuario(OrderId(7)) não compila. O compilador responde com argument type mismatch: actual type is 'OrderId', but 'UserId' was expected. Os dois tipos carregam o mesmo Long, mas são incompatíveis entre si. O init garante uma regra de domínio, que é o id ser positivo, e essa regra vale para todo UserId que existir no programa.

A parte que a trilha pergunta é se isso tem custo. A resposta honesta é que, na maioria dos casos, não tem, mas há uma condição. Compilando buscarUsuario e inspecionando o bytecode com javap, a função apareceu como buscarUsuario-Bu7z9Ig(long). O parâmetro é um long primitivo, e não um objeto UserId. O sufixo no nome é um mangling que o compilador aplica para distinguir essa versão de funções com outros tipos. Quem chama esse código pelo Java precisa conhecer esse nome, o que é um custo de interoperabilidade, e não de desempenho.

Quando o UserId precisa virar objeto, o compilador o empacota numa caixa. A documentação diz que a regra prática é esta: um valor inline é empacotado sempre que é usado como outro tipo. No teste, a função aceitaNulo(id: UserId?) recebeu o parâmetro como UserId, com a caixa. Ou seja, UserId? e UserId são tipos diferentes para a JVM, e o nulável custa uma alocação. O mesmo vale para uso genérico, como List<UserId>, em que cada elemento é uma caixa. Essa é a ressalva que justifica o “na maioria dos casos” da trilha, e ela é exatamente o que a aula sobre boxing na fase 3 vai aprofundar.

value class: quando vira objeto. UserId(7) → buscarUsuario(id: UserId) (desembrulha); UserId(7) → aceitaNulo(id: UserId?) (empacota (boxing))…
figura 2 · value class: quando vira objeto

O diagrama mostra o mesmo UserId seguindo dois caminhos. Na seta verde, o valor é desembrulhado e a função recebe o long puro. Na seta laranja, o valor vira um objeto dentro de um UserId? e a alocação acontece. A seta vermelha mostra o caso que o tipo impede: um OrderId tentando entrar numa função de usuário é barrado antes de qualquer execução.

5. erros comuns e boas práticas

O primeiro erro é usar data class para tudo. Um serviço que transforma cada classe de domínio em data class acaba com entidades que se comparam por valor, conjuntos que se quebram quando um campo muda e toString que vaza dados sensíveis no log. A pergunta para decidir é se duas instâncias com os mesmos campos representam a mesma coisa. Se sim, data class. Se não, classe comum.

O segundo erro é um data class com var que entra em coleções com hash. O exemplo do HashSet mostrou o sintoma. A correção é usar val e tratar mudanças como criação de uma nova cópia com copy, o que também deixa a história das mudanças mais legível.

O terceiro erro é embrulhar tudo em value class sem uma regra de domínio por trás. Um value class Nome(val valor: String) sem validação nem comportamento é só um rótulo, e custa a mesma leitura que um comentário. O valor da classe aparece quando o tipo tem uma regra, como o id positivo do UserId, ou quando ele evita a troca de argumentos entre tipos parecidos.

O quarto erro é ignorar o boxing em caminhos quentes. Um UserId? em um laço que roda milhões de vezes aloca objetos a cada iteração. Antes de otimizar, meça; mas saiba que a alocação existe e de onde ela vem.

A boa prática que resume a aula é modelar identificadores e valores com os tipos que o compilador consegue verificar. Você ganha a checagem de tipo sem pagar com alocação no caminho comum, e ganha uma data class que se comporta como o valor que ela representa.

6. exercícios

Crie uma data class Endereco com rua, número e cidade. Teste a igualdade entre dois objetos iguais e o efeito de copy ao trocar só o número. Depois, adicione uma propriedade no corpo da classe e confirme, com equals, que ela não participa da comparação.

Crie value class para PedidoId e ClienteId, ambos sobre Long, com init que rejeita valores não positivos. Escreva uma função vincularPedido(pedido: PedidoId, cliente: ClienteId) e confirme que a troca de ordem dos argumentos não compila.

Escreva um enum class Prioridade com um campo peso: Int e um método que devolve a prioridade com o maior peso entre duas. Depois use javap -p para conferir se a constante gera um campo estático por valor do enum.

7. glossário

data class: classe cujo construtor primário define igualdade, hashCode, toString, copy e desestruturação automaticamente.

Desestruturação: atribuição de vários campos de um objeto a variáveis de uma vez, como val (a, b) = objeto, que usa component1 e component2.

enum class: classe com um conjunto fixo de instâncias, cada uma com seus próprios campos e métodos.

value class: classe com uma única propriedade no construtor, que cria um tipo distinto do tipo base sem exigir alocação na maioria dos usos.

Boxing: empacotamento de um valor primitivo ou de uma value class num objeto, o que acontece quando ele é usado como nulável, genérico ou interface.

Mangling: alteração do nome de uma função no bytecode para incluir informação de tipo, como o sufixo que aparece em buscarUsuario-Bu7z9Ig.

Fontes

Consultadas em 20/10/2026.

A página oficial de enum foi consultada em 2026-10-20. Ela confirma que entries lista as constantes, mas não menciona values() nem a versão em que entries apareceu, por isso a aula não afirma uma versão. A página de data class não foi consultada nesta revisão. O comportamento dela foi verificado pela execução dos exemplos no kotlinc 2.1.21 e pela inspeção do bytecode.

Relacionados

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?