Treinamento do Kotlin para programadores 5.2: genéricos

Este codelab faz parte do curso Treinamento do Kotlin para programadores. Você aproveitará mais o 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ê conhecerá classes, funções e métodos genéricos e como eles funcionam em Kotlin.

Em vez de criar um único app de exemplo, as lições deste curso foram desenvolvidas para aumentar seu conhecimento, mas são semi-independentes umas das outras para que você possa ler as seções que já conhece. Para conectá-los, muitos dos exemplos usam um tema de aquário. Se você quiser ver a história completa do aquário, confira o curso Bootcamp de Kotlin para programadores na Udacity.

O que você já precisa saber

  • 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 do laboratório

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

Introdução aos dados genéricos

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

Imagine que você esteja implementando uma classe MyList que contém uma lista de itens. Sem os genéricos, seria necessário implementar uma nova versão de MyList para cada tipo: uma para Double, uma para String e outra para Fish. Com genéricos, você pode tornar a lista genérica para incluir qualquer tipo de objeto. É como transformar o tipo em 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 é a T.

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

É possível referenciar T como se fosse um tipo normal. O tipo de retorno de get() é T, e o parâmetro para addItem() é do tipo T. 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ê criará algumas classes para usar na próxima etapa. A subclasse foi abordada em um codelab anterior, mas veja uma breve revisão.

  1. Para manter o exemplo organizado, crie um novo pacote em src e o chame generics.
  2. No pacote generics, crie um novo arquivo Aquarium.kt. Isso permite redefinir itens usando os mesmos nomes sem conflitos, de modo que o restante do código deste codelab entra nesse arquivo.
  3. Crie uma hierarquia de tipos de fontes 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, com um getter e um setter.
  5. Crie uma subclasse TapWater que estenda WaterSupply e transmita true para needsProcessing, porque a água de toque contém aditivos nocivos para o peixe.
  6. No TapWater, defina uma função com o nome addChemicalCleaners() que define needsProcessing como false após a limpeza da água. A propriedade needsProcessing pode ser definida a partir de TapWater, porque é public por padrão e acessível para subclasses. Veja o código completo.
package generics

open class WaterSupply(var needsProcessing: Boolean)

class TapWater : WaterSupply(true) {
   fun addChemicalCleaners() {
       needsProcessing = false
   }
}
  1. Crie mais duas subclasses de WaterSupply, com o nome 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 processá-la 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 no Kotlin.

Etapa 2: criar uma classe genérica

Nesta etapa, você modificará a classe Aquarium para oferecer compatibilidade com diferentes tipos de suprimentos de água.

  1. No Aquarium.kt, defina uma classe Aquarium, com <T> entre os nomes das classes.
  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(). Ele não faz parte de uma classe, então pode estar no nível superior do arquivo, como a função main() ou as definições de classe. Na função, crie uma Aquarium e transmita um WaterSupply. Como o parâmetro waterSupply é genérico, é necessário especificar o tipo entre colchetes <>.
fun genericsExample() {
    val aquarium = Aquarium<TapWater>(TapWater())
}
  1. Em genericsExample(), seu código pode acessar o waterSupply do aquário. Como ele é do tipo TapWater, é possível chamar addChemicalCleaners() sem nenhum tipo de transmissão.
fun genericsExample() {
    val aquarium = Aquarium<TapWater>(TapWater())
    aquarium.waterSupply.addChemicalCleaners()
}
  1. Ao criar o objeto Aquarium, você pode remover os sinais de "menor que" e "o que" 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 para Aquarium. Ele ainda criará um Aquarium do tipo TapWater.
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    aquarium.waterSupply.addChemicalCleaners()
}
  1. Para ver o que está acontecendo, exiba needsProcessing antes e depois de chamar addChemicalCleaners(). Veja 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(), execute seu programa e observe o resultado.
fun main() {
    genericsExample()
}
⇒ water needs processing: true
water needs processing: false

Etapa 3: torná-la mais específica

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

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

O resultado é a string que você transmitiu, porque Aquarium não estabelece limitações em T.Qualquer tipo, incluindo String, pode ser transmitido.

  1. Em genericsExample(), crie outro Aquarium, transmitindo null para o waterSupply. Se waterSupply for nulo, exibir "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 você pode transmitir null ao criar um Aquarium? Isso é possível porque, por padrão, a T representa o tipo Any? anulável, que é o tipo na parte superior da hierarquia de tipos. O exemplo abaixo equivale ao que você digitou anteriormente.

class Aquarium<T: Any?>(val waterSupply: T)
  1. Para não permitir a transmissão do null, torne o T 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) 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ê aprenderá sobre a função check() para garantir que seu código tenha o comportamento esperado. A função check() é uma função de biblioteca padrão no Kotlin. Ele atuará como uma declaração e gerará uma IllegalStateException se o argumento for avaliado como false.

  1. Adicione um método addWater() à classe Aquarium para adicionar água, com um check() que certifica-se de 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() gerará uma exceção.

  1. Em genericsExample(), adicione um código para criar uma Aquarium com LakeWater e adicione água.
fun genericsExample() {
    val aquarium4 = Aquarium(LakeWater())
    aquarium4.addWater()
}
  1. Execute o programa. Você 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 exemplo acima abrange o básico de generalizações. As tarefas a seguir abrangem mais, mas o conceito importante é como declarar e usar uma classe genérica com uma restrição genérica.

Nesta tarefa, você aprenderá sobre tipos de entrada e saída com genéricos. 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.

Ao analisar a classe Aquarium, você verá que o tipo genérico só é retornado quando recebe a propriedade waterSupply. Não há métodos que usem um valor do tipo T como parâmetro, exceto para defini-lo no construtor. O Kotlin permite definir tipos out para este caso e pode inferir mais informações sobre onde os tipos são seguros. Da mesma forma, você pode definir tipos in para tipos genéricos que são apenas transmitidos para métodos, não retornados. Isso permite que o Kotlin faça verificações adicionais para a segurança do código.

Os tipos in e out são diretivas do sistema de tipos Kotlin. A explicação de todo o sistema de tipos está fora do escopo deste treinamento (isso é bem complexo). No entanto, o compilador sinalizará os tipos que não estão marcados como in e out adequadamente. Por isso, você precisa conhecê-los.

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 espere um Aquarium de WaterSupply.
fun addItemTo(aquarium: Aquarium<WaterSupply>) = println("item added")
  1. Chame addItemTo() de genericsExample() e execute seu programa.
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    addItemTo(aquarium)
}
⇒ item added

O Kotlin pode garantir que addItemTo() não faça nada que não seja seguro com o WaterSupply genérico, porque ele é declarado como um tipo de out

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

Etapa 2: definir um tipo

O tipo in é semelhante ao out, mas para tipos genéricos que são transmitidos apenas para funções, não retornados. Se você tentar retornar um tipo in, receberá um erro do compilador. Neste exemplo, você 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(), é possível torná-lo 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 a addWater() para usar 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 código de exemplo da genericsExample() para criar uma TapWaterCleaner, uma Aquarium com TapWater e adicione água usando um limpador. Ele usará o mais limpo possível.
fun genericsExample() {
    val cleaner = TapWaterCleaner()
    val aquarium = Aquarium(TapWater())
    aquarium.addWater(cleaner)
}

O Kotlin usará as informações do 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 externamente como valores de retorno, os tipos in podem ser transmitidos para dentro como argumentos.

A documentação aborda mais detalhes sobre o tipo de problemas nos tipos e os tipos de saída.

Nesta tarefa, você aprenderá sobre as funções genéricas e quando usá-las. Normalmente, fazer uma função genérica é uma boa ideia sempre que a função usa um argumento de uma classe com 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 de parâmetro. Uma opção é usar WaterSupply.
fun isWaterClean(aquarium: Aquarium<WaterSupply>) {
   println("aquarium water is clean: ${aquarium.waterSupply.needsProcessing}")
}

No entanto, isso significa que o Aquarium precisa ter um parâmetro do tipo out para que ele seja chamado. Às vezes, out ou in são muito restritivos, porque você precisa usar um tipo para entrada e saída. Para remover o requisito out, torne 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 qualquer restrição, neste caso, WaterSupply. Mude Aquarium para ser restringido 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 pensar nisso.

  1. Chame a função isWaterClean() especificando o tipo entre colchetes angulares logo depois do 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 o resultado.
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    isWaterClean(aquarium)
}
⇒ aquarium water is clean: false

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

Você também pode usar funções genéricas para métodos, mesmo em classes que tenham o próprio tipo genérico. Nesta etapa, você adicionará um método genérico ao 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á é usado) restrito a WaterSupply e retorna true se waterSupply for do tipo R. Essa é a função que você declarou anteriormente, 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, é necessário informar ao Kotlin que o tipo é reificado ou 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 é um tipo real depois de in-line. Isso significa que é possível realizar verificações is usando o tipo.

Se você não usar reified aqui, o tipo não será "real" suficiente para que o Kotlin permita verificações is. Isso ocorre porque tipos não reificados estão disponíveis somente no momento da compilação e não podem ser usados no momento da execução pelo programa. Falaremos mais sobre isso na próxima seção.

  1. Transmita TapWater como o tipo. Como para 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

Também é possível usar tipos materializados para funções comuns e de extensão.

  1. Fora da classe Aquarium, defina uma função de extensão no 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 da mesma forma que um método.
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    println(aquarium.waterSupply.isOfType<TapWater>())  
}
⇒ true

Com essas funções de extensão, independentemente do tipo de Aquarium (Aquarium, TowerTank ou alguma outra subclasse), desde que seja um Aquarium. Usar a sintaxe star-projection é uma maneira conveniente de especificar uma variedade de correspondências. E, ao usar uma projeção em estrela, o Kotlin também garante que você não faça nada que não seja seguro.

  1. Para usar uma projeção em estrela, coloque <*> depois de Aquarium. Mova hasWaterSupplyOfType() para ser uma função de extensão, porque ele 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 seu programa.
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    println(aquarium.hasWaterSupplyOfType<TapWater>())
}
⇒ true

No exemplo anterior, você tinha que marcar o tipo genérico como reified e tornar a função inline, porque o Kotlin precisa conhecê-los em tempo de execução, não apenas no tempo de compilação.

Todos os tipos genéricos são usados apenas no momento da compilação por Kotlin. Isso permite que o compilador verifique se você está fazendo tudo de forma segura. Durante o tempo de execução, todos os tipos genéricos são apagados. Portanto, a mensagem de erro anterior sobre a verificação de um tipo.

Acontece que o compilador pode criar o código correto sem manter os tipos genéricos até o momento da execução. Mas ela significa que, às vezes, você faz algo, como a is, verifica tipos genéricos que o compilador não oferece suporte. É por isso que o Kotlin adicionou tipos materializados ou reais.

Saiba mais sobre os tipos materializados e a exclusão de tipos na documentação do Kotlin.

Esta lição se concentra nos genéricos, o que é importante para tornar o código mais flexível e mais fácil de reutilizar.

  • Criar 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 para restringir os tipos transmitidos ou retornados de classes.
  • Criar 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 materializados são necessários devido à exclusão de tipos. Ao contrário dos tipos genéricos, os tipos restaurados permanecem no ambiente de execução.
  • Use a função check() para verificar se o código está sendo executado como esperado. Exemplo:
    check(!waterSupply.needsProcessing) { "water supply needs processing first" }

Documentação do Kotlin

Se quiser mais informações sobre qualquer assunto deste curso ou se tiver dificuldades, https://kotlinlang.org é seu melhor ponto de partida.

Tutoriais do Kotlin

O site https://try.kotlinlang.org (link em inglês) tem tutoriais elaborados chamados Kotlin Koans, um intérprete baseado na Web, e um conjunto completo de documentação de referência com exemplos.

Curso Udacity

Para ver o curso da Udacity sobre esse assunto, consulte Bootcamp de Kotlin para programadores (link em inglês).

IntelliJ IDEA

A documentação do IntelliJ IDEA (em inglês) pode ser encontrada no site da JetBrains.

Esta seção lista as possíveis atividades para os alunos que estão trabalhando neste codelab como parte de um curso ministrado por um instrutor. Cabe ao instrutor fazer o seguinte:

  • Se necessário, atribua o dever de casa.
  • Informe aos alunos como enviar o dever de casa.
  • Atribua nota aos trabalhos de casa.

Os professores podem usar essas sugestões o quanto quiserem, e eles devem se sentir à vontade para passar o dever de casa como achar adequado.

Se você estiver fazendo este codelab por conta própria, use essas atividades para testar seu conhecimento.

Responda a estas perguntas.

Pergunta 1

Qual das seguintes opções é 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:

▢ uma restrição genérica

▢ uma restrição genérica

▢ desambiguação

▢ um limite de tipo genérico

Pergunta 3

Isso significa o seguinte:

▢ O verdadeiro impacto na execução de um objeto foi calculado.

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

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

▢ Um indicador de erro remoto foi acionado.

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

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