Skip to content

7. Types fonctionnels, lambdas et événements

Dans de nombreux programmes, on a besoin de passer à une fonction non pas une donnée, mais un comportement : le critère d'un tri, la condition d'un filtrage, le traitement à exécuter quand quelque chose se produit. Kotlin répond à ce besoin en faisant des fonctions des valeurs comme les autres : une fonction a un type, le type fonctionnel, noté par exemple [(Int, Int) -> Int] ; on peut la ranger dans une variable, la passer en paramètre, la rendre comme résultat d'une autre fonction, et l'exécuter quand on le souhaite. Une fonction qui reçoit ou rend une fonction est dite d'ordre supérieur.

Java, rappelons-le, n'a pas de types fonctionnels : il passe par les interfaces fonctionnelles, des interfaces n'ayant qu'une seule méthode abstraite ([Runnable], [Comparator], [Function<T, R>]…), et c'est seulement depuis Java 8 (2014) qu'il permet de les implémenter par des expressions lambda et des références de méthodes. Kotlin a eu les lambdas dès sa première version, et il sait aussi travailler avec les interfaces fonctionnelles de Java, puisqu'il utilise directement les bibliothèques Java. Nous avons déjà beaucoup utilisé les lambdas sans les détailler, avec les opérations sur les collections au chapitre 5 : dans [personnes.filter { it.age >= 18 }], on passe une lambda à la fonction [filter]. Ce chapitre explique ce qui se cache derrière cette écriture. Il se termine par les événements, que Kotlin, comme Java, gère avec des écouteurs (« listeners ») : des fonctions, le plus souvent des lambdas, qui s'inscrivent auprès d'un émetteur pour être averties de ce qui s'y passe. Les interfaces graphiques (Swing, JavaFX, Compose) comme les cadriciels web reposent sur ce mécanisme : un clic sur un bouton, l'arrivée d'une requête HTTP sont signalés à des écouteurs inscrits. Ce cours ne traite pas des interfaces graphiques : tous les exemples sont des programmes console.

Les programmes du chapitre sont rangés dans le projet Gradle [07-lambdas], un paquetage par programme : [src/main/kotlin/lambdas01/Lambdas01.kt], [src/main/kotlin/lambdas02/Lambdas02.kt]… On les exécute par [gradlew -q run -Pmain=lambdas01.Lambdas01Kt] ou par le triangle vert d'IntelliJ IDEA. Le projet n'a besoin d'aucune bibliothèque en plus de la bibliothèque standard de Kotlin : les types fonctionnels, les lambdas et les fonctions de portée en font partie, et les interfaces du paquetage [java.util.function] sont celles du JDK.

7.1. Les types fonctionnels et les interfaces fonctionnelles prédéfinies

7.1.1. La notion de type fonctionnel

Un type fonctionnel décrit un modèle (on dit aussi une signature, ou une forme) de fonction : les types de ses paramètres, entre parenthèses, puis une flèche, puis le type de son résultat. Une variable d'un type fonctionnel peut référencer n'importe quelle fonction de cette forme, et l'exécuter. Quelques exemples :

  • [() -> Unit] : une fonction sans paramètre qui ne rend aucun résultat utile ([Unit] joue le rôle du [void] de Java) ;
  • [() -> String] : une fonction sans paramètre qui rend une chaîne ;
  • [(Int) -> Boolean] : une fonction qui reçoit un entier et rend un booléen, autrement dit un prédicat ;
  • [(Int, Int) -> Int] : une fonction qui reçoit deux entiers et rend un entier ;
  • [(String) -> (Int) -> Int] : une fonction qui reçoit une chaîne et rend… une fonction de type [(Int) -> Int] (la flèche se lit de droite à gauche) ;
  • [(() -> Unit)?] : une fonction éventuellement absente ; les parenthèses sont nécessaires, car [() -> Unit?] désignerait une fonction rendant un [Unit?]. Nous retrouvons ici la sûreté vis-à-vis de [null] du chapitre 3 : on appelle une telle fonction par [f?.invoke()] ;
  • [String.(Int) -> String] : une fonction avec receveur ; elle s'applique à une chaîne, que son code désigne par [this], comme une fonction d'extension (chapitre 4). On peut l'appeler comme une extension (["ab".f(3)]) ou comme une fonction ordinaire dont le receveur est le premier paramètre ([f("ab", 3)]).

Les paramètres peuvent être nommés, pour documenter le type : [(n1: Int, n2: Int) -> Int] est le même type que [(Int, Int) -> Int].

Il existe trois façons d'obtenir une valeur de type fonctionnel :

  • écrire une référence de fonction vers une fonction existante qui a la bonne forme : [val tache: () -> Unit = ::afficher] ;
  • écrire une expression lambda : [val tache: () -> Unit = { println("tâche") }] ;
  • écrire une fonction anonyme : [val tache = fun() { println("tâche") }], ou un objet d'une classe qui implémente le type fonctionnel (un type fonctionnel est une interface, nous allons le voir). Ces deux écritures sont plus rares.

On exécute ensuite la fonction comme une fonction ordinaire : [tache()]. C'est une écriture abrégée de l'appel de la méthode [invoke] de l'objet fonction : [tache.invoke()]. C'est une première différence visible avec Java, où l'on doit appeler la méthode de l'interface par son nom ([run], [apply], [test]…) ; Kotlin fait ici comme C#, où un délégué s'appelle comme une méthode.

7.1.2. Types fonctionnels de Kotlin et interfaces de java.util.function

Avant Java 8, chaque bibliothèque Java définissait ses propres interfaces à une méthode ([Runnable], [Comparator], [ActionListener]…). Java 8 a ajouté le paquetage [java.util.function], qui fournit 43 interfaces fonctionnelles génériques. Kotlin n'en a pas besoin : un type fonctionnel s'écrit directement. Mais ces interfaces restent utilisables depuis Kotlin, et on les rencontre dès que l'on appelle une bibliothèque Java. Le tableau suivant met en correspondance les principales interfaces Java et les types fonctionnels de Kotlin :

Interface Java
Type Kotlin
Fonctions représentées
[Runnable] : [void run()]
[() -> Unit]
aucun paramètre, aucun résultat
[Supplier<T>] : [T get()]
[() -> T]
aucun paramètre, un résultat : un « fournisseur »
[Consumer<T>] : [void accept(T t)]
[(T) -> Unit]
un paramètre, aucun résultat : un « consommateur »
[BiConsumer<T, U>]
[(T, U) -> Unit]
deux paramètres, aucun résultat
[Function<T, R>] : [R apply(T t)]
[(T) -> R]
un paramètre, un résultat
[BiFunction<T, U, R>]
[(T, U) -> R]
deux paramètres, un résultat
[UnaryOperator<T>]
[(T) -> T]
paramètre et résultat de même type
[BinaryOperator<T>]
[(T, T) -> T]
deux paramètres et un résultat de même type
[Predicate<T>] : [boolean test(T t)]
[(T) -> Boolean]
un paramètre, un résultat booléen : un « prédicat »
[BiPredicate<T, U>]
[(T, U) -> Boolean]
deux paramètres, un résultat booléen
[Comparator<T>] : [int compare(T o1, T o2)]
[(T, T) -> Int]
deux paramètres de même type, un résultat entier
[Callable<V>] : [V call()]
[() -> V]
comme [Supplier<V>] ; en Kotlin, pas d'exception contrôlée à déclarer
[IntBinaryOperator], [IntPredicate]…
[(Int, Int) -> Int], [(Int) -> Boolean]…
les versions Java pour les types primitifs

Il n'y a pas, en Kotlin, de limite pratique au nombre de paramètres d'un type fonctionnel : [(Int, Int, Int) -> Int] s'écrit aussi simplement que les autres, alors qu'en Java, faute d'interface prédéfinie à trois paramètres, il faut définir la sienne.

Comment la machine virtuelle Java, qui ne connaît que des classes et des interfaces, représente-t-elle un type fonctionnel ? Par des interfaces génériques de la bibliothèque de Kotlin : [kotlin.jvm.functions.Function0<R>], [Function1<P1, R>], [Function2<P1, P2, R>]… jusqu'à [Function22], puis une interface [FunctionN] au-delà. Chacune a une unique méthode [invoke] : [(Int, Int) -> Int] est compilé en [Function2<Integer, Integer, Integer>], dont la méthode est [operator fun invoke(p1: Int, p2: Int): Int]. C'est le modificateur [operator] de [invoke] (cf. la surcharge des opérateurs au chapitre 4) qui permet l'écriture [f(2, 3)]. Un type fonctionnel est donc, pour la JVM, une interface fonctionnelle comme celles de Java. Comme les génériques Java ne s'appliquent qu'aux types objets, les [Int] passés à un [(Int, Int) -> Int] sont convertis en [Integer] et inversement (boxing et unboxing). Java évite ces conversions avec ses interfaces spécialisées ([IntBinaryOperator]…) ; Kotlin les évite autrement, avec les fonctions [inline] que nous verrons plus loin.

Le programme [Lambdas01.kt] utilise ces types avec des références de fonctions :

package lambdas01

import java.util.function.IntBinaryOperator
import java.util.function.Predicate
import java.util.function.Supplier

// les types fonctionnels de Kotlin et les interfaces fonctionnelles de Java

fun main() {
    // 1 - () -> Unit : aucun paramètre, aucun résultat
    val tache: () -> Unit = ::afficher
    // exécution : on appelle la fonction comme une fonction ordinaire
    tache()
    // ce qui est une écriture abrégée de l'appel de sa méthode invoke
    tache.invoke()

    // 2 - (Int, Int) -> Int : deux paramètres, un résultat
    val operation: (Int, Int) -> Int = ::ajouter
    println("operation(2, 3)=${operation(2, 3)}")
    // () -> String : aucun paramètre, un résultat
    val saluer: () -> String = ::saluer
    println("saluer()=${saluer()}")

    // 3 - (String) -> Unit : un paramètre, pas de résultat
    val ecrire: (String) -> Unit = ::println
    ecrire("(String) -> Unit a exécuté println")

    // 4 - (Int) -> Boolean : un prédicat
    val estPair: (Int) -> Boolean = ::estPair
    println("estPair(8)=${estPair(8)}")
    val nombres = mutableListOf(3, 8, 1, 6, 5, 4)
    // removeAll de la bibliothèque Kotlin attend un (Int) -> Boolean
    val modifiee = nombres.removeAll(estPair)
    println("nombres pairs retirés : $modifiee, reste : $nombres")

    // 5 - un Comparator<T> (interface Java) construit à partir d'une fonction (conversion SAM)
    val noms = mutableListOf("Paul", "Mélanie", "Jo", "Sébastien")
    noms.sortWith(Comparator(::comparerLongueurs))
    println("triés par longueur : $noms")

    // 6 - les interfaces de java.util.function restent utilisables
    val fournisseur: Supplier<String> = Supplier(::saluer)
    val multiplier = IntBinaryOperator(::multiplier)
    val predicat = Predicate<Int> { it > 4 }
    println("fournisseur.get()=${fournisseur.get()}")
    println("multiplier.applyAsInt(2, 3)=${multiplier.applyAsInt(2, 3)}")
    // removeIf est une méthode Java (Collection) : elle attend un Predicate
    val valeurs = mutableListOf(10, 3, 8, 7, 2, 1)
    valeurs.removeIf(predicat)
    // une fonction Kotlin est convertie automatiquement en Predicate (conversion SAM)
    valeurs.removeIf(estPair)
    valeurs.removeIf { it == 1 }
    println("après removeIf : $valeurs")

    // 7 - un type fonctionnel nullable : la fonction peut être absente
    executer(null)
    executer(tache)

    // 8 - un type fonctionnel avec receveur : String.(Int) -> String
    val repeter: String.(Int) -> String = String::repeat
    println("\"ab\".repeter(3)=${"ab".repeter(3)}, repeter(\"cd\", 2)=${repeter("cd", 2)}")

    // 9 - une fonction est un objet, instance d'une classe qui implémente une interface
    println("classe : ${operation::class.java.name}")
    println("interfaces : ${operation::class.java.interfaces.contentToString()}")
    val somme: (Int, Int) -> Int = { n1, n2 -> n1 + n2 }
    println("classe de la lambda : ${somme::class.java.name}")
    println("interfaces : ${somme::class.java.interfaces.contentToString()}")
}

// une fonction qui reçoit une fonction éventuellement absente
fun executer(rappel: (() -> Unit)?) {
    // appel sûr : rien n'est exécuté si rappel vaut null
    rappel?.invoke()
    println("rappel ${if (rappel == null) "absent" else "présent"}")
}

// les fonctions référencées : des fonctions de haut niveau du fichier
fun afficher() {
    println("afficher() exécutée par tache()")
}

fun ajouter(n1: Int, n2: Int): Int = n1 + n2

fun multiplier(n1: Int, n2: Int): Int = n1 * n2

fun saluer(): String = "Bonjour"

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

fun comparerLongueurs(s1: String, s2: String): Int = s1.length.compareTo(s2.length)

Résultats :

> gradlew -q run -Pmain=lambdas01.Lambdas01Kt
afficher() exécutée par tache()
afficher() exécutée par tache()
operation(2, 3)=5
saluer()=Bonjour
(String) -> Unit a exécuté println
estPair(8)=true
nombres pairs retirés : true, reste : [3, 1, 5]
triés par longueur : [Jo, Paul, Mélanie, Sébastien]
fournisseur.get()=Bonjour
multiplier.applyAsInt(2, 3)=6
après removeIf : [3]
rappel absent
afficher() exécutée par tache()
rappel présent
"ab".repeter(3)=ababab, repeter("cd", 2)=cdcd
classe : lambdas01.Lambdas01Kt$main$operation$1
interfaces : [interface kotlin.jvm.functions.Function2]
classe de la lambda : lambdas01.Lambdas01Kt$$Lambda/0x000000001d04a7f0
interfaces : [interface kotlin.jvm.functions.Function2]

Commentaires :

  • ligne 11 : la variable [tache] est de type [() -> Unit]. On lui affecte une référence de fonction : [::afficher] désigne la fonction de haut niveau [afficher] déclarée plus bas dans le fichier (ligne 79). Le compilateur vérifie qu'elle a la forme demandée (aucun paramètre, aucun résultat) et crée l'objet fonction qui l'appelle. En Java, il aurait fallu une classe auxiliaire pour ranger ces méthodes, la classe implicite d'un fichier source compact ne pouvant pas être nommée ; en Kotlin, les fonctions de haut niveau se référencent directement ;
  • ligne 13 : la fonction est exécutée en l'appelant comme une fonction ordinaire. C'est la fonction [afficher] qui s'exécute (ligne 1 des résultats). Ligne 15, la même chose en appelant explicitement la méthode [invoke] (ligne 2 des résultats) ;
  • ligne 18 : [(Int, Int) -> Int] désigne une fonction ayant deux paramètres [Int] et rendant un [Int]. La fonction [ajouter] (ligne 83) convient. On exécute la fonction par [operation(2, 3)] (ligne 19) ;
  • ligne 21 : une fonction sans paramètre rendant une chaîne, l'équivalent du [Supplier<String>] de Java. On remarque que la variable locale [saluer] porte le même nom que la fonction [saluer] : dans [::saluer], c'est la fonction qui est désignée, et dans [saluer()], la variable locale, qui masque la fonction ;
  • ligne 25 : une fonction ayant un paramètre [String] et ne rendant rien, l'équivalent du [Consumer<String>] de Java. La fonction [println] de la bibliothèque standard est surchargée ([println(Int)], [println(Any?)]…) : c'est le type attendu, [(String) -> Unit], qui permet au compilateur de choisir la bonne surcharge ;
  • ligne 29 : un prédicat, l'équivalent de [Predicate<Integer>]. Ligne 33, la fonction d'extension [removeAll] de la bibliothèque Kotlin attend justement un paramètre de type [(T) -> Boolean] : elle retire de la liste modifiable tous les éléments pour lesquels le prédicat rend [true], et rend [true] si elle a retiré au moins un élément (ligne 7 des résultats) ;
  • ligne 38 : la fonction [sortWith] attend un [Comparator<T>]. En Kotlin, [Comparator] est un alias de l'interface Java [java.util.Comparator]. [Comparator(::comparerLongueurs)] crée un [Comparator<String>] à partir de la fonction [comparerLongueurs] : c'est une conversion SAM (« Single Abstract Method »), qui fabrique l'objet implémentant l'interface Java à partir d'une fonction Kotlin de même forme. La fonction [comparerLongueurs] (ligne 91) utilise [compareTo], qui compare deux [Int] sans risque de dépassement de capacité (ce qu'une simple soustraction ne garantit pas) ;
  • lignes 42-44 : les interfaces de [java.util.function] sont utilisables telles quelles. On les construit de la même façon, par une conversion SAM explicite : le nom de l'interface suivi d'une référence de fonction ou d'une lambda. On les exécute alors par leur méthode Java ([get], [applyAsInt], [test]…), comme en Java ;
  • lignes 49-52 : [removeIf] est une méthode Java de l'interface [Collection] ; elle attend un [Predicate]. On peut lui passer un [Predicate] (ligne 49), mais aussi directement une valeur de type [(Int) -> Boolean] (ligne 51) ou une lambda (ligne 52) : quand on appelle une méthode Java qui attend une interface fonctionnelle, Kotlin fait la conversion SAM automatiquement. C'est ce qui rend les bibliothèques Java aussi agréables à utiliser depuis Kotlin que depuis Java. La liste initiale (10, 3, 8, 7, 2, 1) perd successivement les éléments supérieurs à 4, les éléments pairs, puis l'élément 1 (ligne 11 des résultats) ;
  • lignes 56-57 et 72-75 : la fonction [executer] reçoit une fonction éventuellement absente, de type [(() -> Unit)?]. L'appel sûr [rappel?.invoke()] n'exécute rien si [rappel] vaut [null]. On ne peut pas écrire [rappel?()] : l'appel sûr d'une fonction nullable passe par [invoke]. Ce sera l'écriture habituelle pour les écouteurs facultatifs ;
  • ligne 60 : un type fonctionnel avec receveur. La référence [String::repeat] désigne la méthode [repeat] de la classe [String], qui s'applique à une chaîne et reçoit un entier. Ligne 61, on l'appelle de deux façons : comme une extension, ["ab".repeter(3)], ou comme une fonction à deux paramètres, [repeter("cd", 2)]. Nous retrouverons les fonctions avec receveur avec les fonctions de portée [apply] et [with] ;
  • lignes 64-68 : une fonction est un objet. La classe de la référence de fonction [operation] (ligne 16 des résultats) est une classe générée par le compilateur Kotlin, nommée d'après l'endroit où la référence apparaît ; la classe de la lambda [somme] (ligne 18 des résultats) est générée à l'exécution par la machine virtuelle, comme pour les lambdas Java : son nom contient [$$Lambda] suivi d'une adresse qui change d'une exécution à l'autre. Dans les deux cas, la classe implémente l'interface [kotlin.jvm.functions.Function2] (lignes 17 et 19 des résultats). Une fonction Kotlin n'est donc pas un nouveau genre d'objet : c'est l'instance d'une classe ordinaire qui implémente une interface fonctionnelle.

7.2. Définir ses propres types fonctionnels

Les types fonctionnels suffisent souvent, mais on peut vouloir donner un nom à un modèle de fonction. Kotlin offre pour cela deux outils, de nature très différente.

Le premier est l'alias de type, déclaré par le mot clé [typealias] :

  • [typealias OperationBinaire = (n1: Int, n2: Int) -> Int]

Un alias ne crée aucun type nouveau : [OperationBinaire] est un autre nom de [(Int, Int) -> Int], et les deux écritures sont interchangeables. L'alias rend le code plus lisible quand le type fonctionnel est long ou revient souvent.

Le second est l'interface fonctionnelle, déclarée par [fun interface] :

  • [fun interface Operation { fun calculer(n1: Int, n2: Int): Int }]

C'est l'équivalent exact de l'interface fonctionnelle de Java : une interface qui n'a qu'une seule méthode abstraite (on dit aussi une interface SAM, « Single Abstract Method »). Elle peut avoir par ailleurs des méthodes avec un corps et des propriétés non abstraites : elles ne comptent pas. Le mot clé [fun] devant [interface] joue le rôle de l'annotation [@FunctionalInterface] de Java, avec une différence importante : il n'est pas facultatif. C'est lui qui autorise la conversion SAM, c'est-à-dire la création d'un objet [Operation] à partir d'une lambda ou d'une référence de fonction : [Operation { n1, n2 -> n1 + n2 }]. Sans le mot clé [fun], une interface Kotlin, même réduite à une méthode, ne peut être implémentée que par une classe ou par un objet anonyme ([object : Operation { … }]).

Par rapport au type [(Int, Int) -> Int], le type [Operation] est un vrai type nouveau, distinct de tous les autres : il a un nom qui dit à quoi il sert, sa méthode a un nom parlant ([calculer] plutôt que [invoke]), il peut porter des méthodes supplémentaires, et il peut être implémenté par une classe nommée. En contrepartie, on l'exécute par le nom de sa méthode ([op.calculer(2, 3)]), et non par [op(2, 3)].

Une variable [op] de type [Operation], comme une variable de type [(Int, Int) -> Int], ne référence qu'une seule fonction à la fois. Contrairement au C#, qui offre des « délégués multicast », ni Java ni Kotlin n'offrent le moyen d'enregistrer plusieurs fonctions dans une même variable pour les exécuter toutes d'un seul appel : quand on en a besoin, on range les fonctions dans une liste et on les exécute une à une. C'est exactement ce que nous ferons avec les écouteurs d'événements.

Considérons l'exemple [Lambdas02.kt] :

package lambdas02

import java.util.function.IntBinaryOperator

// définir ses propres types fonctionnels : fun interface et typealias

fun main() {
    // une variable de type Operation reçoit la fonction augmenter de l'objet compagnon
    var op = Operation(Calculs::augmenter)
    // on exécute l'opération
    var n = op.calculer(4, 7)
    println("n=$n")

    // création d'un objet c1 de type Calculs
    val c1 = Calculs()
    // on range dans op la méthode ajouter de l'objet c1
    op = Operation(c1::ajouter)
    n = op.calculer(2, 3)
    println("n=$n")
    // puis la méthode soustraire de c1
    op = Operation(c1::soustraire)
    n = op.calculer(2, 3)
    println("n=$n")

    // une variable ne référence qu'une fonction : pour en exécuter plusieurs, on utilise une liste
    val operations = listOf(
        Operation(c1::ajouter),
        Operation(c1::soustraire),
        Operation { n1, n2 -> n1 * n2 },
    )
    for (operation in operations) {
        println("-> ${operation.calculer(10, 4)}")
    }

    // une méthode de l'interface construit une nouvelle opération
    val sommeAuCarre = Operation(c1::ajouter).puis { x -> x * x }
    println("sommeAuCarre.calculer(1, 2)=${sommeAuCarre.calculer(1, 2)}")

    // Operation et IntBinaryOperator ont la même forme mais sont deux types différents
    val autre = IntBinaryOperator(op::calculer)
    println("autre.applyAsInt(5, 1)=${autre.applyAsInt(5, 1)}")

    // une interface fonctionnelle générique
    val longueur = Transformation(String::length)
    val etoiles = Transformation("*"::repeat)
    println("longueur(\"Bonjour\")=${longueur.transformer("Bonjour")}")
    println("etoiles(5)=${etoiles.transformer(5)}")

    // un alias de type : un simple autre nom pour (Int, Int) -> Int
    val f: OperationBinaire = { n1, n2 -> n1 - n2 }
    // OperationBinaire et (Int, Int) -> Int sont le même type
    val g: (Int, Int) -> Int = f
    // mais Operation est un type différent : il faut une conversion
    val op2 = Operation(g)
    println("f(5, 1)=${f(5, 1)}, g(5, 1)=${g(5, 1)}, op2.calculer(5, 1)=${op2.calculer(5, 1)}")
}

// définition d'une interface fonctionnelle : une seule méthode abstraite
// acceptant deux entiers en paramètres et rendant un entier
fun interface Operation {
    fun calculer(n1: Int, n2: Int): Int

    // une méthode avec un corps ne compte pas : l'interface reste fonctionnelle
    fun puis(suite: (Int) -> Int): Operation = Operation { n1, n2 -> suite(calculer(n1, n2)) }
}

// une interface fonctionnelle générique : T -> R
fun interface Transformation<T, R> {
    fun transformer(valeur: T): R
}

// un alias de type : aucun type nouveau n'est créé
typealias OperationBinaire = (n1: Int, n2: Int) -> Int

// une classe dont les méthodes ont la forme de l'interface Operation
class Calculs {
    // deux méthodes d'instance
    fun ajouter(n1: Int, n2: Int): Int {
        println("ajouter($n1,$n2)")
        return n1 + n2
    }

    fun soustraire(n1: Int, n2: Int): Int {
        println("soustraire($n1,$n2)")
        return n1 - n2
    }

    // une méthode de l'objet compagnon : l'équivalent d'une méthode statique
    companion object {
        fun augmenter(n1: Int, n2: Int): Int {
            println("augmenter($n1,$n2)")
            return n1 + 2 * n2
        }
    }
}

Résultats :

> gradlew -q run -Pmain=lambdas02.Lambdas02Kt
augmenter(4,7)
n=18
ajouter(2,3)
n=5
soustraire(2,3)
n=-1
ajouter(10,4)
-> 14
soustraire(10,4)
-> 6
-> 40
ajouter(1,2)
sommeAuCarre.calculer(1, 2)=9
soustraire(5,1)
autre.applyAsInt(5, 1)=4
longueur("Bonjour")=7
etoiles(5)=*****
f(5, 1)=4, g(5, 1)=4, op2.calculer(5, 1)=4

Commentaires :

  • lignes 60-64 : définition de l'interface fonctionnelle [Operation]. En Kotlin, les déclarations de types peuvent se placer n'importe où dans le fichier, avant ou après la fonction [main] ; nous les plaçons après ;
  • ligne 64 : une interface fonctionnelle peut avoir des méthodes avec un corps (l'équivalent des méthodes [default] de Java). La méthode [puis] construit une nouvelle [Operation] : celle-ci exécute l'opération courante, puis applique la fonction [suite] à son résultat. Elle rend une lambda convertie en [Operation], que nous étudierons au paragraphe suivant. On remarque que le paramètre [suite] n'a pas besoin d'une interface comme le [IntUnaryOperator] de la version Java : le type fonctionnel [(Int) -> Int] suffit, et on l'exécute par [suite(…)] ;
  • lignes 76-89 : la classe [Calculs] a deux méthodes d'instance, [ajouter] et [soustraire], et une méthode de son objet compagnon, [augmenter] (l'équivalent d'une méthode statique, cf. chapitre 4), qui ont toutes trois la forme de la méthode [calculer] ;
  • ligne 9 : la variable [op] est de type [Operation]. On lui affecte l'objet obtenu par la conversion SAM de la référence [Calculs::augmenter], qui désigne la méthode [augmenter] de l'objet compagnon de la classe [Calculs]. La conversion doit être écrite : [Operation(Calculs::augmenter)] ressemble à l'appel d'un constructeur, mais une interface n'a pas de constructeur ; c'est le compilateur qui crée l'objet implémentant [Operation] ;
  • ligne 11 : l'opération est exécutée par la méthode [calculer]. C'est la méthode [augmenter] qui s'exécute, avec les paramètres passés à [calculer] ;
  • ligne 15 : on crée une instance [c1] de la classe [Calculs] ;
  • ligne 17 : la référence [c1::ajouter] désigne la méthode [ajouter] de l'objet [c1] : on parle de référence liée (« bound reference »). [augmenter] était une méthode de l'objet compagnon, [ajouter] est une méthode d'instance : cela n'a pas d'importance. Dans le second cas, l'objet créé par le compilateur mémorise aussi l'objet [c1] sur lequel la méthode sera appelée ;
  • ligne 21 : on fait de même avec la méthode d'instance [soustraire] ;
  • lignes 26-32 : pour exécuter plusieurs opérations, on les range dans une liste de type [List<Operation>] (type inféré), que l'on parcourt. On peut mélanger références et lambdas (ligne 29). Chaque résultat est récupéré (lignes 7-11 des résultats) ;
  • ligne 36 : on applique la méthode [puis] à l'opération [Operation(c1::ajouter)]. La lambda [{ x -> x * x }] est passée hors des parenthèses, car c'est le dernier (et ici le seul) paramètre de [puis] : nous détaillerons cette écriture au paragraphe suivant. [sommeAuCarre.calculer(1, 2)] calcule (1+2)², soit 9 (lignes 12-13 des résultats). En Java, il fallait écrire la conversion [((Operation) c1::ajouter).puis(…)] ; en Kotlin, la conversion SAM explicite joue ce rôle ;
  • ligne 40 : [Operation] et l'interface Java [IntBinaryOperator] ont exactement la même forme, mais ce sont deux types différents, sans lien d'héritage. L'affectation [val autre: IntBinaryOperator = op] serait refusée ([initializer type mismatch: expected 'IntBinaryOperator', actual 'Operation']). En revanche, la référence [op::calculer] est une fonction de type [(Int, Int) -> Int] : la conversion SAM en fait un [IntBinaryOperator] qui appelle la méthode [calculer] de l'objet [op] ([c1::soustraire] à ce stade du programme) ;
  • lignes 68-69 : une interface fonctionnelle peut être générique. [Transformation<T, R>] représente une fonction qui reçoit un [T] et rend un [R] : c'est l'équivalent de [(T) -> R], avec des noms choisis par nous ;
  • ligne 44 : [String::length] désigne la propriété [length] de la classe [String] : une référence peut viser une propriété aussi bien qu'une fonction. Elle est utilisée comme une fonction de type [(String) -> Int] : la propriété sera lue sur la chaîne passée en paramètre. Les types [T = String] et [R = Int] de [Transformation] sont inférés de la référence. Ligne 45, ["*"::repeat] désigne la méthode [repeat(Int)] de l'objet chaîne ["*"] : elle reçoit un entier et rend une chaîne. Ce sont deux sortes différentes de références (non liée, liée), que nous détaillerons au paragraphe suivant ;
  • ligne 73 : un alias de type. Les paramètres nommés [n1] et [n2] servent seulement de documentation ;
  • lignes 50-54 : la lambda [f] de type [OperationBinaire] est affectée sans conversion à la variable [g] de type [(Int, Int) -> Int] : c'est le même type. Pour en faire une [Operation], il faut en revanche une conversion SAM (ligne 54).

Ces règles se vérifient en provoquant des erreurs. Si l'on écrit [var op: Operation = Calculs::augmenter], sans conversion explicite, le compilateur refuse :

1
2
3
Lambdas02.kt:9:23: error: initializer type mismatch: expected 'Operation', actual 'KFunction2<Int, Int, Int>'.
    var op: Operation = Calculs::augmenter
                      ^

Le message nous apprend au passage le type d'une référence de fonction : [KFunction2<Int, Int, Int>], un type de la réflexion Kotlin ([kotlin.reflect]) qui est aussi un [(Int, Int) -> Int]. La conversion SAM implicite n'a lieu que pour les arguments d'un appel de fonction (par exemple [liste.removeIf(estPair)]), pas pour une affectation.

Si l'on ajoute par erreur une seconde méthode abstraite, par exemple [fun inverser(n: Int): Int], à l'interface [Operation], le compilateur signale la déclaration elle-même et chacune des conversions SAM qui utilisent l'interface (résultat abrégé) :

1
2
3
4
5
6
7
8
Lambdas02.kt:9:14: error: interface 'fun interface Operation : Any' does not have constructors.
    var op = Operation(Calculs::augmenter)
             ^^^^^^^^^
...
Lambdas02.kt:60:1: error: functional interface must have exactly one abstract function.
fun interface Operation {
^^^
...

Ces messages sont ceux du compilateur [kotlinc] ; Gradle les affiche précédés de [e:] et du chemin complet du fichier. Comme ceux de [javac], ils sont en anglais.

7.3. Objets anonymes, expressions lambda et références

7.3.1. Des objets anonymes aux lambdas

Dans les exemples précédents, chaque fonction est une fonction nommée, écrite ailleurs dans le programme. Pour des fonctions courtes, utilisées une seule fois, c'est lourd. Java offrait à l'origine la classe anonyme : la classe qui implémente l'interface est écrite à l'endroit même où l'on crée l'objet. Kotlin a l'équivalent, l'expression objet [object : Type { … }], rencontrée au chapitre 4, mais il l'utilise rarement pour représenter une fonction : il a dès l'origine les expressions lambda, une écriture beaucoup plus concise. Le programme [Lambdas03.kt] présente les différentes formes :

package lambdas03

// des objets anonymes aux expressions lambda

fun main() {
    // 1 - un objet anonyme : la classe qui implémente le type fonctionnel est écrite sur place
    val ajouter = object : (Int, Int) -> Int {
        override fun invoke(n1: Int, n2: Int): Int {
            return n1 + n2
        }
    }
    println("objet anonyme : ajouter(2, 3)=${ajouter(2, 3)}")

    // 2 - la même chose avec une expression lambda : { paramètres -> corps }
    val soustraire = { n1: Int, n2: Int -> n1 - n2 }
    println("lambda aux paramètres typés : soustraire(2, 3)=${soustraire(2, 3)}")

    // 3 - les types des paramètres sont déduits du type attendu
    val multiplier: (Int, Int) -> Int = { n1, n2 -> n1 * n2 }
    println("lambda aux paramètres non typés : multiplier(2, 3)=${multiplier(2, 3)}")

    // 4 - un seul paramètre : on peut ne pas le nommer, il s'appelle alors it
    val carre: (Int) -> Int = { it * it }
    val estVide: (String) -> Boolean = { s -> s.isEmpty() }
    println("carre(7)=${carre(7)}, estVide(\"\")=${estVide("")}")

    // 5 - aucun paramètre : pas de flèche
    val saluer = { "Bonjour" }
    val bip = { println("bip !") }
    println(saluer())
    bip()

    // 6 - un corps de plusieurs instructions : la valeur de la dernière expression est le résultat
    val repeter: (String, Int) -> String = { texte, fois ->
        val tampon = StringBuilder()
        for (i in 1..fois) {
            tampon.append(texte)
        }
        tampon.toString()
    }
    println(repeter("ab", 3))

    // 7 - paramètres inutilisés : _
    val ecouteur: (Any?, String) -> Unit = { _, _ -> println("événement reçu") }
    ecouteur(null, "clic")

    // 8 - une lambda passée en paramètre : hors des parenthèses si c'est le dernier paramètre
    val nombres = mutableListOf(3, 8, 1, 6, 5, 4)
    val pairs = nombres.filter { it % 2 == 0 }
    println("pairs : $pairs")
    nombres.sortWith { n1, n2 -> n2.compareTo(n1) }
    println("ordre décroissant : $nombres")
    // la lambda hors des parenthèses, après d'autres paramètres
    val total = nombres.fold(0) { acc, n -> acc + n }
    println("total : $total")

    // 9 - return dans une lambda : return@étiquette quitte la lambda seulement
    nombres.forEach {
        if (it % 2 == 0) return@forEach
        print("$it ")
    }
    println("<- impairs")
    // une étiquette choisie par le développeur
    nombres.forEach impair@{ n ->
        if (n % 2 != 0) return@impair
        print("$n ")
    }
    println("<- pairs")
    // une fonction anonyme : return y quitte la fonction anonyme
    val estGrand = fun(n: Int): Boolean {
        if (n > 5) return true
        return false
    }
    println("grands : ${nombres.filter(estGrand)}")

    // 10 - déstructuration des paramètres d'une lambda
    val ages = mapOf("Paul" to 40, "Mélanie" to 35)
    ages.forEach { (nom, age) -> println("$nom a $age ans") }
}

Résultats :

> gradlew -q run -Pmain=lambdas03.Lambdas03Kt
objet anonyme : ajouter(2, 3)=5
lambda aux paramètres typés : soustraire(2, 3)=-1
lambda aux paramètres non typés : multiplier(2, 3)=6
carre(7)=49, estVide("")=true
Bonjour
bip !
ababab
événement reçu
pairs : [8, 6, 4]
ordre décroissant : [8, 6, 5, 4, 3, 1]
total : 27
5 3 1 <- impairs
8 6 4 <- pairs
grands : [8, 6]
Paul a 40 ans
Mélanie a 35 ans

Commentaires :

  • lignes 7-12 : un objet anonyme. Le mot clé [object] est suivi de deux-points, du type à implémenter, ici le type fonctionnel [(Int, Int) -> Int] (c'est une interface, nous l'avons vu), et du corps d'une classe qui implémente ce type en redéfinissant sa méthode [invoke]. Le compilateur génère une classe sans nom et en crée une instance. Six lignes pour exprimer [n1 + n2]. On l'exécute pourtant comme une fonction : [ajouter(2, 3)] ;
  • ligne 15 : la même chose avec une expression lambda. Une lambda s'écrit entre accolades : la liste des paramètres, l'opérateur [->] (une flèche, qui se lit « donne »), puis le corps. Le nom du type et celui de la méthode ont disparu. Comme les paramètres sont typés, le compilateur peut calculer le type de la lambda tout seul : [(Int, Int) -> Int]. C'est une différence avec Java, où une lambda n'a jamais de type par elle-même ;
  • ligne 19 : les types des paramètres ont disparu. Le compilateur les déduit du type attendu, déclaré pour la variable : [n1] et [n2] sont de type [Int]. C'est la forme la plus courante. Il n'y a pas, en Kotlin, de distinction entre « corps d'expression » et « corps de bloc » comme en Java : le corps d'une lambda est une suite d'instructions, et la valeur de la dernière expression est le résultat de la lambda ;
  • ligne 23 : avec un seul paramètre, on peut ne pas le déclarer du tout, ni la flèche : il s'appelle alors implicitement [it] (« lui », « cela »). [{ it * it }] se lit « son carré ». Ligne 24, le paramètre unique est nommé [s] : on le fait quand [it] rendrait le code moins lisible, notamment quand des lambdas sont imbriquées (chacune aurait son [it]) ;
  • lignes 28-29 : sans paramètre, il n'y a pas de flèche. Le type de [saluer] est inféré : [() -> String] ; celui de [bip] est [() -> Unit], puisque [println] rend [Unit] ;
  • lignes 34-39 : une lambda peut contenir n'importe quelles instructions : déclarations, boucles, tests… La valeur de la dernière expression, [tampon.toString()], est le résultat. On ne peut pas écrire [return tampon.toString()] : dans une lambda, un [return] sans étiquette ne quitte pas la lambda (voir plus bas) ;
  • ligne 44 : le caractère de soulignement [_] désigne un paramètre inutilisé. On l'utilise quand la forme du type impose des paramètres dont la lambda n'a pas besoin, ce qui est fréquent pour les écouteurs d'événements. On peut en écrire plusieurs dans la même lambda (Java a adopté cette écriture dans sa version 22) ;
  • lignes 48-49 : l'usage le plus fréquent des lambdas consiste à les passer directement en paramètre d'une fonction d'ordre supérieur. Quand la lambda est le dernier paramètre, on l'écrit hors des parenthèses ; s'il n'y a pas d'autre paramètre, les parenthèses disparaissent : [nombres.filter { it % 2 == 0 }] plutôt que [nombres.filter({ it % 2 == 0 })]. C'est cette règle qui donne au code Kotlin son allure particulière, et qui permet d'écrire des fonctions qui ressemblent à des instructions du langage ([repeat], [use], [thread], [synchronized]…). Comparez avec [Lambdas01.kt] : il n'est plus nécessaire d'écrire une fonction [estPair] ;
  • ligne 51 : une lambda qui inverse la comparaison usuelle : la liste est triée par ordre décroissant. [sortWith] attend un [Comparator] (une interface Java) : la conversion SAM est faite automatiquement ;
  • ligne 54 : la lambda reste hors des parenthèses quand la fonction a d'autres paramètres : [fold(0) { acc, n -> acc + n }]. La fonction [fold] (chapitre 5) part de la valeur initiale 0 et lui ajoute un à un les éléments ;
  • lignes 58-62 : dans une lambda, l'instruction [return@forEach] quitte la lambda (et passe donc à l'élément suivant), comme un [continue] dans une boucle. L'étiquette [@forEach] est implicite : c'est le nom de la fonction à laquelle la lambda est passée. Un [return] sans étiquette quitterait la fonction [main] tout entière : on l'appelle un retour non local, et il n'est permis que dans une lambda passée à une fonction [inline] comme [forEach] (nous verrons pourquoi) ;
  • lignes 64-68 : on peut aussi choisir l'étiquette, en la plaçant devant l'accolade ouvrante : [impair@{ … }], puis [return@impair] ;
  • lignes 70-74 : une fonction anonyme. Elle s'écrit comme une fonction ordinaire, sans nom. Son type est inféré : [(Int) -> Boolean]. Dans une fonction anonyme, [return] quitte la fonction anonyme elle-même. On l'utilise quand le corps comporte plusieurs points de sortie, ou quand on veut écrire explicitement le type du résultat ;
  • ligne 78 : les paramètres d'une lambda peuvent être déstructurés (chapitre 3). La lambda passée à [forEach] reçoit une entrée de la table associative ([Map.Entry<String, Int>]) ; l'écriture [(nom, age)], entre parenthèses, la décompose en sa clé et sa valeur. Attention : [{ nom, age -> … }] déclarerait deux paramètres, [{ (nom, age) -> … }] un seul paramètre décomposé.

Retenons les formes d'une expression lambda :

Forme
Exemple
paramètres typés
[{ a: Int, b: Int -> a + b }]
paramètres non typés (type attendu connu)
[{ a, b -> a + b }]
un seul paramètre, implicite
[{ it * it }]
aucun paramètre
[{ "Bonjour" }]
paramètres inutilisés
[{ _, _ -> println("reçu") }]
paramètre déstructuré
[{ (cle, valeur) -> "$cle=$valeur" }]
plusieurs instructions
[{ x -> println(x) ; x * 2 }]
lambda en dernier paramètre
[liste.filter { it > 0 }]
fonction anonyme
[fun(x: Int): Int { return x * 2 }]

Une lambda n'est pas tout à fait un objet anonyme abrégé. Dans un objet anonyme, [this] désigne l'objet anonyme lui-même ; dans une lambda, [this] désigne l'objet de la méthode qui entoure la lambda (ou le receveur, pour une lambda avec receveur). De plus, un objet anonyme peut implémenter une interface à plusieurs méthodes, plusieurs interfaces à la fois, ou hériter d'une classe, ce qui est impossible avec une lambda. Dans le cours, nous utiliserons les lambdas chaque fois que c'est possible.

7.3.2. Compléments : inférence de type et références

Le programme [Lambdas04.kt] précise la façon dont le type d'une lambda est déterminé et présente les différentes sortes de références :

package lambdas04

import java.io.IOException
import java.util.concurrent.Callable
import java.util.function.Supplier

// compléments sur les lambdas et les références

fun main() {
    // 1 - le type d'une lambda est inféré de ses paramètres et de son corps...
    val doubler = { x: Int -> x * 2 }
    println("doubler(21)=${doubler(21)}")
    // ... ou fixé par le type attendu : le même texte de lambda peut donner trois types différents
    val fonction: () -> String = { "Bonjour" }
    val fournisseur = Supplier { "Bonjour" }
    val appelable = Callable { "Bonjour" }
    println("${fonction()} / ${fournisseur.get()} / ${appelable.call()}")

    // 2 - les sortes de références
    // a) fonction de haut niveau : ::fonction
    val tripler: (Int) -> Int = ::tripler
    println("tripler(14)=${tripler(14)}")
    // b) méthode ou propriété d'un objet donné (référence liée) : objet::methode
    val nom = "Pierre"
    val enMajuscules: () -> String = nom::uppercase
    val longueurNom: () -> Int = nom::length
    println("enMajuscules()=${enMajuscules()}, longueurNom()=${longueurNom()}")
    // c) méthode d'un objet quelconque de la classe (référence non liée) : Classe::methode
    //    l'objet sur lequel la méthode est appelée devient le premier paramètre
    val majuscules: (String) -> String = String::uppercase
    val ignorerCasse: (String, String) -> Int = { s1, s2 -> s1.compareTo(s2, ignoreCase = true) }
    println("majuscules(\"paul\")=${majuscules("paul")}")
    println("ignorerCasse(\"abc\", \"ABC\")=${ignorerCasse("abc", "ABC")}")
    // d) fonction d'extension : Classe::extension
    val convertir: (String) -> Int = String::toInt
    println("convertir(\"42\") + 1 = ${convertir("42") + 1}")
    // e) constructeur : ::Classe
    val creerListe: () -> MutableList<String> = ::ArrayList
    val creerTampon: (String) -> StringBuilder = ::StringBuilder
    val creerTableau: (Int) -> IntArray = ::IntArray
    val creerPersonne: (String, Int) -> Personne = ::Personne
    val liste = creerListe()
    liste.add("élément")
    println("$liste ${creerTampon("abc").reverse()} ${creerTableau(3).size} ${creerPersonne("Paul", 40)}")

    // 3 - pas d'exceptions contrôlées : n'importe quelle lambda peut lancer n'importe quelle exception
    val lire: () -> Int = { throw IOException("fichier absent") }
    try {
        lire()
    } catch (ex: IOException) {
        println("exception reçue : ${ex.message}")
    }

    // 4 - égalité des fonctions : les références sont comparables, les lambdas non
    val r1: () -> String = nom::uppercase
    val r2: () -> String = nom::uppercase
    val l1 = { nom.uppercase() }
    val l2 = { nom.uppercase() }
    println("r1 == r2 : ${r1 == r2}, r1 === r2 : ${r1 === r2}, l1 == l2 : ${l1 == l2}")
}

fun tripler(n: Int) = n * 3

// une classe de données, pour la référence à son constructeur
data class Personne(val nom: String, val age: Int)

Résultats :

> gradlew -q run -Pmain=lambdas04.Lambdas04Kt
doubler(21)=42
Bonjour / Bonjour / Bonjour
tripler(14)=42
enMajuscules()=PIERRE, longueurNom()=6
majuscules("paul")=PAUL
ignorerCasse("abc", "ABC")=0
convertir("42") + 1 = 43
[élément] cba 3 Personne(nom=Paul, age=40)
exception reçue : fichier absent
r1 == r2 : true, r1 === r2 : false, l1 == l2 : false

Commentaires :

  • ligne 11 : la lambda [{ x: Int -> x * 2 }] a un type par elle-même, [(Int) -> Int], calculé à partir du type de son paramètre et de celui de son corps. La déclaration [val doubler = { x -> x * 2 }] est en revanche refusée : le compilateur ne peut pas deviner le type de [x] ([an explicit type is required on a value parameter]). En Java, [var doubler = x -> x * 2;] est refusée pour une raison plus radicale : une lambda Java ne peut exister sans un type cible, qui désigne l'interface à implémenter ;
  • lignes 14-16 : le même texte de lambda, [{ "Bonjour" }], donne ici un [() -> String], là un [Supplier<String>] et là un [Callable<String>]. Dans les deux derniers cas, c'est le nom de l'interface Java qui, placé devant la lambda, demande la conversion SAM. Ce sont trois objets de classes différentes, qui n'ont en commun que la forme de leur méthode ;
  • lignes 19-41 : les sortes de références. Kotlin les appelle références appelables (« callable references ») : elles désignent des fonctions, mais aussi des propriétés et des constructeurs :
    • ligne 21 : la référence à une fonction de haut niveau, [::fonction]. [::tripler] est équivalente à la lambda [{ n -> tripler(n) }]. Elle remplace la référence à une méthode statique de Java ([Classe::methodeStatique]). Pour une fonction d'un objet compagnon, on écrit [Classe::fonction], comme dans [Lambdas02.kt] ;
    • lignes 25-26 : la référence à une méthode ou à une propriété d'un objet donné, [objet::methode], appelée référence liée. [nom::uppercase] est équivalente à [{ nom.uppercase() }] : l'objet [nom] est fixé une fois pour toutes. [nom::length] référence la propriété [length] de la chaîne [nom] ;
    • lignes 30-31 : la référence à une méthode d'un objet quelconque d'une classe, [Classe::methode], appelée référence non liée. L'objet sur lequel la méthode sera appelée n'est pas encore connu : il devient le premier paramètre de la fonction. [String::uppercase] est équivalente à [{ s: String -> s.uppercase() }]. Pour [ignorerCasse], on a préféré une lambda, qui permet de passer l'argument nommé [ignoreCase = true] à [compareTo] ;
    • ligne 35 : une fonction d'extension se référence comme une méthode : [String::toInt] est équivalente à [{ s: String -> s.toInt() }] ;
    • lignes 38-41 : la référence à un constructeur, [::Classe] (en Java : [Classe::new]). [::ArrayList] est équivalente à [{ ArrayList() }] ; [::StringBuilder], avec le type attendu [(String) -> StringBuilder], choisit le constructeur qui reçoit une chaîne ; [::IntArray] crée un tableau d'entiers de la taille reçue en paramètre ; [::Personne] désigne le constructeur primaire de la classe de données [Personne] (ligne 65) ;
  • ligne 44 : on utilise les fonctions de création ; [reverse] inverse le contenu du [StringBuilder] (ligne 8 des résultats) ;
  • lignes 47-51 : Kotlin n'a pas d'exceptions contrôlées (chapitre 3). Une lambda de type [() -> Int] peut donc lancer une [IOException], sans rien déclarer. En Java, c'était une difficulté réelle : aucune interface de [java.util.function] n'autorise les exceptions contrôlées, et une lambda de type [Supplier<Integer>] qui lance une [IOException] est refusée ; il fallait passer par [Callable], dont la méthode est déclarée [throws Exception]. On remarque aussi que la lambda [{ throw IOException("fichier absent") }] est acceptée comme un [() -> Int] : l'expression [throw] est de type [Nothing], qui est compatible avec tous les types ;
  • lignes 55-59 : deux évaluations de la même référence [nom::uppercase] produisent deux objets différents ([r1 === r2] vaut [false]), mais, contrairement à Java, Kotlin les considère comme égaux au sens de [equals] ([r1 == r2] vaut [true]) : deux références sont égales si elles désignent la même fonction, liée au même objet. Deux lambdas, même de texte identique, ne sont jamais égales (ligne 10 des résultats). Retenons-le : il aura son importance pour désinscrire un écouteur d'événement. On remarque enfin que le type de [r1] a dû être écrit : la fonction [uppercase] est surchargée ([uppercase()] et [uppercase(locale: Locale)]), et sans type attendu le compilateur refuse de choisir ([overload resolution ambiguity between candidates]).

Une référence n'est qu'une écriture abrégée d'une lambda qui se contente d'appeler une fonction. On l'utilise quand elle est plus lisible : [String::length] plutôt que [{ it.length }]. Dans la pratique, avec [it], les lambdas Kotlin sont si courtes que les références sont un peu moins utilisées qu'en Java.

7.3.3. Composer des fonctions

Les interfaces fonctionnelles de Java ont des méthodes [default] et [static] qui fabriquent de nouvelles fonctions à partir de fonctions existantes : [andThen], [compose], [and], [or], [negate]… Les types fonctionnels de Kotlin n'en ont pas : la bibliothèque standard n'offre pas de fonction [andThen] pour un [(A) -> B]. Mais il est très facile de les écrire soi-même, sous forme de fonctions d'extension sur les types fonctionnels (chapitre 4), puisqu'une fonction peut rendre une fonction. Le programme [Lambdas05.kt] le montre, puis présente les fonctions de la bibliothèque Kotlin qui construisent des comparateurs :

package lambdas05

import java.util.function.Function

// composer des fonctions : des fonctions qui rendent des fonctions

fun main() {
    // 1 - composition : nos extensions puis (d'abord this, ensuite l'autre) et apres (d'abord l'autre)
    val plusUn: (Int) -> Int = { it + 1 }
    val foisDeux: (Int) -> Int = { it * 2 }
    println("(plusUn puis foisDeux)(5)=${(plusUn puis foisDeux)(5)}")
    println("(plusUn apres foisDeux)(5)=${(plusUn apres foisDeux)(5)}")
    // les méthodes de l'interface Java Function restent disponibles
    val f = Function<Int, Int> { it + 1 }.andThen { it * 2 }
    println("Function.andThen : f.apply(5)=${f.apply(5)}")
    // une fonction qui fabrique des fonctions
    val plusDix = ajouteur(10)
    println("ajouteur(10)(5)=${plusDix(5)}, ajouteur(3)(5)=${ajouteur(3)(5)}")
    // une chaîne de traitements construite à partir d'une liste de fonctions
    val etapes: List<(String) -> String> = listOf(String::trim, String::lowercase, { it.replace(' ', '-') })
    val normaliser = etapes.fold({ s: String -> s }) { fonction, etape -> fonction puis etape }
    println("normaliser(\"  Mon Beau Titre \")=${normaliser("  Mon Beau Titre ")}")

    // 2 - prédicats : nos extensions et, ou, non
    val court: (String) -> Boolean = { it.length <= 4 }
    val commenceParP: (String) -> Boolean = { it.startsWith("P") }
    val prenoms = listOf("Paul", "Mélanie", "Jo", "Sébastien", "Pierre")
    println("court et P : ${filtrer(prenoms, court et commenceParP)}")
    println("court ou P : ${filtrer(prenoms, court ou commenceParP)}")
    println("non court : ${filtrer(prenoms, !court)}")
    // un prédicat sur CharSequence convient pour filtrer des String
    val nonVide: (CharSequence) -> Boolean = { it.isNotEmpty() }
    println("non vides : ${filtrer(listOf("a", "", "b"), nonVide)}")

    // 3 - plusieurs traitements sur la même valeur
    val journal = mutableListOf<String>()
    val afficher: (String) -> Unit = { println("affichage : $it") }
    val memoriser: (String) -> Unit = { journal.add(it) }
    val lesDeux = afficher aussi memoriser
    lesDeux("premier")
    lesDeux("second")
    println("journal : $journal")

    // 4 - comparateurs : compareBy, thenBy, thenByDescending, reversed
    val personnes = mutableListOf(Personne("Paul", 32), Personne("Mélanie", 25),
        Personne("Jo", 32), Personne("Sébastien", 25))
    personnes.sortWith(compareBy(Personne::age).thenBy(Personne::nom))
    println("par âge puis nom : $personnes")
    personnes.sortWith(compareBy(Personne::age).reversed().thenByDescending(Personne::nom))
    println("par âge décroissant puis nom décroissant : $personnes")
    // plusieurs critères d'un coup
    personnes.sortWith(compareBy({ it.nom.length }, { it.nom }))
    println("par longueur du nom puis nom : $personnes")
    // un comparateur sert aussi à choisir le plus petit de deux éléments
    val parAge = compareBy(Personne::age)
    println("plus jeune de Paul et Mélanie : ${minOf(personnes[1], personnes[2], parAge)}")
}

// la composition : d'abord this, puis suite
infix fun <A, B, C> ((A) -> B).puis(suite: (B) -> C): (A) -> C = { x -> suite(this(x)) }

// la composition inverse : d'abord avant, puis this
infix fun <A, B, C> ((B) -> C).apres(avant: (A) -> B): (A) -> C = { x -> this(avant(x)) }

// les opérations logiques sur les prédicats
infix fun <T> ((T) -> Boolean).et(autre: (T) -> Boolean): (T) -> Boolean = { this(it) && autre(it) }

infix fun <T> ((T) -> Boolean).ou(autre: (T) -> Boolean): (T) -> Boolean = { this(it) || autre(it) }

operator fun <T> ((T) -> Boolean).not(): (T) -> Boolean = { !this(it) }

// deux traitements successifs sur la même valeur
infix fun <T> ((T) -> Unit).aussi(autre: (T) -> Unit): (T) -> Unit = { this(it); autre(it) }

// une fonction qui rend une fonction
fun ajouteur(n: Int): (Int) -> Int = { it + n }

// garde les éléments qui vérifient le prédicat
fun <T> filtrer(elements: List<T>, predicat: (T) -> Boolean): List<T> {
    val resultat = mutableListOf<T>()
    for (element in elements) {
        if (predicat(element)) {
            resultat.add(element)
        }
    }
    return resultat
}

data class Personne(val nom: String, val age: Int) {
    override fun toString() = "$nom($age)"
}

Résultats :

> gradlew -q run -Pmain=lambdas05.Lambdas05Kt
(plusUn puis foisDeux)(5)=12
(plusUn apres foisDeux)(5)=11
Function.andThen : f.apply(5)=12
ajouteur(10)(5)=15, ajouteur(3)(5)=8
normaliser("  Mon Beau Titre ")=mon-beau-titre
court et P : [Paul]
court ou P : [Paul, Jo, Pierre]
non court : [Mélanie, Sébastien, Pierre]
non vides : [a, b]
affichage : premier
affichage : second
journal : [premier, second]
par âge puis nom : [Mélanie(25), Sébastien(25), Jo(32), Paul(32)]
par âge décroissant puis nom décroissant : [Paul(32), Jo(32), Sébastien(25), Mélanie(25)]
par longueur du nom puis nom : [Jo(32), Paul(32), Mélanie(25), Sébastien(25)]
plus jeune de Paul et Mélanie : Mélanie(25)

Commentaires :

  • ligne 60 : la fonction d'extension [puis] s'applique à une fonction de type [(A) -> B] (les parenthèses autour du type fonctionnel sont nécessaires) et reçoit une fonction [suite] de type [(B) -> C]. Elle rend une nouvelle fonction de type [(A) -> C], la lambda [{ x -> suite(this(x)) }], qui applique d'abord la fonction [this] (le receveur de l'extension), puis [suite] à son résultat. C'est la méthode [andThen] de l'interface Java [Function]. Les trois paramètres de type [A], [B], [C] sont génériques : [puis] compose n'importe quelles fonctions à un paramètre, pourvu que le résultat de la première convienne à la seconde. Le modificateur [infix] permet d'écrire [f puis g] plutôt que [f.puis(g)] ;
  • ligne 63 : la composition inverse, l'équivalent de la méthode [compose] de Java : [f apres g] applique d'abord [g], puis [f] ;
  • ligne 11 : [plusUn puis foisDeux] rend la fonction qui applique d'abord [plusUn], puis [foisDeux] au résultat : (5 + 1) × 2 = 12. Les parenthèses autour de la composition sont nécessaires pour pouvoir appeler la fonction obtenue ;
  • ligne 12 : [plusUn apres foisDeux] applique d'abord [foisDeux], puis [plusUn] : 5 × 2 + 1 = 11 ;
  • ligne 14 : si l'on préfère les méthodes de l'interface Java, il suffit de créer une [Function] par une conversion SAM : [andThen] est alors disponible, et l'on peut lui passer une lambda Kotlin, convertie à son tour. Le résultat est une [Function], qui s'exécute par [apply] ;
  • lignes 17-18 et 76 : [ajouteur] est une fonction qui rend une fonction : [ajouteur(10)] rend la fonction qui ajoute 10 à son paramètre. On peut ranger la fonction rendue dans une variable ([plusDix]) ou l'appeler immédiatement : [ajouteur(3)(5)]. Ce procédé, qui consiste à fixer un paramètre pour obtenir une fonction des paramètres restants, est appelé application partielle ;
  • lignes 20-21 : une chaîne de traitements construite dynamiquement. On part de la fonction identité ([{ s: String -> s }], qui rend son paramètre inchangé) et on lui ajoute, par [puis], chacune des étapes de la liste, grâce à la fonction [fold] (chapitre 5) : la valeur accumulée, [fonction], est ici une fonction. La fonction [normaliser] obtenue supprime les espaces de début et de fin ([trim]), passe le texte en minuscules et remplace les espaces par des tirets. Chaque appel de [puis] crée une nouvelle fonction : les fonctions existantes ne sont jamais modifiées. On pourrait, de la même façon, lire la liste des étapes dans un fichier de configuration ;
  • lignes 66-70 : les prédicats se combinent avec nos extensions [et], [ou] et [not]. Cette dernière est déclarée [operator] : c'est la fonction qu'appelle l'opérateur [!] (cf. la surcharge des opérateurs au chapitre 4). On écrit ainsi [!court] pour la négation du prédicat [court] (ligne 30). En Java, on écrit [court.negate()], et [Predicate.not(String::isEmpty)] pour une référence de méthode ;
  • lignes 32-33 : un prédicat de type [(CharSequence) -> Boolean] est accepté par [filtrer] pour filtrer une liste de [String], alors que le paramètre est déclaré [(T) -> Boolean] avec [T = String]. C'est que les types fonctionnels de Kotlin sont contravariants en leurs paramètres et covariants en leur résultat (chapitre 4) : [(T) -> R] est en réalité [Function1<in T, out R>]. Une fonction qui sait traiter n'importe quelle [CharSequence] sait traiter une [String]. En Java, il faut déclarer le paramètre [Predicate<? super T>] pour obtenir ce résultat ; en Kotlin, c'est automatique ;
  • lignes 36-42 et 73 : l'extension [aussi] rend une fonction qui exécute deux traitements successifs sur la même valeur, comme la méthode [andThen] de l'interface Java [Consumer]. C'est la façon la plus simple de faire exécuter plusieurs traitements par un seul appel. On remarque que la lambda [{ this(it); autre(it) }] a deux instructions sur une même ligne, séparées par un point-virgule ;
  • ligne 47 : la fonction [compareBy(f)] de la bibliothèque Kotlin construit un comparateur qui compare les valeurs de [f] (ici l'âge) ; [thenBy] indique le critère à utiliser en cas d'égalité (ici le nom). Ce sont les équivalents de [Comparator.comparing] et [thenComparing] de Java. Les références [Personne::age] et [Personne::nom] désignent les propriétés de la classe de données [Personne] (lignes 89-90), dont nous avons redéfini la méthode [toString] pour abréger les affichages ;
  • ligne 49 : [reversed] inverse l'ordre obtenu jusque-là, et [thenByDescending] ajoute un critère en ordre décroissant ;
  • ligne 52 : [compareBy] accepte aussi plusieurs critères, sous forme de lambdas, appliqués dans l'ordre : ici la longueur du nom, puis le nom. Ce sont deux lambdas passées entre les parenthèses, car [compareBy] les reçoit dans un paramètre [vararg] ;
  • lignes 55-56 : la fonction [minOf] rend le plus petit de ses deux premiers paramètres selon le comparateur passé en troisième paramètre (en Java : [BinaryOperator.minBy]) ;
  • lignes 79-86 : la fonction [filtrer] utilisée pour ces essais. C'est une fonction d'ordre supérieur, qui fait ce que fait la fonction [filter] de la bibliothèque : elle exécute le prédicat [predicat] sur chaque élément, en l'appelant comme une fonction ordinaire, et garde les éléments pour lesquels il rend [true].

Ces fonctions permettent d'écrire des traitements complexes en assemblant des fonctions simples, sans écrire une seule classe. Nous les retrouverons avec les collections et les séquences.

7.3.4. Capture de variables et fermetures

Une lambda peut utiliser les variables locales de la fonction dans laquelle elle est écrite. On dit qu'elle capture ces variables. Sur ce point, Kotlin se sépare nettement de Java. Java impose qu'une variable locale capturée soit effectivement finale (« effectively final ») : elle n'est jamais modifiée après son initialisation, ni dans la méthode, ni dans la lambda. Kotlin, comme C#, JavaScript ou Python, n'impose rien de tel : une lambda peut capturer une variable [var], la lire alors qu'elle a changé, et même la modifier. Le programme [Lambdas06.kt] montre ce que cette liberté permet, comment elle est réalisée, et le piège qu'elle comporte :

package lambdas06

// capture de variables et fermetures

// une variable de haut niveau : les lambdas peuvent la modifier
var nbAppels = 0

fun main() {
    // 1 - une lambda peut utiliser les variables locales qui l'entourent
    val seuil = 5
    val depasse: (Int) -> Boolean = { n -> n > seuil }
    println("seuil=$seuil : depasse(7)=${depasse(7)}")

    // 2 - en Kotlin, la variable capturée peut être une var...
    var seuilVariable = 5
    val depasseVariable: (Int) -> Boolean = { n -> n > seuilVariable }
    println("seuilVariable=$seuilVariable : depasseVariable(7)=${depasseVariable(7)}")
    // ... la lambda voit alors la valeur de la variable au moment où elle s'exécute
    seuilVariable = 10
    println("seuilVariable=$seuilVariable : depasseVariable(7)=${depasseVariable(7)}")

    // 3 - une lambda peut modifier une variable locale capturée
    var total = 0
    val cumuler: (Int) -> Unit = { n -> total += n }
    cumuler(3)
    cumuler(4)
    println("total=$total")
    // la variable capturée est rangée dans un objet Ref, que la lambda mémorise
    println("champs de la lambda : ${cumuler::class.java.declaredFields.map { it.type.name }}")
    // une variable de haut niveau (ou une propriété) peut aussi être modifiée
    val compter = { nbAppels++ }
    compter()
    compter()
    println("nbAppels=$nbAppels")

    // 4 - une fermeture : la variable capturée survit à la fonction qui l'a créée
    val compteurA = creerCompteur()
    val compteurB = creerCompteur()
    println("A : ${compteurA()} ${compteurA()} ${compteurA()}")
    println("B : ${compteurB()} ${compteurB()}")

    // 5 - la boucle for déclare une nouvelle variable i à chaque tour
    val actions = mutableListOf<() -> Unit>()
    for (i in 0..2) {
        actions.add { print("$i ") }
    }
    print("for : ")
    actions.forEach { it() }
    println()

    // le piège : toutes les lambdas partagent la même var
    actions.clear()
    var k = 0
    while (k < 3) {
        actions.add { print("$k ") }
        k++
    }
    print("while, capture de la var k : ")
    actions.forEach { action -> action() }
    println()
}

// une fonction qui rend une fonction
fun creerCompteur(): () -> Int {
    // variable locale capturée par la lambda rendue
    var valeur = 0
    return { ++valeur }
}

Résultats :

> gradlew -q run -Pmain=lambdas06.Lambdas06Kt
seuil=5 : depasse(7)=true
seuilVariable=5 : depasseVariable(7)=true
seuilVariable=10 : depasseVariable(7)=false
total=7
champs de la lambda : [kotlin.jvm.internal.Ref$IntRef]
nbAppels=2
A : 1 2 3
B : 1 2
for : 0 1 2
while, capture de la var k : 3 3 3

Commentaires :

  • ligne 11 : la lambda utilise la variable locale [seuil] déclarée ligne 10. [seuil] n'est pas un paramètre de la lambda : c'est une variable capturée. Comme c'est une [val], la lambda en reçoit simplement une copie de la valeur lorsqu'elle est créée, exactement comme en Java ;
  • lignes 15-19 : la lambda capture cette fois une [var], ce que Java refuserait ([local variables referenced from a lambda expression must be final or effectively final]). Elle ne reçoit pas une copie de la valeur, mais partage la variable avec la fonction [main] : quand [seuilVariable] passe à 10 (ligne 19), la même lambda rend un autre résultat (lignes 2-3 des résultats). La lambda voit donc la valeur de la variable au moment où elle s'exécute, et non au moment où elle a été créée. C'est la question que Java évite de poser en interdisant ces captures ;
  • lignes 23-27 : la lambda [cumuler] modifie la variable locale [total]. En Java, il faut passer par un objet modifiable ([AtomicInteger], ou l'astuce d'un tableau d'un élément) ; en Kotlin, on écrit directement [total += n] ;
  • ligne 29 : comment est-ce possible, puisque la machine virtuelle elle-même ne permet de capturer que des valeurs ? Le compilateur Kotlin fait le travail que le développeur Java doit faire à la main : une variable locale [var] capturée et modifiée n'est plus rangée dans la pile, mais dans un petit objet de la classe [kotlin.jvm.internal.Ref.IntRef] (il en existe une par type de base, et [Ref.ObjectRef] pour les références), qui a un unique champ modifiable, [element]. La fonction [main] et la lambda partagent cet objet : chaque lecture ou écriture de [total], dans l'une ou dans l'autre, devient une lecture ou une écriture de [total.element]. On le vérifie par la réflexion : l'unique champ de l'objet lambda est de type [Ref$IntRef] (ligne 5 des résultats). C'est exactement ce que fait le compilateur C# avec ses variables capturées ;
  • lignes 31-34 : une lambda peut aussi lire et modifier une variable de haut niveau (ligne 6) ou une propriété de l'objet courant. Il n'y a là rien à capturer : la variable de haut niveau est un champ statique de la classe [Lambdas06Kt] ; pour une propriété, la lambda capture la référence [this] ;
  • lignes 64-67 : la fonction [creerCompteur] rend une lambda qui capture et modifie sa variable locale [valeur]. Normalement, une variable locale disparaît quand la fonction se termine. Ici, la variable est rangée dans un objet [Ref.IntRef] référencé par la lambda : elle survit à [creerCompteur] tant que la lambda existe. L'ensemble formé par la lambda et les variables qu'elle capture s'appelle une fermeture (« closure ») ;
  • lignes 37-40 : chaque appel de [creerCompteur] crée une nouvelle variable [valeur], donc un nouveau compteur, indépendant des autres. Les résultats (lignes 7-8) le confirment ;
  • lignes 44-48 : dans une boucle [for] de Kotlin, la variable [i] est une [val], nouvelle à chaque tour : chaque lambda capture la sienne, et les trois lambdas affichent 0, 1 et 2. On remarque qu'une liste de fonctions s'exécute par [actions.forEach { it() }] : [it] est une fonction, que l'on appelle ;
  • lignes 53-59 : voici le revers de la liberté de Kotlin. La boucle [while] utilise une seule variable [k], modifiée à chaque tour. Les trois lambdas capturent la même variable, et l'affichent quand elles s'exécutent, c'est-à-dire après la boucle, quand elle vaut 3 : elles affichent toutes 3 (ligne 10 des résultats). Java élimine ce piège à la compilation ; en Kotlin, c'est au développeur d'y penser. La solution est la même qu'en Java : déclarer dans le corps de la boucle une [val] nouvelle à chaque tour ([val copie = k]) et capturer celle-ci, ou, mieux, utiliser une boucle [for].

La règle de Java a une justification solide : une lambda peut être exécutée plus tard, ou dans un autre thread (chapitre 8). Si elle peut modifier les variables locales d'une fonction, ou les lire pendant qu'un autre thread les modifie, on peut obtenir des comportements difficiles à prévoir. Kotlin fait un autre choix, plus pratique : la capture des [var] est permise, mais les objets [Ref] ne sont pas protégés contre les accès simultanés. Retenons donc que la capture d'une [var] est sans danger dans un programme à un seul thread et dans les lambdas exécutées immédiatement ([forEach], [filter]…), mais que, dès qu'une lambda peut s'exécuter dans un autre thread, l'état modifiable partagé doit être rendu explicite, avec un objet dont on choisit le comportement vis-à-vis des threads ([AtomicInteger], collection concurrente…). Nous y reviendrons aux chapitres 8 et 9. Les fonctions [inline], présentées plus loin, évitent d'ailleurs la création des objets [Ref] quand la lambda est recopiée dans le code appelant.

7.4. Fonctions inline et fonctions de portée

7.4.1. Les fonctions inline

Une lambda est un objet. Passer une lambda à une fonction d'ordre supérieur a donc un coût : la création de l'objet (sauf pour une lambda qui ne capture rien, que la machine virtuelle peut réutiliser), l'appel de sa méthode [invoke], les conversions [Int] ↔ [Integer] imposées par les interfaces génériques [Function1], [Function2]…, et les objets [Ref] des variables capturées. Pour une fonction comme [filter], appelée sur chaque élément de collections parfois très grandes, ce coût compte. Kotlin le supprime avec le modificateur [inline] : quand une fonction est déclarée [inline fun], le compilateur ne génère pas d'appel à cette fonction ; il recopie son code à chaque endroit où elle est appelée, et il y recopie aussi le code des lambdas qu'on lui passe. Il n'y a plus alors ni objet lambda, ni appel de [invoke], ni conversion. Les fonctions de la bibliothèque standard qui reçoivent des lambdas ([forEach], [filter], [map], [fold], [let], [apply], [repeat], [use], [synchronized]…) sont presque toutes [inline]. Java n'a pas d'équivalent : il compte sur le compilateur à la volée de la machine virtuelle pour obtenir un résultat comparable à l'exécution.

L'insertion du code a une seconde conséquence, plus visible : puisque le code de la lambda est recopié dans la fonction appelante, une instruction [return] sans étiquette peut y quitter la fonction appelante. C'est le retour non local rencontré avec [forEach] dans [Lambdas03.kt]. Deux modificateurs complètent [inline] :

  • [noinline], sur un paramètre fonctionnel d'une fonction [inline], demande que la lambda correspondante ne soit pas recopiée, mais passée comme un objet ordinaire. C'est nécessaire quand la fonction veut conserver la lambda (la ranger dans une liste, la rendre comme résultat…) : du code recopié ne peut pas être rangé dans une liste ;
  • [crossinline] demande que la lambda soit recopiée, mais interdit le retour non local. C'est nécessaire quand la fonction [inline] n'exécute pas la lambda elle-même, mais dans un autre contexte, par exemple dans un objet [Runnable] qu'elle crée : quand le [Runnable] s'exécutera, la fonction appelante sera peut-être terminée depuis longtemps, et un [return] ne pourrait plus la quitter.

Le programme [Lambdas07.kt] illustre ces notions (ce programme n'a pas d'équivalent dans le cours Java) :

package lambdas07

// fonctions inline, noinline, crossinline

fun main() {
    // 1 - une fonction ordinaire reçoit un objet fonction ; une fonction inline reçoit le code de la lambda
    executer { println("lambda passée à executer, exécutée par : ${methodeCourante()}") }
    executerInline { println("lambda passée à executerInline, exécutée par : ${methodeCourante()}") }

    // 2 - le retour non local n'est permis que dans une lambda passée à une fonction inline
    val nombres = listOf(3, 8, 1, 6)
    println("premier pair de $nombres : ${premierPair(nombres)}")

    // 3 - noinline : une lambda qui doit être conservée comme objet
    val rappels = mutableListOf<() -> Unit>()
    executerPuisMemoriser(rappels, { println("exécutée tout de suite") }, { println("rappel mémorisé") })
    println("${rappels.size} rappel mémorisé, que l'on exécute :")
    rappels.forEach { it() }

    // 4 - crossinline : une lambda exécutée dans un autre contexte
    val tache = enTache { println("exécutée par ${Thread.currentThread().name}") }
    val thread = Thread(tache, "travailleur")
    thread.start()
    thread.join()
}

// le nom de la méthode qui appelle methodeCourante (lu dans la pile d'exécution)
fun methodeCourante(): String = Throwable().stackTrace[1].methodName

// une fonction ordinaire : bloc est un objet de type () -> Unit
fun executer(bloc: () -> Unit) {
    bloc()
}

// une fonction inline : son code, et celui de la lambda, sont recopiés à l'endroit de l'appel
inline fun executerInline(bloc: () -> Unit) {
    bloc()
}

// notre propre forEach, inline comme celui de la bibliothèque
inline fun <T> pourChaque(elements: List<T>, action: (T) -> Unit) {
    for (element in elements) action(element)
}

fun premierPair(nombres: List<Int>): Int? {
    pourChaque(nombres) {
        // retour non local : on quitte premierPair, et pas seulement la lambda
        if (it % 2 == 0) return it
    }
    return null
}

// immediat est recopiée ; memorise reste un objet fonction, que l'on peut ranger dans la liste
inline fun executerPuisMemoriser(
    rappels: MutableList<() -> Unit>,
    immediat: () -> Unit,
    noinline memorise: () -> Unit,
) {
    immediat()
    rappels.add(memorise)
}

// action est exécutée dans un objet Runnable, et non dans enTache : un retour non local y est impossible
inline fun enTache(crossinline action: () -> Unit): Runnable = Runnable { action() }

Résultats :

1
2
3
4
5
6
7
8
> gradlew -q run -Pmain=lambdas07.Lambdas07Kt
lambda passée à executer, exécutée par : main$lambda$0
lambda passée à executerInline, exécutée par : main
premier pair de [3, 8, 1, 6] : 8
exécutée tout de suite
1 rappel mémorisé, que l'on exécute :
rappel mémorisé
exécutée par travailleur

Commentaires :

  • ligne 28 : la fonction [methodeCourante] rend le nom de la méthode qui l'a appelée. Elle crée pour cela un objet [Throwable], qui mémorise la pile d'exécution à sa création, et lit le deuxième élément de cette pile (le premier étant [methodeCourante] elle-même). Nous l'utilisons pour savoir où s'exécute le code d'une lambda ;
  • lignes 31-36 : deux fonctions identiques, l'une ordinaire, l'autre déclarée [inline] ;
  • ligne 7 : la lambda est passée à la fonction ordinaire [executer]. Le compilateur en fait le corps d'une méthode privée de la classe [Lambdas07Kt], nommée ici [main$lambda$0], et crée un objet de type [() -> Unit] qui l'appelle. C'est bien [main$lambda$0] qui s'exécute (ligne 1 des résultats) ;
  • ligne 8 : la lambda est passée à la fonction [inline] [executerInline]. Le compilateur a recopié dans [main] le corps d'[executerInline] et celui de la lambda : le [println] de la lambda s'exécute directement dans la méthode [main] (ligne 2 des résultats). Aucun objet fonction n'a été créé ;
  • lignes 41-50 : [pourChaque] est notre propre version de [forEach], [inline] comme l'originale. Dans [premierPair], l'instruction [return it] de la lambda est un retour non local : elle quitte la fonction [premierPair] en rendant le premier nombre pair trouvé (ligne 3 des résultats), et la boucle s'arrête là. Avec une fonction ordinaire comme [executer], le compilateur refuserait un tel [return] (['return' is prohibited here]) : la lambda serait un objet distinct, qui pourrait être exécuté après la fin de [premierPair] ;
  • lignes 54-60 : une fonction [inline] qui reçoit deux lambdas : elle exécute la première, et range la seconde dans une liste pour une exécution ultérieure. Le second paramètre doit être déclaré [noinline] : sans ce modificateur, le compilateur refuse de ranger la lambda dans la liste ([illegal usage of inline parameter 'memorise: () -> Unit'. Add 'noinline' modifier to the parameter declaration.]). Les lignes 16-18 l'utilisent (lignes 4-6 des résultats) ;
  • ligne 64 : la fonction [enTache] crée un objet [Runnable] (conversion SAM) qui exécutera la lambda [action]. Le paramètre doit être déclaré [crossinline] : sans ce modificateur, le compilateur refuse ([cannot inline 'action: () -> Unit' here: it might contain non-local returns. Add 'crossinline' modifier to parameter declaration 'action: () -> Unit'.]). Les lignes 21-24 exécutent le [Runnable] obtenu dans un thread nommé [travailleur] (les threads sont l'objet du chapitre 8), ce que confirme la ligne 7 des résultats.

On n'écrit pas [inline] sur n'importe quelle fonction : le modificateur n'a d'intérêt que pour les fonctions qui reçoivent des paramètres fonctionnels, et il augmente la taille du code produit (le compilateur signale d'ailleurs par un avertissement une fonction [inline] sans paramètre fonctionnel). Il a enfin une troisième utilité, rencontrée au chapitre 4 : seules les fonctions [inline] peuvent avoir des paramètres de type [reified] ([inline fun <reified T> …]), dont le type reste connu à l'exécution, puisque le code est recopié là où ce type est connu.

7.4.2. Les fonctions de portée

La bibliothèque standard de Kotlin offre cinq petites fonctions [inline], très utilisées, appelées fonctions de portée (« scope functions ») : [let], [run], [with], [apply] et [also]. Elles ne font presque rien : chacune exécute une lambda sur un objet. Elles diffèrent sur deux points :

  • la façon dont la lambda désigne l'objet : soit comme son paramètre [it] (la lambda est de type [(T) -> R]), soit comme son receveur [this], que l'on peut alors omettre (la lambda est une lambda avec receveur, de type [T.() -> R], cf. [Lambdas01.kt]) ;
  • ce que rend la fonction : soit le résultat de la lambda, soit l'objet lui-même.
Fonction
Objet désigné par
Rend
Usage typique
[let]
[it]
le résultat de la lambda
transformer une valeur ; avec [?.let], traiter une valeur non nulle
[run]
[this]
le résultat de la lambda
calculer un résultat à partir des propriétés de l'objet
[with(objet)]
[this]
le résultat de la lambda
comme [run], l'objet étant passé en paramètre
[apply]
[this]
l'objet
configurer un objet qui vient d'être créé
[also]
[it]
l'objet
un traitement supplémentaire (journal, mémorisation…) sans modifier la chaîne d'appels

Le programme [Lambdas08.kt] les utilise (il n'a pas d'équivalent dans le cours Java) :

package lambdas08

// les fonctions de portée : let, run, with, apply, also (et takeIf)

fun main() {
    // 1 - apply : configure l'objet (this) et rend l'objet
    val paul = Personne("Paul").apply {
        age = 32
        ville = "Angers"
    }
    println("apply : $paul")

    // 2 - also : un traitement supplémentaire sur l'objet (it), qui est rendu
    val personnes = mutableListOf<Personne>()
    val melanie = Personne("Mélanie", 25).also { personnes.add(it) }
    println("also : $melanie, personnes=$personnes")

    // 3 - let : transforme l'objet (it) en un résultat ; très utilisé avec ?.
    val longueur = paul.nom.let { it.length * 2 }
    println("let : longueur=$longueur")
    for (ville in listOf("Tours", null)) {
        val message = ville?.let { "habite à ${it.uppercase()}" } ?: "ville inconnue"
        println("let et ?. : $message")
    }

    // 4 - run : un calcul sur l'objet (this), qui rend un résultat
    val description = paul.run { "$nom, $age ans, habite à $ville" }
    println("run : $description")

    // 5 - with : comme run, mais l'objet est passé en paramètre
    val resume = with(StringBuilder()) {
        append("Personnes : ")
        append(personnes.joinToString { it.nom })
        toString()
    }
    println("with : $resume")

    // 6 - takeIf : l'objet s'il vérifie le prédicat, null sinon
    for (age in listOf(15, 40)) {
        val majeur = Personne("Jo", age).takeIf { it.age >= 18 }
        println("takeIf : ${majeur ?: "mineur"}")
    }
}

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

    override fun toString() = "Personne(nom=$nom, age=$age, ville=$ville)"
}

Résultats :

> gradlew -q run -Pmain=lambdas08.Lambdas08Kt
apply : Personne(nom=Paul, age=32, ville=Angers)
also : Personne(nom=Mélanie, age=25, ville=null), personnes=[Personne(nom=Mélanie, age=25, ville=null)]
let : longueur=8
let et ?. : habite à TOURS
let et ?. : ville inconnue
run : Paul, 32 ans, habite à Angers
with : Personnes : Mélanie
takeIf : mineur
takeIf : Personne(nom=Jo, age=40, ville=null)

Commentaires :

  • lignes 45-48 : une classe [Personne] ordinaire, avec des propriétés modifiables ;
  • lignes 7-9 : [apply] exécute la lambda avec l'objet comme receveur : dans la lambda, [age = 32] signifie [this.age = 32]. Puis elle rend l'objet, qui est affecté à [paul]. On crée et on configure ainsi un objet en une seule expression. Sa signature montre le mécanisme : [inline fun <T> T.apply(block: T.() -> Unit): T] ;
  • ligne 15 : [also] exécute la lambda avec l'objet comme paramètre [it], puis rend l'objet. On l'utilise pour un effet de bord (ici, l'ajout à une liste) sans avoir à introduire une variable intermédiaire ;
  • ligne 19 : [let] exécute la lambda avec l'objet comme paramètre [it] et rend le résultat de la lambda : c'est une transformation ;
  • lignes 21-23 : l'usage le plus fréquent de [let], rencontré au chapitre 3 : [ville?.let { … }] n'exécute la lambda que si [ville] n'est pas [null], et, dans la lambda, [it] est de type [String] et non [String?]. Si [ville] vaut [null], l'expression vaut [null], et l'opérateur Elvis [?:] fournit la valeur par défaut ;
  • ligne 27 : [run] exécute la lambda avec l'objet comme receveur et rend le résultat de la lambda : on y utilise directement les propriétés [nom], [age] et [ville] de [paul] ;
  • lignes 31-34 : [with] fait la même chose que [run], mais l'objet lui est passé en premier paramètre, et la lambda vient en second, hors des parenthèses. Toutes les méthodes appelées dans la lambda ([append], [toString]) sont celles du [StringBuilder]. La fonction [buildString] du chapitre 5 est construite sur ce modèle ;
  • lignes 39-41 : [takeIf] n'est pas une fonction de portée, mais elle s'utilise de la même façon : elle rend l'objet s'il vérifie le prédicat, [null] sinon. Elle se combine naturellement avec [?:], [?.let]… ([takeUnless] fait l'inverse).

Ces fonctions rendent le code plus compact, mais il ne faut pas en abuser : plusieurs fonctions de portée imbriquées, chacune avec son [it] ou son [this], rendent vite le code illisible. Une variable locale bien nommée est souvent préférable.

7.5. Types fonctionnels, interfaces fonctionnelles ou interfaces ?

Dans beaucoup de langages, la notion de « fonction que l'on passe en paramètre » (pointeur de fonction en C, délégué en C#) est distincte de la notion d'interface, et l'on peut se demander laquelle choisir. En Java, la question se pose autrement, car les deux notions n'en font qu'une : une lambda est l'instance d'une interface. Kotlin est entre les deux : il a des types fonctionnels, distincts des interfaces dans l'écriture, mais qui sont des interfaces pour la machine virtuelle ; et il a des interfaces fonctionnelles ([fun interface]) que l'on peut implémenter par des lambdas. Prenons l'exemple [Lambdas09.kt], proche de [Lambdas02.kt] :

package lambdas09

// un paramètre de type fonctionnel

fun main() {
    // exécution de la fonction ajouter
    println(executer(2, 3, ::ajouter))
    // exécution de la fonction soustraire
    println(executer(2, 3, ::soustraire))
    // exécution d'une méthode de la bibliothèque Java ayant la bonne forme
    println(executer(2, 3, Math::max))
    // exécution d'une lambda : aucune fonction nommée n'est nécessaire
    println(executer(2, 3) { n1, n2 -> n1 * n2 })
}

// exécution d'une opération reçue en paramètre
// (en dernier, pour pouvoir passer une lambda hors des parenthèses)
fun executer(n1: Int, n2: Int, op: (Int, Int) -> Int): Int = op(n1, n2)

// deux fonctions ayant la forme (Int, Int) -> Int
fun ajouter(n1: Int, n2: Int): Int {
    println("ajouter($n1,$n2)")
    return n1 + n2
}

fun soustraire(n1: Int, n2: Int): Int {
    println("soustraire($n1,$n2)")
    return n1 - n2
}

Résultats :

1
2
3
4
5
6
7
> gradlew -q run -Pmain=lambdas09.Lambdas09Kt
ajouter(2,3)
5
soustraire(2,3)
-1
3
6

Ligne 18, la fonction [executer] attend un paramètre de type fonctionnel [(Int, Int) -> Int]. Cela permet de lui passer différentes fonctions : nos fonctions de haut niveau [ajouter] et [soustraire] (lignes 7 et 9), une méthode de la bibliothèque Java ayant la bonne forme (ligne 11 : parmi les surcharges de [Math.max], le compilateur choisit [int max(int a, int b)], vue de Kotlin comme [max(a: Int, b: Int): Int]), et même une lambda (ligne 13) : il n'a pas été nécessaire d'écrire une fonction de multiplication. Le paramètre fonctionnel a été placé en dernier, pour que la lambda puisse être écrite hors des parenthèses : c'est la convention de toute la bibliothèque Kotlin, et il est bon de la suivre dans ses propres fonctions.

Avec une [fun interface], on retrouve le polymorphisme des interfaces étudié au chapitre 4 : le programme [Lambdas10.kt] le montre en implémentant la même interface avec des classes :

package lambdas10

// des classes qui implémentent une interface fonctionnelle ou un type fonctionnel

fun main() {
    // exécution de l'opération Ajouter
    println(executer(Ajouter(), 2, 3))
    // exécution de l'opération Soustraire
    println(executer(Soustraire(), 2, 3))
    // la même fonction executer accepte toujours les lambdas
    println(executer({ n1, n2 -> n1 * n2 }, 2, 3))
    // une interface à deux méthodes abstraites n'accepte plus de lambda : il faut une classe
    val division = object : OperationNommee {
        override fun calculer(n1: Int, n2: Int) = n1 / n2

        override val nom = "division entière"
    }
    println("${division.nom} : ${division.calculer(7, 2)}")
    // une classe peut aussi implémenter un type fonctionnel
    val modulo = Modulo()
    println("modulo(7, 2)=${modulo(7, 2)}, appliquer(modulo)=${appliquer(modulo, 7, 3)}")
}

// exécution de la méthode unique de l'interface Operation
fun executer(op: Operation, n1: Int, n2: Int): Int = op.calculer(n1, n2)

// exécution d'une fonction de type (Int, Int) -> Int
fun appliquer(f: (Int, Int) -> Int, n1: Int, n2: Int): Int = f(n1, n2)

// l'interface fonctionnelle Operation
fun interface Operation {
    fun calculer(n1: Int, n2: Int): Int
}

// une interface qui n'est pas fonctionnelle : une méthode et une propriété abstraites
interface OperationNommee : Operation {
    val nom: String
}

// classe Ajouter
class Ajouter : Operation {
    override fun calculer(n1: Int, n2: Int): Int {
        println("Ajouter($n1,$n2)")
        return n1 + n2
    }
}

// classe Soustraire
class Soustraire : Operation {
    override fun calculer(n1: Int, n2: Int): Int {
        println("Soustraire($n1,$n2)")
        return n1 - n2
    }
}

// une classe qui implémente le type fonctionnel (Int, Int) -> Int
class Modulo : (Int, Int) -> Int {
    override fun invoke(n1: Int, n2: Int): Int = n1 % n2
}

Résultats :

1
2
3
4
5
6
7
8
> gradlew -q run -Pmain=lambdas10.Lambdas10Kt
Ajouter(2,3)
5
Soustraire(2,3)
-1
6
division entière : 3
modulo(7, 2)=1, appliquer(modulo)=1

Commentaires :

  • lignes 31-32 : l'interface fonctionnelle [Operation] définit une méthode [calculer] ;
  • lignes 41-49 : les classes [Ajouter] et [Soustraire] implémentent l'interface [Operation] ;
  • ligne 25 : la fonction [executer], dont le premier paramètre est du type de l'interface [Operation]. Elle reçoit successivement une instance de la classe [Ajouter], une instance de la classe [Soustraire] (lignes 7 et 9), puis une lambda (ligne 11). Pour la lambda, la conversion SAM vers [Operation] est faite automatiquement, puisqu'il s'agit d'un argument d'appel. Pour [executer], ces trois objets sont de même nature : des objets qui implémentent [Operation] ;
  • lignes 36-37 : l'interface [OperationNommee] hérite de la méthode [calculer] et ajoute une propriété abstraite [nom]. Elle n'est pas fonctionnelle (elle ne peut d'ailleurs pas être déclarée [fun interface]) : on ne peut plus l'implémenter par une lambda, il faut une classe, ici un objet anonyme (lignes 13-16) ;
  • lignes 57-58 : la réciproque est vraie aussi : une classe peut implémenter un type fonctionnel, puisque c'est une interface. Ses instances s'appellent alors comme des fonctions ([modulo(7, 2)]) et se passent là où l'on attend une fonction ([appliquer(modulo, 7, 3)], ligne 21).

Ces deux exemples montrent qu'en Kotlin comme en Java, une fonction qui attend un comportement accepte à la fois des objets de classes écrites pour l'occasion, des références et des lambdas. Il reste deux questions. La première est propre à Kotlin : pour un paramètre fonctionnel, faut-il un type fonctionnel ou une [fun interface] ?

  • le type fonctionnel est le choix par défaut. Il ne demande aucune déclaration, il s'appelle comme une fonction, il accepte directement les lambdas et les références, et il profite des fonctions [inline]. Un [typealias] lui donne un nom quand il est long ;
  • la fun interface se justifie quand on veut un type vraiment distinct (qu'on ne puisse pas confondre avec une autre fonction de même forme), une méthode au nom parlant, des méthodes supplémentaires avec un corps, ou une API destinée aussi à des appelants Java : pour Java, une [fun interface] est une interface fonctionnelle ordinaire, utilisable avec ses lambdas, alors qu'un type fonctionnel Kotlin lui apparaît comme une [Function2<Integer, Integer, Integer>] dont la méthode [invoke] doit rendre un objet ([Unit] pour une fonction « sans résultat »).

La seconde question est la même qu'en Java : faut-il que l'interface ait une seule méthode, ou plusieurs ?

  • une fonction, type fonctionnel ou interface à une seule méthode, se montre plus souple d'utilisation : toute fonction existante ayant la bonne forme fait l'affaire, et une lambda permet même d'écrire la fonction à l'endroit de l'appel. Il n'y a pas de classe à écrire. Cette souplesse explique que les bibliothèques modernes (les collections, les coroutines du chapitre 9, Spring) utilisent massivement les paramètres fonctionnels ;
  • une interface à plusieurs membres reprend l'avantage dès que le comportement à transmettre comporte plusieurs opérations liées entre elles ([calculer] et [nom] ci-dessus), ou qu'il a besoin d'un état et d'une identité propres.

C'est le cas de l'interface [IImpotDao] de la couche [dao] du chapitre 6 : bien qu'elle n'ait qu'un membre, la propriété [val tranchesImpot: List<TrancheImpot>], ses implémentations ont un état (les tranches lues, le nom du fichier), sont créées par le conteneur d'injection de dépendances et représentent un rôle clairement nommé de l'application. Un [() -> List<TrancheImpot>] aurait techniquement suffi, mais il aurait été beaucoup moins parlant. On réserve donc les types fonctionnels aux comportements courts et ponctuels (un critère, une transformation, une action), et les interfaces « métier » aux rôles de l'application, quel que soit leur nombre de membres.

7.6. Gestion d'événements

7.6.1. Le modèle des écouteurs

Les fonctions passées en paramètre servent à mettre en place des événements. Pas plus que Java, Kotlin n'a de mot clé dédié aux événements (le C# a son mot clé [event]) : il utilise les conventions établies par Java dès sa version 1.1 pour les composants graphiques et les JavaBeans, appelées le modèle des écouteurs (« listener model »). Une classe [C1] qui veut signaler un événement [Xxx] procède ainsi :

  • une classe [XxxEvent] encapsule les informations sur l'événement, dont l'objet à l'origine de l'événement, qu'on appelle la source. En Java, la convention veut qu'elle dérive de [java.util.EventObject], qui mémorise la source ; en Kotlin, une classe ordinaire avec des propriétés en lecture seule suffit, et c'est ce que nous ferons ;
  • un type d'écouteur définit la forme de la fonction qui sera appelée quand l'événement se produit. En Java, c'est une interface [XxxListener] ; en Kotlin, c'est le plus souvent un simple type fonctionnel, [(XxxEvent) -> Unit], nommé par un [typealias] : un écouteur sera une lambda ou une référence de fonction ;
  • la classe [C1] gère une liste de fonctions et offre deux méthodes publiques pour s'y inscrire et s'en désinscrire : [addXxxListener(listener)] et [removeXxxListener(listener)] ;
  • lorsqu'une instance [c1] de [C1] veut signaler l'événement, elle crée un objet [XxxEvent] et appelle chacun des écouteurs inscrits en lui passant cet objet. Par convention, cette notification est confiée à une méthode protégée [fireXxx(e: XxxEvent)] (« fire » : déclencher), qu'une classe dérivée peut redéfinir pour modifier la façon dont l'événement est publié ;
  • si un objet [c2] veut être averti de l'événement [Xxx] de l'objet [c1], il s'inscrit par [c1.addXxxListener(...)]. Il pourra se désinscrire par [c1.removeXxxListener(...)] lorsqu'il ne voudra plus être averti. Comme [c1] gère une liste, différents objets peuvent s'inscrire pour être prévenus du même événement.

Dans ce scénario, on a :

  • une classe qui signale (on dit aussi publie, ou émet) un événement ;
  • des objets qui sont avertis de cet événement. On dit qu'ils s'abonnent, ou s'inscrivent, à l'événement : ce sont les écouteurs (« listeners »), ou gestionnaires d'événement ;
  • un type d'écouteur qui définit la forme des fonctions qui seront averties de l'événement.

C'est le patron de conception Observateur (« Observer ») : l'émetteur ne connaît pas ses abonnés, il se contente de les avertir. Ce modèle offre la même protection qu'un mot clé dédié : la liste des écouteurs est une propriété privée de l'émetteur. De l'extérieur, on ne peut que s'inscrire ou se désinscrire ; on ne peut ni vider la liste, ni remplacer les écouteurs des autres, ni déclencher l'événement à la place de l'émetteur.

7.6.2. Un exemple : la température trop haute

Considérons l'exemple suivant :

  • une classe [Emetteur] encapsule une température. Lorsque cette température dépasse un certain seuil, un événement doit être signalé. Nous appellerons cet événement [TemperatureTropHaute]. Les informations sur cet événement seront encapsulées dans un objet [TemperatureTropHauteEvent] et les écouteurs seront des fonctions de type [TemperatureTropHauteListener] ;
  • une classe [Souscripteur] s'inscrit comme écouteur de l'événement précédent. Lorsqu'elle est avertie de l'événement, elle affiche un message sur la console. Un souscripteur se désinscrit de lui-même après avoir reçu un nombre donné d'alertes ;
  • un programme console crée un émetteur et deux souscripteurs. Il saisit des températures au clavier et les enregistre dans l'émetteur. Si l'une d'elles est trop haute, l'émetteur signale l'événement [TemperatureTropHaute].

Ce programme est réparti en trois fichiers du paquetage [lambdas11]. Voici d'abord le fichier [Emetteur.kt], qui contient la classe de l'événement, le type des écouteurs et la classe [Emetteur] :

package lambdas11

import java.time.LocalTime
import java.util.concurrent.CopyOnWriteArrayList

// les informations transportées par l'événement
class TemperatureTropHauteEvent(
    // l'émetteur à l'origine de l'événement
    val source: Emetteur,
    // température lors de l'événement
    val temperature: Double,
) {
    // heure de l'événement
    val heure: LocalTime = LocalTime.now()
}

// la forme des écouteurs de l'événement : un simple type fonctionnel
typealias TemperatureTropHauteListener = (TemperatureTropHauteEvent) -> Unit

// la classe qui signale l'événement
open class Emetteur(val nom: String) {
    companion object {
        // seuil de déclenchement de l'événement
        const val SEUIL = 19.0
    }

    // les écouteurs inscrits : une liste de fonctions
    private val listeners = CopyOnWriteArrayList<TemperatureTropHauteListener>()

    // température observée : l'écriture de la propriété peut déclencher l'événement
    var temperature = 0.0
        set(value) {
            field = value
            if (value > SEUIL) {
                // on signale l'événement aux écouteurs
                fireTemperatureTropHaute(TemperatureTropHauteEvent(this, value))
            }
        }

    // inscription d'un écouteur
    fun addTemperatureTropHauteListener(listener: TemperatureTropHauteListener) {
        listeners.add(listener)
    }

    // désinscription d'un écouteur
    fun removeTemperatureTropHauteListener(listener: TemperatureTropHauteListener) {
        listeners.remove(listener)
    }

    // notification de l'événement à tous les écouteurs inscrits
    protected open fun fireTemperatureTropHaute(e: TemperatureTropHauteEvent) {
        for (listener in listeners) {
            listener(e)
        }
    }
}

Commentaires :

  • lignes 7-14 : la classe [TemperatureTropHauteEvent] mémorise la source de l'événement, de type [Emetteur], et la température qui a provoqué l'événement. Nous avons ajouté l'heure de l'événement, [heure], initialisée à la création de l'objet. Les propriétés sont toutes des [val] : un écouteur ne doit pas pouvoir modifier les informations de l'événement pour les écouteurs suivants. Nous n'avons pas fait dériver la classe de [EventObject] comme en Java : cela n'apporterait rien ici, et nous obligerait, pour que la source soit de type [Emetteur] et non [Any], à redéfinir la méthode [getSource] ;
  • ligne 18 : le type des écouteurs. Un écouteur est une fonction qui reçoit l'objet événement et ne rend rien. Là où Java a besoin d'une interface [TemperatureTropHauteListener] dérivée d'[EventListener], Kotlin se contente d'un alias de type fonctionnel. On aurait pu, tout aussi bien, déclarer une [fun interface TemperatureTropHauteListener] : ce serait le bon choix si les écouteurs devaient pouvoir être écrits en Java ;
  • ligne 24 : le seuil de température au-delà duquel l'événement [TemperatureTropHaute] est signalé, une constante de l'objet compagnon ;
  • ligne 21 : l'émetteur a un nom pour être identifié. La classe est déclarée [open], pour qu'une classe dérivée puisse redéfinir la méthode [fireTemperatureTropHaute] ;
  • ligne 28 : la liste des écouteurs inscrits, une liste de fonctions. C'est une [CopyOnWriteArrayList] du paquetage [java.util.concurrent], une liste qui, à chaque modification, recopie son contenu dans un nouveau tableau. Un parcours en cours continue donc sur l'ancien tableau, même si la liste est modifiée pendant ce temps. Nous verrons plus loin pourquoi c'est indispensable ici. Cette liste est le type habituel des listes d'écouteurs : on parcourt la liste à chaque événement, mais on ne la modifie que rarement ;
  • lignes 31-36 : la propriété [temperature] a un accesseur [set] personnalisé (chapitre 4) : il enregistre la nouvelle valeur dans le champ caché [field], puis signale l'événement si elle dépasse le seuil. La publication est confiée à la méthode [fireTemperatureTropHaute], à qui on passe un objet [TemperatureTropHauteEvent] ayant pour source l'émetteur lui-même ([this]) et contenant la température qui a dépassé le seuil. Là où Java a besoin d'une méthode [setTemperature], l'utilisateur de la classe écrit simplement [e1.temperature = 21.0] ;
  • lignes 41-42 : l'inscription d'un écouteur ajoute celui-ci à la liste. Le type du paramètre n'est pas nullable : le compilateur refuse tout écouteur [null], que Java doit refuser à l'exécution par [Objects.requireNonNull] ;
  • lignes 46-47 : la désinscription retire l'écouteur de la liste. La méthode [remove] recherche l'objet à retirer avec [equals] : il faut donc lui passer un objet égal à celui qui a été inscrit ;
  • lignes 51-53 : la notification appelle chaque écouteur inscrit, dans l'ordre de leur inscription, comme une fonction : [listener(e)]. S'il n'y a aucun écouteur, la boucle ne fait rien : il n'y a aucun cas particulier à traiter.

Voici le fichier [Souscripteur.kt] :

package lambdas11

// une classe qui s'inscrit comme écouteur de l'événement
class Souscripteur(
    // nom du souscripteur
    private val nom: String,
    // nombre maximal d'alertes acceptées
    private val nbAlertesMax: Int,
) {
    // nombre d'alertes reçues
    private var nbAlertes = 0

    // inscription auprès d'un émetteur
    fun abonner(emetteur: Emetteur) {
        emetteur.addTemperatureTropHauteListener(::evtTemperatureTropHaute)
    }

    // désinscription : une nouvelle référence à la même méthode du même objet lui est égale
    fun desabonner(emetteur: Emetteur) {
        emetteur.removeTemperatureTropHauteListener(::evtTemperatureTropHaute)
    }

    // gestionnaire de l'événement TemperatureTropHaute
    private fun evtTemperatureTropHaute(e: TemperatureTropHauteEvent) {
        val emetteur = e.source
        val temperature = "%.1f".format(e.temperature)
        println("Souscripteur [$nom] : la source [${emetteur.nom}] " +
            "a signalé une température trop haute : [$temperature]")
        // au-delà de nbAlertesMax alertes, le souscripteur se désinscrit
        nbAlertes++
        if (nbAlertes == nbAlertesMax) {
            desabonner(emetteur)
            println("Souscripteur [$nom] : désabonné après $nbAlertes alerte(s)")
        }
    }
}

Commentaires :

  • lignes 4-11 : chaque souscripteur est identifié par un nom et reçoit le nombre maximal d'alertes qu'il accepte ([nbAlertesMax]). Il compte les alertes reçues ([nbAlertes]) ;
  • lignes 14-15 : l'inscription auprès d'un émetteur. L'écouteur est une référence liée, [::evtTemperatureTropHaute], c'est-à-dire [this::evtTemperatureTropHaute] : la méthode [evtTemperatureTropHaute] de ce souscripteur ;
  • lignes 19-20 : la désinscription passe à [removeTemperatureTropHauteListener] une nouvelle référence [::evtTemperatureTropHaute]. En Java, ce serait une erreur classique : deux évaluations de [this::evtTemperatureTropHaute] produisent deux objets que [equals] considère comme différents, et la désinscription ne retirerait rien, sans aucun message d'erreur ; il faut créer l'écouteur une seule fois et le conserver dans un champ. Nous avons vu avec [Lambdas04.kt] qu'en Kotlin, deux références à la même fonction du même objet sont égales : [remove] retrouve donc l'écouteur inscrit, et seulement lui, puisque les références des autres souscripteurs sont liées à d'autres objets. Ce ne serait pas le cas d'une lambda : [{ e -> evtTemperatureTropHaute(e) }], écrite deux fois, donnerait deux objets différents ;
  • ligne 24 : le gestionnaire de l'événement. Il a la forme imposée par le type [TemperatureTropHauteListener]. Il est privé : seul le souscripteur peut l'inscrire auprès d'un émetteur. Une autre façon de faire consiste à faire implémenter le type d'écouteur par la classe [Souscripteur] elle-même ([class Souscripteur : TemperatureTropHauteListener], avec une méthode [invoke]) et à s'inscrire avec [this]. Elle a l'inconvénient de rendre publique la méthode [invoke], que n'importe qui pourrait alors appeler ;
  • ligne 25 : la source de l'événement est directement de type [Emetteur] ;
  • lignes 26-28 : le gestionnaire affiche le nom du souscripteur, le nom de l'émetteur qui a signalé l'événement et la température qui l'a déclenché. Le format [%.1f] affiche le réel avec un chiffre après la virgule, selon les conventions françaises (la fonction [format] de Kotlin utilise les conventions locales de la machine). La chaîne est écrite en deux morceaux concaténés par [+] pour que la ligne tienne dans la page ;
  • lignes 30-33 : après [nbAlertesMax] alertes, le souscripteur se désinscrit de l'émetteur. Il le fait pendant la notification de l'événement, depuis son propre gestionnaire, c'est-à-dire pendant que la méthode [fireTemperatureTropHaute] parcourt la liste des écouteurs. C'est sans danger grâce à la [CopyOnWriteArrayList] : [remove] crée un nouveau tableau, mais le parcours en cours continue sur l'ancien.

Voici enfin le programme principal, [Lambdas11.kt] :

package lambdas11

// gestion d'événements : un émetteur et des écouteurs

fun main() {
    // création d'un émetteur d'événements
    val e1 = Emetteur("e")
    // création de deux souscripteurs qui s'inscrivent auprès de e1
    val souscripteurs = listOf(Souscripteur("s0", 3), Souscripteur("s1", 1))
    for (souscripteur in souscripteurs) {
        souscripteur.abonner(e1)
    }
    // un troisième écouteur : une lambda
    e1.addTemperatureTropHauteListener { e ->
        println("Journal : %.1f °C à %tT".format(e.temperature, e.heure))
    }

    // on lit les températures au clavier
    while (true) {
        print("Température (rien pour arrêter) : ")
        // fin de la saisie ? (readlnOrNull rend null si l'entrée standard est fermée)
        val saisie = readlnOrNull()
        if (saisie.isNullOrBlank()) {
            break
        }
        // on accepte la virgule décimale
        val temperature = saisie.trim().replace(',', '.').toDoubleOrNull()
        if (temperature == null) {
            // on signale l'erreur
            println("Température incorrecte")
        } else {
            // température correcte - on l'enregistre dans l'émetteur
            e1.temperature = temperature
        }
    }
}

Résultats (l'entrée standard a été alimentée par une redirection ; nous avons replacé les saisies après les invites, là où le programme les lit) :

> gradlew -q run -Pmain=lambdas11.Lambdas11Kt
Température (rien pour arrêter) : 17
Température (rien pour arrêter) : 21
Souscripteur [s0] : la source [e] a signalé une température trop haute : [21,0]
Souscripteur [s1] : la source [e] a signalé une température trop haute : [21,0]
Souscripteur [s1] : désabonné après 1 alerte(s)
Journal : 21,0 °C à 12:33:37
Température (rien pour arrêter) : abc
Température incorrecte
Température (rien pour arrêter) : 22,5
Souscripteur [s0] : la source [e] a signalé une température trop haute : [22,5]
Journal : 22,5 °C à 12:33:37
Température (rien pour arrêter) : 25
Souscripteur [s0] : la source [e] a signalé une température trop haute : [25,0]
Souscripteur [s0] : désabonné après 3 alerte(s)
Journal : 25,0 °C à 12:33:37
Température (rien pour arrêter) : 30
Journal : 30,0 °C à 12:33:37
Température (rien pour arrêter) :

Commentaires :

  • ligne 7 : création de l'émetteur ;
  • lignes 9-11 : création de deux souscripteurs, [s0] (3 alertes au plus) et [s1] (1 alerte), que l'on inscrit auprès de l'émetteur ;
  • lignes 14-15 : un troisième écouteur, une lambda passée hors des parenthèses, qui joue le rôle d'un journal. Le type du paramètre [e] est déduit de celui du paramètre de [addTemperatureTropHauteListener]. Le format [%tT] affiche une heure sous la forme [HH:mm:ss]. Cette lambda ne pourra pas être désinscrite : il faudrait l'avoir rangée dans une variable pour pouvoir passer le même objet à [removeTemperatureTropHauteListener]. On réserve donc les lambdas écrites directement dans l'appel d'inscription aux écouteurs qui restent inscrits aussi longtemps que l'émetteur existe ;
  • lignes 19-33 : boucle de saisie des températures au clavier. [readlnOrNull()] lit une ligne ; elle rend [null] si l'entrée standard est fermée, cas que l'on traite comme une saisie vide grâce à [isNullOrBlank] (ligne 23). Après ce test, le compilateur sait que [saisie] n'est pas [null] (transtypage intelligent) ;
  • ligne 27 : [toDoubleOrNull] n'accepte que le point décimal. Pour qu'un utilisateur français puisse taper [22,5], on remplace la virgule par un point. Si la saisie n'est pas un nombre, [toDoubleOrNull] rend [null] : pas besoin d'intercepter une exception comme le fait la version Java avec [Double.parseDouble] ;
  • ligne 33 : si la température saisie est correcte, elle est affectée à la propriété [temperature] de l'émetteur [e1], dont l'accesseur [set] signale l'événement [TemperatureTropHaute] si elle est supérieure à 19 °C.

Les résultats montrent (les heures varient d'une exécution à l'autre) :

  • ligne 1 : 17 °C ne dépasse pas le seuil : aucun événement ;
  • lignes 2-6 : 21 °C déclenche l'événement. Les trois écouteurs sont avertis dans l'ordre de leur inscription. [s1], qui n'accepte qu'une alerte, se désinscrit aussitôt, ce qui n'empêche pas le journal, inscrit après lui, d'être averti ;
  • lignes 9-11 : [s1] n'est plus averti : la désinscription par une nouvelle référence a bien fonctionné ;
  • lignes 12-15 : [s0] reçoit sa troisième alerte et se désinscrit à son tour ;
  • lignes 16-17 : il ne reste plus que le journal.

Que se passerait-il avec une simple [ArrayList] à la place de la [CopyOnWriteArrayList] dans la classe [Emetteur] ? Nous avons fait l'essai : lorsque [s1] se désinscrit pendant le parcours de la liste, les éléments suivants sont décalés et le parcours s'arrête aussitôt. Le journal n'est pas averti des températures 21 et 25, sans aucun message d'erreur. Dans d'autres configurations, le parcours lance une [ConcurrentModificationException]. Une liste d'écouteurs doit donc toujours supporter les modifications pendant son parcours : la [CopyOnWriteArrayList] est faite pour cela, et elle supporte aussi les inscriptions et désinscriptions faites par d'autres threads (chapitre 8).

7.6.3. Les événements de changement de propriété

Le cas le plus fréquent d'événement est celui où une propriété d'un objet change de valeur : un autre objet (une interface graphique, un cache, un journal) veut en être averti. Kotlin et la bibliothèque Java offrent pour ce cas deux outils complémentaires :

  • les propriétés déléguées de Kotlin. Une propriété déclarée [var p: T by delegue] confie la lecture et l'écriture de sa valeur à un autre objet, le délégué. La bibliothèque standard en fournit plusieurs, comme [by lazy { … }], qui calcule la valeur de la propriété au premier accès. L'objet [kotlin.properties.Delegates] fournit deux délégués qui reçoivent une lambda : [Delegates.observable(valeurInitiale) { propriete, ancienne, nouvelle -> … }] exécute la lambda après chaque écriture de la propriété ; [Delegates.vetoable(valeurInitiale) { propriete, ancienne, nouvelle -> … }] l'exécute avant, et n'accepte la nouvelle valeur que si la lambda rend [true]. Le paramètre [propriete] est une référence à la propriété ([KProperty]), dont on obtient le nom par [propriete.name] ;
  • la classe Java [java.beans.PropertyChangeSupport], qui gère la liste des écouteurs à notre place. Les écouteurs implémentent l'interface fonctionnelle [PropertyChangeListener], dont la méthode [void propertyChange(PropertyChangeEvent evt)] reçoit un événement contenant la source, le nom de la propriété, son ancienne et sa nouvelle valeur.

Le délégué [observable] sait qu'une propriété change, et [PropertyChangeSupport] sait qui avertir : le programme [Lambdas12.kt] les associe :

package lambdas12

import java.beans.PropertyChangeListener
import java.beans.PropertyChangeSupport
import kotlin.properties.Delegates
import kotlin.properties.ReadWriteProperty

// les événements de changement de propriété : Delegates.observable, vetoable et PropertyChangeSupport

fun main() {
    val thermometre = Thermometre("salon")
    // un écouteur de toutes les propriétés
    val journal = PropertyChangeListener { e ->
        val source = e.source as Thermometre
        println("Journal : ${source.nom}.${e.propertyName} : ${e.oldValue} -> ${e.newValue}")
    }
    thermometre.addPropertyChangeListener(journal)
    // un écouteur de la seule propriété temperature
    thermometre.addPropertyChangeListener("temperature") { e ->
        if (e.newValue as Double > 19) {
            println("Alerte : température trop haute")
        }
    }

    thermometre.temperature = 18.0
    thermometre.temperature = 21.5
    // même valeur : aucun événement
    thermometre.temperature = 21.5
    thermometre.nom = "séjour"
    // désinscription du journal
    thermometre.removePropertyChangeListener(journal)
    thermometre.temperature = 22.0
    println("écouteurs restants : ${thermometre.nbEcouteurs}")

    // une propriété qui peut refuser une nouvelle valeur
    for (valeur in listOf(21.0, 45.0)) {
        thermometre.consigne = valeur
        println("consigne demandée : $valeur, consigne retenue : ${thermometre.consigne}")
    }
}

// une classe dont les propriétés signalent leurs changements
class Thermometre(nom: String) {
    // la gestion des écouteurs est déléguée à un objet PropertyChangeSupport
    private val changements = PropertyChangeSupport(this)

    // une propriété observable : après chaque écriture, on signale le changement
    private fun <T> observable(valeurInitiale: T): ReadWriteProperty<Any?, T> =
        Delegates.observable(valeurInitiale) { propriete, ancienne, nouvelle ->
            changements.firePropertyChange(propriete.name, ancienne, nouvelle)
        }

    var nom: String by observable(nom)
    var temperature: Double by observable(0.0)

    // une propriété qui refuse les valeurs hors de l'intervalle [5, 30]
    var consigne: Double by Delegates.vetoable(20.0) { _, _, nouvelle -> nouvelle in 5.0..30.0 }

    // inscription et désinscription des écouteurs
    fun addPropertyChangeListener(listener: PropertyChangeListener) {
        changements.addPropertyChangeListener(listener)
    }

    fun addPropertyChangeListener(propriete: String, listener: PropertyChangeListener) {
        changements.addPropertyChangeListener(propriete, listener)
    }

    fun removePropertyChangeListener(listener: PropertyChangeListener) {
        changements.removePropertyChangeListener(listener)
    }

    val nbEcouteurs: Int
        get() = changements.propertyChangeListeners.size
}

Résultats :

1
2
3
4
5
6
7
8
9
> gradlew -q run -Pmain=lambdas12.Lambdas12Kt
Journal : salon.temperature : 0.0 -> 18.0
Journal : salon.temperature : 18.0 -> 21.5
Alerte : température trop haute
Journal : séjour.nom : salon -> séjour
Alerte : température trop haute
écouteurs restants : 1
consigne demandée : 21.0, consigne retenue : 21.0
consigne demandée : 45.0, consigne retenue : 21.0

Commentaires :

  • lignes 3-4 : le paquetage [java.beans] appartient au module [java.desktop] du JDK, et non au module [java.base]. Notre programme n'étant pas un module Java, toutes les classes du JDK lui sont accessibles : il suffit de les importer ;
  • ligne 45 : la classe [Thermometre] délègue la gestion de ses écouteurs à un objet [PropertyChangeSupport], construit avec la source des événements ([this]). Les méthodes des lignes 60-69 se contentent de lui transmettre les inscriptions et désinscriptions, et la propriété [nbEcouteurs] (lignes 72-73) rend le nombre d'écouteurs inscrits ;
  • lignes 48-50 : une petite fonction générique qui fabrique un délégué [Delegates.observable] dont la lambda transmet chaque changement à [firePropertyChange], avec le nom de la propriété ([propriete.name]), l'ancienne et la nouvelle valeur. Elle rend un [ReadWriteProperty<Any?, T>], l'interface des délégués de propriétés modifiables. Comme une méthode, une lambda écrite dans une classe peut utiliser les propriétés privées de l'objet, ici [changements] ;
  • lignes 53-54 : les deux propriétés observables de la classe. Le mot clé [by] confie leur gestion à un délégué créé par notre fonction [observable]. Comparée à la version Java, où chaque modificateur doit mémoriser l'ancienne valeur, enregistrer la nouvelle et appeler [firePropertyChange] avec le nom de la propriété écrit dans une chaîne, la version Kotlin tient en une ligne par propriété, et le nom de la propriété ne peut pas être mal orthographié. Le délégué exécute la lambda à chaque écriture, même si la valeur ne change pas ; mais [firePropertyChange] n'avertit les écouteurs que si les deux valeurs sont différentes : l'écriture de la ligne 28, qui ne change pas la valeur, ne produit donc aucun affichage ;
  • lignes 13-17 : un premier écouteur, une lambda convertie en [PropertyChangeListener] et rangée dans une variable pour pouvoir être désinscrite (ligne 31). Il est inscrit pour toutes les propriétés de l'objet. [e.source] (c'est-à-dire [e.getSource()], vu de Kotlin comme une propriété) est de type [Any], qu'il faut transtyper par [as]. Les valeurs sont des objets : les [Double] sont affichés par leur méthode [toString], qui utilise le point décimal ;
  • lignes 19-21 : un second écouteur, inscrit pour la seule propriété [temperature]. La lambda est passée hors des parenthèses et convertie en [PropertyChangeListener] (conversion SAM). Il émet une alerte au-delà de 19 °C. [e.newValue as Double] transtype la nouvelle valeur, de type [Any?] ;
  • lignes 25-33 : les modifications de propriétés, par de simples affectations. Le changement de nom (ligne 29) n'est signalé qu'au journal. Ligne 4 des résultats, le journal affiche le nouveau nom de la source, [séjour] : l'événement est signalé après la modification. Après la désinscription du journal, seule l'alerte est affichée, et il ne reste qu'un écouteur ;
  • ligne 57 : la propriété [consigne] utilise le délégué [vetoable]. Sa lambda, appelée avant chaque écriture, n'accepte la nouvelle valeur que si elle est comprise entre 5 et 30 °C ([nouvelle in 5.0..30.0]) ; ses deux premiers paramètres, inutiles ici, sont notés [_]. Lignes 36-38, la consigne 21 est acceptée, la consigne 45 est refusée : la propriété garde sa valeur précédente (lignes 7-8 des résultats). Les JavaBeans ont un mécanisme équivalent, avec [VetoableChangeSupport] et des écouteurs qui lancent une [PropertyVetoException].

Sans écrire de classe événement ni de type d'écouteur, on obtient ainsi un mécanisme complet. [PropertyChangeSupport] est celui qu'utilisent les composants graphiques Swing et les JavaBeans ; JavaFX a son propre mécanisme, très proche, de propriétés observables. Les délégués [observable] et [vetoable], eux, suffisent seuls quand il n'y a qu'un observateur, connu à l'écriture de la classe. Signalons enfin que, pour les flux d'événements asynchrones, Kotlin propose les [Flow] de la bibliothèque kotlinx.coroutines, que nous verrons au chapitre 9 ([StateFlow] en particulier joue le rôle d'une propriété observable).

7.7. Les lambdas, les collections et les séquences

Au chapitre 5, nous avons utilisé les opérations fonctionnelles des collections ([filter], [map], [sortedBy]…) et des séquences en leur passant des lambdas. Nous savons maintenant ce que sont ces paramètres : la fonction [filter] est déclarée ainsi dans la bibliothèque standard : [inline fun <T> Iterable<T>.filter(predicate: (T) -> Boolean): List<T>].

C'est une fonction d'extension, [inline], dont le paramètre est un type fonctionnel : toute fonction, ou toute lambda, qui reçoit un élément et rend un booléen convient. De même, [map] attend un [(T) -> R], [sortedBy] un [(T) -> R?] où [R] est comparable, [forEach] un [(T) -> Unit], [reduce] un [(S, T) -> S]… Pour bien comprendre leur fonctionnement, écrivons nous-mêmes des opérations de filtrage et de transformation. C'est le programme [Lambdas13.kt] :

package lambdas13

// les lambdas, les collections et les séquences

fun main() {
    val nombres = listOf(3, 8, 1, 6, 5, 4, 9, 2)

    // 1 - filter et map attendent des fonctions (T) -> Boolean et (T) -> R
    val estPair: (Int) -> Boolean = { it % 2 == 0 }
    val carre: (Int) -> Int = { it * it }
    println("collections : ${nombres.filter(estPair).map(carre)}")
    // l'API Stream de Java reste utilisable : les lambdas sont converties en Predicate et Function
    println("Stream : ${nombres.stream().filter { it % 2 == 0 }.map { it * it }.toList()}")

    // 2 - notre propre fonction de filtrage, appelée avec une lambda puis une référence
    println("filtrer(n > 4) : ${nombres.filtrer { it > 4 }}")
    println("filtrer(estImpair) : ${nombres.filtrer(::estImpair)}")

    // 3 - une collection est évaluée immédiatement : chaque étape produit une liste complète
    val grandsListe = nombres.filter {
        print("[test $it] ")
        it > 7
    }
    println()
    println("la liste est déjà calculée : $grandsListe")

    // 4 - une séquence est évaluée à la demande :
    //     le prédicat n'est appelé que par l'opération terminale
    val grands = nombres.asSequence().filter {
        print("[test $it] ")
        it > 7
    }
    println("la séquence est définie, rien n'a encore été exécuté")
    grands.forEach { print("-> $it ") }
    println()

    // 5 - nos propres opérations intermédiaires sur les séquences, paresseuses elles aussi
    val villes = listOf("Angers", "Paris", "Lyon", "Nantes", "Aix")
    val resultat = villes.asSequence()
        .garder { it.length <= 5 }
        .transformer(String::uppercase)
        .toList()
    println("nos opérations : $resultat")
    // une séquence infinie : seuls les éléments demandés sont calculés
    val puissancesDeDeux = generateSequence(1) { it * 2 }
    val premieres = puissancesDeDeux.transformer { "2^${it.countTrailingZeroBits()}=$it" }.take(5)
    println("puissances de 2 : ${premieres.toList()}")
}

// filtre une liste avec un prédicat : le travail que fait filter
inline fun <T> List<T>.filtrer(predicat: (T) -> Boolean): List<T> {
    val resultat = mutableListOf<T>()
    for (element in this) {
        if (predicat(element)) {
            resultat.add(element)
        }
    }
    return resultat
}

fun estImpair(n: Int) = n % 2 != 0

// une opération intermédiaire de séquence qui ne laisse passer que les éléments vérifiant le prédicat
fun <T> Sequence<T>.garder(predicat: (T) -> Boolean): Sequence<T> = sequence {
    for (element in this@garder) {
        if (predicat(element)) yield(element)
    }
}

// une opération intermédiaire qui transforme chaque élément : l'équivalent de map
fun <T, R> Sequence<T>.transformer(transformation: (T) -> R): Sequence<R> = sequence {
    for (element in this@transformer) {
        yield(transformation(element))
    }
}

Résultats :

> gradlew -q run -Pmain=lambdas13.Lambdas13Kt
collections : [64, 36, 16, 4]
Stream : [64, 36, 16, 4]
filtrer(n > 4) : [8, 6, 5, 9]
filtrer(estImpair) : [3, 1, 5, 9]
[test 3] [test 8] [test 1] [test 6] [test 5] [test 4] [test 9] [test 2]
la liste est déjà calculée : [8, 9]
la séquence est définie, rien n'a encore été exécuté
[test 3] [test 8] -> 8 [test 1] [test 6] [test 5] [test 4] [test 9] -> 9 [test 2]
nos opérations : [PARIS, LYON, AIX]
puissances de 2 : [2^0=1, 2^1=2, 2^2=4, 2^3=8, 2^4=16]

Commentaires :

  • lignes 9-11 : les lambdas de [filter] et de [map] sont d'abord rangées dans des variables, pour faire apparaître leur type. [map] transforme chaque élément : on obtient les carrés des nombres pairs ;
  • ligne 13 : le même calcul avec l'API Stream de Java, utilisable telle quelle depuis Kotlin : la méthode Java [filter] attend un [Predicate], [map] une [Function], et les lambdas Kotlin sont converties automatiquement (conversion SAM). On ne l'utilise guère en Kotlin pour des calculs séquentiels, les fonctions des collections et des séquences étant plus simples ; elle reste utile pour les flux parallèles du chapitre 8, ou quand une bibliothèque Java rend un [Stream] ;
  • lignes 51-58 : notre fonction [filtrer], écrite comme une fonction d'extension de [List<T>] et déclarée [inline], comme [filter]. Elle est générique : elle filtre une liste d'éléments de n'importe quel type [T]. Elle parcourt la liste ([this]), appelle le prédicat sur chaque élément (ligne 54) et rend la liste des éléments retenus ;
  • ligne 16 : appel de [filtrer] avec une lambda, hors des parenthèses. Le type [T] est déduit de la liste : [Int]. Ligne 17, on lui passe une référence de fonction : [estImpair] (ligne 61) a la forme [(Int) -> Boolean] ;
  • lignes 20-25 : un prédicat bavard, qui affiche chaque élément qu'il teste, appliqué à une liste. Les opérations des collections sont immédiates : [filter] teste tous les éléments et construit la liste résultat complète avant de la rendre (lignes 5-6 des résultats). Dans une chaîne [liste.filter { … }.map { … }], chaque étape crée ainsi une liste intermédiaire ;
  • lignes 29-34 : le même prédicat appliqué à une séquence ([asSequence()]). Le résultat (lignes 7-8) montre que la définition de la séquence n'exécute rien : le message de la ligne 33 s'affiche avant le premier test. Ce n'est que lorsque l'opération terminale [forEach] réclame les éléments que le prédicat s'exécute, et les tests alternent avec les affichages de [forEach] : chaque élément traverse toute la chaîne avant que le suivant n'y entre. C'est l'exécution différée (ou paresseuse) des séquences, la même que celle des flux Java. Les opérations intermédiaires des séquences ne sont pas [inline] : elles doivent conserver les lambdas, pour les exécuter plus tard ;
  • lignes 64-73 : pour écrire nos propres opérations intermédiaires, paresseuses comme celles de la bibliothèque, nous utilisons le constructeur de séquences [sequence]. Il reçoit une lambda dans laquelle la fonction [yield] produit un élément de la séquence : à chaque élément demandé par l'aval, la lambda reprend son exécution là où elle s'était arrêtée, jusqu'au [yield] suivant (c'est une coroutine, notion que nous étudierons au chapitre 9). L'étiquette de [this@garder] désigne le receveur de la fonction d'extension [garder], c'est-à-dire la séquence d'origine : sans étiquette, [this] désignerait le receveur de la lambda de [sequence], car cette lambda est une lambda avec receveur. La fonction [garder] ne transmet que les éléments qui vérifient le prédicat ; la fonction [transformer], doublement générique ([T] est le type des éléments reçus, [R] celui des éléments produits), transmet le résultat de la fonction appliquée à chaque élément. Java 24 a introduit pour le même usage les collecteurs intermédiaires ([Gatherer]), d'une écriture nettement plus complexe ;
  • lignes 39-42 : nos fonctions d'extension s'insèrent dans la chaîne comme les opérations de la bibliothèque : on garde les villes de 5 lettres au plus, puis on les met en majuscules. L'opération terminale [toList] déclenche le calcul ;
  • lignes 45-47 : [generateSequence(1) { it * 2 }] définit une séquence infinie : 1, puis chaque élément obtenu en doublant le précédent. Elle n'est utilisable que grâce à l'évaluation paresseuse : [take(5)] ne demande que les cinq premiers éléments, et seuls ceux-ci sont calculés. La lambda passée à [transformer] affiche chaque puissance sous la forme [2^n=valeur] ([countTrailingZeroBits] compte les zéros de droite de l'écriture binaire, c'est-à-dire l'exposant).

Toutes les opérations des collections et des séquences sont construites sur ce modèle : des fonctions d'ordre supérieur qui reçoivent des types fonctionnels. Pour les collections, elles sont [inline] et immédiates ; pour les séquences, elles conservent les lambdas et ne calculent les éléments qu'à la demande de l'opération terminale.

7.8. Les arbres d'expression

Certains langages, comme C#, permettent de convertir une lambda non pas en code exécutable, mais en une structure de données qui la décrit (un « arbre d'expression ») : un programme peut alors analyser la lambda [t => t.Limite > 20000] et la traduire, par exemple, en une clause SQL [WHERE limite > 20000] exécutée par le serveur de bases de données.

Kotlin, pas plus que Java, n'a d'équivalent : une lambda Kotlin est toujours compilée en code exécutable ; on peut l'appeler, mais on ne peut pas examiner ce qu'elle fait. Plusieurs outils s'en approchent cependant :

  • les références de propriétés : [Personne::age] est un objet ([KProperty1<Personne, Int>]) qui connaît le nom de la propriété ([Personne::age.name]) et son type. Une bibliothèque peut s'en servir pour construire une requête sans écrire de chaîne : c'est ce que fait par exemple Spring Data MongoDB avec ses extensions Kotlin, qui permettent d'écrire un critère comme [Personne::age gt 18] ;
  • les DSL (« Domain Specific Languages ») internes : grâce aux lambdas avec receveur, aux fonctions [infix] et à la surcharge des opérateurs, une bibliothèque Kotlin peut offrir une syntaxe proche de SQL, que le programme exécute pour construire une structure de données décrivant la requête. C'est le principe de la bibliothèque Exposed, de JetBrains : [Tranches.selectAll().where { Tranches.limite greater 20000 }] ;
  • les greffons du compilateur (« compiler plugins ») : le compilateur Kotlin peut être étendu par des greffons qui transforment le code. C'est ainsi que fonctionnent kotlinx.serialization, les greffons [all-open] et [no-arg] utilisés avec Spring et Hibernate, ou le greffon [power-assert], qui analyse l'expression d'une assertion pour en afficher le détail en cas d'échec. Mais il s'agit d'outils de construction, et non d'une fonctionnalité offerte au programme.

Hibernate, que nous étudierons au chapitre 11, propose pour sa part les mêmes solutions qu'en Java : un langage de requêtes proche de SQL mais exprimé en termes de classes et de propriétés, HQL (["from Tranche t where t.limite > 20000"]), et l'API Criteria de Jakarta Persistence, qui construit la requête par des appels de méthodes ([cb.gt(t.get("limite"), 20000)]). Dans les deux cas, la requête est une donnée (une chaîne ou un arbre d'objets) que Hibernate traduit en SQL, alors qu'une lambda passée à [filter] s'exécute dans le programme Kotlin.

7.9. Conclusion

Nous retiendrons de ce chapitre :

  • en Kotlin, les fonctions sont des valeurs : elles ont un type fonctionnel ([(Int, Int) -> Int], [() -> Unit], [(T) -> Boolean], [(() -> Unit)?], [T.() -> R]…), se rangent dans des variables, se passent en paramètre, se rendent comme résultat, et s'appellent comme des fonctions ordinaires. Pour la machine virtuelle, ce sont des objets qui implémentent les interfaces [Function0], [Function1]… ;
  • les interfaces de [java.util.function] ([Function], [Supplier], [Consumer], [Predicate]…), avec [Runnable], [Callable] et [Comparator], restent utilisables : quand une méthode Java attend une interface fonctionnelle, Kotlin y convertit automatiquement une lambda ou une référence (conversion SAM) ; ailleurs, on écrit la conversion : [Comparator { … }] ;
  • pour nommer un type de fonction, on dispose de l'alias [typealias] (un simple autre nom) et de la [fun interface] (un vrai type nouveau, implémentable par une lambda) ;
  • une fonction s'obtient par une expression lambda ([{ a, b -> a + b }], [{ it * it }], écrite hors des parenthèses quand elle est le dernier paramètre, quittée par [return@etiquette]), par une fonction anonyme, par un objet anonyme, ou par une référence ([::fonction], [objet::methode], [Classe::methode], [::Classe]). Deux références à la même fonction du même objet sont égales, deux lambdas ne le sont jamais ;
  • les lambdas capturent les variables qui les entourent, y compris les [var], qu'elles peuvent modifier (le compilateur les range dans des objets [Ref]) ; elles voient alors la valeur de la variable au moment de leur exécution, ce qui demande de l'attention dans les boucles et entre threads ;
  • des fonctions qui rendent des fonctions et des extensions sur les types fonctionnels ([puis], [et], [ou]…) composent de nouvelles fonctions ; [compareBy], [thenBy], [reversed] construisent des comparateurs ;
  • les fonctions [inline] recopient leur code et celui des lambdas à l'endroit de l'appel, sans objet ni conversion, et permettent le retour non local ; [noinline] et [crossinline] règlent les cas particuliers. Les fonctions de portée [let], [run], [with], [apply] et [also] en sont l'application la plus courante ;
  • on choisit un type fonctionnel pour un comportement court et ponctuel, une [fun interface] pour un type distinct ou une API utilisable depuis Java, une interface à plusieurs membres (ou une interface « métier ») pour un rôle de l'application ;
  • un événement se met en place avec le modèle des écouteurs : une classe [XxxEvent], un type d'écouteur (souvent un [typealias] de type fonctionnel), des méthodes [addXxxListener] / [removeXxxListener] et une liste de fonctions ([CopyOnWriteArrayList]) parcourue par une méthode [fireXxx]. Les propriétés déléguées [Delegates.observable] et [Delegates.vetoable] signalent ou filtrent les changements d'une propriété, et [PropertyChangeSupport] gère pour elles la liste des écouteurs ;
  • les opérations des collections (immédiates, [inline]) et des séquences (paresseuses) sont des fonctions d'ordre supérieur ; Kotlin n'a pas d'arbres d'expression, et Hibernate exprime ses requêtes en HQL ou avec l'API Criteria.

Les lambdas reviendront dans tous les chapitres suivants : un thread exécute une lambda ([thread { … }]) et un [ExecutorService] un [Callable] (chapitre 8), une coroutine est lancée par [launch { … }] ou [async { … }] (chapitre 9), des fonctions d'extension de [ResultSet] recevront sous forme de lambda la transformation d'une ligne du résultat en objet (chapitre 10), et les services web de Spring Boot traitent les requêtes HTTP dans des méthodes que le cadriciel appelle comme des écouteurs (chapitre 13).