Tutoriel sur le langage Kotlin

Les fonctions et les bases des classes (partie 3/4)

Laurent Bernabé

Dans cet article en plusieurs parties, vous pourrez être initiés aux bases du langage Kotlin.

Le public visé est principalement les développeurs familiers avec le langage Java et la POO : même si tout langage orienté objet devrait convenir. Je fais de temps en temps référence à Java pour essayer d’aider à visualiser certains concepts.

C’est la version 1.3.70 qui a été utilisée lors de l’écriture de l’article.

Voici les différentes parties :

Introduction rapide

Bases de la syntaxe

Fonctions et notions sur les classes

Notions avancées sur les classes et diverses fonctionnalités

Cette troisième partie présente les fonctions et les notions de base des classes.

Pour réagir au contenu de cet article, un espace de dialogue vous est proposé sur le forum. Commentez

- Titre : Tutoriel sur le langage Kotlin

- Auteur : Laurent Bernabé

- Parution : 29 juin 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 Bernabé. 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. Focus sur les fonctions

- I-A. Les bases

- I-A-1. Fonction sans valeur de retour ni paramètres

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

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

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

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

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

- I-A-7. Fonction dans une fonction

- I-A-8. Fonctions variadiques

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

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

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

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

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

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

- I-C. Fonctions anonymes et lambda

- I-C-1. Fonction anonyme

- I-C-2. Lambda

- I-C-2-a. Utilisation simple

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

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

- II. Les classes : syntaxe de base

- II-A. Classe vide

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

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

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

- III. Conclusion et remerciements

I - Focus sur les fonctions

Dans ce chapitre, nous allons explorer les fonctions un peu plus en détail que ce que nous avons vu dans l’introduction.

I-A - Les bases

I-A-1 - Fonction sans valeur de retour ni paramètres

Voici notre première fonction :

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

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

Plusieurs remarques :

- on utilise le mot-clef 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, dans ce cas-ci nous n’en avons pas besoin.

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

Maintenant, personnalisons un peu l’invite 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 y 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é.

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

Maintenant, pourquoi ne pas fournir une valeur par défaut à notre invite 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ètres 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ètres.

I-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 un quelconque espace entre la parenthèse fermante et les deux points ;

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

- rien ne nous empêche de combiner avec des valeurs par défaut : 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.

I-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.

I-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.

I-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 l’une des conséquences du fait qu’en Kotlin, les fonctions sont également des valeurs. Et cela devient particulièrement intéressant lorsqu’on les utilise en tant que fonctions d’ordre supérieur.

I-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, c’est grâce au mot-clef vararg que le paramètre params accepte 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 seul 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.

I-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.

I-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.

I-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’élèves 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édant 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 autre possibilité 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 :

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 sont basés sur les tableaux natifs de Java, lesquels n’ont pas de formatage de leur affichage par défaut. Au lieu de cela, pour les tableaux, 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.

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

Une autre méthode d’ordre supérieur souvent présente dans beaucoup de types de collections : 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.

I-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 1re 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 premier 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.

I-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ération 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 ne 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-points.

I-C - Fonctions anonymes et lambda

Parfois, il peut s’avérer fastidieux de devoir créer une fonction, juste pour ne 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 lambda.

I-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 utilisée 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ée plus haut pourrait 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.

I-C-2 - Lambda

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

I-C-2-a - Utilisation simple

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

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 la 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 doit donc, dans ce cas précis, être aussi une expression. (C’est le cas dans le code précédent.)

Tout comme les fonctions anonymes, les lambda 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.

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

Une caractéristique sympathique des lambda 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 en 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.

I-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 pour tester ensuite 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 mot-clef it. Une fois de plus, l’écriture du calcul est plus élégante.

II - Les classes : syntaxe de base

II-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ée une classe simplement en suffixant le nom de la classe avec des parenthèses : aucun mot-clef new.

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

II-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 des 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)
}

II-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 » de l’ê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 qu’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-clef 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’âge, une méthode supplémentaire getAge() a été ajoutée.

II-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).

III - Conclusion et remerciements

Dans cette section, nous avons vu les déclarations et des utilisations de fonctions, ainsi que les notions de base sur les classes.

Dans la section suivante, la dernière, nous verrons des concepts avancés sur les classes ainsi que diverses fonctionnalités utiles.

Je tiens à remercier escartefigue pour la relecture orthographique, et Mickael Baron pour la relecture technique.