Generics e Variance em Kotlin (in, out, T)

Thiago Pereira
Nov 1, 2019 · 9 min read

Esse artigo aborda o conceito de Generics e Variance em Kotlin e faz uma comparação em relação ao Java.

Primeiro, o que é Generics?

Em java, o conceito foi introduzido em 2004. Ele foi desenhado para estender o sistema de Tipos do Java e permitir “um tipo ou método de operar em objetos de vários tipos provendo type safety em tempo de compilação”.

O que isso significa?

Vamos supor o seguinte trecho de código:

Em tempo de compilação, nenhum erro vai ser identificado no trecho acima, mas ao tentar executar, isso acontece no get() na última linha:

Exception in thread “main” java.lang.ClassCastException: class java.lang.String cannot be cast to class java.lang.Integer

Isso acontece porque sem o uso de Generics, o compilador não consegue identificar em tempo de compilação que o que está sendo atribuído a uma variável do tipo Integer não é um inteiro.

Com o uso de Generics isso seria identificado:

Perceba o erro em tempo de compilação ao tentar o get() novamente na última linha.

Segundo, o que é Variance?

Aqui há duas descrições que eu particularmente gosto:

“Variance refere-se a como a subtipagem entre tipos mais complexos está relacionada à subtipagem entre seus componentes.”

“O conceito de Variance descreve como tipos como o mesmo tipo base e diferentes argumentos se relacionam entre si.”

Há três termos que precisamos ter em mente: Invariance, Covariance e Contravariance.

Vamos a prática pra entender melhor.

Variance na prática (um comparativo entre Java e Kotlin)

Antes de tudo vamos abstrair um pouco o conceito:

interface VarianceExample<T> {
fun producer(): T //comportamento de Covariance
fun consumer(t: T) //comportamento de Contravariance
fun both(t: T): T //comportamento de Invariance
}

PECS: “Producer Extends, Consumer Super”. (vamos entender melhor mais a frente)

Covariance implica que uma relação de subtipagem de tipos simples é preservada para os tipos complexos.

Isso nos permite garantir o seguinte:

open class Ave
open class Passaro: Ave()
open class Arara: Ave()

var ave: Ave = Passaro()
var ave2: Ave = Arara()
ave2 = ave

Passaro e Arara são subclasses de Ave, então nós podemos atribuir uma Arara ou um Passaro para uma Ave, isso é covariance.

Contravariance é o exato oposto de Covariance. Contravariance implica que uma relação de subtipagem de tipos simples é invertida.

Vamos entender melhor usando o exemplo anterior:

open class Ave
open class Passaro: Ave()
open class Arara: Ave()
var arara: Arara = Ave()
var passaro: Passaro = Ave()

A atribuição acima apenas é permitida usando contravariance, onde a subtipagem é invertida. Nesse caso. agora Ave é um subtipo de Arara e Passaro.

Por último, o mais simples, mas não menos importante. Invariance ignora subtipo e supertipo, o que significa que dado um tipo, apenas aquele tipo poderá ser consumido ou produzido, vamos a um exemplo:

interface Invariance <T> {
fun consumer(t: T)
fun producer(): T
fun both(t: T): T
}

Dado um tipo T, tanto o input quanto o output apenas poderá ser T. Usando esse conceito, nós podemos ter o método acima both(t: T): T que é tanto um consumer quanto um producer.

Declaration-site e Use-site Variance

Tá, mas finalmente, como podemos fazer uso de generics variance?

Em java, variance apenas é permitido através de wildcards types (use-site variance). Os generics types precisam ter sua variance manipulada cada vez que um tipo especifico precisa ser usado. Vamos a um exemplo pra entender melhor:

public interface List<E> extends java.util.Collection<E> {
...
}

Perceba que com o uso do wildcard “? extends Number” podemos tornar List que é invariant em covariant. O que nos permite fazer as atribuições acima de forma segura em tempo de compilação.

Dessa forma podemos ler Number dessas listas de forma segura, porém não podemos escrever nada porque não conseguimos saber qual tipo esta lista está apontando, então você não consegue garantir se o que está tentando adicionar é permitido nessa lista.

Vamos ver o que acontece se definirmos a nossa List sem uso de variance:

Podemos ver que as linhas 10, 11, 15 e 16 nem sequer compilam mais agora. O motivo é que sem o uso de covariance nossa List apenas aceita o próprio Number.

Em Kotlin não existe “wildcards types”. A linguagem nos oferece uma anotação a nível de declaração para trabalhar com variance (in e out). Vamos novamente a um exemplo:

interface Source<out T> { //Java: ...Source<? extends T>...
fun nextT(): T
}

fun demo(strs: Source<String>) {
val objects: Source<Any> = strs // Isso é possível já que T está
// anotado com out, então é covariante
}

Podemos anotar o parâmetro de tipo T de Source para garantir que ele seja retornado (produzido) somente de membros de Source <T> e nunca consumido. Para fazer isso usamos o modificador de saída out. A variance é definida a nível declaração da classe, por isso o nome declaration-site.

Um exemplo com a anotação de saída in:

interface Comparable<in T> { //Java: ...Comparable<? super T>...
operator fun compareTo(other: T): Int
}

fun demo(x: Comparable<Number>) {
x.compareTo(1.0) // 1.0 tem tipo Double, que é um subtipo de
// Number
val y: Comparable<Double> = x
// Porém, nós podemos atribuir x a uma variável do tipo
// Comparable<Double>
// Funciona!
}

Além de out, Kotlin fornece uma anotação de variance complementar: in. Ela faz um parâmetro de tipo contravariante: ele só pode ser consumido e nunca produzido.

Exemplo do equivalente de in em Java:

Perceba que com o uso do wildcard “? super Integer” nossa lista se torna contravariant, em outras palavras: sua subtipagem é invertida. O que nos permite atribuir qualquer supertipo de Integer a uma lista de Integer.

Mais uma vez, o que aconteceria se não usássemos variance? Vejamos:

As linhas 9 e 10 agora não compilam mais, já que Number e Object é um supertipo de Integer. e sem uso do wildcard, nossa lista é invariant e só aceita Integer.

Isso nos garante que podemos ler uma instance de Object, ou uma subclasse de Object, mas não é possível saber qual subclasse. Você consegue inserir nessa lista Integer ou qualquer subclasse de Integer, já que uma instância de uma subclasse de Integer é permitida é qualquer das listas acimas. Você não consegue inserir Number, Object ou Double, já que a lista pode estar apontando para uma lista de Integer.

Como nem sempre é possível usar declaration-site até mesmo no Kotlin, a linguagem também permite o uso de use-site variance, exemplo:

fun copy(from: Array<out Any>, to: Array<Any>) {
// ...
}

Agora podemos entender melhor o conceito de PECS apresentado acima: “Producer Extends, Consumer Super”.

  • “Producer Extends” —Se você precisa de uma lista que produza valores do tipo T (você quer ler T’s da lista), você precisa declará-la com <out T> (em java: <? extends T>). Mas não é possível adicionar nada a lista.
  • “Consumer Super” — Se você precisa de uma lista que consuma valores do tipo T (você quer escrever T’s nessa lista), você precisa declará-la com <in T> (em java: <? super T>). Mas não há garantida qual tipo de objeto você leia dessa lista.
  • Se você precisa ler e escrever em uma lista, você precisa declará-la exatamente sem wildcards types no Java, ou anotação de parâmetro no Kotlin, e.g. List<Integer>.

Real life cases

Vamos explorar agora alguns casos reais de uso de variance na própria stdlib do Kotlin. O conceito é bastante fundamental na API de Collections.

List (covariant)

Vamos dar uma olhada na interface de List:

public interface List<out E> : Collection<E> {
override val size: Int

override fun isEmpty(): Boolean
override fun contains(element: @UnsafeVariance E): Boolean
override fun iterator(): Iterator<E>

// Bulk Operations
override fun containsAll(elements: Collection<@UnsafeVariance E>): Boolean

public operator fun get(index: Int): E

public fun indexOf(element: @UnsafeVariance E): Int
...
}

Perceba que List usa declaration-site variance para se tornar covariant em E. Dessa forma apenas métodos de leitura são permitidos na lista. Lembre do conceito citado acima, Producer Extends.

Mas você deve estar se perguntando: como é permitido existir os métodos contains, containsAll e indexOf?

Originalmente esse comportamento de consumer não deveria ser permitido por definição, mas dada a necessidade do comportamento, a solução é usar a anotação @UnsafeVariance. O comportamento dessa anotação é bem simples, ela simplesmente suprime qualquer erro de conflito de variance.

Se removermos a anotação, agora temos um erro em tempo de compilação:

Mas porque nos é permitido fazer isso, já que de certa forma estamos “quebrando o conceito de variance”? Basicamente o conceito de variance tem objetivo de te proteger de usuários externos, não de você mesmo. Vejamos o seguinte trecho de código:

Perceba que field1 não compila, já que val possui um método get e GenericClass é contravariant em T.

Agora vamos analisar nosso código novamente:

Podemos perceber que field1 agora é compilado sem erro. Porque? Como dito anteriormente, o conceito de variance é para te proteger de usuários externos, dado que agora seu field1 é private, é como se você estivesse dizendo ao compilador “eu sei o que estou fazendo aqui”.

Vamos mais uma vez dar uma olhada em uma interface presente em Collections:

public interface MutableList<E> : List<E>, MutableCollection<E> {    override fun add(element: E): Boolean

override fun remove(element: E): Boolean

override fun addAll(elements: Collection<E>): Boolean
public fun addAll(index: Int, elements: Collection<E>): Boolean

override fun removeAll(elements: Collection<E>): Boolean
override fun retainAll(elements: Collection<E>): Boolean
override fun clear(): Unit
...
}

Como podemos ver, MutableList é invariant em E. O motivo pra isso é que essa lista precisa ser tanto consumer quanto producer em E. Se MutableList fosse covariant em E, por exemplo, os métodos add, remove, addAll não seriam possíveis.

Conclusão

Nesse artigo nós abordamos o complexo conceito de variance em generics. Usamos Java para demonstrar o conceito de generics e variance, assim como sua motivação. Logo depois introduzimos como o conceito é abordado em Kotlin e comparamos com a abordagem do Java. Diferenciamos o que seria use-site e declaration-site variance. E pudemos entender a implicação de variance através de exemplos e casos de uso reais.

O conceito é bem útil no dia a dia, porém aparentemente bem desconhecido. Comentei sobre aqui na empresa onde trabalho e a galera conhecia bem pouco sobre. Essa foi justamente minha motivação para escrever o artigo: disseminar conhecimento sobre o assunto.

Referências

Um agradecimento especial ao Ubiratan Soares pela disponibilidade de revisar e contribuir para o artigo.


Se encontrou algum problema, deseja discutir sobre o assunto, opinar, contribuir, etc… Pode me procurar em uma das opções abaixo:

Twitter: litjc13

Linkedin: Thiago Pereira

Android Dev BR

Artigos em português sobre Android, curados pela comunidade Android Dev BR. Junte-se a nós: slack.androiddevbr.org.

Thanks to Lucas Cavalcante

Thiago Pereira

Written by

Android Developer

Android Dev BR

Artigos em português sobre Android, curados pela comunidade Android Dev BR. Junte-se a nós: slack.androiddevbr.org.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade