Kotlin Bootcamp for Programmers 5.2: Generics

Questo codelab fa parte del corso Kotlin Bootcamp for Programmers. Per ottenere il massimo valore da questo corso, ti consigliamo di seguire le codelab in sequenza. A seconda delle tue conoscenze, potresti riuscire a leggere rapidamente alcune sezioni. Questo corso è rivolto ai programmatori che conoscono un linguaggio orientato agli oggetti e vogliono imparare Kotlin.

Introduzione

In questo codelab vengono presentate classi, funzioni e metodi generici e il loro funzionamento in Kotlin.

Anziché creare una singola app di esempio, le lezioni di questo corso sono progettate per sviluppare le tue conoscenze, ma sono semi-indipendenti l'una dall'altra, in modo che tu possa scorrere rapidamente le sezioni che conosci. Per collegarli, molti esempi utilizzano un tema acquatico. Se vuoi scoprire tutta la storia dell'acquario, dai un'occhiata al corso Kotlin Bootcamp for Programmers di Udacity.

Cosa devi già sapere

  • La sintassi di funzioni, classi e metodi Kotlin
  • Come creare una nuova classe in IntelliJ IDEA ed eseguire un programma

Obiettivi didattici

  • Come utilizzare classi, metodi e funzioni generici

In questo lab proverai a:

  • Creare una classe generica e aggiungere vincoli
  • Crea tipi in e out
  • Creare funzioni generiche, metodi e funzioni di estensione

Introduzione ai generici

Kotlin, come molti linguaggi di programmazione, ha tipi generici. Un tipo generico ti consente di rendere generica una classe e quindi molto più flessibile.

Immagina di implementare una classe MyList che contiene un elenco di elementi. Senza i generici, dovresti implementare una nuova versione di MyList per ogni tipo: una per Double, una per String e una per Fish. Con i generici, puoi rendere l'elenco generico, in modo che possa contenere qualsiasi tipo di oggetto. È come rendere il tipo un carattere jolly che si adatta a molti tipi.

Per definire un tipo generico, inserisci T tra parentesi angolari <T> dopo il nome della classe. Puoi utilizzare un'altra lettera o un nome più lungo, ma la convenzione per un tipo generico è T.

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

Puoi fare riferimento a T come se fosse un tipo normale. Il tipo restituito per get() è T e il parametro per addItem() è di tipo T. Naturalmente, gli elenchi generici sono molto utili, quindi la classe List è integrata in Kotlin.

Passaggio 1: crea una gerarchia dei tipi

In questo passaggio crei alcune classi da utilizzare nel passaggio successivo. La creazione di sottoclassi è stata trattata in un codelab precedente, ma ecco un breve riepilogo.

  1. Per mantenere l'esempio pulito, crea un nuovo pacchetto in src e chiamalo generics.
  2. Nel pacchetto generics, crea un nuovo file Aquarium.kt. In questo modo puoi ridefinire gli elementi utilizzando gli stessi nomi senza conflitti, quindi il resto del codice di questo codelab viene inserito in questo file.
  3. Crea una gerarchia di tipi di approvvigionamento idrico. Inizia rendendo WaterSupply una classe open, in modo che possa essere suddivisa in sottoclassi.
  4. Aggiungi un parametro booleano var, needsProcessing. In questo modo viene creata automaticamente una proprietà modificabile, insieme a un getter e un setter.
  5. Crea una sottoclasse TapWater che estenda WaterSupply e passa true per needsProcessing, perché l'acqua del rubinetto contiene additivi dannosi per i pesci.
  6. In TapWater, definisci una funzione chiamata addChemicalCleaners() che imposta needsProcessing su false dopo aver pulito l'acqua. La proprietà needsProcessing può essere impostata da TapWater perché è public per impostazione predefinita e accessibile alle sottoclassi. Ecco il codice completato.
package generics

open class WaterSupply(var needsProcessing: Boolean)

class TapWater : WaterSupply(true) {
   fun addChemicalCleaners() {
       needsProcessing = false
   }
}
  1. Crea altre due sottoclassi di WaterSupply, denominate FishStoreWater e LakeWater. FishStoreWater non richiede l'elaborazione, ma LakeWater deve essere filtrato con il metodo filter(). Dopo il filtraggio, non è necessario elaborarlo di nuovo, quindi in filter() imposta needsProcessing = false.
class FishStoreWater : WaterSupply(false)

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

Se hai bisogno di ulteriori informazioni, rivedi la lezione precedente sull'ereditarietà in Kotlin.

Passaggio 2: crea una classe generica

In questo passaggio modifichi la classe Aquarium per supportare diversi tipi di approvvigionamento idrico.

  1. In Aquarium.kt, definisci una classe Aquarium, con <T> tra parentesi dopo il nome della classe.
  2. Aggiungi una proprietà immutabile waterSupply di tipo T a Aquarium.
class Aquarium<T>(val waterSupply: T)
  1. Scrivi una funzione denominata genericsExample(). Non fa parte di una classe, quindi può essere inserito nel livello superiore del file, come la funzione main() o le definizioni di classe. Nella funzione, crea un Aquarium e trasmetti un WaterSupply. Poiché il parametro waterSupply è generico, devi specificare il tipo tra parentesi angolari <>.
fun genericsExample() {
    val aquarium = Aquarium<TapWater>(TapWater())
}
  1. In genericsExample() il tuo codice può accedere all'waterSupply dell'acquario. Poiché è di tipo TapWater, puoi chiamare addChemicalCleaners() senza alcun cast di tipo.
fun genericsExample() {
    val aquarium = Aquarium<TapWater>(TapWater())
    aquarium.waterSupply.addChemicalCleaners()
}
  1. Quando crei l'oggetto Aquarium, puoi rimuovere le parentesi angolari e ciò che è contenuto al loro interno perché Kotlin ha l'inferenza del tipo. Quindi non c'è motivo di dire TapWater due volte quando crei l'istanza. Il tipo può essere dedotto dall'argomento di Aquarium; verrà comunque creato un Aquarium di tipo TapWater.
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    aquarium.waterSupply.addChemicalCleaners()
}
  1. Per vedere cosa sta succedendo, stampa needsProcessing prima e dopo aver chiamato addChemicalCleaners(). Di seguito è riportata la funzione completata.
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. Aggiungi una funzione main() per chiamare genericsExample(), quindi esegui il programma e osserva il risultato.
fun main() {
    genericsExample()
}
⇒ water needs processing: true
water needs processing: false

Passaggio 3: specifica ulteriormente

Generico significa che puoi superare quasi tutto e a volte questo è un problema. In questo passaggio rendi la classe Aquarium più specifica su cosa puoi inserirvi.

  1. In genericsExample(), crea un Aquarium, passando una stringa per waterSupply, quindi stampa la proprietà waterSupply dell'acquario.
fun genericsExample() {
    val aquarium2 = Aquarium("string")
    println(aquarium2.waterSupply)
}
  1. Esegui il programma e osserva il risultato.
⇒ string

Il risultato è la stringa che hai passato, perché Aquarium non impone limitazioni a T.. È possibile passare qualsiasi tipo, incluso String.

  1. In genericsExample(), crea un altro Aquarium, passando null per waterSupply. Se waterSupply è null, stampa "waterSupply is null".
fun genericsExample() {
    val aquarium3 = Aquarium(null)
    if (aquarium3.waterSupply == null) {
        println("waterSupply is null")
    }
}
  1. Esegui il programma e osserva il risultato.
⇒ waterSupply is null

Perché puoi passare null quando crei un Aquarium? Ciò è possibile perché per impostazione predefinita T indica il tipo Any? nullable, il tipo in cima alla gerarchia dei tipi. Di seguito è riportato l'equivalente di ciò che hai digitato in precedenza.

class Aquarium<T: Any?>(val waterSupply: T)
  1. Per non consentire il passaggio di null, crea T di tipo Any in modo esplicito rimuovendo ? dopo Any.
class Aquarium<T: Any>(val waterSupply: T)

In questo contesto, Any è chiamato vincolo generico. Ciò significa che per T può essere passato qualsiasi tipo, purché non sia null.

  1. Quello che vuoi davvero è assicurarti che solo un WaterSupply (o una delle sue sottoclassi) possa essere passato per T. Sostituisci Any con WaterSupply per definire un vincolo generico più specifico.
class Aquarium<T: WaterSupply>(val waterSupply: T)

Passaggio 4: aggiungi altre verifiche

In questo passaggio scoprirai la funzione check() per assicurarti che il codice si comporti come previsto. La funzione check() è una funzione di libreria standard in Kotlin. Funge da asserzione e genera un IllegalStateException se il relativo argomento restituisce false.

  1. Aggiungi un metodo addWater() alla classe Aquarium per aggiungere acqua, con un check() che assicura di non doverla trattare prima.
class Aquarium<T: WaterSupply>(val waterSupply: T) {
    fun addWater() {
        check(!waterSupply.needsProcessing) { "water supply needs processing first" }
        println("adding water from $waterSupply")
    }    
}

In questo caso, se needsProcessing è true, check() genererà un'eccezione.

  1. In genericsExample(), aggiungi il codice per creare un Aquarium con LakeWater, poi aggiungi un po' d'acqua.
fun genericsExample() {
    val aquarium4 = Aquarium(LakeWater())
    aquarium4.addWater()
}
  1. Esegui il programma e riceverai un'eccezione perché l'acqua deve essere filtrata prima.
⇒ Exception in thread "main" java.lang.IllegalStateException: water supply needs processing first
        at Aquarium.generics.Aquarium.addWater(Aquarium.kt:21)
  1. Aggiungi una chiamata per filtrare l'acqua prima di aggiungerla a Aquarium. Ora, quando esegui il programma, non viene generata alcuna eccezione.
fun genericsExample() {
    val aquarium4 = Aquarium(LakeWater())
    aquarium4.waterSupply.filter()
    aquarium4.addWater()
}
⇒ adding water from generics.LakeWater@880ec60

Quanto sopra illustra le nozioni di base dei generici. Le seguenti attività riguardano più aspetti, ma il concetto importante è come dichiarare e utilizzare una classe generica con un vincolo generico.

In questa attività, scoprirai i tipi in e out con i generici. Un tipo in è un tipo che può essere passato solo a una classe, non restituito. Un tipo out è un tipo che può essere restituito solo da una classe.

Guarda la classe Aquarium e vedrai che il tipo generico viene restituito solo quando si ottiene la proprietà waterSupply. Non esistono metodi che accettano un valore di tipo T come parametro (tranne per la definizione nel costruttore). Kotlin ti consente di definire tipi out esattamente per questo caso e può dedurre informazioni aggiuntive su dove è sicuro utilizzare i tipi. Analogamente, puoi definire i tipi in per i tipi generici che vengono passati solo ai metodi, non restituiti. In questo modo, Kotlin può eseguire controlli aggiuntivi per la sicurezza del codice.

I tipi in e out sono direttive per il sistema di tipi di Kotlin. Spiegare l'intero sistema di tipi non rientra nell'ambito di questo bootcamp (è piuttosto complesso). Tuttavia, il compilatore segnalerà i tipi che non sono contrassegnati in modo appropriato con in e out, quindi devi conoscerli.

Passaggio 1: definisci un tipo di uscita

  1. Nella classe Aquarium, modifica T: WaterSupply in modo che sia di tipo out.
class Aquarium<out T: WaterSupply>(val waterSupply: T) {
    ...
}
  1. Nello stesso file, al di fuori della classe, dichiara una funzione addItemTo() che prevede un Aquarium di WaterSupply.
fun addItemTo(aquarium: Aquarium<WaterSupply>) = println("item added")
  1. Chiama addItemTo() da genericsExample() ed esegui il programma.
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    addItemTo(aquarium)
}
⇒ item added

Kotlin può garantire che addItemTo() non esegua operazioni non sicure per il tipo con il generico WaterSupply, perché è dichiarato come tipo out.

  1. Se rimuovi la parola chiave out, il compilatore restituirà un errore quando chiami addItemTo(), perché Kotlin non può garantire che non stai facendo nulla di non sicuro con il tipo.

Passaggio 2: definisci un tipo di ingresso

Il tipo in è simile al tipo out, ma per i tipi generici che vengono passati solo alle funzioni, non restituiti. Se provi a restituire un tipo in, riceverai un errore del compilatore. In questo esempio definirai un tipo in come parte di un'interfaccia.

  1. In Aquarium.kt, definisci un'interfaccia Cleaner che accetta un T generico vincolato a WaterSupply. Poiché viene utilizzato solo come argomento per clean(), puoi impostarlo come parametro in.
interface Cleaner<in T: WaterSupply> {
    fun clean(waterSupply: T)
}
  1. Per utilizzare l'interfaccia Cleaner, crea una classe TapWaterCleaner che implementi Cleaner per la pulizia di TapWater aggiungendo prodotti chimici.
class TapWaterCleaner : Cleaner<TapWater> {
    override fun clean(waterSupply: TapWater) =   waterSupply.addChemicalCleaners()
}
  1. Nella classe Aquarium, aggiorna addWater() per prelevare un Cleaner di tipo T e pulisci l'acqua prima di aggiungerla.
class Aquarium<out T: WaterSupply>(val waterSupply: T) {
    fun addWater(cleaner: Cleaner<T>) {
        if (waterSupply.needsProcessing) {
            cleaner.clean(waterSupply)
        }
        println("water added")
    }
}
  1. Aggiorna il codice di esempio genericsExample() per creare un TapWaterCleaner, un Aquarium con TapWater, quindi aggiungi un po' d'acqua utilizzando il detergente. Utilizzerà il detergente quando necessario.
fun genericsExample() {
    val cleaner = TapWaterCleaner()
    val aquarium = Aquarium(TapWater())
    aquarium.addWater(cleaner)
}

Kotlin utilizzerà le informazioni sui tipi in e out per assicurarsi che il codice utilizzi i generici in modo sicuro. Out e in sono facili da ricordare: i tipi out possono essere passati verso l'esterno come valori restituiti, mentre i tipi in possono essere passati verso l'interno come argomenti.

Se vuoi approfondire il tipo di problemi che risolvono i tipi in entrata e in uscita, la documentazione li tratta in modo approfondito.

In questa attività imparerai a conoscere le funzioni generiche e quando utilizzarle. In genere, creare una funzione generica è una buona idea quando la funzione accetta un argomento di una classe con un tipo generico.

Passaggio 1: crea una funzione generica

  1. In generics/Aquarium.kt, crea una funzione isWaterClean() che accetta un Aquarium. Devi specificare il tipo generico del parametro. Un'opzione è utilizzare WaterSupply.
fun isWaterClean(aquarium: Aquarium<WaterSupply>) {
   println("aquarium water is clean: ${aquarium.waterSupply.needsProcessing}")
}

Tuttavia, ciò significa che Aquarium deve avere un parametro di tipo out per poter essere chiamato. A volte out o in sono troppo restrittivi perché devi utilizzare un tipo sia per l'input che per l'output. Puoi rimuovere il requisito out rendendo generica la funzione.

  1. Per rendere generica la funzione, inserisci le parentesi angolari dopo la parola chiave fun con un tipo generico T e eventuali vincoli, in questo caso WaterSupply. Modifica Aquarium in modo che sia vincolato da T anziché da WaterSupply.
fun <T: WaterSupply> isWaterClean(aquarium: Aquarium<T>) {
   println("aquarium water is clean: ${!aquarium.waterSupply.needsProcessing}")
}

T è un parametro di tipo per isWaterClean() che viene utilizzato per specificare il tipo generico di acquario. Questo pattern è molto comune ed è una buona idea dedicare un po' di tempo per analizzarlo.

  1. Chiama la funzione isWaterClean() specificando il tipo tra parentesi angolari subito dopo il nome della funzione e prima delle parentesi.
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    isWaterClean<TapWater>(aquarium)
}
  1. A causa dell'inferenza del tipo dall'argomento aquarium, il tipo non è necessario, quindi rimuovilo. Esegui il programma e osserva l'output.
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    isWaterClean(aquarium)
}
⇒ aquarium water is clean: false

Passaggio 2: crea un metodo generico con un tipo reificato

Puoi utilizzare funzioni generiche anche per i metodi, persino nelle classi che hanno il proprio tipo generico. In questo passaggio, aggiungi un metodo generico a Aquarium che controlla se ha un tipo di WaterSupply.

  1. Nella classe Aquarium, dichiara un metodo, hasWaterSupplyOfType(), che accetta un parametro generico R (T è già utilizzato) vincolato a WaterSupply e restituisce true se waterSupply è di tipo R. È simile alla funzione dichiarata in precedenza, ma all'interno della classe Aquarium.
fun <R: WaterSupply> hasWaterSupplyOfType() = waterSupply is R
  1. Nota che l'ultimo R è sottolineato in rosso. Tieni il puntatore sopra l'icona per vedere qual è l'errore.
  2. Per eseguire un controllo is, devi indicare a Kotlin che il tipo è reified, ovvero reale, e può essere utilizzato nella funzione. Per farlo, inserisci inline davanti alla parola chiave fun e reified davanti al tipo generico R.
inline fun <reified R: WaterSupply> hasWaterSupplyOfType() = waterSupply is R

Una volta che un tipo è stato reso concreto, puoi utilizzarlo come un tipo normale, perché è un tipo reale dopo l'inlining. Ciò significa che puoi eseguire controlli is utilizzando il tipo.

Se non utilizzi reified qui, il tipo non sarà "reale" abbastanza da consentire a Kotlin di eseguire i controlli is. Questo perché i tipi non reificati sono disponibili solo in fase di compilazione e non possono essere utilizzati in fase di runtime dal programma. Questo aspetto viene trattato in modo più approfondito nella sezione successiva.

  1. Pass TapWater come tipo. Come per le funzioni generiche, chiama i metodi generici utilizzando le parentesi angolari con il tipo dopo il nome della funzione. Esegui il programma e osserva il risultato.
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    println(aquarium.hasWaterSupplyOfType<TapWater>())   // true
}
⇒ true

Passaggio 3: crea le funzioni di estensione

Puoi utilizzare i tipi concreti anche per le funzioni regolari e di estensione.

  1. Al di fuori della classe Aquarium, definisci una funzione di estensione su WaterSupply chiamata isOfType() che controlla se WaterSupply passato è di un tipo specifico, ad esempio TapWater.
inline fun <reified T: WaterSupply> WaterSupply.isOfType() = this is T
  1. Chiama la funzione di estensione proprio come un metodo.
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    println(aquarium.waterSupply.isOfType<TapWater>())  
}
⇒ true

Con queste funzioni di estensione, non importa il tipo di Aquarium (Aquarium o TowerTank o qualche altra sottoclasse), purché sia un Aquarium. L'utilizzo della sintassi di proiezione con asterisco è un modo pratico per specificare una serie di corrispondenze. Quando utilizzi una proiezione a stella, Kotlin si assicura che tu non faccia nulla di pericoloso.

  1. Per utilizzare una proiezione stellare, inserisci <*> dopo Aquarium. Sposta hasWaterSupplyOfType() in modo che sia una funzione di estensione, perché non fa parte dell'API di base di Aquarium.
inline fun <reified R: WaterSupply> Aquarium<*>.hasWaterSupplyOfType() = waterSupply is R
  1. Modifica la chiamata in hasWaterSupplyOfType() ed esegui il programma.
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    println(aquarium.hasWaterSupplyOfType<TapWater>())
}
⇒ true

Nell'esempio precedente, dovevi contrassegnare il tipo generico come reified e rendere la funzione inline, perché Kotlin deve conoscerli in fase di runtime, non solo in fase di compilazione.

Tutti i tipi generici vengono utilizzati solo in fase di compilazione da Kotlin. In questo modo, il compilatore può assicurarsi che tu stia facendo tutto in sicurezza. In fase di runtime tutti i tipi generici vengono cancellati, da cui il messaggio di errore precedente relativo al controllo di un tipo cancellato.

Il compilatore può creare codice corretto senza conservare i tipi generici fino al runtime. Tuttavia, a volte fai qualcosa, come i controlli is sui tipi generici, che il compilatore non può supportare. Per questo motivo, Kotlin ha aggiunto i tipi reified, o reali.

Puoi scoprire di più sui tipi reificati e sull'eliminazione dei tipi nella documentazione di Kotlin.

Questa lezione si è concentrata sui generici, che sono importanti per rendere il codice più flessibile e più facile da riutilizzare.

  • Crea classi generiche per rendere il codice più flessibile.
  • Aggiungi vincoli generici per limitare i tipi utilizzati con i generici.
  • Utilizza i tipi in e out con i generici per fornire un controllo dei tipi migliore per limitare i tipi passati o restituiti dalle classi.
  • Crea funzioni e metodi generici per lavorare con tipi generici. Ad esempio:
    fun <T: WaterSupply> isWaterClean(aquarium: Aquarium<T>) { ... }
  • Utilizza funzioni di estensione generiche per aggiungere funzionalità non di base a una classe.
  • A volte i tipi concreti sono necessari a causa dell'eliminazione dei tipi. I tipi concreti, a differenza di quelli generici, vengono mantenuti durante l'esecuzione.
  • Utilizza la funzione check() per verificare che il codice venga eseguito come previsto. Ad esempio:
    check(!waterSupply.needsProcessing) { "water supply needs processing first" }

Documentazione di Kotlin

Se vuoi maggiori informazioni su un argomento di questo corso o se hai difficoltà, https://kotlinlang.org è il punto di partenza migliore.

Tutorial di Kotlin

Il sito web https://try.kotlinlang.org include tutorial dettagliati chiamati Kotlin Koans, un interprete basato sul web e una serie completa di documentazione di riferimento con esempi.

Corso Udacity

Per visualizzare il corso Udacity su questo argomento, consulta Kotlin Bootcamp for Programmers.

IntelliJ IDEA

La documentazione di IntelliJ IDEA è disponibile sul sito web di JetBrains.

Questa sezione elenca i possibili compiti a casa per gli studenti che seguono questo codelab nell'ambito di un corso guidato da un insegnante. Spetta all'insegnante:

  • Assegna i compiti, se richiesto.
  • Comunica agli studenti come inviare i compiti.
  • Valuta i compiti a casa.

Gli insegnanti possono utilizzare questi suggerimenti nella misura che ritengono opportuna e sono liberi di assegnare qualsiasi altro compito a casa che ritengono appropriato.

Se stai seguendo questo codelab in autonomia, sentiti libero di utilizzare questi compiti per casa per mettere alla prova le tue conoscenze.

Rispondi a queste domande

Domanda 1

Quale delle seguenti è la convenzione per la denominazione di un tipo generico?

<Gen>

<Generic>

<T>

<X>

Domanda 2

Una limitazione dei tipi consentiti per un tipo generico è chiamata:

▢ una limitazione generica

▢ un vincolo generico

▢ disambiguazione

▢ un limite di tipo generico

Domanda 3

Per reificazione si intende:

▢ È stato calcolato l'impatto dell'esecuzione reale di un oggetto.

▢ È stato impostato un indice di voci con limitazioni per il corso.

▢ Il parametro di tipo generico è stato trasformato in un tipo reale.

▢ È stato attivato un indicatore di errore remoto.

Passa alla lezione successiva: 6. Manipolazione funzionale

Per una panoramica del corso, inclusi i link ad altri codelab, vedi "Kotlin Bootcamp for Programmers: Welcome to the course" (Kotlin Bootcamp per programmatori: benvenuto al corso).