Trilha de Kotlin · parte 18 de 59
Variância e genéricos reified
Aula da fase 3 da trilha de Kotlin: Por que `List<out T>` aceita subtipos, e como `reified` permite acessar o tipo em tempo de execução?
avançadoPublicada em · 13 min de leitura
Um genérico é uma forma de escrever código que funciona para vários tipos sem repetir a implementação. List<Pedido> e List<Cliente> compartilham o mesmo código, e o compilador garante que você não misture os dois. A parte difícil começa quando surge a pergunta seguinte: se Cachorro é um Animal, uma lista de cachorros pode ser usada onde se espera uma lista de animais? A resposta depende de a lista ser só de leitura ou de permitir escrita, e essa distinção é o que se chama variância. Esta aula explica as palavras out e in, por que o Java tem regras diferentes e mais confusas, e o que reified faz para contornar um limite do JVM chamado apagamento de tipos.
Uma analogia que ajuda: pense numa caixa de entrega. Se você só vai retirar encomendas dela, uma caixa cheia de encomendas grandes serve no lugar de uma caixa de encomendas comuns, porque tudo o que sai é pelo menos do tipo esperado. Se você também vai colocar coisas nela, a caixa de encomendas grandes deixa de servir, porque alguém pode pôr uma encomenda pequena lá dentro e quebrar quem espera só as grandes. Quase toda a variância do Kotlin cabe nessa diferença entre retirar e colocar.
o mapa da aula
Primeiro o problema que a variância resolve, com um caso concreto que o Java trata de um jeito que causa erros em tempo de execução. Depois as palavras out e in, com exemplos compilados e os erros que o compilador mostra quando a regra é violada. Em seguida a projeção na chamada, que é o mesmo conceito aplicado ao uso do tipo. A segunda metade trata de reified: o que é o apagamento de tipos, por que o JVM não sabe o tipo de uma lista em tempo de execução, e como uma função inline com reified consegue acessar esse tipo. Todo código é de Kotlin puro e foi compilado com o kotlinc 2.1.21.
1. o problema que a variância resolve
Considere duas classes simples, Animal e Cachorro, em que Cachorro herda de Animal. Quando você escreve uma função que recebe uma List<Animal>, espera que ela possa ser usada com qualquer lista de animais. Mas uma List<Cachorro> é uma lista de animais, então faz sentido aceitá-la. Sem nenhuma regra, o compilador teria que escolher entre aceitar ou recusar, e qualquer uma das escolhas seria ruim.
A resposta do Kotlin é simples de explicar e precisa de atenção: a lista somente leitura List<E> é declarada como List<out E>, e por isso List<Cachorro> é subtipo de List<Animal>. A lista mutável MutableList<E> não tem essa garantia, porque uma função com MutableList<Animal> poderia adicionar um gato na lista que na verdade é de cachorros.
open class Animal(val nome: String)
class Cachorro(nome: String) : Animal(nome)
// List<out E> é somente leitura: aceita subtipos.
fun imprimirNomes(animais: List<Animal>) {
animais.forEach { println(it.nome) }
}
fun main() {
val cachorros: List<Cachorro> = listOf(Cachorro("Rex"), Cachorro("Bob"))
imprimirNomes(cachorros)
}
A saída é Rex e Bob, e o código compila sem nenhuma conversão. A figura abaixo mostra as três situações que aparecem nesta aula: leitura, escrita e o caso do Java.
A figura organiza a aula em três grupos. No primeiro, a lista de leitura aceita a lista de subtipo, por isso a seta verde. No segundo, a lista mutável não aceita a de subtipo, e a seta vermelha bloqueada mostra que o compilador recusa a atribuição. O terceiro grupo é o caso do Java, que aceita a atribuição e só falha quando o código tenta gravar algo errado, durante a execução.
2. como o Java faz diferente e por que isso dói
O Java também tem genéricos com variância, mas usa uma notação diferente. Em um parâmetro, você escreve List<? extends Animal> para dizer que aceita subtipos, e List<? super Cachorro> para dizer que aceita supertipos. Essa forma é chamada de curinga, ou wildcard, e é declarada em cada uso do tipo, e não na definição da classe. Por isso um mesmo tipo pode ser usado de jeitos diferentes em lugares diferentes, o que dá mais flexibilidade e mais chances de erro.
Os arrays do Java são um caso à parte. Eles são covariantes desde a origem da linguagem: um String[] pode ser tratado como Object[]. Essa escolha veio antes dos genéricos e permite que um array de strings seja passado a um método que recebe Object[]. O custo aparece em tempo de execução. Se o método gravar um inteiro no array que na verdade é de strings, a JVM percebe a incompatibilidade e lança ArrayStoreException. Esse comportamento está na especificação da linguagem Java, e não foi executado nesta sessão, mas é a origem do problema que o Kotlin evita.
No Kotlin, um Array<String> não é subtipo de Array<Any>. O compilador recusa a atribuição antes de o código rodar, e o erro não chega ao usuário. Veja a mensagem real que o kotlinc mostra para um array de cachorros atribuído a um array de animais:
initializer type mismatch: expected 'Array<Animal>', actual 'Array<Cachorro>'.
Essa é a principal diferença de filosofia: Kotlin prefere recusar a atribuição na compilação a permitir um erro de runtime que depois aparece sem aviso.
3. out: o tipo só sai
A palavra out na declaração de uma classe diz que o parâmetro de tipo só aparece em posições de saída, ou seja, como retorno ou como propriedade somente leitura. Como nada entra pelo tipo, é seguro tratar Caixa<Cachorro> como Caixa<Animal>. Esse tipo de tipo é chamado de covariante.
class Caixa<out T>(val conteudo: T)
fun main() {
val caixa: Caixa<Animal> = Caixa(Cachorro("Mel"))
println("caixa contém: ${caixa.conteudo.nome}")
}
A saída é caixa contém: Mel. Agora veja o que acontece quando a classe tenta colocar um valor do tipo T por um parâmetro:
class CaixaErrada<out T>(private var valor: T) {
fun guardar(novo: T) {
valor = novo
}
}
O compilador responde com type parameter 'T' is declared as 'out' but occurs in 'in' position in type 'T (of class CaixaErrada<out T>)'.. A mensagem diz o que está errado: T foi declarado como saída, mas aparece como entrada no parâmetro de guardar. Se essa regra não existisse, uma Caixa<Cachorro> vista como Caixa<Animal> aceitaria um gato pelo método guardar.
4. in: o tipo só entra
A palavra in é o espelho de out. Ela diz que o parâmetro de tipo só aparece em posições de entrada, como parâmetros de função. Um objeto que consome valores de um tipo mais geral pode ser usado no lugar de um consumidor de um tipo mais específico, porque ele aceita tudo o que o outro aceitaria. Esse tipo de tipo é contravariante.
O exemplo a seguir usa um validador. Um validador de animais sabe testar qualquer animal, e por isso também serve para testar apenas cachorros:
fun interface Validador<in T> {
fun valida(valor: T): Boolean
}
fun main() {
val validadorAnimal: Validador<Animal> = Validador { it.nome.isNotEmpty() }
val validadorCachorro: Validador<Cachorro> = validadorAnimal
println("valida cachorro: ${validadorCachorro.valida(Cachorro("Lua"))}")
}
A saída é valida cachorro: true. A atribuição de Validador<Animal> para Validador<Cachorro> compila porque a declaração com in permite. A regra simétrica vale: um T declarado como in não pode aparecer num retorno.
Vale lembrar que fun interface é uma interface com um único método abstrato, e o Kotlin cria a implementação com uma lambda. Ela aparece no exemplo para deixar o validador curto.
Pergunta de entrevistaPor que List pode ser usada onde se espera List?
Gato é subtipo de Animal, mas por padrão List
5. projeção na chamada: o mesmo conceito no uso
A declaração com out ou in na classe é chamada de variância na declaração, e vale para todos os usos. Às vezes você não controla a declaração, como acontece com MutableList, que é invariante. Nesse caso, a projeção na chamada permite declarar o uso: um MutableList<out Animal> é uma lista mutável da qual você só pode ler animais. A regra é a mesma, mas aplicada a um parâmetro específico.
A projeção também resolve a pergunta da seção 1 quando você precisa de uma lista mutável com flexibilidade. A decisão fica no ponto de uso, e não na definição do tipo. Como a projeção é mais uma ferramenta, não substitui a leitura cuidadosa dos parâmetros de cada função.
6. o apagamento de tipos no JVM
Agora a segunda metade da aula. O JVM não guarda, em tempo de execução, o argumento de tipo de uma lista. A instância de MutableList<String> e a de MutableList<Int> são objetos da mesma classe, e o argumento genérico é apagado depois da compilação. Esse mecanismo se chama apagamento de tipos, e existe por razões históricas: os genéricos foram adicionados ao Java depois que o bytecode já tinha sido definido, e o apagamento permitiu manter compatibilidade com código antigo.
O efeito prático é que não dá para perguntar, em tempo de execução, se um objeto é List<String> ou List<Int>. Veja a comparação, que roda sem erro e mostra que as duas listas pertencem à mesma classe:
fun main() {
val listaDeStrings = mutableListOf<String>()
val listaDeInts = mutableListOf<Int>()
println("mesma classe em runtime: ${listaDeStrings.javaClass == listaDeInts.javaClass}")
}
A saída é mesma classe em runtime: true. Quem já programou em Java conhece esse limite, porque ele existe nos dois lados. A diferença é que, no Kotlin, a função inline com reified oferece uma saída.
7. reified: o tipo vem para dentro da função
Uma função inline copia o corpo dela para o ponto de chamada, em vez de criar uma função separada no bytecode. A consequência é que, se o parâmetro de tipo for marcado como reified, o compilador consegue substituir T pelo tipo concreto em cada chamada, porque o corpo já está no lugar certo. Assim o código dentro da função pode usar T::class, comparar com is T e chamar filterIsInstance<T>().
inline fun <reified T> nomeDoTipo(): String = T::class.simpleName ?: "anônimo"
fun main() {
println(nomeDoTipo<Cachorro>())
}
A saída é Cachorro. Dentro de nomeDoTipo, T é Cachorro, porque a chamada nomeDoTipo<Cachorro>() levou o tipo consigo. A figura mostra o mesmo mecanismo, comparado com um parâmetro de tipo comum.
A figura mostra os dois caminhos. A função inline com reified recebe o tipo no ponto de chamada, e por isso a seta verde chega com T resolvido. A função genérica comum tem um único corpo no bytecode, o T é apagado para Object, e a seta vermelha mostra a perda de informação. As duas listas do exemplo anterior aparecem no quadro de baixo como a mesma classe.
Um exemplo útil da stdlib é filterIsInstance, que também é reified:
inline fun <reified T> Iterable<*>.somenteDo(): List<T> = filterIsInstance<T>()
fun main() {
val mistura: List<Any> = listOf(1, "dois", 3, "quatro")
println("strings: ${mistura.somenteDo<String>()}")
}
A saída é strings: [dois, quatro]. A função filtra os elementos pelo tipo sem que o chamador precise passar uma classe como argumento, o que deixa a chamada curta e segura.
8. limites de reified
reified recupera a classe do tipo, mas não os argumentos genéricos dele. Quando você escreve T como List<String>, o compilador sabe que é uma lista, mas T::class devolve apenas List. Para obter a forma completa, existe typeOf, de kotlin.reflect, que devolve um KType com os argumentos. Esta sessão confirmou que o código a seguir compila e roda na versão 2.1.21 sem opt-in:
import kotlin.reflect.typeOf
fun main() {
println("typeOf: ${typeOf<List<String>>()}")
}
A saída é typeOf: kotlin.collections.List<kotlin.String>. Isso mostra o que reified não consegue fazer sozinho, e o motivo de bibliotecas de serialização precisarem de uma forma de capturar o tipo completo.
Também existem limites de sintaxe. Um parâmetro reified só funciona numa função inline. Quando você tenta usá-lo numa função comum, o compilador recusa:
cannot use 'T' as reified type parameter. Use a class instead.
A mensagem tem a parte “use uma classe” em sentido literal: o T::class precisa de um tipo que o compilador consiga resolver no ponto de chamada. Numa função comum, T é só um símbolo que será apagado.
9. erros comuns e boas práticas
O erro mais frequente é querer escrever List<Animal> para aceitar cachorros, gatos e qualquer subtipo, e depois se perguntar por que a lista mutável não aceita MutableList<Cachorro>. A solução idiomática é receber List<Animal> quando a função só lê, e deixar a mutabilidade fora da assinatura.
O segundo erro é declarar out numa classe que na verdade recebe valores de volta, como um repositório com salvar(item: T). O compilador impede isso, mas quem não entende a regra costuma remover o out e depois não consegue atribuir o repositório ao tipo mais geral. A correção é projetar a classe com clareza sobre quem produz e quem consome.
O terceiro erro é achar que reified permite distinguir argumentos genéricos. Ele só dá acesso à classe. Se a função precisar de List<String> e não de List<Int>, a verificação precisa ser feita sobre os elementos, ou o tipo precisa vir de typeOf.
A boa prática geral é ler a assinatura de uma função como um contrato: um parâmetro de tipo out promete que a classe só devolve valores, in promete que só recebe, e um tipo reified promete que a função depende da classe em tempo de execução. Essas promessas têm consequências que o compilador checa, e é melhor escrevê-las de forma explícita do que contorná-las com conversões.
10. exercícios
- Escreva uma interface
Fonte<out T>comproximo(): T?e uma implementação que devolve cachorros. Passe umaFonte<Cachorro>para uma função que recebeFonte<Animal>. Depois tente adicionar um métodoalimentar(valor: T)e leia o erro do compilador. - Escreva uma função
inline fun <reified T> Map<*, *>.valoresDo(): List<T>que devolve só os valores do tipoT. Teste com um mapa misto. - Tente usar
reifiednuma função comum e leia a mensagem. Depois explique com suas palavras por que o compilador precisa do ponto de chamada.
11. glossário
Variância: regra que diz quando um tipo genérico com argumento mais específico pode ser usado no lugar de um com argumento mais geral.
Covariância: tipo declarado com out, que aceita subtipos porque só produz valores.
Contravariância: tipo declarado com in, que aceita supertipos porque só consome valores.
Invariância: tipo sem out nem in, que não aceita nem subtipos nem supertipos no argumento.
Apagamento de tipos: remoção dos argumentos genéricos depois da compilação, que faz o JVM enxergar List<String> e List<Int> como a mesma classe.
Reified: parâmetro de tipo de uma função inline que mantém o tipo concreto disponível em tempo de execução.
fontes
Os exemplos foram compilados e executados com o kotlinc 2.1.21 instalado junto ao IntelliJ IDEA nesta máquina, em 2026-10-20. O comportamento dos arrays covariantes do Java foi descrito a partir da especificação da linguagem, sem execução nesta sessão. A documentação oficial de genéricos do Kotlin não foi consultada nesta aula, e os conceitos de variância e apagamento foram verificados pelos próprios exemplos.
relacionados
- Trilha de Kotlin
- Curriculo de um Desenvolvedor Kotlin
- Interfaces, delegação e classes open
- Coleções imutáveis e Sequence
- Funções inline e custo de lambdas
- Generics em Go