Treinamento do Kotlin para programadores 5.2: genéricos

Este codelab faz parte do curso de treinamento do Kotlin para programadores. Você vai aproveitar mais este curso se fizer os codelabs em sequência. Dependendo do seu conhecimento, talvez você possa passar mais rápido por algumas seções. Este curso é destinado a programadores que conhecem uma linguagem orientada a objetos e querem aprender Kotlin.

Introdução

Neste codelab, você vai conhecer classes, funções e métodos genéricos e como eles funcionam no Kotlin.

Em vez de criar um único app de exemplo, as lições deste curso foram criadas para aumentar seu conhecimento, mas são semi-independentes umas das outras para que você possa ler as seções com que já está familiarizado. Para unificar os exemplos, muitos deles usam um tema de aquário. Se quiser conferir a história completa do aquário, acesse o curso da Udacity Treinamento do Kotlin para programadores (link em inglês).

O que você já precisa saber

  • A sintaxe de funções, classes e métodos do Kotlin
  • Como criar uma nova classe no IntelliJ IDEA e executar um programa

O que você vai aprender

  • Como trabalhar com classes, métodos e funções genéricos

Atividades deste laboratório

  • Criar uma classe genérica e adicionar restrições
  • Criar tipos in e out
  • Criar funções, métodos e funções de extensão genéricos

Introdução aos tipos genéricos

O Kotlin, assim como muitas linguagens de programação, tem tipos genéricos. Um tipo genérico permite tornar uma classe genérica e, assim, muito mais flexível.

Imagine que você esteja implementando uma classe MyList que contém uma lista de itens. Sem os tipos genéricos, você precisaria implementar uma nova versão de MyList para cada tipo: uma para Double, uma para String e uma para Fish. Com os tipos genéricos, você pode tornar a lista genérica para que ela possa conter qualquer tipo de objeto. É como tornar o tipo um caractere curinga que se encaixa em muitos tipos.

Para definir um tipo genérico, coloque T entre colchetes angulares <T> após o nome da classe. Você pode usar outra letra ou um nome mais longo, mas a convenção para um tipo genérico é T.

class MyList<T> {
    fun get(pos: Int): T {
        TODO("implement")
    }
    fun addItem(item: T) {}
}

Você pode referenciar T como se fosse um tipo normal. O tipo de retorno para get() é T, e o parâmetro para addItem() é do tipo T. É claro que as listas genéricas são muito úteis, então a classe List é integrada ao Kotlin.

Etapa 1: criar uma hierarquia de tipos

Nesta etapa, você vai criar algumas classes para usar na próxima etapa. A criação de subclasses foi abordada em um codelab anterior, mas aqui está uma breve revisão.

  1. Para manter o exemplo organizado, crie um novo pacote em src e chame-o de generics.
  2. No pacote generics, crie um arquivo Aquarium.kt. Isso permite redefinir coisas usando os mesmos nomes sem conflitos. Portanto, o restante do código deste codelab vai para esse arquivo.
  3. Crie uma hierarquia de tipos de abastecimento de água. Comece transformando WaterSupply em uma classe open para que ela possa ser transformada em subclasse.
  4. Adicione um parâmetro booleano var, needsProcessing. Isso cria automaticamente uma propriedade mutável, além de um getter e um setter.
  5. Crie uma subclasse TapWater que estenda WaterSupply e transmita true para needsProcessing, porque a água da torneira contém aditivos que são ruins para os peixes.
  6. Em TapWater, defina uma função chamada addChemicalCleaners() que define needsProcessing como false depois de limpar a água. A propriedade needsProcessing pode ser definida em TapWater porque é public por padrão e acessível a subclasses. Este é o código concluído.
package generics

open class WaterSupply(var needsProcessing: Boolean)

class TapWater : WaterSupply(true) {
   fun addChemicalCleaners() {
       needsProcessing = false
   }
}
  1. Crie mais duas subclasses de WaterSupply, chamadas FishStoreWater e LakeWater. FishStoreWater não precisa de processamento, mas LakeWater precisa ser filtrado com o método filter(). Depois da filtragem, não é necessário processar novamente. Portanto, em filter(), defina needsProcessing = false.
class FishStoreWater : WaterSupply(false)

class LakeWater : WaterSupply(true) {
   fun filter() {
       needsProcessing = false
   }
}

Se você precisar de mais informações, consulte a lição anterior sobre herança em Kotlin.

Etapa 2: criar uma classe genérica

Nesta etapa, você vai modificar a classe Aquarium para oferecer suporte a diferentes tipos de abastecimento de água.

  1. Em Aquarium.kt, defina uma classe Aquarium com <T> entre colchetes após o nome da classe.
  2. Adicione uma propriedade imutável waterSupply do tipo T a Aquarium.
class Aquarium<T>(val waterSupply: T)
  1. Escreva uma função chamada genericsExample(). Como não faz parte de uma classe, ele pode ficar no nível superior do arquivo, como a função main() ou as definições de classe. Na função, crie um Aquarium e transmita um WaterSupply. Como o parâmetro waterSupply é genérico, especifique o tipo entre colchetes angulares <>.
fun genericsExample() {
    val aquarium = Aquarium<TapWater>(TapWater())
}
  1. Em genericsExample(), seu código pode acessar o waterSupply do aquário. Como é do tipo TapWater, você pode chamar addChemicalCleaners() sem conversões de tipo.
fun genericsExample() {
    val aquarium = Aquarium<TapWater>(TapWater())
    aquarium.waterSupply.addChemicalCleaners()
}
  1. Ao criar o objeto Aquarium, você pode remover os colchetes angulares e o que está entre eles porque o Kotlin tem inferência de tipo. Portanto, não há motivo para dizer TapWater duas vezes ao criar a instância. O tipo pode ser inferido pelo argumento de Aquarium, mas ainda vai criar um Aquarium do tipo TapWater.
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    aquarium.waterSupply.addChemicalCleaners()
}
  1. Para conferir o que está acontecendo, imprima needsProcessing antes e depois de chamar addChemicalCleaners(). Confira abaixo a função concluída.
fun genericsExample() {
    val aquarium = Aquarium<TapWater>(TapWater())
    println("water needs processing: ${aquarium.waterSupply.needsProcessing}")
    aquarium.waterSupply.addChemicalCleaners()
    println("water needs processing: ${aquarium.waterSupply.needsProcessing}")
}
  1. Adicione uma função main() para chamar genericsExample(). Em seguida, execute o programa e observe o resultado.
fun main() {
    genericsExample()
}
⇒ water needs processing: true
water needs processing: false

Etapa 3: especifique mais

Genérico significa que você pode transmitir quase qualquer coisa, e às vezes isso é um problema. Nesta etapa, você vai tornar a classe Aquarium mais específica sobre o que pode ser colocado nela.

  1. Em genericsExample(), crie um Aquarium, transmitindo uma string para o waterSupply e imprima a propriedade waterSupply do aquário.
fun genericsExample() {
    val aquarium2 = Aquarium("string")
    println(aquarium2.waterSupply)
}
  1. Execute o programa e observe o resultado.
⇒ string

O resultado é a string transmitida, porque Aquarium não impõe limitações a T.. Qualquer tipo, incluindo String, pode ser transmitido.

  1. Em genericsExample(), crie outra Aquarium, transmitindo null para o waterSupply. Se waterSupply for nulo, imprima "waterSupply is null".
fun genericsExample() {
    val aquarium3 = Aquarium(null)
    if (aquarium3.waterSupply == null) {
        println("waterSupply is null")
    }
}
  1. Execute o programa e observe o resultado.
⇒ waterSupply is null

Por que é possível transmitir null ao criar um Aquarium? Isso é possível porque, por padrão, T representa o tipo anulável Any?, que está no topo da hierarquia de tipos. O seguinte é equivalente ao que você digitou antes.

class Aquarium<T: Any?>(val waterSupply: T)
  1. Para não permitir a transmissão de null, faça com que T seja do tipo Any explicitamente, removendo o ? após Any.
class Aquarium<T: Any>(val waterSupply: T)

Nesse contexto, Any é chamada de restrição genérica. Isso significa que qualquer tipo pode ser transmitido para T, desde que não seja null.

  1. O que você realmente quer é garantir que apenas um WaterSupply (ou uma das subclasses dele) possa ser transmitido para T. Substitua Any por WaterSupply para definir uma restrição genérica mais específica.
class Aquarium<T: WaterSupply>(val waterSupply: T)

Etapa 4: adicionar mais verificações

Nesta etapa, você vai aprender sobre a função check() para garantir que seu código esteja funcionando como esperado. A função check() é uma função de biblioteca padrão em Kotlin. Ele funciona como uma declaração e vai gerar um IllegalStateException se o argumento for avaliado como false.

  1. Adicione um método addWater() à classe Aquarium para adicionar água, com um check() que garante que você não precise processar a água primeiro.
class Aquarium<T: WaterSupply>(val waterSupply: T) {
    fun addWater() {
        check(!waterSupply.needsProcessing) { "water supply needs processing first" }
        println("adding water from $waterSupply")
    }    
}

Nesse caso, se needsProcessing for verdadeiro, check() vai gerar uma exceção.

  1. Em genericsExample(), adicione um código para criar um Aquarium com LakeWater e adicione água a ele.
fun genericsExample() {
    val aquarium4 = Aquarium(LakeWater())
    aquarium4.addWater()
}
  1. Execute o programa. Você vai receber uma exceção porque a água precisa ser filtrada primeiro.
⇒ Exception in thread "main" java.lang.IllegalStateException: water supply needs processing first
        at Aquarium.generics.Aquarium.addWater(Aquarium.kt:21)
  1. Adicione uma chamada para filtrar a água antes de adicioná-la ao Aquarium. Agora, quando você executa o programa, nenhuma exceção é gerada.
fun genericsExample() {
    val aquarium4 = Aquarium(LakeWater())
    aquarium4.waterSupply.filter()
    aquarium4.addWater()
}
⇒ adding water from generics.LakeWater@880ec60

O texto acima aborda os conceitos básicos de tipos genéricos. As tarefas a seguir abordam mais detalhes, mas o conceito importante é como declarar e usar uma classe genérica com uma restrição genérica.

Nesta tarefa, você vai aprender sobre tipos de entrada e saída com classes genéricas. Um tipo in é um tipo que só pode ser transmitido para uma classe, não retornado. Um tipo out é um tipo que só pode ser retornado de uma classe.

Confira a classe Aquarium e você vai perceber que o tipo genérico só é retornado ao receber a propriedade waterSupply. Não há métodos que usam um valor do tipo T como parâmetro, exceto para defini-lo no construtor. O Kotlin permite definir tipos out exatamente para esse caso e pode inferir informações extras sobre onde os tipos são seguros para uso. Da mesma forma, é possível definir tipos in para tipos genéricos que são transmitidos apenas para métodos, não retornados. Isso permite que o Kotlin faça verificações extras para a segurança do código.

Os tipos in e out são diretivas para o sistema de tipos do Kotlin. Explicar todo o sistema de tipos está fora do escopo deste bootcamp (é bem complexo). No entanto, o compilador vai sinalizar os tipos que não estão marcados como in e out adequadamente, então você precisa saber sobre eles.

Etapa 1: definir um tipo de saída

  1. Na classe Aquarium, mude T: WaterSupply para ser do tipo out.
class Aquarium<out T: WaterSupply>(val waterSupply: T) {
    ...
}
  1. No mesmo arquivo, fora da classe, declare uma função addItemTo() que espera um Aquarium de WaterSupply.
fun addItemTo(aquarium: Aquarium<WaterSupply>) = println("item added")
  1. Chame addItemTo() de genericsExample() e execute o programa.
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    addItemTo(aquarium)
}
⇒ item added

O Kotlin pode garantir que addItemTo() não fará nada que não seja seguro para o tipo com o WaterSupply genérico, porque ele é declarado como um tipo out.

  1. Se você remover a palavra-chave out, o compilador vai gerar um erro ao chamar addItemTo(), porque o Kotlin não pode garantir que você não está fazendo nada inseguro com o tipo.

Etapa 2: definir um tipo "in"

O tipo in é semelhante ao tipo out, mas para tipos genéricos que são transmitidos apenas para funções, não retornados. Se você tentar retornar um tipo in, vai receber um erro do compilador. Neste exemplo, você vai definir um tipo in como parte de uma interface.

  1. Em Aquarium.kt, defina uma interface Cleaner que usa um T genérico restrito a WaterSupply. Como ele é usado apenas como um argumento para clean(), você pode transformá-lo em um parâmetro in.
interface Cleaner<in T: WaterSupply> {
    fun clean(waterSupply: T)
}
  1. Para usar a interface Cleaner, crie uma classe TapWaterCleaner que implemente Cleaner para limpar TapWater adicionando produtos químicos.
class TapWaterCleaner : Cleaner<TapWater> {
    override fun clean(waterSupply: TapWater) =   waterSupply.addChemicalCleaners()
}
  1. Na classe Aquarium, atualize addWater() para receber um Cleaner do tipo T e limpe a água antes de adicioná-la.
class Aquarium<out T: WaterSupply>(val waterSupply: T) {
    fun addWater(cleaner: Cleaner<T>) {
        if (waterSupply.needsProcessing) {
            cleaner.clean(waterSupply)
        }
        println("water added")
    }
}
  1. Atualize o exemplo de código genericsExample() para criar um TapWaterCleaner, um Aquarium com TapWater e adicione água usando o limpador. Ele vai usar o limpador conforme necessário.
fun genericsExample() {
    val cleaner = TapWaterCleaner()
    val aquarium = Aquarium(TapWater())
    aquarium.addWater(cleaner)
}

O Kotlin usa as informações de tipo in e out para garantir que seu código use os genéricos com segurança. Out e in são fáceis de lembrar: os tipos out podem ser transmitidos como valores de retorno, e os tipos in podem ser transmitidos como argumentos.

Se quiser saber mais sobre os tipos de problemas que os tipos de entrada e saída resolvem, consulte a documentação, que aborda esses assuntos em detalhes.

Nesta tarefa, você vai aprender sobre funções genéricas e quando usá-las. Normalmente, é uma boa ideia criar uma função genérica sempre que ela recebe um argumento de uma classe que tem um tipo genérico.

Etapa 1: criar uma função genérica

  1. Em generics/Aquarium.kt, crie uma função isWaterClean() que use um Aquarium. É necessário especificar o tipo genérico do parâmetro. Uma opção é usar WaterSupply.
fun isWaterClean(aquarium: Aquarium<WaterSupply>) {
   println("aquarium water is clean: ${aquarium.waterSupply.needsProcessing}")
}

Mas isso significa que Aquarium precisa ter um parâmetro de tipo out para ser chamado. Às vezes, out ou in são muito restritivos porque você precisa usar um tipo para entrada e saída. É possível remover o requisito out tornando a função genérica.

  1. Para tornar a função genérica, coloque colchetes angulares após a palavra-chave fun com um tipo genérico T e quaisquer restrições, neste caso, WaterSupply. Mude Aquarium para ser restrito por T em vez de WaterSupply.
fun <T: WaterSupply> isWaterClean(aquarium: Aquarium<T>) {
   println("aquarium water is clean: ${!aquarium.waterSupply.needsProcessing}")
}

T é um parâmetro de tipo para isWaterClean() que está sendo usado para especificar o tipo genérico do aquário. Esse padrão é muito comum, e é uma boa ideia dedicar um tempo para trabalhar nele.

  1. Chame a função isWaterClean() especificando o tipo entre colchetes angulares logo após o nome da função e antes dos parênteses.
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    isWaterClean<TapWater>(aquarium)
}
  1. Devido à inferência de tipo do argumento aquarium, o tipo não é necessário. Portanto, remova-o. Execute o programa e observe a saída.
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    isWaterClean(aquarium)
}
⇒ aquarium water is clean: false

Etapa 2: criar um método genérico com um tipo concretizado

Você também pode usar funções genéricas para métodos, mesmo em classes que têm seu próprio tipo genérico. Nesta etapa, você vai adicionar um método genérico a Aquarium que verifica se ele tem um tipo de WaterSupply.

  1. Na classe Aquarium, declare um método, hasWaterSupplyOfType(), que usa um parâmetro genérico R (T já está em uso) restrito a WaterSupply e retorna true se waterSupply for do tipo R. É como a função que você declarou antes, mas dentro da classe Aquarium.
fun <R: WaterSupply> hasWaterSupplyOfType() = waterSupply is R
  1. Observe que o R final está sublinhado em vermelho. Mantenha o ponteiro sobre ele para ver qual é o erro.
  2. Para fazer uma verificação de is, você precisa informar ao Kotlin que o tipo é materializado, ou seja, real, e pode ser usado na função. Para fazer isso, coloque inline na frente da palavra-chave fun e reified na frente do tipo genérico R.
inline fun <reified R: WaterSupply> hasWaterSupplyOfType() = waterSupply is R

Depois que um tipo é reificado, você pode usá-lo como um tipo normal, porque ele é um tipo real após a inclusão inline. Isso significa que você pode fazer verificações de is usando o tipo.

Se você não usar reified aqui, o tipo não será "real" o suficiente para que o Kotlin permita verificações de is. Isso acontece porque os tipos não concretizados só estão disponíveis no momento da compilação e não podem ser usados durante a execução pelo programa. Vamos falar mais sobre isso na próxima seção.

  1. Transmita TapWater como o tipo. Assim como ao chamar funções genéricas, chame métodos genéricos usando colchetes angulares com o tipo após o nome da função. Execute o programa e observe o resultado.
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    println(aquarium.hasWaterSupplyOfType<TapWater>())   // true
}
⇒ true

Etapa 3: criar funções de extensão

Você também pode usar tipos concretos para funções regulares e de extensão.

  1. Fora da classe Aquarium, defina uma função de extensão em WaterSupply chamada isOfType() que verifica se o WaterSupply transmitido é de um tipo específico, por exemplo, TapWater.
inline fun <reified T: WaterSupply> WaterSupply.isOfType() = this is T
  1. Chame a função de extensão como um método.
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    println(aquarium.waterSupply.isOfType<TapWater>())  
}
⇒ true

Com essas funções de extensão, não importa o tipo de Aquarium (Aquarium, TowerTank ou alguma outra subclasse), desde que seja um Aquarium. Usar a sintaxe de projeção de estrela é uma maneira conveniente de especificar uma variedade de correspondências. E quando você usa uma projeção por asterisco, o Kotlin garante que você não faça nada inseguro.

  1. Para usar uma projeção de estrela, coloque <*> depois de Aquarium. Mova hasWaterSupplyOfType() para ser uma função de extensão, porque ela não faz parte da API principal de Aquarium.
inline fun <reified R: WaterSupply> Aquarium<*>.hasWaterSupplyOfType() = waterSupply is R
  1. Mude a chamada para hasWaterSupplyOfType() e execute o programa.
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    println(aquarium.hasWaterSupplyOfType<TapWater>())
}
⇒ true

No exemplo anterior, você precisou marcar o tipo genérico como reified e tornar a função inline, porque o Kotlin precisa saber sobre eles em tempo de execução, não apenas em tempo de compilação.

Todos os tipos genéricos são usados apenas no momento da compilação pelo Kotlin. Isso permite que o compilador garanta que você está fazendo tudo com segurança. No tempo de execução, todos os tipos genéricos são apagados. Por isso, a mensagem de erro anterior sobre a verificação de um tipo apagado.

Acontece que o compilador pode criar o código correto sem manter os tipos genéricos até o tempo de execução. Mas isso significa que, às vezes, você faz algo, como verificações is em tipos genéricos, que o compilador não pode oferecer suporte. Por isso, o Kotlin adicionou tipos materializados ou reais.

Leia mais sobre tipos concretos e eliminação de tipos na documentação do Kotlin.

Esta lição se concentrou em tipos genéricos, que são importantes para tornar o código mais flexível e fácil de reutilizar.

  • Crie classes genéricas para tornar o código mais flexível.
  • Adicione restrições genéricas para limitar os tipos usados com genéricos.
  • Use os tipos in e out com genéricos para oferecer uma verificação de tipo melhor e restringir os tipos transmitidos ou retornados de classes.
  • Crie funções e métodos genéricos para trabalhar com tipos genéricos. Exemplo:
    fun <T: WaterSupply> isWaterClean(aquarium: Aquarium<T>) { ... }
  • Use funções de extensão genéricas para adicionar funcionalidades não principais a uma classe.
  • Às vezes, os tipos concretos são necessários devido ao apagamento de tipos. Os tipos concretos, ao contrário dos tipos genéricos, persistem até o tempo de execução.
  • Use a função check() para verificar se o código está sendo executado como esperado. Por exemplo:
    check(!waterSupply.needsProcessing) { "water supply needs processing first" }

Documentação do Kotlin

Se você quiser mais informações sobre algum tópico deste curso ou se tiver dúvidas, https://kotlinlang.org é o melhor ponto de partida.

Tutoriais do Kotlin

O site https://try.kotlinlang.org inclui tutoriais avançados chamados Kotlin Koans, um interpretador baseado na Web e um conjunto completo de documentação de referência com exemplos.

Curso Udacity

Para conferir o curso da Udacity sobre esse tema, consulte Treinamento do Kotlin para programadores (link em inglês).

IntelliJ IDEA

A documentação do IntelliJ IDEA está disponível no site da JetBrains.

Esta seção lista as possíveis atividades de dever de casa para os alunos que estão fazendo este codelab como parte de um curso ministrado por um professor. Cabe ao professor fazer o seguinte:

  • Atribuir o dever de casa, se necessário.
  • Informar aos alunos como enviar deveres de casa.
  • Atribuir nota aos deveres de casa.

Os professores podem usar essas sugestões o quanto quiserem, podendo passar os exercícios que acharem mais apropriados como dever de casa.

Se você estiver seguindo este codelab por conta própria, sinta-se à vontade para usar esses deveres de casa para testar seu conhecimento.

Responda estas perguntas

Pergunta 1

Qual das opções a seguir é a convenção para nomear um tipo genérico?

<Gen>

<Generic>

<T>

<X>

Pergunta 2

Uma restrição nos tipos permitidos para um tipo genérico é chamada de:

▢ uma restrição genérica

▢ uma restrição genérica

▢ desambiguação

▢ um limite de tipo genérico

Pergunta 3

"Materializado" significa:

▢ O impacto real da execução de um objeto foi calculado.

▢ Um índice de entrada restrita foi definido na classe.

▢ O parâmetro de tipo genérico foi transformado em um tipo real.

▢ Um indicador de erro remoto foi acionado.

Siga para a próxima lição: 6. Manipulação funcional

Para ter uma visão geral do curso, incluindo links para outros codelabs, consulte "Treinamento do Kotlin para programadores: seja bem-vindo ao curso".