Kotlin Bootcamp for Programmers 5.2: Générique

Cet atelier de programmation fait partie du cours Kotlin Bootcamp for Programmers (Formation Kotlin pour les programmeurs). Vous tirerez le meilleur parti de ce cours si vous suivez les ateliers de programmation dans l'ordre. En fonction de vos connaissances, vous pouvez parcourir certaines sections. Ce cours s'adresse aux programmeurs qui connaissent un langage orienté objet et souhaitent apprendre l'Kotlin.

Introduction

Dans cet atelier de programmation, vous avez découvert des classes, des fonctions et des méthodes génériques, et comment elles fonctionnent en langage Kotlin.

Plutôt que de créer un exemple d'application, les leçons de ce cours sont conçues pour développer vos connaissances, mais elles ne doivent pas être indépendantes les unes des autres pour vous permettre de parcourir les sections que vous connaissez déjà. Pour les assembler, de nombreux exemples utilisent un thème d'aquarium. Et si vous voulez voir l'histoire complète de l'aquarium, consultez le cours Udacity Kotlin Bootcamp for Programmers.

Ce que vous devez déjà savoir

  • Syntaxe des fonctions, classes et méthodes Kotlin
  • Créer une classe dans IntelliJ IDEA et exécuter un programme

Points abordés

  • Utiliser des classes, des méthodes et des fonctions génériques

Objectifs de l'atelier

  • Créer une classe générique et ajouter des contraintes
  • Créer les types in et out
  • Créer des fonctions génériques, des méthodes et des extensions

Présentation des génériques

Tout comme de nombreux langages de programmation, Kotlin utilise des types génériques. Un type générique vous permet de rendre une classe générique, et ainsi de gagner en flexibilité.

Imaginez que vous implémentez une classe MyList contenant une liste d'éléments. Sans génériques, vous devrez implémenter une nouvelle version de MyList pour chaque type: une pour Double, une pour String et une pour Fish. Vous pouvez rendre la liste générique afin qu'elle puisse contenir n'importe quel type d'objet. C'est l'exemple d'un caractère générique adapté à de nombreux types.

Pour définir un type générique, ajoutez T entre chevrons <T> après le nom de la classe. Vous pouvez utiliser une autre lettre ou un nom plus long, mais la convention pour un type générique est la lettre "T".

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

Vous pouvez référencer T comme s'il s'agissait d'un type normal. Le type renvoyé pour get() est T, et le paramètre sur addItem() est de type T. Bien entendu, les listes génériques étant très utiles, la classe List est intégrée à Kotlin.

Étape 1: Définissez une hiérarchie de types

Au cours de cette étape, vous allez créer des classes à utiliser à l'étape suivante. Nous avons étudié la sous-classe dans un atelier de programmation précédent, mais voici un résumé.

  1. Pour pouvoir libérer l'exemple, créez un package sous src et appelez-le generics.
  2. Dans le package generics, créez un fichier Aquarium.kt. Cela vous permet de redéfinir des éléments avec les mêmes noms sans conflit. Le reste du code de cet atelier de programmation est donc stocké dans ce fichier.
  3. Définissez une hiérarchie des types d'approvisionnement en eau. Commencez par créer une classe open pour WaterSupply afin qu'elle puisse être sous-classée.
  4. Ajoutez un paramètre booléen var, needsProcessing. Cela crée automatiquement une propriété modifiable avec un getter et un setter.
  5. Créez une sous-classe TapWater qui étend WaterSupply et transmet true pour needsProcessing, car l'eau du robinet contient des additifs dangereux pour le poisson.
  6. Dans TapWater, définissez une fonction appelée addChemicalCleaners() qui définit needsProcessing sur false après avoir nettoyé l'eau. La propriété needsProcessing peut être définie depuis TapWater, car elle est public par défaut et accessible aux sous-classes. Voici le code final.
package generics

open class WaterSupply(var needsProcessing: Boolean)

class TapWater : WaterSupply(true) {
   fun addChemicalCleaners() {
       needsProcessing = false
   }
}
  1. Créez deux autres sous-classes de WaterSupply, appelées FishStoreWater et LakeWater. FishStoreWater n'a pas besoin d'être traitée, mais LakeWater doit être filtré à l'aide de la méthode filter(). Après le filtrage, il n'est pas nécessaire de le traiter à nouveau. Dans filter(), définissez needsProcessing = false.
class FishStoreWater : WaterSupply(false)

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

Pour en savoir plus, consultez la leçon précédente sur l'héritage en langage Kotlin.

Étape 2: Créer un cours générique

Au cours de cette étape, vous allez modifier la classe Aquarium pour qu'elle accepte différents types d'eau.

  1. Dans Aquarium.kt, définissez une classe Aquarium en saisissant <T> entre crochets.
  2. Ajoutez une propriété immuable waterSupply de type T à Aquarium.
class Aquarium<T>(val waterSupply: T)
  1. Écrivez une fonction appelée genericsExample(). Il ne fait pas partie d'une classe. Il peut donc se trouver au premier niveau du fichier, comme la fonction main() ou les définitions de classe. Dans la fonction, créez un Aquarium et transmettez-lui un WaterSupply. Le paramètre waterSupply étant générique, vous devez spécifier le type entre chevrons <>.
fun genericsExample() {
    val aquarium = Aquarium<TapWater>(TapWater())
}
  1. Dans genericsExample(), votre code peut accéder au waterSupply de l'aquarium. Comme il s'agit du type TapWater, vous pouvez appeler addChemicalCleaners() sans aucun type.
fun genericsExample() {
    val aquarium = Aquarium<TapWater>(TapWater())
    aquarium.waterSupply.addChemicalCleaners()
}
  1. Lors de la création de l'objet Aquarium, vous pouvez supprimer les chevrons et les éléments entre eux, car Kotlin possède une inférence de type. Il n'y a donc pas de raison de dire TapWater deux fois lorsque vous créez l'instance. Le type peut être déduit par l'argument à Aquarium. La valeur Aquarium sera quand même de type TapWater.
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    aquarium.waterSupply.addChemicalCleaners()
}
  1. Pour voir ce qui se passe, imprimez needsProcessing avant et après avoir appelé addChemicalCleaners(). La fonction ci-dessous est terminée.
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. Ajoutez une fonction main() pour appeler genericsExample(), puis exécutez votre programme et observez le résultat.
fun main() {
    genericsExample()
}
⇒ water needs processing: true
water needs processing: false

Étape 3: Soyez plus précis

Un code générique vous permet de transmettre presque tous les problèmes, ce qui constitue parfois un problème. Dans cette étape, vous allez rendre la classe Aquarium plus spécifique pour ce qu'elle peut contenir.

  1. Dans genericsExample(), créez un Aquarium en transmettant une chaîne pour le waterSupply, puis imprimez la propriété waterSupply de l'aquarium.
fun genericsExample() {
    val aquarium2 = Aquarium("string")
    println(aquarium2.waterSupply)
}
  1. Exécutez le programme pour observer le résultat.
⇒ string

Le résultat correspond à la chaîne que vous avez transmise, car Aquarium n'applique aucune restriction à T.Tous les types, y compris String, peuvent être transmis.

  1. Dans genericsExample(), créez un autre Aquarium en transmettant null pour waterSupply. Si la valeur de waterSupply est nulle, imprimez le "waterSupply is null".
fun genericsExample() {
    val aquarium3 = Aquarium(null)
    if (aquarium3.waterSupply == null) {
        println("waterSupply is null")
    }
}
  1. Exécutez votre programme et observez le résultat.
⇒ waterSupply is null

Pourquoi transmettre des null lorsque vous créez un Aquarium ? Cela est possible, car par défaut, T représente le type Any? pouvant être nul (type en haut de la hiérarchie des types). Les données suivantes sont équivalentes à celles que vous avez saisies précédemment.

class Aquarium<T: Any?>(val waterSupply: T)
  1. Pour ne pas autoriser la transmission de null, définissez explicitement T sur Any en supprimant ? après Any.
class Aquarium<T: Any>(val waterSupply: T)

Dans ce contexte, Any est appelé une contrainte générique. Cela signifie que n'importe quel type peut être transmis pour T tant qu'il n'est pas null.

  1. Assurez-vous que seul un élément WaterSupply (ou l'une de ses sous-classes) peut être transmis pour T. Remplacez Any par WaterSupply pour définir une contrainte générique plus spécifique.
class Aquarium<T: WaterSupply>(val waterSupply: T)

Étape 4: Ajoutez des vérifications

Cette étape consiste à vous familiariser avec la fonction check() afin de vous assurer que votre code se comporte comme prévu. La fonction check() est une fonction de bibliothèque standard en langage Kotlin. Il agit en tant qu'assertion et renvoie une erreur IllegalStateException si son argument a la valeur false.

  1. Ajoutez une méthode addWater() à la classe Aquarium pour ajouter de l'eau, avec un check() qui vous évite d'avoir à traiter l'eau en premier.
class Aquarium<T: WaterSupply>(val waterSupply: T) {
    fun addWater() {
        check(!waterSupply.needsProcessing) { "water supply needs processing first" }
        println("adding water from $waterSupply")
    }    
}

Dans ce cas, si needsProcessing est défini sur "true", check() renvoie une exception.

  1. Dans genericsExample(), ajoutez du code pour créer un Aquarium avec LakeWater, puis ajoutez-y de l'eau.
fun genericsExample() {
    val aquarium4 = Aquarium(LakeWater())
    aquarium4.addWater()
}
  1. Exécutez votre programme, et vous recevrez une exception, car l'eau doit d'abord être filtrée.
⇒ Exception in thread "main" java.lang.IllegalStateException: water supply needs processing first
        at Aquarium.generics.Aquarium.addWater(Aquarium.kt:21)
  1. Ajoutez un appel pour filtrer l'eau avant de l'ajouter au Aquarium. Désormais, lorsque vous exécutez votre programme, aucune exception n'est générée.
fun genericsExample() {
    val aquarium4 = Aquarium(LakeWater())
    aquarium4.waterSupply.filter()
    aquarium4.addWater()
}
⇒ adding water from generics.LakeWater@880ec60

La section ci-dessus aborde les bases des génériques. Les tâches suivantes en couvrent davantage, mais le concept important est de déclarer et d'utiliser une classe générique avec une contrainte générique.

Dans cette tâche, vous allez découvrir les types d'entrées et de sorties à l'aide de génériques. Un type in est un type qui ne peut être transmis que dans une classe, et non renvoyé. Un type out est un type qui ne peut être renvoyé qu'à partir d'une classe.

Examinez la classe Aquarium. Vous constaterez que le type générique n'est renvoyé que lors de l'obtention de la propriété waterSupply. Il n'existe aucune méthode utilisant une valeur de type T comme paramètre (sauf pour la définir dans le constructeur). Kotlin vous permet de définir les types out dans ce cas précis, et il peut déduire des informations supplémentaires sur les endroits où les types peuvent être utilisés en toute sécurité. De même, vous pouvez définir des types in pour les types génériques qui ne sont transmis que dans les méthodes, et non renvoyés. Cela permet à Kotlin d'effectuer des vérifications supplémentaires pour contrôler la sécurité du code.

Les types in et out sont des instructions concernant le système de types Kotlin. L'ensemble du système de types n'est pas expliqué dans ce tutoriel (le programme est assez impliqué). Toutefois, le compilateur signale les types qui ne sont pas correctement associés aux libellés in et out. Vous devez donc les connaître.

Étape 1: Définissez un type de sortie

  1. Dans la classe Aquarium, remplacez T: WaterSupply par le type out.
class Aquarium<out T: WaterSupply>(val waterSupply: T) {
    ...
}
  1. Dans le même fichier, en dehors de la classe, déclarez une fonction addItemTo() qui attend un Aquarium de WaterSupply.
fun addItemTo(aquarium: Aquarium<WaterSupply>) = println("item added")
  1. Appelez addItemTo() depuis genericsExample() et exécutez votre programme.
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    addItemTo(aquarium)
}
⇒ item added

Kotlin peut garantir que addItemTo() n'effectue aucune action non sécurisée avec le code générique WaterSupply, car il a été déclaré en tant que type out.

  1. Si vous supprimez le mot clé out, le compilateur génère une erreur lors de l'appel de addItemTo(), car Kotlin ne peut pas garantir que le contenu n'est pas dangereux.

Étape 2: Définissez un type

Le type in est semblable au type out, mais pour les types génériques qui ne sont transmis que dans des fonctions et qui ne sont pas renvoyés. Si vous essayez de renvoyer un type in, une erreur de compilation s'affichera. Dans cet exemple, vous allez définir un type in dans une interface.

  1. Dans Aquarium.kt, définissez une interface Cleaner qui utilise un T générique limité à WaterSupply. Il n'est utilisé qu'en tant qu'argument de clean(). Vous pouvez donc en faire un paramètre in.
interface Cleaner<in T: WaterSupply> {
    fun clean(waterSupply: T)
}
  1. Pour utiliser l'interface Cleaner, créez une classe TapWaterCleaner qui met en œuvre Cleaner pour nettoyer TapWater en ajoutant des produits chimiques.
class TapWaterCleaner : Cleaner<TapWater> {
    override fun clean(waterSupply: TapWater) =   waterSupply.addChemicalCleaners()
}
  1. Dans la classe Aquarium, mettez à jour le addWater() pour prendre un Cleaner de type T, puis nettoyez l'eau avant de l'ajouter.
class Aquarium<out T: WaterSupply>(val waterSupply: T) {
    fun addWater(cleaner: Cleaner<T>) {
        if (waterSupply.needsProcessing) {
            cleaner.clean(waterSupply)
        }
        println("water added")
    }
}
  1. Mettez à jour l'exemple de code genericsExample() pour créer un TapWaterCleaner avec un Aquarium avec TapWater, puis ajoutez de l'eau à l'aide du nettoyeur. Il utilisera le produit plus propre si nécessaire.
fun genericsExample() {
    val cleaner = TapWaterCleaner()
    val aquarium = Aquarium(TapWater())
    aquarium.addWater(cleaner)
}

Kotlin utilisera les informations de type in et out pour s'assurer que votre code utilise les informations génériques de manière sécurisée. Out et in sont faciles à retenir: les types out peuvent être transmis en tant que valeurs de retour, les types in en tant qu'arguments.

Si vous souhaitez en savoir plus sur les problèmes de type et de résolution, consultez la documentation pour en savoir plus.

Dans cette tâche, vous allez découvrir les fonctions génériques et leur utilisation. En règle générale, nous vous recommandons d'utiliser une fonction générique chaque fois qu'elle utilise un argument d'une classe de type générique.

Étape 1: Créez une fonction générique

  1. Dans generics/Aquarium.kt, créez une fonction isWaterClean() qui utilise un Aquarium. Vous devez spécifier le type générique du paramètre. Vous avez la possibilité d'utiliser WaterSupply.
fun isWaterClean(aquarium: Aquarium<WaterSupply>) {
   println("aquarium water is clean: ${aquarium.waterSupply.needsProcessing}")
}

Cela signifie que Aquarium doit disposer d'un paramètre de type out pour que cet appel soit appelé. Parfois, out ou in sont trop restrictifs, car vous devez utiliser un type à la fois pour les entrées et les sorties. Vous pouvez supprimer l'exigence out en rendant la fonction générique.

  1. Pour que la fonction soit générique, ajoutez des chevrons après le mot clé fun avec un type générique T et toute contrainte, dans ce cas WaterSupply. Modifiez Aquarium de sorte que la contrainte soit appliquée par T et non par WaterSupply.
fun <T: WaterSupply> isWaterClean(aquarium: Aquarium<T>) {
   println("aquarium water is clean: ${!aquarium.waterSupply.needsProcessing}")
}

T est un paramètre de type isWaterClean() qui permet de spécifier le type générique de l'aquarium. Ce schéma est très courant et il est utile de prendre un moment pour y parvenir.

  1. Appelez la fonction isWaterClean() en spécifiant le type entre chevrons juste après le nom de la fonction et avant les parenthèses.
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    isWaterClean<TapWater>(aquarium)
}
  1. L'inférence de type provenant de l'argument aquarium est nécessaire pour le supprimer. Vous devez donc le supprimer. Exécutez votre programme et observez le résultat.
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    isWaterClean(aquarium)
}
⇒ aquarium water is clean: false

Étape 2: Créez une méthode générique avec un type rectifié

Vous pouvez également utiliser des fonctions génériques pour des méthodes, même dans les classes ayant leur propre type générique. Dans cette étape, vous allez ajouter à Aquarium une méthode générique pour vérifier si elle est de type WaterSupply.

  1. Dans la classe Aquarium, déclarez une méthode hasWaterSupplyOfType() qui utilise un paramètre générique R (T est déjà utilisé) limité à WaterSupply, et renvoie true si waterSupply est de type R. Il s'agit de la fonction que vous avez déclarée précédemment, mais dans la classe Aquarium.
fun <R: WaterSupply> hasWaterSupplyOfType() = waterSupply is R
  1. Notez que l'élément R final est souligné en rouge. Passez la souris dessus pour découvrir l'erreur.
  2. Pour effectuer une vérification is, vous devez indiquer à Kotlin que le type est réifié (ou réel) et peut être utilisé dans la fonction. Pour ce faire, ajoutez inline devant le mot clé fun et reified devant le type générique R.
inline fun <reified R: WaterSupply> hasWaterSupplyOfType() = waterSupply is R

Une fois un type redéfini, vous pouvez l'utiliser comme un type normal, car il est réel après intégration. Cela signifie que vous pouvez effectuer des vérifications is à l'aide du type choisi.

Si vous n'utilisez pas reified ici, le type ne sera pas suffisant pour que Kotlin puisse autoriser les vérifications is. C'est parce que les types non unifiés ne sont disponibles qu'au moment de la compilation et ne peuvent pas être utilisés au moment de l'exécution par votre programme. Ce point est abordé plus en détail dans la section suivante.

  1. Transmettre TapWater comme type Comme pour appeler des fonctions génériques, appelez des méthodes génériques en utilisant des chevrons dont le type est situé après le nom de la fonction. Exécutez votre programme et observez le résultat.
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    println(aquarium.hasWaterSupplyOfType<TapWater>())   // true
}
⇒ true

Étape 3: Créez des fonctions d'extension

Vous pouvez également utiliser les types rectifiés pour les fonctions standards et les fonctions d'extension.

  1. En dehors de la classe Aquarium, définissez une fonction d'extension sur WaterSupply appelée isOfType(), qui vérifie si WaterSupply est de type spécifique, par exemple TapWater.
inline fun <reified T: WaterSupply> WaterSupply.isOfType() = this is T
  1. Appelez la fonction d'extension comme une méthode.
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    println(aquarium.waterSupply.isOfType<TapWater>())  
}
⇒ true

Avec ces fonctions d'extension, peu importe le type de Aquarium (Aquarium, TowerTank ou une autre sous-classe), tant qu'il s'agit d'une propriété Aquarium. La syntaxe star-projection est un moyen pratique de spécifier différentes correspondances. Et lorsque vous utilisez une projection étoile, Kotlin veillera à ce que vous ne fassiez rien d'autre.

  1. Pour utiliser une projection étoile, ajoutez <*> après Aquarium. Déplacez hasWaterSupplyOfType() pour en faire une fonction d'extension, car elle ne fait pas vraiment partie de l'API principale de Aquarium.
inline fun <reified R: WaterSupply> Aquarium<*>.hasWaterSupplyOfType() = waterSupply is R
  1. Remplacez l'appel par hasWaterSupplyOfType() et exécutez votre programme.
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    println(aquarium.hasWaterSupplyOfType<TapWater>())
}
⇒ true

Dans l'exemple précédent, vous deviez marquer le type générique comme reified et définir la fonction inline, car Kotlin doit les connaître au moment de l'exécution, et pas seulement du temps de compilation.

Tous les types génériques ne sont utilisés qu'au moment de la compilation par Kotlin. Cela permet au compilateur de s'assurer que vous faites tout cela en toute sécurité. Lors de l'exécution, tous les types génériques sont effacés, d'où le message d'erreur précédent sur la vérification d'un type effacé.

Le compilateur peut créer un code correct sans conserver les types génériques jusqu'à l'exécution. Mais cela implique parfois d'effectuer certaines opérations, comme vérifier des types génériques sur is, que le compilateur ne peut pas prendre en charge. C'est pourquoi Kotlin a ajouté des types concrets ou réels.

Pour en savoir plus sur les types unifiés et l'effacement des types, consultez la documentation Kotlin.

Dans cette leçon, nous nous sommes concentrés sur les termes génériques, qui sont importants pour rendre le code plus flexible et plus facile à réutiliser.

  • Créez des classes génériques pour rendre le code plus flexible.
  • Ajoutez des contraintes génériques pour limiter les types utilisés avec des caractères génériques.
  • Utilisez les types in et out avec des caractères génériques pour améliorer la vérification du type afin de limiter les types qui sont transmis ou renvoyés par les classes.
  • Créer des fonctions et des méthodes génériques pour utiliser des types génériques Exemples :
    fun <T: WaterSupply> isWaterClean(aquarium: Aquarium<T>) { ... }
  • Les fonctions d'extension génériques permettent d'ajouter des fonctionnalités qui ne font pas partie de la suite principale à une classe.
  • Les types redéfinis sont parfois nécessaires en raison de l'effacement des types. Contrairement aux types génériques, les types améliorés sont conservés pendant l'exécution.
  • Utilisez la fonction check() pour vérifier que votre code s'exécute comme prévu. Exemple:
    check(!waterSupply.needsProcessing) { "water supply needs processing first" }

Documentation Kotlin

Si vous souhaitez en savoir plus sur un sujet de ce cours ou si vous rencontrez des difficultés, https://kotlinlang.org constitue le meilleur point de départ.

Tutoriels Kotlin

Le site Web https://try.kotlinlang.org propose des tutoriels enrichis appelés Kotlin Koans, un interpréteur Web ainsi qu'une documentation de référence complète avec des exemples.

Cours Udacity

Pour afficher le cours Udacity sur ce sujet, consultez Kotlin Bootcamp for Programmers (Formation Kotlin pour les programmeurs).

IntelliJ IDEA

Consultez la documentation sur IntelliJ IDEA sur le site Web de JetBrains.

Cette section répertorie les devoirs possibles pour les élèves qui effectuent cet atelier de programmation dans le cadre d'un cours animé par un enseignant. C'est à l'enseignant de procéder comme suit:

  • Si nécessaire, rendez-les.
  • Communiquez aux élèves comment rendre leurs devoirs.
  • Notez les devoirs.

Les enseignants peuvent utiliser ces suggestions autant qu'ils le souhaitent, et ils n'ont pas besoin d'attribuer les devoirs de leur choix.

Si vous suivez vous-même cet atelier de programmation, n'hésitez pas à utiliser ces devoirs pour tester vos connaissances.

Répondez à ces questions

Question 1

Quelle proposition correspond à la convention pour nommer un type générique ?

<Gen>

<Generic>

<T>

<X>

Question 2

Une restriction applicable aux types autorisés pour un type générique est appelée:

▢ une restriction générique

▢ une contrainte générique

Désambigulation de ▢

▢ une limite de type générique

Question 3

"Signifié" signifie:

▢ L'impact réel de l'exécution d'un objet a été calculé.

▢ L'index d'entrée à accès limité a été défini sur la classe.

▢ Le type générique est devenu un type réel.

▢ Un indicateur d'erreur à distance s'est déclenché.

Passez à la leçon suivante: 6. Manipulation fonctionnelle

Pour une présentation du cours, y compris des liens vers d'autres ateliers de programmation, consultez le "Kotlin Bootcamp for Programmers: Welcome to the course."