Introduction au langage Kotlin

Laurent Bernabe

Ce tutoriel est une introduction au langage Kotlin pour les développeurs connaissant déjà le langage Java (ou autre langage oriente objet).

- Titre : Introduction au langage Kotlin

- Auteur : Laurent Bernabe

- Parution : 1 mai 2020

- Public visé :

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2020 Laurent Bernabe. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Presentation et installation

- I-A. Presentation

- I-B. Installation

- I-C. Première prise en main

- I-C-1. Création du projet

- I-C-2. Ajout du fichier source et exécution

- I-D. Création d'un Jar autoexécutable

- II. Bases du langage

- II-A. Les commentaires

- II-B. Déclarations de variables

- II-C. Types de base

- II-D. Les collections natives

- II-D-1. Les tableaux

- II-D-2. Les listes

- II-D-3. Les tables associatives

- II-D-4. Les sets

- II-E. Les imports / espaces de nommage

- II-F. Interpolation de chaînes

- II-G. Inférence de type

- II-H. Expressions

- II-I. Tuples et déconstruction

- II-J. Structures de contrôle

- II-J-1. L'instruction while

- II-J-2. L'instruction if

- II-J-3. L'instruction for

- II-J-4. L'instruction when

- II-J-4-a. Cas ultra simple

- II-J-4-b. Test en fonction d'une variable en entree

- II-J-4-c. Pas d'obligation de tester des constantes

- II-J-4-d. Exécution de bloc

- II-J-4-e. Regrouper plusieurs valeurs de test

- II-J-4-f. Test du type de données

- II-K. La conversion de type

- II-K-1. La conversion classique

- II-K-2. Le SmartCast

- II-K-3. La conversion de types numériques

- II-L. Null safety

- III. Focus sur les fonctions

- III-A. Les bases

- III-A-1. Fonction sans valeur de retour ni paramètre

- III-A-2. Fonction sans valeur de retour mais avec paramètre

- III-A-3. Fonction avec un paramètre ayant une valeur par défaut

- III-A-4. Fonction avec une valeur de retour

- III-A-5. La valeur de retour est une simple expression

- III-A-6. Appel avec un ordre de paramètres différent

- III-A-7. Fonction dans une fonction

- III-A-8. Fonctions variadiques

- III-B. Fonctions d’ordre supérieur

- III-B-1. Les exemples simples de la librairie standard

- III-B-1-a. La fonction map

- III-B-1-b. La méthode filter

- III-B-1-c. La méthode reduce

- III-B-2. Codage d’une fonction d’ordre supérieur

- III-C. Fonctions anonymes et lambdas

- III-C-1. Fonction anonyme

- III-C-2. Lambda

- III-C-2-a. Utilisation simple

- III-C-2-b. Lambda en tant que dernier paramètre d’une fonction

- III-C-2-c. Lambda à paramètre unique

- IV. Les classes : syntaxe de base

- IV-A. Classe vide

- IV-B. Classe englobant des propriétés et méthodes

- IV-C. Visibilité des propriétés et méthodes dans les classes

- IV-D. Personnalisation des propriétés mutables

- V. Utilisation plus élaborée des classes

- V-A. Constructeur primaire

- V-B. Constructeurs auxiliaires

- V-C. Classes imbriquées

- V-D. Les Objets

- V-E. Les Objets compagnons (companion object)

- V-F. Les fonctions d’extension

- V-G. Les propriétés d’extension

- V-H. Les compagnons d’objet d’extensions

- V-I. Introduction basique aux génériques

- V-I-1. Fonction générique

- V-I-2. Classe générique

- V-I-3. Aller plus loin

- VI. L’héritage de classes

- VI-A. Bases de l’héritage

- VI-A-1. Héritage simple

- VI-A-2. La visibilité protected

- VI-A-3. Héritage et constructeurs secondaires

- VI-B. Héritage plus élaboré

- VI-B-1. Héritage de classes abstraites

- VI-B-2. Héritage d’interfaces

- VI-B-3. Héritage d’une classe et d’interfaces simultanée

- VI-B-4. Le polymorphisme

- VII. Autres notions liées aux classes

- VII-A. Objet compagnon

- VII-B. Classe anonyme

- VII-C. Les classes de données

- VII-D. Les classes scellées

- VII-E. Les classes énumérations

- VII-F. La surcharge d’opérateurs

I - Presentation et installation

I-A - Presentation

Le langage Kotlin est un langage développé par JetBrains, entreprise conceptrice de l'EDI Intellij Idea entre autres. Cependant il est open-source.

Ses principaux avantages, pour reprendre les principaux arguments du site officiel, résident dans la simplification du code pour de nombreuses taches courantes (par exemple, la création de POJO), une sécurité de code accrue grâce a un contrôle plus stricte que Java (notamment pour la nullité des valeurs), et une facilite d'interaction vers et a partir du langage Java et avec la JVM - ce qui permet notamment la réutilisation directe de la plupart des codes écrits en Java,

Le langage Kotlin est un langage qui a plusieurs déclinaisons :

- une version visant une interaction avec le langage Java, et qui est compile en bytecode afin d’être exécuté sur la JVM ou sur les périphériques Android,

- une version visant a être compilée pour le langage Javascript,

- une version dite native, visant a être compilée directement en exécutable indépendant de toute plateforme interprétation.

Ce tutoriel a pour objectif l'enseignement de la version pour la JVM, même s'il ne devrait pas être ardu de passer a l'une des deux autres versions.

Il vise principalement les développeurs étant familiers avec le langage Java et la POO : même si tout langage impératif et oriente objet devrait convenir.

I-B - Installation

Nous allons ici aborder l'environnement de développement.

Sachez également qu'il est possible de tester les codes directement en ligne (y compris pour les projets incluant plusieurs fichiers).

Ce tutoriel se basera sur l’éditeur Intellij Idea (la version Community est gratuite), mais est adaptable a tout éditeur. Sachez aussi que pour mieux gérer les différentes versions et mises a jour d'Intellij Idea, vous disposez aussi du programme gratuit JetBrain Toolbox.

Par ailleurs, ce tutoriel est base sur la version 1.3.70 de Kotlin et la version d'Intellij Idea Community 2019.2.

Afin d'installer l'environnement de développement, nous pouvons procéder comme suit :

1. Nous utiliserons Kotlin a travers la JVM : nous avons donc besoin d'installer Java correspondant a notre Systeme d'Exploitation, si ce n'est déjà fait : Page de telechargement du SDK Java 8 (la version 8 de Java devrait être amplement suffisante),

2. Nous pouvons installer la version d'Intellij Idea Community correspondant a notre système (ou passer par la JetBrains Toolbox afin de mieux gérer les mises a jour),

3. Afin de bénéficier de la coloration syntaxique et de la vérification syntaxique du code par l'EDI, nous pouvons aussi installer le plugin Kotlin. Pour cela cliquez d'abord sur le bouton Configure dans l'accueil (fig 1.1), et choisissez le menu Plugins. Il se peut que vous ne soyez pas dans la fenêtre d'accueil, mais qu'un projet soit déjà chargé : dans ce cas il suffit simplement de refermer le projet (menu File→Close project) pour revenir a l’accueil. Activez l'onglet « Marketplace », cherchez le plugin Kotlin et installez-le (fig 1.2) (dans mon cas, le plugin a déjà été installe), puis cliquez sur Restart Intellij IDEA (puis validez avec Ok puis Restart) une fois que cela est fait afin de prendre en compte les mises a jour.

Fig 1.1 : bouton « configure »

(cliquez ici pour aggrandir l'image)

Fig 1.2 : installation du plugin Kotlin

(cliquez ici pour aggrandir l'image)

I-C - Première prise en main

Nous allons créer un simple programme affichant « Bonjour, les développeurs ! » depuis Intellij Idea en Kotlin, sans rien installer de plus : grâce au logiciel Gradle intégré dans notre EDI.

I-C-1 - Création du projet

1. Démarrez un nouveau projet de Kotlin/JVM et passez a l’étape suivante (fig 1.3),

2. Nommez le projet comme bon vous semble, pour ma part j'ai choisit « BonjourLesDevs ». Par contre, assurez-vous que le JDK est configure. (Sinon le bouton « New… » vous permet de le définir) (fig 1.4). Cela étant assure, vous pouvez valider la création du projet (bouton « Finish »).

Fig 1.3: projet de type Kotlin/JVM

(cliquez ici pour aggrandir l'image)

Fig 1.4 : création du projet en précisant le SDK Java

(cliquez ici pour aggrandir l'image)

I-C-2 - Ajout du fichier source et exécution

1. Une fois le projet généré et ouvert par l'IDE, accédez au menu contextuel du dossier src juste crée et sélectionnez le menu New → Kotlin File/Class… choisissez le type File, et entrez le nom du fichier Hello (sans extension) (fig 1.5),

2. Entrez le code suivant dans le fichier Hello.kt :

Hello.kt

/*
La fonction main est "rendue disponible" dans la classe HelloKt
(com.developpez.HelloKt, plus precisement).
*/
package com.developpez

fun main(args: Array<String>){
println("Bonjour, les developpeurs !")
}

Vous pouvez alors exécuter le programme en cliquant sur la flèche verte a cote de la fonction main (choisissez l'option Run), ou également sur la flèche verte dans la zone d’exécution, où est d'ailleurs affiche le message de notre programme (fig 1.6).

Fig 1.5: création du fichier source

(cliquez ici pour aggrandir l'image)

Fig 1.6: exécution du programme

(cliquez ici pour aggrandir l'image)

Voici quelques remarques :

- Il n'y a pas obligation de terminer une instruction par un point-virgule (contrairement au Java),

- La notion de package est beaucoup plus souple qu'en Java : pas d'obligation d'utiliser un package correspondant au dossier où réside le fichier : dans ce cas-la, le package sera com.developpez, bien que le fichier soit situe a la racine des fichiers sources (src/main/java).

- C'est le mot-clé fun qui permet de déclarer une fonction,

- La fonction main doit être déclarée comme acceptant un tableau de String et sans valeur de retour,

- Remarquez également qu'en Kotlin un tableau est une classe générique (Array),

- également une déclaration se constitue d'abord du nom de la variable et ensuite du type,

- La fonction println, disponible immédiatement et sans aucun import, permet d'afficher du texte a l’écran et sans aucun retour a la ligne. C'est l’équivalent de l'appel fastidieux System.out.printlnde Java,

- Enfin, ce code n'a pas déclaré de classe : contrairement au Java où toute fonction doit être membre d'une classe. Il n'y aucun package déclaré non plus. Elle sera donc déclarée dans une classe nommée HelloKt, qui représente l'ensemble des déclarations au niveau du fichier Hello.kt. Pour vous en convaincre, vous pouvez regarder le contenu du dossier out, a la racine du projet.

Mais il nous reste cependant une notion essentielle de configuration a voir : comment réaliser un Jar autoexécutable ?

I-D - Création d'un Jar autoexécutable

Il faut d'abord créer la configuration nous permettant une telle compilation :

1. Accédez d'abord au menu File→Project Structure…,

2. Sélectionnez la section Artifacts, dans la zone gauche de la nouvelle fenêtre de configuration,

3. Effectuez alors une command Add→Jar→From module with dependencies… (Fig 1.7),

4. Dans la nouvelle fenêtre, sélectionnez la classe principale, c'est-a-dire dans notre situation la seule classe disposant d'une fonction main, et la seule classe du projet tout court. L'option « Extract to the target JAR » est correcte, veuillez la sélectionner si ce n'est pas le cas, de même l'option par défaut pour le chemin du Manifest est aussi correct (Fig 1.8),

5. Vous pouvez alors nommer le Jar et valider la configuration.

Fig 1.7: création de l'artifact pour le jar

(cliquez ici pour aggrandir l'image)

Fig 1.8 : options de l'artifact

(cliquez ici pour aggrandir l'image)

Ensuite, il nous faut réaliser la compilation proprement dite :

1. Exécutez le menu Buid→Build artifacts…,

2. Dans le menu qui apparaît, choisissez le menu BonjourLesDevs:jar→Build (il y a aussi des options pour effacer le jar généré entre autres),

3. Après avoir rafraîchit la liste des fichiers (menu contextuel Syncronize sur le nœud racine), vous pouvez trouver le jar autoexécutable dans le dossier out/artifacts/BonjourLesDevs_jar.

Il est possible de s'assurer que le jar produit fonctionne bien en accédant au terminal intégré (qui sera alors directement positionne sur le dossier racine du projet) (fig 1.9) et en y exécutant les commandes suivantes, dans l'ordre :

Test du jar autoexécutable

$> cd out/artifacts/BonjourLesDevs_jar
$> java -jar BonjourLesDevs.jar

Fig 1.9: test du jar dans le terminal

(cliquez ici pour aggrandir l'image)

Remarquez que les commandes exécutées dans le terminal intégré sont similaires aux commandes d'un terminal BASH y compris dans le format de dossiers. Une fois dans le dossier approprie (C'est la commande cd qui permet de le faire en environnement Linux), la commande effectuée pour lancer l'application est la commande standard java -jar #NomJar.jar.

Vous disposez donc d'un fichier Jar autonome.

II - Bases du langage

Dans ce chapitre, nous allons voir les bases de la syntaxe.

II-A - Les commentaires

Les commentaires fonctionnent comme en Java

// Commentaire en une ligne

/*
Commentaire sur plusieurs lignes
*/

II-B - Déclarations de variables

En Kotlin, on distingue les variables dont on peut changer la valeur, et les variables immuables qui ne sont pas tout a fait des constantes :

- le mot-clé val permet de déclarer une variable immuable,

- le mots-clés var permet de déclarer une variable altérable.

val nom:String = "Toto"
// nom = "Dodo" // Interdit!!! Car nom a ete declare avec val
var age = 10
age += 12 // aucun probleme car age est alterable.

Pour rappel, il faut préciser le type de variable après le nom, et pas avant comme en Java.

Évidemment, une variable immuable doit être initialisée lors de sa déclaration.

Cela fonctionne aussi bien pour les types « primitifs » (tout est objet en Kotlin) que pour les types personnels.

class Personne(val nom: String, var age: Int = 10)

val jean:Personne = Personne("Jean", 25)

Même si les classes en Kotlin seront expliquées ultérieurement, sachez qu'ici la classe Personne dispose d'une propriété nom en lecture-seule, et d'une propriété age en lecture-écriture, dont la valeur par défaut est 10. (Notez bien cependant qu'ici la classe Personne n'a pas défini de méthode modificatrice, mais si c’était le cas, il aurait aussi été probable que la variable jean puisse voir son état modifie bien qu'ayant été déclarée comme val ! Ici le mot-clé val empêche juste de changer l'objet pointe par jean.). Aussi, on n’utilise pas de mot-clé new, contrairement au Java.

II-C - Types de base

Les types numériques Byte, Short, Int, Long, Float, et Double sont équivalents aux types existants en Java : si ce n'est que, pour rappel, en Kotlin, tout est objet. De surcroît Kotlin dispose d'un mécanisme, dont nous reparlerons ultérieurement, permettant « d'ajouter » des méthodes aux classes existantes (donc y compris nos classes personnelles et les classes qui nous sont proposées par les librairies). Ainsi on peut écrire :

2.toString()
10.downTo(0) // genere le range (c'est a dire l'intervalle) decroissant   de 10 a 0.

Le type Boolean fonctionne aussi comme en Java :

val moinsDe18 = age < 18
val estLucas = nom == "Lucas"
val trouve = moinsDe18 && estLucas

Le type String dispose des mêmes méthodes qu'en Java, mais aussi des méthodes supplémentaires (liste complète ici). Ainsi on peut écrire :

println("martin".all{it.isLowerCase()}) // Teste si le mot entier est en basse casse.

La méthode all, permettant de tester si tous les éléments d'une collection remplissent un critère, n'existe pas en Java : c'est une extension du langage Kotlin. Le morceau de code constitue par les accolades est une fonction anonyme (ou lambda) : il en sera question ultérieurement.

Kotlin ajoute aussi le type Range (intervalle), très utile.

Ainsi on peut notamment exécuter une boucle grâce a un objet Range (en effet, la syntaxe basique Java pour effectuer une boucle n'existe pas en Kotlin) :

for (i in 3..7){
println(i)
} // Affiche les chiffres de 3 a 7 sur plusieurs lignes.

Mais on peut aussi obtenir une intervalle décroissant avec downTo :

for (i in 10 downTo 0) {
println(i)
} // Compte a rebours de 10 a 0, sur plusieurs lignes.

Il est aussi possible de préciser une progression autre que 1 :

for (i in 2..36 step 3) println(i) // Affiche les nombres 2,5,8,&#8230;,35

Il est aussi possible de combiner un intervalle décroissant et une progression définie :

for (i in 36 downTo 2 step 3) println(i) // Affiche les nombres 36,33,&#8230;,3

Nous reviendrons plus tard dans ce chapitre sur la boucle for.

Sachez aussi, comme nous le verrons plus tard avec les fonctions, le type void n'existe pas en Kotlin, mais on utilise a la place le type Unit (qui ne s'applique pas qu'aux fonctions).

N'oubliez pas que vous pouvez vous rendre compte des méthodes disponibles pour un objet donne dans le playground Kotlin ou dans l'IDE Intellij Idea grâce a la commande CTRL+ESPACE après avoir saisi le point accole a l'objet.

Nous reviendrons plus loin sur les tableaux (array) ainsi que sur les tables d'associations (map) dans la prochaine section.

II-D - Les collections natives

En Kotlin, il n'y a pas besoin d'importer quoi que ce soit pour utiliser les collections. De plus, grâce au mécanisme de fonctions d'extension (que nous verrons ultérieurement lors du prochain chapitre sur les classes), les collections bénéficient de plusieurs méthodes supplémentaires par rapport a leurs équivalents Java.

II-D-1 - Les tableaux

Tout d'abord, en Kotlin, les tableaux sont représentés par la classe générique Array : ainsi l'on peut déclarer par exemple, un Array<Int>, un Array<String>, voire même un Array<Personne> (type personnel défini peu avant dans ce chapitre). Évidement, un Array ne peut contenir que des valeurs d'un même type commun.

On peut construire un Array de deux manières différentes : soit par le constructeur Array, soit par la fonction arrayOf. La fonction arrayOf est plus proche de ce que l'on connaît en Java, tandis que le constructeur Array permet de bénéficier de plus de contrôle sur l'initialisation :

val tableau1 = arrayOf(2,10,-1,4,9)
val tableau2 = Array(10, { i -> i * 2 }) // utilisation d'une fonction lambda (fonction anonyme)
assert(tableau1[1] == 10) // On accede aux elements comme en Java
tableau1[0] = 170
assert(tableau1[0] == 170) // De meme pour la modification

Ici j'utilise notamment l'instruction assert, dans un but purement illustratif : si l'expression contenue en son appel est fausse, une exception est lancée, sinon, il ne se passe rien de nouveau. Ainsi c'est un simple moyen d'illustrer la valeur de la variable ainsi testée au lecteur.

Plusieurs remarques au sujet de l'initialisation via le constructeur :

- le premier paramètre décrit le nombre d'éléments du tableau

- le deuxième est une fonction qui prend l'index de l’élément à initialiser et qui retourne une valeur. Bien qu'ici j'ai utilise une fonction anonyme pour la clarté du code, je pouvais aussi bien utiliser une fonction régulière. Même si nous verrons les fonctions ultérieurement, vous pouvez également remarquer qu'il s'agit d'une situation où l'on passe une fonction a une fonction (ici, la fonction anonyme au constructeur).

Rien ne nous empêche de déclarer des tableau multidimensionnels, que ce soit par le biais du constructeur ou de la fonction arrayOf :

val tableau1 = arrayOf(arrayOf(1,2,3), arrayOf(4,5))
val tableau2 = Array(3, {i -> Array(3, {j -> i*j})})

Évidemment, les tableaux gardent une taille fixe.

Les tableaux disposent aussi de fonctions supplémentaires par rapport a leur version Java. Parmi la multitude de fonctions, citons :

val tab = arrayOf(7,10,15,3,6,9,12)
tab.sum()
tab.sort()
tab.sorted() // idem que sort() mais se contente de retourner le resultat au lieu de modifier le tableau contenu dans tab
tab.reverse()
tab.reversed()
tab.min()
tab.max()
tab.first()
tab.last()
tab.take(3) // les 3 premiers elements du tableau sans le modifier
tab.drop(3) // le tableau sans ses 3 premiers elements sans le modifier

II-D-2 - Les listes

Les listes sont similaires aux tableaux, mais sont plus puissantes, du moins dans leur version modifiable. Car en effet, car comme d'autres types de collections, il y a les listes immuables (non modifiables) et les listes modifiables. Tout dépend de la manière dont elles sont déclarées.

val maListeNonModifiable = listOf(1,2,3)
// Interdit !!! maListeNonModifiable[2] = 6;

val maListeModifiable = mutableListOf(1,2,3)
maListeModifiable[2] = 6;
maListeModifiable += 10; // Equivalent a maListeModifiable.add(10)

print(maListeModifiable) // => [1, 2, 6, 10]

Ainsi :

- on peut créer une liste immuable grâce a la fonction listOf(), et créer une liste modifiable par l’intermédiaire de la fonction mutableListOf(). Ces deux méthodes sont par ailleurs génériques, mais l’inférence de type nous permet d'eviter d'en déclarer le type,

- vous pouvez constater que l'on ne peut pas modifier un élément d'un objet List, contrairement a un objet MutableList,

- on peut facilement ajouter des éléments aux listes modifiables.

Pourquoi aurait-on alors intérêt a utiliser les listes non modifiables ? Parmi les cas utiles, on peut citer la création d'algorithmes, notamment parce que les listes immuables garantissent l’immutabilité - notion très utile dans la programmation fonctionnelle - et qu'elles peuvent facilement être interprétées en tant que « schéma » via un mécanisme que l'on appelle la déconstruction, que nous verrons plus loin, et qui est aussi très utilise dans la programmation fonctionnelle.

Par ailleurs, rien ne nous empêche d’écrire du code tel que le suivant, générant ainsi une liste immuable a partir d'une autre.

val maListeNonModifiable = listOf(1,2,3)
val maListe2 = maListeNonModifiable + 5 // maListeNonModifiable reste telle quelle.

Le type MutableList étant dérivé du type List, elles ont de nombreuses méthodes communes qui peuvent s’avérer très utiles. On peut notamment citer :

val maListeImmuable = listOf(1,2,3,4,5)
maListeImmuable.isEmpty() // Est-elle vide ?
maListeImmuable.contains(2) // Le chiffre 2 y figure-t-il ?
maListeImmuable.all({it % 2 == 0}) // Ne contient que des chiffres pairs ?
maListeImmuable.chunked(2) // => [[1, 2], [3, 4], [5]]
maListeImmuable.count({it % 2 == 0}) // Combien de chiffres pairs a-t-elle ?
maListeImmuable.take(3) // Retourne une liste juste avec les 3 premiers elements
maListeImmuable.drop(2) // Retourne une liste sans les 2 premiers elements

Certaines lignes du code précédent utilisent les lambdas, des fonctions anonymes que nous verrons ultérieurement.

N’hésitez pas a aller consulter les méthodes disponibles pour List et MutableList.

II-D-3 - Les tables associatives

Kotlin supporte aussi les tables associatives : collections qui associent leurs valeurs a des clés, ou Map en anglais.

Comme pour les listes, il y a les tables associatives immuables (Map) et les tables associatives modifiables (MutableMap), et on peut le instancier grâce aux fonctions mapOf() et mutableMapOf()

val monDictionnaireImmuable = mapOf("Sandra" to 27, "Alex" to 10)
val monDictionnaireModifiable = mutableMapOf("Sandra" to "0102030405", "Alex" to "0104050607")

monDictionnaireModifiable += ("Beatrice" to "0809101112")
monDictionnaireModifiable["Sandra"] = "0802030405"
val monDictionnaireImmuableEnrichi = monDictionnaireImmuable + ("Beatrice" to 30)

Elles disposent elles aussi de nombreuses méthodes qui peuvent vous simplifier le développement :

val monDictionnaireImmuable = mapOf("Sandra" to 27, "Alex" to 10)

monDictionnaireImmuable.getOrDefault("Beatrice", 33) // Retourne l'age de Beatrice ou 33 si elle ne figure pas dans la table
monDictionnaireImmuable.all({ it.value > 18 }) // Indique si toutes les personnes de la table sont majeur
monDictionnaireImmuable.any({ it.key == "Sandra" }) // Sandra est-elle dans la table&#160;?
monDictionnaireImmuable.contains("Sandra") // Idem
monDictionnaireImmuable.filter({ it.value > 18 }) // Retourne une table mais en ne gardant que les personnes majeures (ne modifie pas la table originale)
monDictionnaireImmuable.map({ it.value - 10 }) // Retourne une liste des ages des personnes, auquels on aura retranche 10
monDictionnaireImmuable.count() // Le nombre d'associations de la table

Encore une fois, n’hésitez pas a aller regarder les documentations officielles pour ces deux classes, dont j'ai passe les liens plus haut.

II-D-4 - Les sets

L'utilisation des Sets est elle aussi intuitive :

val mesPiecesCapturees = setOf("Cavalier", "Dame", "Fou")
val mesPiecesCaptureesV2 = mesPiecesCapturees - "Dame"

val mesComics = mutableSetOf("Spiderman", "Batman", "Superman")
mesComics += "Green Lantern"

De même, vous pouvez retrouver les API pour les classes Set (immuable) et MutableSet (modifiable). Quelques méthodes parmi tant d'autres :

val mesPiecesCapturees = setOf("Cavalier", "Dame", "Fou")
mesPiecesCapturees.count() // Nombre d'elements
mesPiecesCapturees.filter{it.startsWith("C")} // Set filtre ne retenant que les elements commen&#231;ant par &#171;&#160;C&#160;&#187;
mesPiecesCapturees.map{it.toLowerCase()} // Set o&#249; les elements ont ete convertis en minuscule
mesPiecesCapturees.isEmpty() // Est-il vide&#160;?
mesPiecesCapturees.isNotEmpty() // A-t-il des elements&#160;?
mesPiecesCapturees.maxBy{it.length} // Quel element est le plus long&#160;?

II-E - Les imports / espaces de nommage

Les imports et espaces de nommage fonctionnent dans l'ensemble comme en Java

package com.monprojet.test // Definit un espace de nommage

import com.projetimporte.* // Importe tout le paquetage
import com.projet2.classe1 as cls1 // Importe une classe et lui attribue un alias

A noter :

- un espace de nommage n'a pas pour obligation de respecter le chemin du fichier : cela peut être n'importe quelle valeur acceptable,

- il n'y a pas d'import statique en Kotlin : il faut simplement utiliser la syntaxe standard d'import sur l'Object en question (car -nous le verrons plus tard- il n'y a pas de méthode statique proprement dite en Kotlin, mais une classe de type Singleton, c'est a dire une classe de type Object),

- il est possible d'importer des fonctions globales (ne faisant partie d'aucune classe).

Kotlin importe déjà automatiquement les paquetages suivant :

- kotlin.*

- kotlin.annotation.*

- kotlin.collections.*

- kotlin.comparisons.*

- kotlin.io.*

- kotlin.ranges.*

- kotlin.sequences.*

- kotlin.text.*

- java.lang.* (seulement sur la version JVM de Kotlin)

- kotlin.jvm.* (même remarque)

II-F - Interpolation de chaînes

Cette fonctionnalité nous permet de concaténer des chaînes plus facilement. Si en java nous voulons écrire une séquence du type :

int a = 10;
int b = 6;
String c = "abc";
System.out.println("[" + a + ", " + b*2 + ", " + c + "]");

En Kotlin on pourra simplement écrire :

val a = 10
val b = 6
val c = "abc"
println("[$a, ${b*2}, $c]")

Ainsi :

- On utilise qu'une seule chaîne de caractères,

- $a permet de substituer la valeur de la variable a dans la chaîne, quel que soit son type (souvenez-vous qu'en Kotlin, tout est objet),

- ${b*2} s’écrit avec accolades étant donne qu'il s'agit d'une expression, et non plus d'une simple référence à une variable.

II-G - Inférence de type

Dans de nombreuses situations, le compilateur Kotlin est capable de reconnaître le type d'une variable grâce a l'expression qui a servi a l'initialiser :

val age = 10 // age est declare comme Int
val nom = "Toto" // nom est declare comme String
var prix = 10.2 // prix est declare comme Double

Il va de soi que pour une variable non initialisée de suite (donc de type var), il faut en préciser le type : l’inférence de type ne pouvant s'appliquer dans ce cas.

De même l’inférence de type fonctionne aussi avec les types complexes : qu'ils soient définis par Kotlin ou par nous-même.

class Personne(val name: String)
val jean = Personne("Jean") // jean est de type Personne.

Nous verrons par la suite d'autre inférence de type.

II-H - Expressions

Toute instruction qui retourne une valeur constitue une expression : simple valeur littérale (3, « Toto »…), expression arithmétique ou booléenne, appel de fonction (même une fonction ne retournant aucune valeur exploitable : c'est-a-dire une fonction définie comme retournant Unit), déclaration d'une fonction anonyme… Ces expressions peuvent donc être utilisées pour initialiser des variables.

val message = if (age < 18) "C'est un mineur&#160;!" else "Il est majeur." // Se referer a la section suivante sur les structures de controle.

Certaines instructions ne retournent jamais de valeur. C'est le cas pour la boucle while, comme nous le verrons dans une prochaine section du chapitre :

val resultat = while(i < 10) {i += 1} // Strictement interdit&#160;!!!

Par ailleurs les affectations de variables ne sont pas des expressions : il est donc impossible de les enchaîner au sein d'une même instruction :

var b = 0
val a = b = 10 // Strictement interdit !!!

II-I - Tuples et déconstruction

Kotlin dispose d'un support pour les tuples simples que sont les Paires (Pair) et Triples (Triple). Si un tableau ne peut contenir que des valeurs de types communs, les tuples permettent d’agréger plusieurs types de données : en Kotlin on peut simplement utiliser les Pair et Triple pour combiner respectivement deux ou trois éléments. On accède alors simplement aux différentes composantes par l’intermédiaire des méthodes component1(), component2(), component3(), où component1() retourne la 1re composante du tuple.

val tuple1 = Pair('a', 25) // de type Pair<Char, Int>
val valeur1_1 = tuple1.component1()
val valeur1_2 = tuple1.component2()
assert(valeur1_1 == 'a')
assert(valeur1_2 == 25)

val tuple2 = Triple('a', 10, "Toto") // de type Triple<Char, Int, String>
val valeur2_1 = tuple2.component1()
val valeur2_2 = tuple2.component2()
val valeur2_3 = tuple2.component3()
assert(valeur2_1 == 'a')
assert(valeur2_2 == 10)
assert(valeur2_3 == "Toto")

Nous pouvons aussi accéder aux différentes composantes d'un tuple par le biais de ce que l'on appelle une déconstruction (destructuring) :

val monTuple = Triple('a', 10, "Toto")
val (a, b, c) = monTuple // deconstruction&#160;!
assert(a == 'a')
assert(b == 10)
assert(c == "Toto")

La déconstruction a ici lieu parce que l'on tente ici d'affecter, non pas une simple variable, mais une structure qui dispose de plusieurs variables (ici la structure (a,b,c)) a une structure qui a la même forme (soit ici une agrégation de trois valeurs). Remarquez qu'il est interdit de déclarer val Triple(a,b,c) = Triple('a', 10, "Toto") : il faut simplement décrire la forme de la structure a gauche de l'affectation.

Sachez également qu'il est possible d'ignorer certaines valeurs lors de la déconstruction :

val (a, _ , c) = Triple('a', 10, "Toto")
assert (a == 'a')
assert (c == "Toto")

II-J - Structures de contrôle

Les structures de contrôle sont similaires a celles du java (if, for, while, switch…), a ceci près que la plupart des structures de Kotlin sont des expressions, et donc leur résultat peuvent être affectes a des variables, telles que nous l'avons vu dans la section précédente ; mais également, l’équivalent de la structure switch en Kotlin est beaucoup plus simple d'utilisation et beaucoup plus puissante.

II-J-1 - L'instruction while

L'instruction while (ou do … while) en Kotlin, tout comme en Java, ne retourne pas d'expression. Elle s'utilise aussi comme en Java : rien de nouveau.

var i = 0
while (i < 10) {
i += 1
println(i)
}

var j = 0
do {
j += 1
println(j)
} while (j < 10)

Les mots-clés break et continue fonctionnent comme en Java.

II-J-2 - L'instruction if

En revanche, l'instruction if peut être utilisée aussi bien en tant que structure de contrôle traditionnelle qu'en tant qu'expression. Voici les deux cas de figures en situation :

if (1<2) {
println("ok")
}
else if (2 != 2) {
println("ko 1")
}
else {
println("ko 2")
}

Ça, c'est le cas de figure que nous connaissions déjà.

Et bien l'autre, c'est comme l’opérateur ternaire de Java, sauf que l’opérateur ternaire n'existe pas en Kotlin, il a simplement été remplace par l'expression if-else.

val couleur = if (caseBlanche) "white" else "black"

Ce qui évidemment, nous évite de créer une variable temporaire, mais a la place de créer directement un valeur immuable.

II-J-3 - L'instruction for

L'instruction for, quand a elle, ne peut pas être utilisée en tant qu'expression.

L'utilisation de l'instruction for, telle que l'on connaît en Java (initialisation/condition/mise a jour accompagnée d'un bloc d'instructions) est strictement interdite en Kotlin. En effet, on ne peut utiliser la boucle for que sur des objets « iterable » : traditionnellement des ranges ou certains types de collections. Par « iterable » je veux simplement parler de types où l'on puisse obtenir les différentes valeurs d'une variable de ce type par appels successifs a la méthode d'extraction (appelons-la next() pour mieux illustrer).

Ce qui peut par exemple donner :

// i n'existe pas dans cette portee
for (i in 0..10) println(i)

Remarquez-bien ici qu'il ne faut utiliser ni le mot-clé val, ni var, afin de déclarer la variable i : qu'elle existe déjà ou non, il ne faut surtout pas les utiliser.

Un autre exemple avec simple tableau :

val monTableau = arrayOf(2,3,5,7,11,13)
for (premier in monTableau) {
println("$premier est un nombre premier")
}

Remarquez aussi que je peux m'affranchir des accolades si je n'ai besoin que d'une simple expression pour la boucle for (ce qui est donc possible dans ce deuxième exemple).

Enfin, si l'on souhaite parcourir un tableau tout en conservant les valeurs des différents index, on peut utilise la méthode withIndex() mais avec une syntaxe un peu particulière pour les variables d'index et de valeur :

val monTableau = arrayOf(2,3,5,7,11,13)
for ((index, valeur) in monTableau.withIndex()) {
println("$index: $valeur")
}

Remarquez que la méthode withIndex() retourne une Pair où la première valeur est l'index, donc il faut empaqueter les variables d'index et de valeur entre parenthèses afin de procéder au mécanisme de déconstruction. Mais rien ne nous empêche de procéder en deux étapes :

val monTableau = arrayOf(2,3,5,7,11,13)
for (tupleCourant in monTableau.withIndex()) {
val index = tupleCourant.component1()
val valeur = tupleCourant.component2()
println("$index: $valeur")
}

Bien évidemment, l'instruction for permet aussi de parcourir les tables associatives

val monRepertoireTel = mapOf("Bea" to "0123456789", "Lily" to "08123456", "Max" to "06123456")

for (entree in monRepertoireTel) {
println("${entree.key} => ${entree.value}")
}

// Version utilisant la deconstruction
for ((nom, numero) in monRepertoireTel) {
println("$nom => $numero")
}

Tout comme pour les boucles while (do…while), les mots-clés break et continue fonctionnent comme en Java.

II-J-4 - L'instruction when

L'instruction est une instruction ultra puissante, basée sur une fonctionnalité communément appelée le « pattern-matching » dans un certain nombre de langages. Un concept plus ou moins lie au mécanisme de déconstruction que nous avons vu auparavant. D'ailleurs, avant de vous expliquer comment elle fonctionne et a quoi elle peut-être utile, je tiens a vous préciser qu'en plus de sa puissance et sa flexibilité, l'instruction when est aussi une expression.

II-J-4-a - Cas ultra simple

Pour commencer, un cas ultra simple : tester le signe d'une nombre

fun main() {
val a = 10;
var signeDeA: Int;
when {
a < 0 -> println("negatif")
a == 0 -> println("nul")
else -> println("positif")
}
}

Ici, nous créons d'abord la variable évolutive signeDeA sans lui affecter de valeur, auquel cas il faut obligatoirement préciser un type.

Puis nous effectuons une instruction conditionnelle : ici la structure when parcourt chacun de ses tests dans l'ordre et s'interrompt (éventuellement) des que l'un des tests correspond, auquel cas elle exécute auparavant le code associe au test. Le test est situe avant la flèche (signe moins combine a une signe supérieur), et le code associe après. Remarquez qu'il n'y a pas d'instruction break : pas d'instruction break dans les instructions when !

Évidemment, dans le cas de figure présent, c'est l'instruction else – qui correspond a une branche default du switch de java – qui est exécutée. Car tous les tests précédents ont échoués.

D'ailleurs cette instruction else n'est pas obligatoire, nous aurions pu négliger le cas du signe positif :

fun main() {
val a = 10;
var signeDeA: Int;
when {
a < 0 -> println("negatif")
a == 0 -> println("nul")
}
}

Et pour être complet sur cet exemple, on peut inverse les trois tests, afin de bien se rendre compte que l'instruction when s'interrompt des que c'est nécessaire.

fun main() {
val a = 10;
var signeDeA: Int;
when {
a > 0 -> println("positif")
a == 0 -> println("nul")
else -> println("negatif")
}
}

II-J-4-b - Test en fonction d'une variable en entree

On peut aussi baser les tests de l'instruction when sur une unique variable :

fun main() {
val de = java.util.Random()
val valeurDe = de.nextInt(6) + 1
val texteValeur = when (valeurDe)
{
1 -> "Un"
2 -> "Deux"
3 -> "Trois"
4 -> "Quatre"
5 -> "Cinq"
else -> "Six"
}
println(texteValeur);
}

Plusieurs remarques :

1. On vient de fournir la variable valeurDe a l'instruction when, ce qui aura pour conséquence que dans chaque test de l'instruction, valeurDe sera compare a l'expression du test.

2. Ici, comme nous passons une variable a l'instruction when, celle-ci doit être testée de manière exhaustive. Ce qui est facile avec les énumérations (un type personnalise avec un nombre de valeurs définies, mais nous en reparlerons plus tard), mais pour un type numérique comme le type Int : il faudra obligatoirement fournir une branche else.

3. Nous avons décidé d'utiliser l'instruction when en tant qu'expression et nous en affectons le résultat dans la variable texteValeur.

On aurait aussi bien pu se passer de créer la variable texteValeur :

val de = java.util.Random()
val valeurDe = de.nextInt(6) + 1
println(when(valeurDe) {
1 -> "Un"
2 -> "Deux"
3 -> "Trois"
4 -> "Quatre"
5 -> "Cinq"
else -> "Six"
})

II-J-4-c - Pas d'obligation de tester des constantes

Augmentons le code précédant avec un texte a peine plus complexe (nous reviendrons ultérieurement sur la création de fonctions) :

1. fun estPaire(texteValeur: String): Boolean {

2. return when (texteValeur) {

3. "Deux", "Quatre", "Six" -> true

4. else -> false

5. }

6. }

8. fun main(args: Array<String>) {

9. val de = java.util.Random()

10. val valeurDe = de.nextInt(6) + 1

11. val texteValeur = when(valeurDe) {

12. 1 -> "Un"

13. 2 -> "Deux"

14. 3 -> "Trois"

15. 4 -> "Quatre"

16. 5 -> "Cinq"

17. else -> "Six"

18. }

19. println(texteValeur)

21. println(when {

22. estPaire(texteValeur) -> "Paire"

23. else -> "Impaire"

24. })

25. }

La fonction estPaire indique si, pour les six valeurs textes du résultat d'un tirage de de, la valeur représentée est paire. Nous nous en servons donc pour savoir si la valeur de texteValeur est paire (ligne 21).

II-J-4-d - Exécution de bloc

Jusqu'ici nous n'avons retourne que de simples expressions pour chaque test de l'instruction when, en fait nous pouvons faire plus que cela :

when (ageGuillaume) {
estMineur(ageGuillaume) -> {
println("Desole, le droit de vote est interdit aux mineurs")
repousserGuillaumeALEntree()
sermonerGuillaume()
}
else -> {
println("Voici les bulletins de vote")
indiquerIsoloirAGuillaume()
}
}

II-J-4-e - Regrouper plusieurs valeurs de test

Nous avons vu auparavant que l'instruction break ne peut pas être utilisée avec l'instruction when, et que sur un test réussi, son instruction/expression est traitée et c'est la fin de la structure. Donc si l'on veut tester plusieurs valeurs a tester : ceci – qui était autorise avec un switch en java – est strictement interdit :

switch (a) {
2:
3:
5:
6: println("2,3,5 ou 6");
default: println("autre");
}

On écrira plutôt :

when (a) {
2, 3, 5, 6 -> println("2,3,5 ou 6")
else -> "autre"
}

On peut même tester avec des intervalles de valeurs, que nous avons déjà vu auparavant :

when (age) {
in 13..19 -> "adolescent"
else -> "autre"
}

Remarquez l'utilisation du mot-clé in. On peut aussi inverse le test en écrivant !in début..fin .

II-J-4-f - Test du type de données

Grâce a l'instruction when, il devient facile de tester le type de données d'une variable :

when (age) {
is String -> "Il faut traiter la valeur auparavant"
is Int -> "Ok"
else -> "Impossible de faire quoi que ce soit avec"
}

Mais aussi, grâce au SmartCast, fonctionnalité dont nous reparlerons sous peu, il n'est pas nécessaire d'effectuer de casting dans les instructions associées aux tests.

when (age) {
is String -> println(age.length) // age est un String donc la propriete length existe
is Int -> println(age.dec()) // affiche age - 1
else -> println("Impossible quoi que ce soit avec")

II-K - La conversion de type

II-K-1 - La conversion classique

On peut utiliser les mots-clés is et !is pour respectivement tester si un type est ou n’est pas d’un type donné.

if (obj is String)
{
// Faire quelque chose
}

if (a!is Int)
{
// Faire quelque chose
}

Le mot clé as permet d’effectuer une conversion de type.

val s:String = valeur as String

Mais si valeur est null, cela lèvera une exception. (Car le type String ne peut prendre la valeur null, contrairement au type String ? : nous en reparlerons sous peu, ainsi que du mot-clé as?)

II-K-2 - Le SmartCast

Tout d'abord examinez le code suivant :

if (figureCourante is Cercle)
{
println(figureCourante.rayon)
}
else if (figureCourante is Rectangle)
{
println(figureCourante.largeur)
}

Admettons, qu'auparavant, son auteur ait prit soin de définir une hiérarchie de figures (classe Figure), dont la classe fille Cercle dispose de la propriété rayon et pas la classe fille Rectangle. Et de même la classe fille Rectangle dispose de la propriété largeur et pas la classe Cercle. Et donc que la classe Figure ne dispose d'aucune de ces deux propriétés.

Ce qui peut choquer de prime abord avec le code ci-dessus, c'est que, bien que figureCourante s'apparente a une instance de Figure, il n'y a a aucun moment quelconque cast avant d’accéder aux propriétés rayon (de la classe fille Cercle) et largeur (de la classe fille Rectangle). Mais juste deux branches if-else.

Et pourtant, il s'agit bien d'une fonctionnalité de Kotlin :

- dans chaque test, il est sûr que la variable est d'un type donne

- donc inutile d'y effectuer un cast : il est assez intelligent pour réduire le champ des possibles a l’intérieur du if.

Et nous avons vu plus haut, une façon bien plus élégante de réaliser cela : when !

when (figureCourante) {
is Cercle -> println(figureCourante.rayon)
is Rectangle -> println(figureCourante.largeur)
else -> println("Echec d'exploitation de la figure")
}

II-K-3 - La conversion de types numériques

En Kotlin, il n’y a pas de conversion automatique des types numériques. Si l’on veut convertir un Int en Long, il faut passer par une méthode prévue à cet effet :

val a = 10.0 // a est un Double
val b = a.toLong()

val c = 10.0 as Int // la conversion automatique entre types numeriques n’existe pas: une ClassCastException sera levee
val d = 10 as Long // de meme

Les méthodes s’appellent simplement toInt(), toDouble(), toLong() … et font partie de l’API standard de Kotlin. (Vous pouvez toujours y accéder depuis le site officiel.)

II-L - Null safety

En Kotlin, pour un type donne, il existe deux variantes :

- une qui n'acceptera jamais d’être associée a la valeur null,

- une autre qui le permettra

Par exemple : une variable de type Int ne vaudra jamais null, mais une variable de type Int ? Oui.

Cela a donc une conséquence directe : il est interdit d'attribuer une valeur de type Int ? a une valeur de type Int, a moins de la convertir auparavant dans le cas où elle vaudrait null.

On peut utiliser un smartCast pour cela :

val a:Int? = null
val b:Int = when (a) {
null -> 0
else -> a
}

println(b)

Mais on peut aussi utiliser l’opérateur elvis :

val a:Int? = null
val b:Int = a ?: 0

println(b)

Ainsi la variable b vaudra 0 si a est null, et la valeur de a sinon.

Enfin, la il possible de convertir une valeur « nullable » en une autre (équivalente bien sûr) grâce au mot-clé as?

val s:String? = valeur as? String

Ainsi si valeur vaut null, s vaudra également null. On aurait très bien pu utiliser le mot-clé as, mais une valeur null aurait alors levé une exception.

III - Focus sur les fonctions

Dans ce chapitre, nous allons explorer les fonctions un peu plus en détail.

III-A - Les bases

III-A-1 - Fonction sans valeur de retour ni paramètre

Voici notre première fonction

fun direBonjour()
{
println("Bonjour")
}

fun main(args: Array<String>)
{
direBonjour()
direBonjour()
}

Plusieurs remarques :

- on utilise le mot-clé fun et c’est le premier mot de la définition de fonction

- ici, il n’y a aucune indication sur la valeur de retour : et pour cause, de cas-ci nous n’en avons pas besoin.

III-A-2 - Fonction sans valeur de retour mais avec paramètre

Maintenant, personnalisons un peu l’invité d’accueil :

fun direBonjour(nom: String)
{
println("Bonjour ${nom} !")
}

fun main(args: Array<String>)
{
direBonjour("Hubert")
direBonjour("Francis")
}

Alors :

- pour chaque paramètre de la fonction, il faut faire attention à préciser sous la forme nom: type ! Nous avons donc précisé « nom » avant le type « String », en pensant aux deux points (attention, il ne doit pas avoir d’espace entre le nom de la variable et les deux points),

- pour le message à afficher nous utilisons ici l’interpolation de chaîne dont nous avons déjà parlé. C’est ici une occasion idéale d’écrire un code concis et compréhensible grâce à cette fonctionnalité.

III-A-3 - Fonction avec un paramètre ayant une valeur par défaut

Maintenant, pourquoi pas fournir une valeur par défaut à notre invité d’accueil ?

fun direBonjour(nom: String = "le monde") {
println("Bonjour ${nom} !")
}

fun main(args: Array<String>)
{
direBonjour("Hubert")
direBonjour("Francis")
direBonjour()
}

En fait, on peut définir autant de paramètre avec valeur par défaut que souhaité (même si le nombre de paramètres d’une fonction doit toujours rester raisonnable) mais il faut veiller à toujours les placer en dernier dans la liste des paramètre.

III-A-4 - Fonction avec une valeur de retour

Dans ce cas, il faut préciser le type de valeur de retour dans la signature de la fonction (c’est le type de retour associé aux éventuels paramètres de la fonction).

fun additionner(a: Int, b: Int = 0): Int {
return a + b
}

fun multiplier(a: Int, b: Int = 1): Int {
return a * b
}

fun main(args: Array<String>) {
println(multiplier(additionner(2,3), additionner(5)))
}

Remarques

- c’est après la liste des paramètres que l’on précise le type de retour. Et, comme précédemment, il faut absolument éviter quelconque espace entre la parenthèse fermante et les deux points,

- c’est avec le mot clé return, obligatoire, que l’on précise la valeur de retour,

- rien ne nous empêche de combiner avec des valeurs par défauts : ici j’ai choisi les éléments neutres des deux opérations (ceux qui permettent d’obtenir exactement la valeur du premier paramètre).

Veuillez également remarquer que :

- pour une fonction sans valeur de retour, on peut très bien préciser le type Unit, équivalent de void en Java/C++,

- le paramètre de la fonction main correspond à un tableau de String : il s’agit donc de la valeur des paramètres d’exécution du programme.

III-A-5 - La valeur de retour est une simple expression

Si la valeur de retour est une expression, alors on peut utiliser la syntaxe simplifiée

fun additionner(a: Int, b: Int = 0) = a+b

Cela fonctionne donc avec les expressions if et when notamment.

III-A-6 - Appel avec un ordre de paramètres différent

Rien ne nous empêche d’appeler une fonction avec un ordre de paramètres différent

fun additionner(a: Int, b: Int = 0) = a + b

fun soustraire(a: Int, b: Int = 0) = a - b

fun multiplier(a: Int, b: Int = 1) = a * b

fun main(args: Array<String>) {
println(multiplier(additionner(2), soustraire(b = 5, a = 10)))
}

Vous pouvez remarquer l’inversion des paramètres de l’appel à la fonction soustraire.

III-A-7 - Fonction dans une fonction

En Kotlin, il est aussi possible de déclarer une fonction à l’intérieur d’une fonction.

fun saluer() {
fun obtenirNom() = "Philae"

println("Bonjour, ${obtenirNom()} !")
}

fun main(args: Array<String>) {
saluer()
}

Et c’est notamment une des conséquences du fait qu’en Kotlin, les fonctions sont également des valeurs. Et cela devient particulièrement intéressant lorsque l’on les utilise en tant que fonctions d’ordre supérieurs.

III-A-8 - Fonctions variadiques

Il est également possible de définir des fonctions avec un nombre variable d’arguments

fun additionPersonnalisee(vararg params: Double): Double {
var total = 0.0
for (value in params) total += value
return total
}

fun main(args: Array<String>) {
println(additionPersonnalisee(10.0, 20.0, 30.0, 40.0, 50.0))
}

En fait grâce au mot-clé vararg que le paramètre params accèpte en fait un nombre indéterminé de valeurs. Ainsi params sera en quelque sorte un Array<Double>. La conséquence directe est qu’on ne peut utiliser qu’un paramètre variadique par fonction (mais il ne doit pas forcément être déclaré en dernier).

Remarquez aussi que passer un simple tableau en tant que paramètre ne fonctionnera pas, pour cela il faut utiliser une petite astuce :

fun additionPersonnalisee(vararg params: Double): Double {
var total = 0.0
for (value in params) total += value
return total
}

fun main(args: Array<String>) {
println(additionPersonnalisee(*arrayOf(10.0, 20.0, 30.0, 40.0, 50.0).toDoubleArray()))
}

L’étoile ! Ajouter une simple étoile devant le tableau permet de l’interpréter en tant que liste d’arguments, et non plus comme un unique argument.

Remarquez aussi que params étant en fait un DoubleArray et non un Array<Double> (deux types différents !), il a fallu effectuer une conversion préalable.

III-B - Fonctions d’ordre supérieur

Une fonction d’ordre supérieur effectue au moins l’une des deux tâches suivantes :

- elle prend au moins une fonction en tant que paramètre,

- elle retourne une fonction.

Vous vous doutez bien que cela ajoute une flexibilité à nos possibilités de codage.

III-B-1 - Les exemples simples de la librairie standard

Dans la librairie standard, de nombreuses méthodes associées aux collections sont écrites en tant que fonctions d’ordre supérieur, notamment en acceptant au moins une fonction en entrée.

III-B-1-a - La fonction map

Admettons que nous disposons des données des élèves d’une classe, et chaque donnée dispose notamment d’une propriété prénom. Nous souhaitons convertir ce tableau de données en un simple tableau de chaînes de caractères.

Une solution dite « naïve » serait codée comme suit :

var resultat = mutableListOf<String>()
for (eleve in listeEleves) {
resultats.add(eleve.prenom)
}

Mais il y a bien mieux, grâce à la méthode map, dont notamment notre Array d’élèves bénéficie :

fun extrairePrenom(eleve: Eleve) = eleve.prenom

fun main(args: Array<String>)
{
// recuperation du tableau d’eleves volontairement omise

val resultats = listeEleves.map(::extrairePrenom)
}

Ici j’ai créé une fonction qui permet d’extraire le prénom d’un élève de son objet le représentant (en supposant ici que la classe Eleve dispose de la propriété prénom), et ensuite je passe la référence de la fonction. Et cela grâce à la paire de signes deux-points précédent son nom et uniquement son nom.

En effet, la fonction map a besoin de savoir quelle fonction appliquer à chacun des éléments. Ici nous passons une référence sur une fonction existante, mais lorsque nous parlerons de fonctions anonymes nous verrons une alternative qui s’avère utile dans bien des cas.

Remarquez également que la fonction map ne modifie pas la collection originale, mais en retourne une nouvelle.

Voici un autre exemple, que vous pouvez cette fois tester directement dans le playground Kotlin, ou dans l’IDE Intellij Idea.

fun maConversion(entree: Int): Int = entree * 50

fun main(args: Array<String>) {
var resultats = arrayOf(1,2,3,4,5,6)
println(resultats.map(::maConversion))
println(java.util.Arrays.toString(resultats))
}

Attention toutefois, les tableaux (array) en Kotlin étant basés sur les tableaux natifs de Java, lesquels n’ont pas de formatage de leurs affichage par défaut. A la place la méthode toString() par défaut affiche l’identifiant mémoire de la variable. C’est pour cela que je fais appel à java.util.Arrays.toString afin d’afficher les résultats. La méthode map retourne une liste, qui est basée sur la classe ArrayList de Java, et donc dispose déjà d’un affichage par défaut.

III-B-1-b - La méthode filter

Une autre méthode d’ordre supérieur souvent présente dans beaucoup de types de collection : la fonction filter. Elle permet simplement de retourner une nouvelle collection avec des éléments filtrés selon une fonction que l’on passe en tant que critère de sélection.

fun estPair(entree: Int): Boolean = entree % 2 == 0

fun main(args: Array<String>) {
var resultats = arrayOf(1,2,3,4,5,6)
println(resultats.filter(::estPair))
println(java.util.Arrays.toString(resultats))
}

Tout comme la méthode map, la méthode filter ne modifie pas la collection originale.

III-B-1-c - La méthode reduce

Cette méthode est un peu plus complexe, mais peut s’avérer très utile si elle est comprise.

En tant que premier exemple, nous allons réaliser le calcul de la somme des éléments d’un tableau de chiffres. Je donne le code, avant de le commenter.

fun additionner(a: Int, b: Int): Int = a + b

fun main(args: Array<String>) {
var resultats = arrayOf(1,2,3,4,5,6)
println(resultats.reduce(::additionner))
}

En fait, c’est comme si la méthode reduce effectuait l’algorithme suivant

1. Créé une variable numérique valant 0

2. Prends les deux premiers chiffres du tableau, dans l’ordre : 1 et 2.

3. Passe-les à la fonction additionner que j’ai reçue et conserve le résultat

4. Ajoute ce résultat à la valeur courante de la variable

5. Prends la 1ere valeur du tableau qui n’a pas encore été exploitée (lors du 1er passage ce sera l’élément d’indice 2)

6. Passe la valeur courante de la variable en 1er paramètre et la valeur que je viens de récupérer en 2e paramètre à la fonction additionner et conserve le résultat

7. Recommence à l’étape 4 jusqu’à ce qu’il ne reste plus de valeur à exploiter

8. Retourne la valeur de la variable.

On peut donc dire que le 1er paramètre de la fonction passée à la méthode reduce correspond à la valeur accumulée lors de l’appel, et le deuxième paramètre à la valeur nouvellement extraite du tableau. Pour être plus clair, j’aurais aussi pu définir la fonction additionner comme suit :

fun additionner(accumulateur: Int, courant: Int): Int = accumulateur + courant

Ceci est un type d’utilisation parmi tant d’autres.

III-B-2 - Codage d’une fonction d’ordre supérieur

Voyons cela à travers un exemple simple. Je souhaite par exemple effectuer un calcul entre deux nombres, mais je veux, dans ma fonction de calcul, laisser à l’utilisateur le soin de préciser le calcul à effectuer.

fun add(a: Int, b: Int) = a + b
fun sub(a: Int, b: Int) = a - b
fun mul(a: Int, b: Int) = a * b
fun div(a: Int, b:Int) = a / b

fun afficherCalcul(a: Int, b: Int, calcul: (a: Int, b: Int) -> Int)
{
println("Le calcul de ${a} et ${b} vaut ${calcul(a,b)}")
}

fun main(args: Array<String>) {
afficherCalcul(10,5, ::add)
afficherCalcul(10,5, ::sub)
afficherCalcul(10,5, ::mul)
afficherCalcul(10,5, ::div)
}

Ici, les quatre fonctions d’opérations add, sub, mul et div ont la même signature (pour rappel, le nom des variables n’influe pas sur la définition d’une signature : manger(aliment : String) a exactement la même signature que manger(dessert : String)). Elles acceptent deux Int, et retournent un Int (type déduit par le compilateur d’après chaque expression de retour).

On peut donc passer cette signature à notre fonction de calcul, de sorte que les quatre fonctions sont interchangeables. La signature est on en peut plus simple à définir :

1. la liste des paramètres,

2. le type de retour mais précédé d’une flèche et non de deux-point.

III-C - Fonctions anonymes et lambdas

Parfois, il peut s’avérer fastidieux de devoir créer une fonction, juste pour l’utiliser que dans un seul appel à une fonction/méthode d’ordre supérieur. Pour résoudre cette problématique, on peut utiliser les fonctions anonymes ou les lambdas.

III-C-1 - Fonction anonyme

Une fonction anonyme est une fonction qui, comme son nom l’indique, ne se voit pas attribuer d’identité, de nom. Il va de soi qu’une telle fonction ne peut pas être utiliser de manière classique :

- nous pouvons la stocker dans une variable (val ou var)

- ou la passer directement à une fonction/méthode d’ordre supérieur

L’exemple de la fonction de calcul développé plus haut pourrais aussi être adapté comme suit :

fun afficherCalcul(a: Int, b: Int, calcul: (a: Int, b: Int) -> Int)
{
println("Le calcul de ${a} et ${b} vaut ${calcul(a,b)}")
}

fun main(args: Array<String>) {
afficherCalcul(10,5, fun (a: Int, b: Int) = a + b)
afficherCalcul(10,5, fun (a: Int, b: Int) = a - b)
afficherCalcul(10,5, fun (a: Int, b: Int) = a * b)
afficherCalcul(10,5, fun (a: Int, b:Int) = a / b)
}

Rien de compliqué dans la définition d’une fonction anonyme.

III-C-2 - Lambda

Les lambdas, en plus de présenter une syntaxe différente des fonctions anonymes, présentent d’autres particularités.

III-C-2-a - Utilisation simple

D’abord, voyons une manière de réutiliser l’exemple précédent avec des lambdas.

fun afficherCalcul(a: Int, b: Int, calcul: (a: Int, b: Int) -> Int)
{
println("Le calcul de ${a} et ${b} vaut ${calcul(a,b)}")
}

fun main(args: Array<String>) {
afficherCalcul(10,5, {a: Int, b: Int -> a + b})
afficherCalcul(10,5, {a: Int, b: Int -> a - b})
afficherCalcul(10,5, {a: Int, b: Int -> a * b})
afficherCalcul(10,5, {a: Int, b: Int -> a / b})
}

Une lambda s’écrit simplement : { [paramètres] -> [expression/ liste d’instructions sans accolades] }

Si l’on souhaite réaliser plusieurs instructions dans le lambda, on les enchaîne simplement, sans mettre d’accolades.

fun afficherCalcul(a: Int, b: Int, calcul: (a: Int, b: Int) -> Int)
{
println("Le calcul de ${a} et ${b} vaut ${calcul(a,b)}")
}

fun main(args: Array<String>) {
afficherCalcul(10,5, {a: Int, b: Int ->
println("Dans le lambda de calcul")
a + b
})
}

Si la lambda doit retourner une valeur, elle correspondra alors à la valeur de la dernière instruction, qui donc doit dans ce cas précis aussi être une expression. (C’est le cas dans le code précédent.)

Tout comme les fonctions anonymes, elles peuvent être stockées dans des variables.

En revanche, on ne peut pas préciser le type de retour à une lambda: pour cela il faudra utiliser une fonction anonyme à la place. En général, le compilateur parvient lui-même à vérifier si le type de retour de la lambda correspond bien au type attendu.

III-C-2-b - Lambda en tant que dernier paramètre d’une fonction

Une caractéristique sympathique des lambdas, est que si une lambda est le dernier paramètre d’une fonction/méthode, alors on peut « l’extraire des paramètres » et la placer juste derrière la fonction.

fun afficherCalcul(a: Int, b: Int, calcul: (a: Int, b: Int) -> Int)
{
println("Le calcul de ${a} et ${b} vaut ${calcul(a,b)}")
}

fun main(args: Array<String>) {
afficherCalcul(10,5) {a: Int, b: Int -> a + b}
afficherCalcul(10,5) {a: Int, b: Int -> a - b}
afficherCalcul(10,5) {a: Int, b: Int -> a * b}
afficherCalcul(10,5) {a: Int, b: Int -> a / b}
}

Ici, le paramètre calcul, qui définit une fonction à passer un paramètre, est le dernier paramètre de la fonction. Cela implique que si c’est une lambda que je passe, je peux la sortir de la liste des paramètres et la coller juste après l’appel de la fonction. C’est une simplification syntaxique qui peut clarifier les choses, même si dans le cas présent ça ne saute pas aux yeux.

On peut en tous cas séparer la liste des valeurs, de la logique à y appliquer.

III-C-2-c - Lambda à paramètre unique

Vous souvenez-vous de l’exemple où l’on souhaitait trier un tableau en ne retenant que les nombres pairs ?

On pourrait commencer par le réécrire simplement avec une lambda

fun main(args: Array<String>) {
var resultats = arrayOf(1,2,3,4,5,6)
println(resultats.filter({a -> a % 2 == 0}))
println(java.util.Arrays.toString(resultats))
}

Par contre je trouve bien dommage de devoir créer un paramètre a juste ensuite tester son modulo : pas-vous ?

Et bien il est possible de contourner cette bizarrerie :

fun main(args: Array<String>) {
var resultats = arrayOf(1,2,3,4,5,6)
println(resultats.filter({it % 2 == 0}))
println(java.util.Arrays.toString(resultats))
}

Dans une lambda à unique argument, il est possible de faire appel à cet argument avec le mots-clé it. Une fois de plus, l’écriture du calcul est plus élégante.

IV - Les classes : syntaxe de base

IV-A - Classe vide

En kotlin, il est possible de déclarer et instancier des classes vides

class ClasseVide

fun main(args: Array<String>) {
val instance = ClasseVide()
}

Remarquez :

- les accolades ne sont pas obligatoires lors de la déclaration d’une classe vide,

- on créé une classe simplement en suffixant le nom de la classe avec des parenthèses : aucun mot-clé new.

Évidemment, dans le cas présent, une telle classe vide ne sert à rien.

IV-B - Classe englobant des propriétés et méthodes

Dès lors, nous pouvons ajouter des variables (une variable dans une classe est une propriété) et fonctions (une fonction dans une classe est plus communément appelée méthode).

class Personne {
val nom = "Jean"
var age = 0

fun grandir() {
age += 1
}
}

fun main(args: Array<String>) {
val humain = Personne()
println(humain.nom)
humain.age += 1
println(humain.age)
humain.grandir()
println(humain.age)
}

IV-C - Visibilité des propriétés et méthodes dans les classes

Dans l’exemple précédent, nous pouvions accéder à la variable age d’être humain directement depuis la fonction principale. En effet, en Kotlin, par défaut, les éléments d’une classe sont public. C’est-à-dire que l’on peut y accéder aussi bien dans le code de définition de la classe qu’à l’extérieur (ici, dans la fonction principale).

Mais il est possible de restreindre tout ou partie des variables et méthodes de la classe à une utilisation interne :

class Personne {
val nom = "Jean"
private var age = 0

fun grandir() {
age += 1
}

fun getAge() = age
}

fun main(args: Array<String>) {
val humain = Personne()
println(humain.nom)
// Interdit !!! humain.age += 1
// Interdit !! println(humain.age)
println(humain.getAge())
humain.grandir()
println(humain.getAge())
}

Ici nous avons restreint l’accès de la variable age à la classe, avec le mot-clé private. Cela permet notamment d’avoir un meilleur contrôle sur l’évolution de cette variable. Et pour permettre aux utilisateurs de continuer à pouvoir lire l’age, une méthode supplémentaire getAge() a été ajoutée.

IV-D - Personnalisation des propriétés mutables

Dans le code précédent, nous avons rendu la propriété age privée, et créé une méthode getAge() afin de pouvoir y accéder depuis l’extérieur.

Mais nous pouvons faire mieux : laisser la propriété age publique, et personnaliser son accesseur en écriture (setter).

1. class Personne {

2. val nom = "Jean"

3. var age = 0

4. private set

6. fun grandir() {

7. age += 1

8. }

10. }

12. fun main(args: Array<String>) {

13. val humain = Personne()

14. println(humain.nom)

15. // Interdit !!! humain.age += 1

16. println(humain.age)

17. humain.grandir()

18. println(humain.age)

19. }

Ainsi :

- dans la classe Personne nous avons restreint l’accès du setter de la propriété age à la classe elle-même,

- dans la méthode principale nous pouvons simplement accéder à la propriété age, mais uniquement en lecture (ligne 11) : il est impossible de la modifier par un accès direct depuis la fonction principale (lignes 13 et 15).

Cette personnalisation de getter/setter est ici possible parce que la propriété age est variable (de type var).

V - Utilisation plus élaborée des classes

V-A - Constructeur primaire

Dès lors qu’une classe doive disposer d’un unique constructeur (nous verrons le cas de constructeurs multiples dans la section suivante), il faut respecter la syntaxe du constructeur primaire.

1. class Personne(nom: String) {

2. val nom: String

3. var age = 0

4. private set

6. init {

7. this.nom = nom

8. }

10. fun grandir() {

11. age += 1

12. }

14. }

16. fun main(args: Array<String>) {

17. val humain = Personne("Jean")

18. println(humain.nom)

19. println(humain.age)

20. humain.grandir()

21. println(humain.age)

22. }

Ainsi la liste des paramètres est précisée juste après le nom de classe, et entre parenthèses. En outre, pour chaque paramètre, telle la déclaration d’un paramètre de fonction, il faut préciser le nom de la variable avant son type.

Remarquez également que la propriété nom pouvait ne pas être attribuée de valeur immédiatement (ligne 2), parce que cela a été fait dans le bloc d’initialisation init (lignes 6-8).

Et donc, grâce au constructeur primaire, c’est lors de la construction de l’instance que nous attribuons une valeur pour le nom (ligne 17).

Mais nous pouvons simplifier ce code :

class Personne(val nom: String) {
var age = 0
private set

fun grandir() {
age += 1
}

}

fun main(args: Array<String>) {
val humain = Personne("Jean")
println(humain.nom)
println(humain.age)
humain.grandir()
println(humain.age)
}

Ce que nous avons fait ici :

- nous avons préfixé le paramètre nom du constructeur primaire par le mot-clé val. De cette manière, Kotlin ajoutera automatiquement une propriété nom en lecture-seule, et dont la valeur sera celle passée en paramètre lors de la construction de l’instance (ligne 11),

- de ce fait, nous avons pu supprimer le bloc init, devenu inutile.

Et si nous faisions de même pour la propriété age ?

class Personne(val nom: String, var age: Int = 0) {

fun grandir() {
age += 1
}

}

fun main(args: Array<String>) {
val humain = Personne("Jean")
println(humain.nom)
println(humain.age)
humain.grandir()
println(humain.age)

val deuxiemeHumain = Personne("Alice", 18)
println(deuxiemeHumain.age)
}

Nous avons donc :

- ajouté le paramètre age dans le constructeur primaire avec une valeur par défaut,

- supprimé la propriété age de la classe

En revanche, rien ne nous empêche ici de modifier l’âge depuis l’extérieur. Et si nous avions voulu utiliser une propriété en lecture-seule à la place, la méthode grandir() n’aurait pu fonctionner.

C’est donc inconvénient de cette technique : l’impossibilité de restreindre l’accès au setter.

V-B - Constructeurs auxiliaires

Si nous voulons d’autres constructeurs, il faut les déclarer à l’intérieur de la classe, et les baser sur le constructeur principal.

1. class Personne(val nom: String, var age: Int) {

3. constructor(nom: String) : this(nom, 0) {

4. // Nous pouvons m&#234;me effectuer d'autres t&#226;ches

5. // d'initialisation ici

6. println("Dans un constructeur auxiliaire")

7. }

9. fun grandir() {

10. age += 1

11. }

13. }

15. fun main(args: Array<String>) {

16. val humain = Personne("Jean")

17. println(humain.nom)

18. println(humain.age)

19. humain.grandir()

20. println(humain.age)

22. val deuxiemeHumain = Personne("Alice", 18)

23. println(deuxiemeHumain.age)

24. }

J’ai volontairement supprimé la valeur par défaut du paramètre age dans le constructeur principal afin que l’exemple soit plus pédagogique.

C’est donc le mot-clé constructor qui permet de déclarer un constructeur auxiliaire. De prime abord, sa signature ressemble énormément à la signature d’une fonction. Mais vous avez peut-être remarqué l’appel à this(nom, 0)juste avant les accolages. C’est l’équivalent de l’appel à super() du Java/C++, et qui permet d’abord d’initialiser l’instance en faisant appel au constructeur primaire (donc ici nous initialisons l’age à 0). Enfin il est également possible d’effectuer d’autres instructions après cet appel, grâce au code situé entre les accolades.

V-C - Classes imbriquées

En Kotlin, il est également possible de déclarer des classes imbriquées.

class Externe(val a: Int) {
class Interne(var b: Int) {
fun augmenterB() {
b += 1
}
}
}

fun main(args: Array<String>) {
val externe = Externe(3)
val interne = Externe.Interne(2)

println(externe.a)
println(interne.b)
interne.augmenterB()
println(interne.b)
}

Prêtez bien attention d’ailleurs aux différentes casses (majuscule/minuscule) des différentes instances et classes, en particulier lors de la création de la variable interne (ligne 10).

V-D - Les Objets

Les Objets regroupent des fonctions que l’on peut appeler sans devoir instancier le modèle en question (toutes leurs méthodes et valeurs sont des équivalents des méthodes et valeurs statiques de Java/C++).

Un Objet est en quelque sorte, une classe Singleton, pour ceux qui connaissent ce patron de conception.

object Math {
fun moyenne(valeurs: Array<Int>): Double {
if (valeurs.isEmpty()) return 0.0
return (valeurs.sum() / valeurs.size).toDouble()
}

fun min(valeurs: Array<Int>): Int {
return valeurs.min() ?: 0
}
}

fun main(args: Array<String>) {
println(Math.moyenne(arrayOf(2,10,18,13)))
println(Math.min(arrayOf(3,1,10)))
}

Ainsi, pour accéder aux méthodes moyenne et min, je n’ai pas eu à créer d’instance de la classe Math. En revanche, j’accède à ces méthodes « en tant que propriétés de la classe Math ».

Attention toutefois: un objet ne peut être imbriquée que dans un autre objet ou une classe non-interne.

V-E - Les Objets compagnons (companion object)

Il est possible d’ajouter des méthodes « statiques » dans une classe grâce à la fonctionnalité objet compagnon.

class Personne(val nom: String, age: Int)
{
var age = age
private set

fun grandir()
{
age += 1
}

companion object Math {
fun moyenne(valeurs: Array<Int>): Double {
if (valeurs.isEmpty()) return 0.0
return (valeurs.sum() / valeurs.size).toDouble()
}

fun min(valeurs: Array<Int>): Int {
return valeurs.min() ?: 0
}
}
}

fun main(args: Array<String>) {
println(Personne.moyenne(arrayOf(2,10,18,13)))
println(Personne.min(arrayOf(3, 12, 8)))
val guillaume = Personne("Guillaume", 15)
println(guillaume.nom)
}

- Je déclare un objet compagnon dans la classe Personne : je peux donc directement appeler les méthodes moyenne et min de la classe Personne, sans même avoir instancié de variable du modèle Personne. Ce sont des méthodes « statiques » ajoutées à la classe Personne.

Nous pouvons même ignorer le nom du compagnon objet dans la classe Personne :

class Personne(val nom: String, age: Int)
{
var age = age
private set

fun grandir()
{
age += 1
}

companion object { // Il n&#8217;y a plus le nom Math
fun moyenne(valeurs: Array<Int>): Double {
if (valeurs.isEmpty()) return 0.0
return (valeurs.sum() / valeurs.size).toDouble()
}

fun min(valeurs: Array<Int>): Int {
return valeurs.min() ?: 0
}
}
}

fun main(args: Array<String>) {
println(Personne.moyenne(arrayOf(2,10,18,13)))
println(Personne.min(arrayOf(3, 12, 8)))
val guillaume = Personne("Guillaume", 15)
println(guillaume.nom)
}

V-F - Les fonctions d’extension

Les fonctions d’extensions permettent d’enrichir une classe déjà existante (mais juste dans le contexte du projet dans lequel on se situe, évidemment).

Vous connaissez déjà certaines fonctions d’extension : les fameuses fonctions map/filter/reduce que nous avons utilisées avec certaines collections.

Ainsi, le type Array est basé sur les tableaux natifs de Java, lesquels ne possèdent aucune des trois méthodes.

On peut même, de cette manière, « augmenter » le type String, pourtant à l’origine immuable en Java.

fun String.capitalize() : String {
return "${this[0].toUpperCase()}${this.substring(1)}"
}

fun main(args: Array<String>) {
println("hello".capitalize())
}

Ici j’ai défini une nouvelle méthode capitalize() sur la classe String :

- c’est presque une fonction classique : il faut juste penser à préfixer le nom de la méthode par le nom de la classe destinataire (y compris nos propres types) suivi d’un point,

- à l’intérieur de la fonction, this représente l’instance de l’objet sur lequel a été appelée la méthode (pour les non habitués à Java, c’est un concept de la programmation orientée objet dont nous reparlerons ultérieurement),

- évidemment, nous ne sommes pas limité à une fonction sans argument,

- de même nous pouvons utiliser la simple syntaxe d’une expression en valeur de retour.

Un autre exemple intéressant, c’est d’écrire une méthode pour les nombres premiers :

fun Int.estPremier() : Boolean {
return (2 until this).none{this % it == 0}
}

fun main(args: Array<String>) {
for (i in 1..20)
{
println("$i est premier ? ${if (i.estPremier()) "oui" else "non"}")
}
}

Remarquez :

- 2 until 5 représente une intervalle commençant à 2 mais n’incluant pas 5,

- la fonction d’ordre supérieur none est une méthode présente dans les intervalle et permettant de vérifier qu’aucun élément ne vérifie la condition énoncée.

Une fonction d’extension a évidemment accès à l’ensemble des membres et méthodes publiques de la classe en question.

Mais il est aussi possible de définir une fonction d’extension sur un type « nullable » !

fun Int?.estPair() : Boolean {
if (this == null) return false
return this % 2 == 0
}

fun main(args: Array<String>) {
val i:Int? = null
val j = 3

println(i.estPair())
println(j.estPair())
}

Et donc, comme l’extrait de code précédent le démontre, nous pouvons éventuellement tester si la valeur est null afin de réaliser un traitement adéquat.

V-G - Les propriétés d’extension

Tout comme nous pouvons ajouter des méthodes aux classes existantes, nous pouvons ajouter des propriétés.

val String.demiTaille: Int
get() = length / 2

fun main(args: Array<String>) {
println("Hello World !".demiTaille)
}

Il faut absolument respecter cette forme :

1. on définit le type de la propriété sans l’initialiser

2. on ajouter un getter pour la propriété (d’ailleurs ici il fait appel à une autre propriété de la classe String : length).

Cela fonctionne également avec les génériques, que nous verrons sous peut-être

val <T> Array<T>.demiTaille : Int
get() = size / 2

fun main(args: Array<String>) {
println(arrayOf(2,3,4,5,6).demiTaille)
println(arrayOf(1.0,2.0,3.0,4.0,5.0).demiTaille)
}

V-H - Les compagnons d’objet d’extensions

Tout compagnon d’objet de classe peut se voir ajouter des fonctions et valeurs. Le mécanisme est le même que pour les fonctions et propriétés d’extension.

class Vecteur(val dx: Double, val dy: Double) {
companion object {

}
}

// Mince l'auteur original a oublie d'ajouter le vecteur nul en tant que valeur de classe
// Pas grave, je vais le faire moi-meme, localement

val Vecteur.Companion.NUL: Vecteur
get() = Vecteur(0.0, 0.0)

fun main(args: Array<String>) {
println(Vecteur.NUL.dx)
println(Vecteur.NUL.dy)
}

Ainsi j’ai appliqué une propriété d’extension sur le compagnon d’objet de la classe Vecteur. Ici le nom du compagnon d’objet s’appelle tout simplement Companion, étant donné qu’il est anonyme dans la classe que l’on souhaite étendre. Pour être plus complet, j’aurais d’ailleurs pu ajouter une extension de méthode sur la classe Vecteur elle-même afin d’en calculer la norme (la longueur du vecteur).

Nous reparlerons des objets compagnons ultérieurement.

V-I - Introduction basique aux génériques

V-I-1 - Fonction générique

Dans sa forme la plus basique, une fonction générique est une fonction dont tout ou partie des paramètres fonctionne sans restriction sur le type d’argument.

fun <T> afficher(a: T, b: T) {
println("A est $a, B est $b")
}

fun main(args: Array<String>) {
afficher(3,2)
afficher(2.0,10.0)
afficher("Hello", "World !")
}

1. Donc, entre le mot-clé fun et la signature de la fonction, j’insère une liste d’identifiants de types. Ici, il n’y a qu’un réceptacle de type que l’on a nommé T,

2. Ensuite j’ai pu déclarer chaque paramètre comme étant du type T,

3. Nous avons pu utiliser la fonction avec plusieurs types de données.

Mais nous ne sommes pas limité à exiger un unique type pour la liste des paramètres, ni à exiger un type pour l’ensemble des paramètres.

fun <T, U> afficher(a: T, b: U) {
println("A est $a, B est $b")
}

fun main(args: Array<String>) {
afficher(3, 'a')
afficher(2.0, "euros")
afficher("Bonjour", "les codeurs !")
}

L’utilisation de deux String dans le dernier exemple est purement pédagogique.

fun <T> afficher(a: T, b: Int) {
println("A est $a, B est $b")
}

fun main(args: Array<String>) {
afficher(3, 10)
afficher(2.0, 20)
afficher("Bonjour", 30)
}

L’ordre de déclarations des types aussi est libre :

fun <T, U> afficher(a: U, b: T) {
println("A est $a, B est $b")
}

fun main(args: Array<String>) {
afficher(3, 'a')
afficher(2.0, "euros")
afficher("Bonjour", "les codeurs !")
}

Enfin, le choix des noms T et U n’est nullement obligatoire : il s’agit juste d’une convention.

V-I-2 - Classe générique

Les classes génériques se déclarent et fonctionnent de manière similaire

class MaPaire<T, U>(val prem: T, val deux: U)

fun main(args: Array<String>) {
val paire = MaPaire(10, "euros")
println(paire.prem)
println(paire.deux)
}

Cette fois-ci, la liste des types à fournir est déclarée en pleine signature de classe : entre le nom de la classe et son constructeur primaire si définit.

V-I-3 - Aller plus loin

Une couverture complète des génériques ne rentre pas dans le cadre de ce tutoriel. Toutefois, je ne peux que vous inviter à aller consulter la documentation officielle (en anglais).

VI - L’héritage de classes

En Kotlin, comme dans de nombreux langages, il est possible de bénéficier des fonctionnalités de la Programmation Orientée Objets afin de pouvoir dupliquer valeurs ou comportements entre plusieurs entités à moindre frais.

L’entité classique est la classe, mais pas seulement : il est par exemple possible de combiner plusieurs interfaces en une. (Nous arriverons très vite sur les interfaces.)

VI-A - Bases de l’héritage

VI-A-1 - Héritage simple

Tout d’abord, un simple rappel sur l’héritage dans la programmation orientée objets.

Prenons, pour illustre avec un cas de la vie courante, le cas d’un véhicule. Quand nous parlons de véhicule, nous parlons de véhicule, notamment nous pensons à quelque chose

- avec des roues,

- une ou plusieurs places où s’installer,

- …, et j’en passe

Ce que je viens de décrire, c’est la représentation commune que l’on se fait d’un véhicule.

Maintenant, nous pouvons diviser les véhicules en plusieurs catégories, disons par exemple

- les automobiles :

- disposant de moteurs,

- pouvant reculer

- les vélos :

- disposant d’un pédalier

- disposant d’un guidon

Je viens donc de décrire une relation d’héritage :

- une automobile est un véhicule, avec certaines caractéristiques communes aux vélos, mais aussi des attributs et comportements propres

- on peut appliquer le même raisonnement aux vélos

Une automobile est un véhicule, un vélo est un véhicule.

En Kotlin, nous pouvons donc définir une classe de base Vehicule :

open class Vehicule(nombreRoues: Int, nombrePlaces)

Nous avons ajouté le mots-clé open, qui nous autorisera à dériver la classe Vehicule, notamment par les classes Velo et Automobile. Justement, définissons-les :

class Velo : Vehicule(2,1)
class Automobile(nombreRoues: Int, nombrePlaces: Int) : Vehicule(nombreRoues, nombrePlaces)

Il a donc suffit, pour chacune des sous-classes, ajouter :

1. les deux-points,

2. le nom de classe de base,

3. les valeurs à passer au constructeur primaire de la classe de base.

Ainsi, nous déclarons un Velo comme étant un Vehicule ayant 2 roues (afin de simplifier notre exemple) et une unique place. Par contre, nous laissons de la flexibilité en ce qui concerne la classe Automobile.

Ne l’oubliez pas, le mot-clé open est obligatoire afin de rendre une classe héritable !

Enfin, nous pouvons en créer des instances comme l’on a l’habitude

fun main(args: Array<String>) {
val monVehicule = Vehicule(3, 1)
// println(monVehicule.nombreRoues): erreur ! Sera resolue plus tard
// println(monVehicule.nombrePlaces): erreur ! Sera resolue plus tard
val monVelo = Velo()
// println(monVelo.nombreRoues) : erreur ! Sera resolue plus tard
// println(monVelo.nombrePlaces) : erreur ! Sera resolue plus tard
val maVoiture = Automobile(4,4)
// println(maVoiture.nombreRoues) : erreur ! Sera resolue plus tard
// println(maVoiture.nombrePlaces) : erreur ! Sera resolue plus tard
}

Remarquez également qu’aucune de ces trois classes ne dispose des propriétés nombreRoues et nombrePlaces: ce qui est normal, étant donné que l’on a juste passé ces définitions en tant que valeurs du constructeur primaire de la classe Vehicule.

Par contre, il suffit de le rendre propriétés au sein de la classe Vehicule, soit en ajoutant des mots-clés val/var aux paramètres désirés du constructeur primaire (comme nous l’avons vu auparavant), soit en ajoutant des propriétés dédiées à l’intérieur de la classe Vehicule.

open class Vehicule(val nombreRoues: Int, val nombrePlaces: Int)
class Velo : Vehicule(2,1)
class Automobile(nombreRoues: Int, nombrePlaces: Int) : Vehicule(nombreRoues, nombrePlaces)

fun main(args: Array<String>) {
val monVehicule = Vehicule(3, 1)
println(monVehicule.nombreRoues)
println(monVehicule.nombrePlaces)

val monVelo = Velo()
println(monVelo.nombreRoues)
println(monVelo.nombrePlaces)

val maVoiture = Automobile(4,4)
println(maVoiture.nombreRoues)
println(maVoiture.nombrePlaces)
}

Tout d’abord, remarquez que l’appel au constructeur primaire de la classe de base doit obligatoirement se faire avant le corps de la classe.

L’ajout des mots-clé val dans le constructeur primaire de la classe Vehicule a permit de disposer des propriétés voulues dans les trois classes liées hiérarchiquement. Ainsi, il n’a pas été nécessaire de déclarer des propriétés nombreRoues et nombrePlaces dans aucune des deux classes Velo et Vehicule.

En ce qui concerne l’autre méthode :

1. open class Vehicule(nombreRoues: Int, nombrePlaces: Int)

2. {

3. val nombreRoues: Int

4. val nombrePlaces: Int

6. init {

7. this.nombreRoues = nombreRoues

8. this.nombrePlaces = nombrePlaces

9. }

10. }

12. class Velo : Vehicule(2,1)

13. class Automobile(nombreRoues: Int, nombrePlaces: Int) : Vehicule(nombreRoues, nombrePlaces)

15. fun main(args: Array<String>) {

16. val monVehicule = Vehicule(3, 1)

17. println(monVehicule.nombreRoues)

18. println(monVehicule.nombrePlaces)

20. val monVelo = Velo()

21. println(monVelo.nombreRoues)

22. println(monVelo.nombrePlaces)

24. val maVoiture = Automobile(4,4)

25. println(maVoiture.nombreRoues)

26. println(maVoiture.nombrePlaces)

27. }

Cette fois-ci nous avons ajouté manuellement les propriétés dans la classe Vehicule. Le mot-clé this fait référence à l’instance de la classe Vehicule (donc c’est éventuellement aussi une instance de la classe Velo ou Automobile) dans laquelle nous nous situons, ou plutôt que nous essayons d’initialiser, et permet ici d’éviter la confusion avec les paramètres du constructeur primaire.

Évidemment, parmi les automobiles, nous pouvons notamment distinguer :

- les voitures

- les cyclomoteurs

Et parmi les cyclomoteurs : les scooters, les motos… rien ne nous limite dans la profondeur de la hiérarchie.

open class Vehicule(val nombreRoues: Int, val nombrePlaces: Int)

class Velo : Vehicule(2,1)
open class Automobile(nombreRoues: Int, nombrePlaces: Int) : Vehicule(nombreRoues, nombrePlaces)

class Voiture: Automobile(4,4)

Évidemment, il a fallu ajouter le mot-clé open à la déclaration de la classe Automobile.

Enfin, note importante, une classe ne peut hériter que d’une seule classe à la fois. Plus tard, avec les interfaces, nous verrons comment un peu contourner cette limitation.

VI-A-2 - La visibilité protected

Dans la section IV-C, nous avons vu les visibilités public et private. Lorsque nous définissons une relation d’héritage entre classes, la prise en compte des visibilités est légèrement plus complexe :

- un membre ou une fonction public dans une classe, le sera aussi dans l’ensemble des classes filles, Et ce, quelle que soit la profondeur d’héritage,

- un membre ou une fonction private dans une classe ne sera visible que dans la définition de la classe proprement dite : elle ne sera accessible ni de l’extérieur, ni des classes filles.

Il y a donc une troisième possibilité, plus souple que private, qui est protected :

- une propriété ou fonction déclarée protected ne sera pas accessible de l’extérieur,

- en revanche, elle sera accessible dans les classes filles, quelles qu’en soient la profondeur.

VI-A-3 - Héritage et constructeurs secondaires

Dès lors que l’on ajoute des constructeurs secondaires dans une classe fille, la syntaxe simple dans laquelle nous avions simplement suffixé le nom de la classe-mère avec les paramètres n’est plus autorisée :

1. open class Vehicule(val nombreRoues: Int, val nombrePlaces: Int)

2. class Velo: Vehicule(4,4) {

3. constructor(): super(3,3)

4. }

La ligne 2 provoquera une erreur de compilation. Il y a deux remèdes possibles :

- ajouter un constructeur primaire à la classe fille Velo, et baser les constructeurs secondaires dessus comme nous l’avons déjà vu,

- ou bien, s’il n’y a pas de constructeur primaire dans la classe fille Velo, chaque constructeur secondaire devra faire appel au constructeur primaire de la classe mère grâce au mot-clé super.

(Exemple purement pédagogique.)

open class Vehicule(val nombreRoues: Int, val nombrePlaces: Int)
class Velo: Vehicule {
constructor(): super(3,3)
constructor(moitieNombrePlaces: Int): super(4, moitieNombrePlaces * 2)
}

fun main(args: Array<String>) {
val velo1 = Velo()
println(velo1.nombrePlaces)
println(velo1.nombreRoues)

val velo2 = Velo(2)
println(velo2.nombrePlaces)
println(velo2.nombreRoues)
}

VI-B - Héritage plus élaboré

VI-B-1 - Héritage de classes abstraites

Il se peut que, dans la définition de notre hiérarchie de classes, nous ne souhaitions pas que l’utilisateur puisse créer et utiliser des instances de la classe racine (la classe au sommet de la hiérarchie). Cela peut-être notamment utile dans l’utilisation du polymorphisme, que nous verrons ultérieurement.

Voici un cas concret: une hiérarchie de figures géométriques, appelons la racine Shape. Une figure, en soit, ne définit pas grand-chose, En revanche, des triangles, rectangles, carrés…, oui. Nous pouvons donc décider de rendre la classe Shape abstraite.

Pour cela, il faut:

- déclarer la classe comme abstract,

- déclarer ce qui doit absolument être redéfini dans les classes filles (propriétés ou fonctions) comme abstract.

En revanche, il est possible de définir des propriétés et fonctions par défaut : elles ne seront donc pas déclarées abstract.

Voici ce que cela pourrait donner en code:

Classe abstraite

1. abstract class Figure {

2. abstract val nombreCotes: Int

4. abstract fun perimetre(): Double

5. abstract fun aire(): Double

7. val nom = "Figure"

9. fun afficher() {

10. println("Affichage de la figure sur l’écran")

11. }

12. }

14. class Carre(val longueurCotes: Double): Figure() {

15. override val nombreCotes = 4

17. override fun perimetre(): Double {

18. return 4 * nombreCotes;

19. }

21. override fun aire(): Double {

22. return nombreCotes * longueurCotes;

23. }

24. }

26. fun main() {

27. // Ceci ne compilera pas => val figure0 = Figure();

28. val figure1 = Carre(10.0)

29. println(figure1.perimetre()) // affiche 40.0

30. println(figure1.aire()) // affiche 100.0

31. }

Veuillez donc remarquer:

- il est possible, dans une classe abstraite, d’utiliser des propriétés/méthodes abstraites et non abstraites,

- il est impossible d’instancier la classe Figure

- il n’y a aucune obligation d’utiliser le mots-clé open, car tout élément abstrait doit obligatoirement être défini dans toute classe fille destinée à être instanciée. Et ce, une fois de plus, quelle que soit la profondeur. On appelle classe concrète, par opposition à une classe abstraite, une classe qui a l’ensemble de ses éléments définis, et qui donc dont l’on peut créer des instances,

- les classes filles n’ont donc pas pour obligation d’être toutes concrètes et de définir l’ensemble des propriétés/méthodes de leurs classes ancêtres. Seules celles dont on doit pouvoir créer des instances.

VI-B-2 - Héritage d’interfaces

Outre les classes abstraites, une autre notion est fréquemment utilisée dans la programmation orientée objets: la notion d’interfaces.

Les interfaces sont en quelques sortes des classes abstraites, mais ayant l’interdiction de stocker des états. Par exemple, dans notre classe Shape de la section précédente, nous avons utilisé un état, la propriété name. Et bien dans une interface, cela aurait été impossible.

En revanche, il reste possible de définir des implémentations par défaut de méthodes.

Une version interface de la classe Shape pourrait donner:

La version interface de la classe Figure

1. interface Figure {

2. abstract val nombreCotes: Int

4. abstract fun perimetre(): Double

5. abstract fun aire(): Double

7. fun afficher() {

8. println("Affichage de la figure sur l’écran")

9. }

10. }

12. class Carre(val longueurCotes: Double): Figure {

13. override val nombreCotes = 4

15. override fun perimetre(): Double {

16. return 4 * longueurCotes;

17. }

19. override fun area(): Double {

20. return longueurCotes * longueurCotes;

21. }

22. }

24. fun main() {

25. // Ceci ne compilera pas => val figure0 = Figure();

26. val figure1 = Carre(10.0)

27. println(figure1.perimetre())

28. println(figure1.aire())

29. }

Tout d’abord, veuillez remarquer:

- c’est avec le simple mot-clé interface que l’on déclare une interface,

- la propriété nom a été supprimée de l’interface Figure,

- il ne faut pas déclarer les parenthèses lorsque l’on hérite de la classe Figure, les interfaces n’ont pas de constructeur, et pour cause, elles n’ont pas d’état.

J’ajouterais même que, les interfaces n’ayant pas d’état, tout membre d’interface est automatiquement abstrait. Il devient donc superflu de déclarer les éléments abstract:

Version simplifiée de l'interface Figure

1. interface Figure {

2. val nombreCotes: Int

4. fun perimetre(): Double

5. fun aire(): Double

7. fun afficher() {

8. println("Affichage de la figure sur l’écran")

9. }

10. }

12. class Carre(val longueurCotes: Double): Carre {

13. override val nombreCotes = 4

15. override fun perimetre(): Double {

16. return 4 * longueurCotes;

17. }

19. override fun aire(): Double {

20. return longueurCotes * longueurCotes;

21. }

22. }

24. fun main() {

25. // Ceci ne compilera pas => val figure0 = Figure();

26. val figure1 = Carre(10.0)

27. println(figure1.perimeter())

28. println(figure1.area())

29. }

Mais ce qui est vraiment pratique avec les interfaces, c’est que une classe peut hériter de plusieurs interfaces:

Heriter de plusieurs interfaces

1. interface PeutVoler {

2. fun voler() { println("Je vole") }

3. }

5. interface PeutNager {

6. fun nager() { println("Je nage") }

7. }

9. class Canard: PeutVoler, PeutNager {

11. }

13. class Poisson : PeutNager {

15. }

19. fun main() {

20. val c = Canard()

21. c.voler()

22. c.nager()

24. val p = Poisson()

25. p.nager()

26. }

VI-B-3 - Héritage d’une classe et d’interfaces simultanée

Rien ne nous empêche d’hériter d’une classe (abstraite ou non) et d’interfaces en même temps.

Ainsi, nous pouvons adapter l’exemple précédent en définissant une classe commune Animal, que nous rendons ici abstraite, juste par choix:

Héritage de classe et d'interfaces simultanée

1. interface PeutVoler {

2. fun voler() { println("Je vole") }

3. }

5. interface PeutNager {

6. fun nager() { println("Je nage") }

7. }

9. abstract class Animal

11. class Canard: Animal(), PeutVoler, PeutNager {

13. }

15. class Poisson : Animal(), PeutNager {

17. }

21. fun main() {

22. val c = Canard()

23. c.voler()

24. c.nager()

26. val p = Poisson()

27. p.nager()

28. }

Ainsi un canard peut et voler et nager, un poisson ne peut que nager. Et il n’a pas été nécessaire de définir les caractéristiques plusieurs fois. Juste deux interfaces qu’il a fallu hériter convenablement.

Pourquoi avoir rajouté une telle classe Animal à la fois abstraite et vide dans cette exemple ? En clair, qu’est-ce que cela nous a-t-il apporté ? Justement la section suivante traite du polymorphisme, où cela prendra plus de sens.

VI-B-4 - Le polymorphisme

Encore une notion fréquente de la programmation orientée objets.

En Kotlin, tout comme en Java, il est possible de stocker une variable de classe fille dans une variable du type de l’une de ses classes mères. Plus concrètement, dans notre exemple précédent, nous avions des Animal de type Canard et Poisson. Et bien nous pouvons stocker une instance de Canard ou une instance de Poisson dans une variable de type Animal. Bien que Animal soit abstraite, il est possible de déclarer une variable de type Animal, c’est juste le fait de créer une instance de Animal qui est interdit.

Et cette possibilité nous permet d’effectuer du polymorphisme:

Polymorphisme

1. interface PeutVoler {

2. fun voler() { println("Je vole") }

3. }

5. interface PeutNager {

6. fun nager() { println("Je nage") }

7. }

9. abstract class Animal {

10. abstract fun bouger()

11. }

13. class Canard: Animal(), PeutVoler, PeutNager {

14. override fun bouger() {

15. voler()

16. nager()

17. }

18. }

20. class Poisson : Animal(), PeutNager {

21. override fun bouger() {

22. nager()

23. }

24. }

28. fun main() {

29. val ferme = arrayOf<Animal>(Canard(), Poisson(), Poisson(), Canard())

30. for (animal in ferme) animal.bouger()

31. }

Tout d’abord, afin de bénéficier du polymorphisme, j’ai ajouté une méthode bouger() dans la classe Animal, qui est redéfinie par chaque Animal afin de bénéficier d’une fonctionnalité commune à l’ensemble des Animal.

Ensuite, dans la fonction main, j’ai crée un tableau d’Animal. Et donc, en parcourant chaque élément du tableau, c’est la fonctionnalité bouger() propre au type d’Animal qui est appelée.

VII - Autres notions liées aux classes

VII-A - Objet compagnon

Nous avons déjà rencontré les objets compagnons auparavant dans ce tutoriel lorsque nous souhaitions ajouter des fonctionnalités à des classes existantes(section V-H).

De manière générale, les objets compagnons permettent de définir des propriétés/méthodes comme appartenant à une classe, au lieu de les faire appartenir aux instances de cette classe:

Companion Object

1. class Math {

2. companion object {

3. val PI = 3.1415927

4. fun carre(x: Double) = x*x

5. }

6. }

8. fun main() {

9. println(Math.carre(Math.PI))

10. }

Ainsi j’ai défini une classe Math, contenant une constante PI et une fonction carre. Elles sont directement accessibles via la syntaxe Math.. C’est l’équivalent des propriétés et méthodes statiques du langage Java.

Rien ne nous interdit de déclarer un objet compagnon dans une classe normalement prévue pour créer des instances:

Classe standard avec objet compagnon

1. class Livre(val contenu: String, val pages: Int) {

2. companion object {

3. val possesseur = "Moi"

4. }

5. }

7. fun main() {

8. val l = Livre("Je suis un livre", 100)

9. println(l.contenu)

10. // Ceci ne compile pas : println(l.possesseur )

11. println(Livre.possesseur )

12. }

On remarque donc que les éléments définis par un objet compagnon ne restent accessibles que par la syntaxe Classe..

VII-B - Classe anonyme

En pratique, de nombreuses fonctions attendent des instances de classes, qui implémentent une interface particulière, en paramètre. Et dans de nombreux cas, nous sommes amenés à créer des instances de telles classes, juste avant d’appeler lesdites fonctions, et sans nous en servir une fois cette fonction terminée. En clair, on a crée un objet temporaire juste pour pouvoir satisfaire la fonction.

Par exemple

Création d'une instance de classe quasi-inutile

1. interface Appelable {

2. abstract val nom: String

3. }

5. fun direBonjourA(a: Appelable) {

6. println("Bonjour ${a.nom} !")

7. }

9. class Personne(nom: String): Appelable {

10. override val nom = nom

11. }

13. fun main() {

14. val leo = Personne("Leo")

15. direBonjourA(leo)

16. }

Ici, nous avons crée une variable leo, juste pour pouvoir la passer à la fonction direBonjour (l’exemple n’est pas très représentatif de la problématique, je vous l’accorde).

Les classes anonymes, fonctionnalité qui existe notamment déjà en Java, permettent de contourner cette problématique.

« Interface anonyme »

1. interface Appelable {

2. abstract val nom: String

3. }

5. fun direBonjourA(a: Appelable) {

6. println("Bonjour ${a.nom} !")

7. }

9. fun main() {

10. direBonjourA(object: Appelable {

11. override val nom = "Leo"

12. })

13. }

Ainsi, nous avons pu appeler la fonction direBonjourA sans même avoir eu à créer un objet de type Personne (qui du coup n’est plus nécessaire). Il s’agit simplement d’un « héritage d’interface » local. Évidement, les interfaces n’ayant pas de constructeur, il ne faut pas de parenthèses lors de la définition de l’interface anonyme.

Il est possible de faire la même chose si le paramètre attendu est une classe:

« Classe anonyme »

1. abstract class Appelable(val nom: String) {}

3. fun direBonjourA(a: Appelable) {

4. println("Bonjour ${a.nom} !")

5. }

7. fun main() {

8. direBonjourA(object: Appelable("Leo"){

9. })

10. }

Veuillez noter l’appel du constructeur parent lors de la définition de la classe anonyme. Tout comme l’on aurait fait pour définir une classe standard.

Dans les deux cas, classe ou interface, la dénomination officielle est expression objet. C’est important car dans la documentation officielle, c’est sous ce nom que l’on y fait référence.

Enfin, tout comme dans toute classe, on peut redéfinir de la même manière les méthodes et propriétés de la hiérarchie, voire même en ajouter d’autres.

VII-C - Les classes de données

Les classes de données (data classes) peuvent souvent nous simplifier beaucoup de choses.

Prenons un exemple simple:

data class Personne(val prenom: String, val nom: String, age: Int)

Vous avez sans doute remarquez le mot-clé data.

Grâce à cela, nous avons un certain nombre de choses déjà implémentées pour nous, rien que par cet ajout. Entre autres:

- une définition automatique des méthodes equals et hashCode : ce qui nous permet d’utiliser directement l’opérateur == pour comparer des objets de type Personne,

- une définition automatique de la méthode toString(): ce qui nous permet d’afficher proprement les objets de type Personne, avec une commande println par exemple,

- une définition automatique des méthodes component1, component2 et component3. Ici jusqu’à 3 car nous avons 3 paramètres dans notre constructeur. Ces méthodes permettent de déconstruire les objets de type Personne, comme nous l’avons vu auparavant dans ce tutoriel (Section II-i).

En revanche, il y a des restrictions:

- le constructeur primaire doit avoir au moins un paramètre,

- tous les paramètres du constructeur primaire doivent être marqués comme soit val, soit var,

- ces classes ne peuvent être ni open, ni abstract (donc non héritables), ni inner (donc non internes), ni sealed. Nous verrons ce que sont les classes sealed dans la section suivante.

Il y a d’autres restrictions, je vous invite à consulter la documentation officielle pour approfondir le sujet.

VII-D - Les classes scellées

Lorsque l’on veut être certain qu’une classe ne soit pas héritable à l’infini, mais qu’en revanche seulement un certain nombre de classes en soient héritées, on peut la définir comme scellée.

De ce fait, toutes les classes filles doivent être déclarées dans le même fichier que cette classe mère:

Classe scellée

1. sealed class Expression

2. data class Constante(val nombre: Double): Expression()

3. data class Somme(val op1: Expression, val op2: Expression): Expression()

4. object NaN: Expression()

Ici je me suis contenté d’adapter l’exemple officiel.

Bien évidemment, ici NaN est bien une classe object au sens propre du terme, et non une classe anonyme.

La classe expression ne peut avoir d’autre classe fille que les classes Constante, Somme et Nan. Et l’on peut utiliser le polymorphisme dans une fonction dédiée pour évaluer des instances de la classe Expression. Ce n’est pas difficile à imaginer, à l’aide d’un algorithme récursif.

VII-E - Les classes énumérations

Les classes énumérations sont des énumérations plus puissantes que dans un certain nombre de langages, dont Java.

Une énumération est un type dont l’ensemble des valeurs a été défini à l’avance. C’est donc équivalent aux classes scellées, mais pour des simples valeurs.

De bonnes notions candidates pour les énumérations peuvent être : les mois de l’année, les points cardinaux, les langues vivantes enseignées dans un établissement…

Un exemple simple pour commencer

Enumération points cardinaux

enum class Direction {
Nord, Est, Sud, Ouest
}

fun main() {
val destination = Direction.Nord
println(when (destination) {
Direction.Nord -> "Je vais au nord"
Direction.Sud -> "Je vais au sud"
else -> "Direction interdite !"
})
}

Prenons maintenant une énumération RGB, qui permettrait d’obtenir du rouge/vert ou du bleu. Nous pouvons faire en sorte de pouvoir lire aussi la valeur (hexadécimale mais codée sous forme d’entier) de la couleur choisie:

Enumération avec constructeur primaire

1. enum class RVB(val hexa: Int) {

2. Rouge(0xFF0000),

3. Vert(0x00FF00),

4. Bleu(0x0000FF)

5. }

7. fun main() {

8. val couleur = RVB.Rouge

9. println("0x${Integer.toHexString(color.hexa)}")

10. }

Ainsi, grâce au constructeur primaire de l’énumération RVB, nous pouvons en personnaliser et lire la valeur associée.

Allons un peu plus loin:

Enumération avec constructeur primaire complexe

1. enum class RVB(val r: Int, val g: Int, val b: Int) {

2. rouge(255, 0, 0),

3. vert(0, 255, 0),

4. bleu(0, 0, 255),

5. noir(0,0,0),

6. blanc(255,255,255)

7. }

9. fun main() {

10. val color = RVB.noir

11. println(color.r)

12. }

Une énumération peut aussi avoir des éléments abstraits, que les différentes valeurs doivent alors redéfinir en tant classes anonymes, certes avec une syntaxe épurée:

Enumération avec élément abstrait

1. enum class Animal {

2. Chien {

3. override fun crier() = "YapYap!"

4. },

5. Chat {

6. override fun crier() = "Miaou!"

7. };

8. abstract fun crier(): String

9. }

10. fun main() {

11. val animal = Animal.Chat

12. println(animal.crier())

13. }

Attention à ne pas oublier le point-virgule après la définition des valeurs !

Veuillez également remarquer que les éléments n’ont pas besoin de la syntaxe Object : {afin de redéfinir la base de l’énumération.

Sachez aussi qu’il est possible, de manière similaire, d’implémenter des interfaces dans une énumération. N’hésitez pas à aller consulter la documentation officielle si besoin.

VII-F - La surcharge d’opérateurs

Kotlin permet de surcharger un certain nombre d’opérateurs au sein de nos propres classes.

Ainsi, on pourrait additionner deux dates de calendrier directement avec la notation d1 + d2, on pourrait multiplier deux matrices à l’aide de m1 * m2 …

Comprenez bien que tout mécanisme permettant d’augmenter une classe : donc l’héritage ou les fonctions d’extension, permettent la surcharge d’opérateurs.

Évidemment, le but ne sera pas d’être exhaustif, mais simplement de vous présenter un simple exemple. Je vous invite donc à vous rendre à la documentation officielle si vous souhaitez approfondir le sujet.

Soit une classe Fraction, et une première tentative infructueuse d’additionner deux fractions:

Tentative infructueuse d'additionner deux fractions

1. class Fraction(val numerateur: Int, val denominateur: Int = 1)

3. fun main() {

4. val a = Fraction(2)

5. val b = Fraction(3,2)

7. // Ceci ne compilera pas => println(a+b)

8. }

Une manière élégante de résoudre ce problème consiste à définir l’opérateur + pour la classe Fraction. Mais attention, en Kotlin, les noms des fonctions à redéfinir ne correspondent aux noms des opérateurs ! D’où la bonne idée de regarder la documentation officielle.

En l’occurrence, la fonction à redéfinir pour l’addition s’appelle plus et prends la 2e opérande de l’addition en paramètre:

Correction avec une extension de fonction

1. class Fraction(val numerateur: Int, val denominateur: Int = 1){

2. override fun toString(): String = "$numerateur/$denominateur"

3. }

5. operator fun Fraction.plus(autre: Fraction): Fraction {

6. return Fraction(numerateur * autre.denominateur + autre.numerateur * denominateur, denominateur * autre.denominateur)

7. }

9. fun main() {

10. val a = Fraction(2)

11. val b = Fraction(3,2)

13. println(a+b)

14. }

Et dans une version plus classique, avec une simple méthode:

Correction avec une simple méthode

1. class Fraction(val numerateur: Int, val denominateur: Int = 1){

2. override fun toString(): String = "$numerateur/$denominateur"

4. operator fun plus(autre: Fraction): Fraction {

5. return Fraction(numerateur * autre.denominateur + autre.numerateur * denominateur, denominateur * autre.denominateur)

6. }

8. }

9. fun main() {

10. val a = Fraction(2)

11. val b = Fraction(3,2)

13. println(a+b)

14. }

Dans les deux cas, vous pouvez remarquer l’utilisation du mot-clé operator: obligatoire pour que la méthode soit reconnue en tant que surcharge d’opérateur !

Retour à l'article

Retour à l'article

Retour à l'article

Retour à l'article

Retour à l'article

Retour à l'article

Retour à l'article

Retour à l'article

Retour à l'article