7. Interfaces fonctionnelles, lambdas et événements
Dans de nombreux programmes, on a besoin de passer à une méthode 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. Java répond à ce besoin avec une notion simple, sur laquelle tout ce chapitre repose : l'interface fonctionnelle, c'est-à-dire une interface qui ne déclare qu'une seule méthode abstraite. Un objet qui implémente une telle interface « transporte » une fonction, que l'on peut ranger dans une variable, passer en paramètre, rendre comme résultat d'une méthode, et exécuter quand on le souhaite.
Avant Java 8 (2014), il fallait écrire une classe, souvent une classe anonyme, pour chaque comportement à transmettre, ce qui était verbeux. Java 8 a introduit les expressions lambda et les références de méthodes, qui permettent d'écrire ce comportement en une ligne. Nous les avons déjà utilisées sans les détailler avec l'API Stream au chapitre 5 : dans [personnes.stream().filter(p -> p.age() >= 18)], on passe une lambda, donc l'instance d'une interface fonctionnelle, à la méthode [filter]. Ce chapitre explique ce qui se cache derrière cette écriture. Il se termine par les événements, que Java gère avec des écouteurs (« listeners ») : des objets, souvent des lambdas, qui s'inscrivent auprès d'un émetteur pour être avertis de ce qui s'y passe. Les interfaces graphiques Java (Swing, JavaFX) comme les frameworks 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 des fichiers source compacts, dans le dossier [07-lambdas]. On les exécute par [java LambdasNN.java]. Rappelons qu'un fichier source compact importe automatiquement tout le module [java.base] : les interfaces des paquetages [java.util.function], [java.util] ou [java.util.concurrent] y sont utilisables sans aucune instruction [import].
7.1. Les interfaces fonctionnelles prédéfinies
7.1.1. La notion d'interface fonctionnelle
Une interface fonctionnelle est une interface qui a exactement une méthode abstraite. Elle peut avoir par ailleurs autant de méthodes [default] et [static] qu'on le souhaite (cf. chapitre 4) : elles ne comptent pas. Cette unique méthode abstraite décrit un modèle (on dit aussi une signature, ou une forme) de fonction : les types de ses paramètres et le type de son résultat. Une variable du type de l'interface peut alors référencer n'importe quel objet qui fournit une fonction de cette forme, et l'exécuter en appelant la méthode.
L'exemple le plus ancien est l'interface [java.lang.Runnable], qui existe depuis Java 1.0 et qui représente une tâche à exécuter (nous la retrouverons avec les threads au chapitre 8). Elle se réduit à une méthode : [void run();]. Elle décrit une fonction :
- sans paramètre ;
- ne rendant aucun résultat ([void]).
Il existe trois façons de créer un objet de type [Runnable] :
- écrire une classe qui implémente l'interface, nommée ou anonyme. C'était la seule façon de faire avant Java 8 ;
- écrire une expression lambda : [Runnable tache = () -> IO.println("tâche");] ;
- écrire une référence de méthode vers une méthode existante qui a la bonne forme : [Runnable tache = Fonctions::afficher;].
Dans les deux derniers cas, le compilateur crée lui-même l'objet qui implémente l'interface : la lambda ou la méthode référencée devient le corps de la méthode [run]. On exécute ensuite la fonction en appelant la méthode de l'interface : [tache.run()]. Contrairement à d'autres langages, Java n'a pas de syntaxe d'appel particulière : on appelle toujours la méthode de l'interface par son nom ([run], [apply], [test]…).
7.1.2. Les interfaces du paquetage java.util.function
Avant Java 8, chaque bibliothèque 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 couvrant la quasi-totalité des besoins. Les principales sont les suivantes :
Interface | Méthode abstraite | Fonctions représentées |
[Runnable] ([java.lang]) | [void run()] | aucun paramètre, aucun résultat |
[Supplier<T>] | [T get()] | aucun paramètre, un résultat : un « fournisseur » |
[Consumer<T>] | [void accept(T t)] | un paramètre, aucun résultat : un « consommateur » |
[BiConsumer<T, U>] | [void accept(T t, U u)] | deux paramètres, aucun résultat |
[Function<T, R>] | [R apply(T t)] | un paramètre, un résultat de type [R] (toujours le dernier type) |
[BiFunction<T, U, R>] | [R apply(T t, U u)] | deux paramètres, un résultat |
[UnaryOperator<T>] | [T apply(T t)] | une [Function<T, T>] : paramètre et résultat de même type |
[BinaryOperator<T>] | [T apply(T t1, T t2)] | une [BiFunction<T, T, T>] |
[Predicate<T>] | [boolean test(T t)] | un paramètre, un résultat booléen : un « prédicat » |
[BiPredicate<T, U>] | [boolean test(T t, U u)] | deux paramètres, un résultat booléen |
[Comparator<T>] ([java.util]) | [int compare(T o1, T o2)] | deux paramètres de même type, un résultat entier |
[Callable<V>] ([java.util.concurrent]) | [V call() throws Exception] | comme [Supplier<V>], mais peut lancer une exception contrôlée |
Les génériques Java ne s'appliquent qu'aux types objets : une [Function<Integer, Integer>] reçoit et rend des [Integer], ce qui impose des conversions automatiques (boxing et unboxing) entre [int] et [Integer]. Pour les éviter, le paquetage propose des versions spécialisées pour les types primitifs [int], [long] et [double] : [IntBinaryOperator] ([int applyAsInt(int left, int right)]), [IntUnaryOperator], [IntPredicate], [IntSupplier], [IntConsumer], [IntFunction<R>] (un [int] en paramètre), [ToIntFunction<T>] (un [int] en résultat), et leurs équivalents [Long…] et [Double…]. Il n'existe pas d'interface prédéfinie à trois paramètres ou plus : il faut alors définir la sienne (paragraphe suivant).
Le programme [Lambdas01.java] utilise ces interfaces avec des références de méthodes :
Résultats :
Commentaires :
- lignes 5-10 : création d'un objet [Runnable] avec une classe anonyme, c'est-à-dire une classe sans nom, déclarée et instanciée à l'endroit même où l'on en a besoin. Elle implémente la méthode [run], qui appelle la méthode [afficher] de la ligne 52. C'était l'écriture obligatoire avant Java 8 ;
- ligne 12 : la même chose avec une référence de méthode : [Fonctions::afficher] désigne la méthode statique [afficher] de la classe [Fonctions]. Le compilateur vérifie qu'elle a la forme de la méthode [run] (aucun paramètre, aucun résultat) et crée l'objet [Runnable] qui l'appelle. C'est l'écriture usuelle aujourd'hui ;
- ligne 14 : la fonction est exécutée en appelant la méthode de l'interface. C'est la méthode [afficher] de la ligne 52 qui s'exécute (ligne 2 des résultats) ;
- lignes 48-50 : les méthodes référencées sont regroupées dans une classe [Fonctions]. On aurait aimé les écrire directement dans le fichier, à côté de [main], et les désigner par [Lambdas01::ajouter]. Mais la classe implicite qui enveloppe un fichier source compact ne peut pas être nommée dans le code (le compilateur répond [cannot find symbol]) ;
- ligne 17 : [BiFunction<Integer, Integer, Integer>] désigne une fonction ayant deux paramètres [Integer] et rendant un [Integer] (le dernier type générique). La méthode [ajouter] de la ligne 56, qui travaille avec des [int], convient : le compilateur ajoute les conversions entre [int] et [Integer]. On exécute la fonction par sa méthode [apply] (ligne 18) ;
- ligne 20 : [Supplier<String>] désigne une fonction sans paramètre rendant une chaîne. On l'exécute par sa méthode [get] ;
- ligne 23 : la version primitive [IntBinaryOperator] travaille directement sur des [int], sans conversion. Sa méthode s'appelle [applyAsInt]. C'est le type à préférer dans les calculs intensifs ;
- ligne 27 : [Consumer<String>] désigne une fonction ayant un paramètre [String] et ne rendant rien. La méthode statique [IO.println(Object)] convient : une [String] est un [Object]. On l'exécute par sa méthode [accept] ;
- ligne 31 : un prédicat est une fonction qui rend un booléen ; on l'exécute par sa méthode [test] (ligne 32). La méthode [removeIf] de l'interface [Collection] (ligne 35) attend un paramètre de type [Predicate] : elle retire de la collection tous les éléments pour lesquels le prédicat rend [true], et rend [true] si elle a retiré au moins un élément ;
- ligne 40 : la méthode [sort] de l'interface [List] accepte un [Comparator] qui compare deux éléments (résultat négatif, nul ou positif, comme [compareTo]). On trie ici les noms par longueur croissante. La méthode [comparerLongueurs] (ligne 72) utilise [Integer.compare], qui compare deux [int] sans risque de dépassement de capacité (ce qu'une simple soustraction ne garantit pas) ;
- lignes 44-45 : une référence de méthode, comme une lambda, est un objet. Sa classe, affichée ligne 10 des résultats, est une classe générée à l'exécution par la machine virtuelle, dont le nom contient [$$Lambda] suivi d'une adresse qui change d'une exécution à l'autre. Ligne 11 des résultats, on constate que cette classe implémente l'interface [BiFunction]. Une lambda 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 interfaces fonctionnelles
Les interfaces prédéfinies suffisent souvent, mais on peut définir les siennes. Il suffit d'écrire une interface ayant une seule méthode abstraite, par exemple :
- [interface Operation { int calculer(int n1, int n2); }]
Cette interface représente une fonction acceptant deux entiers et rendant un entier. Par rapport à [IntBinaryOperator], le type [Operation] a un nom qui dit à quoi il sert, sa méthode a un nom parlant ([calculer] plutôt que [applyAsInt]), et ses paramètres ont des noms ([n1], [n2]) que les outils de développement affichent. On la fait précéder de l'annotation [@FunctionalInterface] : elle n'est pas obligatoire, mais elle demande au compilateur de vérifier que l'interface est bien fonctionnelle, et elle documente l'intention du développeur.
Une variable [op] de type [Operation] ne référence qu'une seule fonction à la fois. Contrairement à d'autres langages, qui offrent des « délégués multicast », Java n'offre pas de 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.java] :
Résultats :
Commentaires :
- lignes 47-55 : définition de l'interface fonctionnelle [Operation]. Dans un fichier source compact, les déclarations de types (classes, interfaces, records…) peuvent se placer avant ou après la méthode [main] ; nous les plaçons après ;
- lignes 52-54 : une interface fonctionnelle peut avoir des méthodes [default]. 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, que nous étudierons au paragraphe suivant. C'est sur ce modèle que sont écrites les méthodes de composition des interfaces prédéfinies ([andThen], [compose]…), que nous verrons plus loin ;
- lignes 64-81 : la classe [Calculs] a deux méthodes d'instance, [ajouter] et [soustraire], et une méthode statique, [augmenter], qui ont toutes trois la forme de la méthode [calculer] ;
- ligne 5 : la variable [op] est de type [Operation]. On lui affecte une référence à la méthode statique [Calculs.augmenter] ;
- ligne 7 : l'opération est exécutée par la méthode [calculer]. C'est la méthode [Calculs.augmenter] qui s'exécute, avec les paramètres passés à [calculer] ;
- ligne 11 : on crée une instance [c1] de la classe [Calculs] ;
- ligne 13 : la référence [c1::ajouter] désigne la méthode [ajouter] de l'objet [c1]. [augmenter] était une méthode statique, [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 17 : on fait de même avec la méthode d'instance [soustraire] ;
- lignes 22-28 : pour exécuter plusieurs opérations, on les range dans une liste [List<Operation>], que l'on parcourt. On peut mélanger références de méthodes et lambdas (ligne 25). Chaque résultat est récupéré (lignes 8-12 des résultats) ;
- ligne 31 : on applique la méthode [default] [puis] à l'opération [c1::ajouter]. La conversion [(Operation)] est nécessaire : une référence de méthode n'a pas de type par elle-même, et l'on ne peut pas appeler une méthode sur elle tant que le compilateur ne sait pas quelle interface elle implémente (nous y reviendrons). [sommeAuCarre.calculer(1, 2)] calcule (1+2)², soit 9 (lignes 13-14 des résultats) ;
- ligne 35 : [Operation] et [IntBinaryOperator] ont exactement la même forme, mais ce sont deux types différents, sans lien d'héritage. L'affectation [IntBinaryOperator autre = op;] serait refusée ([incompatible types: Operation cannot be converted to IntBinaryOperator]). En revanche, la référence de méthode [op::calculer] a la bonne forme : elle crée un [IntBinaryOperator] qui appelle la méthode [calculer] de l'objet [op] ([c1::soustraire] à ce stade du programme) ;
- lignes 57-61 : 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 [Function<T, R>], avec des noms choisis par nous ;
- ligne 39 : [String::length] convient à une [Transformation<String, Integer>] : la méthode [length] sera appelée sur la chaîne passée en paramètre. Ligne 40, ["*"::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 de méthodes, que nous détaillerons au paragraphe suivant.
L'annotation [@FunctionalInterface] joue son rôle si l'on ajoute par erreur une seconde méthode abstraite, par exemple [int inverser(int n);], à l'interface [Operation]. Le compilateur signale alors l'annotation elle-même et chacune des lambdas et références de méthodes qui utilisent l'interface (résultat abrégé) :
Les messages du compilateur [javac] sont en anglais : le JDK ne fournit pas de traduction française.
7.3. Classes anonymes, expressions lambda et références de méthodes
7.3.1. Des classes anonymes aux lambdas
Dans les exemples précédents, chaque fonction est une méthode nommée, écrite ailleurs dans le programme. Pour des fonctions courtes, utilisées une seule fois, c'est lourd. Jusqu'à Java 7, la seule alternative était la classe anonyme : la classe qui implémente l'interface est écrite à l'endroit même où l'on crée l'objet. Java 8 a introduit une écriture beaucoup plus concise, les expressions lambda, qui ont pratiquement remplacé les classes anonymes pour les interfaces fonctionnelles. Le programme [Lambdas03.java] présente les différentes formes :
Résultats :
Commentaires :
- lignes 5-10 : une classe anonyme. L'opérateur [new] est suivi du nom de l'interface, de parenthèses vides et du corps d'une classe qui implémente l'interface. Le compilateur génère une classe (avec un nom qu'il choisit, [Lambdas03$1]) et en crée une instance. Six lignes pour exprimer [n1 + n2] ;
- lignes 14-16 : la même chose avec une expression lambda. On écrit la liste des paramètres, l'opérateur [->] (une flèche, qui se lit « donne »), puis le corps. Le nom de l'interface et celui de la méthode ont disparu : le compilateur les connaît par le type de la variable, qu'on appelle le type cible de la lambda. Une lambda dont le corps est un bloc entre accolades est une lambda à corps de bloc ;
- ligne 20 : les types des paramètres ont disparu. Le compilateur les déduit du type cible [IntBinaryOperator] : [n1] et [n2] sont de type [int]. Le corps est réduit à une expression, dont la valeur est le résultat de la lambda : c'est une lambda à corps d'expression. C'est la forme la plus courante ;
- ligne 24 : avec un seul paramètre dont le type n'est pas précisé, les parenthèses sont facultatives. [x -> x * x] se lit « x donne x fois x ». Ligne 25, une lambda d'un paramètre de type [String] rend un booléen : c'est un [Predicate<String>] ;
- lignes 29-30 : sans paramètre, on écrit des parenthèses vides. Une lambda dont le corps est un appel de méthode [void] convient à un [Runnable] ;
- lignes 35-40 : une lambda à corps de bloc peut contenir n'importe quelles instructions : déclarations, boucles, tests… ;
- ligne 44 : le caractère de soulignement [_] désigne un paramètre anonyme (Java 22). On l'utilise quand la forme de l'interface 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 ;
- lignes 49-51 : l'usage le plus fréquent des lambdas consiste à les passer directement en paramètre d'une méthode qui attend une interface fonctionnelle. Comparez avec [Lambdas01.java] : il n'est plus nécessaire d'écrire une méthode [estPair]. On travaille ici sur une copie de la liste, car [removeIf] modifie la liste sur laquelle elle opère ;
- ligne 52 : une lambda de type [Comparator<Integer>] qui inverse la comparaison usuelle : la liste est triée par ordre décroissant.
Retenons les formes d'une expression lambda :
Forme | Exemple |
paramètres typés, corps de bloc | [(int a, int b) -> { return a + b; }] |
paramètres non typés, corps d'expression | [(a, b) -> a + b] |
un seul paramètre non typé | [x -> x * x] |
aucun paramètre | [() -> "Bonjour"] |
paramètres anonymes | [(_, _) -> IO.println("reçu")] |
paramètres déclarés avec [var] | [(var a, var b) -> a + b] |
Les paramètres sont tous typés ou tous non typés : on ne peut pas mélanger les deux. Dans un corps de bloc, une lambda qui rend un résultat doit avoir une instruction [return] sur chaque chemin d'exécution, comme une méthode.
Une lambda n'est pas tout à fait une classe anonyme abrégée. Dans une classe anonyme, [this] désigne l'instance de la classe anonyme ; dans une lambda, [this] désigne l'objet de la méthode qui entoure la lambda. De plus, une classe anonyme crée une nouvelle portée : elle peut déclarer une variable qui porte le même nom qu'une variable de la méthode, ce qu'une lambda ne peut pas faire. Enfin, une classe anonyme peut implémenter une interface à plusieurs méthodes, ou hériter d'une classe abstraite, 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 : type cible et références de méthodes
Le programme [Lambdas04.java] précise la notion de type cible et présente les quatre sortes de références de méthodes :
Résultats :
Commentaires :
- ligne 5 : une lambda n'a pas de type par elle-même. Son type est imposé par le contexte : le type de la variable qui la reçoit, le type du paramètre auquel on la passe, ou le type indiqué par une conversion. La déclaration [var doubler = x -> x * 2;] est donc refusée par le compilateur : [cannot infer type for local variable doubler (lambda expression needs an explicit target-type)]. Il ne saurait pas quelle interface implémenter ;
- ligne 6 : avec une conversion vers [IntUnaryOperator], le type cible est connu et [var] devient possible. En pratique, on écrit plutôt [IntUnaryOperator doubler = x -> x * 2;] ;
- lignes 9-10 : le même texte de lambda, [() -> "Bonjour"], implémente ici un [Supplier<String>] et là un [Callable<String>]. Ce sont deux objets de classes différentes, qui n'ont en commun que la forme de leur méthode ;
- ligne 14 : depuis Java 11, les paramètres d'une lambda peuvent être déclarés avec [var], ce qui permet de leur ajouter un modificateur ([final]) ou une annotation, impossible sur un paramètre non typé ;
- lignes 17-37 : les quatre sortes de références de méthodes :
- ligne 19 : la référence à une méthode statique, [Classe::methode]. [Integer::parseInt] est équivalente à la lambda [s -> Integer.parseInt(s)] ;
- ligne 23 : la référence à une méthode d'instance d'un objet donné, [objet::methode]. [nom::toUpperCase] est équivalente à [() -> nom.toUpperCase()] : l'objet [nom] est fixé une fois pour toutes ;
- lignes 27-28 : la référence à une méthode d'instance d'un objet quelconque d'une classe, [Classe::methodeInstance]. 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::toUpperCase] est équivalente à [s -> s.toUpperCase()] (un paramètre), et [String::compareToIgnoreCase] à [(s1, s2) -> s1.compareToIgnoreCase(s2)] (deux paramètres), d'où un [Comparator<String>] ;
- lignes 32-34 : la référence à un constructeur, [Classe::new]. [ArrayList::new] est équivalente à [() -> new ArrayList<>()] ; [StringBuilder::new], avec un type cible [Function<String, StringBuilder>], choisit le constructeur qui reçoit une chaîne ; [int[]::new] crée un tableau de la taille reçue en paramètre ;
- ligne 37 : on utilise les trois fonctions de création ; [reverse] inverse le contenu du [StringBuilder] (ligne 8 des résultats) ;
- lignes 40-47 : une différence importante entre [Callable] et [Supplier]. La méthode [call] est déclarée [throws Exception] : une lambda de type [Callable] peut donc lancer une exception contrôlée (cf. chapitre 3), ici une [IOException]. La méthode [get] de [Supplier] ne déclare aucune exception : la même lambda de type [Supplier<Integer>] serait refusée ([unreported exception IOException; must be caught or declared to be thrown]). Aucune interface de [java.util.function] n'autorise les exceptions contrôlées : une lambda qui en provoque une doit l'intercepter elle-même, ou utiliser une interface dont la méthode la déclare. C'est pourquoi [main] est déclarée [throws Exception] (ligne 3) : les appels [appelable.call()] (ligne 11) et [lire.call()] (ligne 44) peuvent en théorie lancer une [Exception] quelconque, que le [catch] de la ligne 45, limité à [IOException], n'intercepte pas ;
- lignes 50-52 : deux évaluations de la même référence de méthode [nom::toUpperCase] produisent deux objets différents, que ni [==] ni [equals] ne considèrent comme égaux (ligne 10 des résultats). Retenons-le : il aura son importance pour désinscrire un écouteur d'événement.
Une référence de méthode n'est qu'une écriture abrégée d'une lambda qui se contente d'appeler une méthode. On l'utilise quand elle est plus lisible : [String::length] plutôt que [s -> s.length()].
7.3.3. Composer des fonctions
Les interfaces fonctionnelles prédéfinies ont des méthodes [default] et [static] qui fabriquent de nouvelles fonctions à partir de fonctions existantes, comme la méthode [puis] de notre interface [Operation]. Le programme [Lambdas05.java] présente les principales :
Résultats :
Commentaires :
- ligne 7 : [f.andThen(g)] rend la fonction qui applique d'abord [f], puis [g] au résultat : (5 + 1) × 2 = 12 ;
- ligne 8 : [f.compose(g)] applique d'abord [g], puis [f] : 5 × 2 + 1 = 11 ;
- lignes 10-15 : une chaîne de traitements construite dynamiquement. On part de la fonction identité ([Function.identity()], qui rend son paramètre inchangé) et on lui ajoute, par [andThen], chacune des étapes de la liste. La fonction [normaliser] obtenue supprime les espaces de début et de fin, passe le texte en minuscules et remplace les espaces par des tirets. Chaque appel de [andThen] 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 19-25 : les prédicats se combinent avec [and] (et), [or] (ou) et [negate] (non). La méthode statique [Predicate.not] (Java 11) rend la négation d'un prédicat : elle permet d'écrire [Predicate.not(String::isEmpty)], alors que [String::isEmpty.negate()] est refusé (une référence de méthode n'a pas de type tant qu'elle n'a pas de type cible) ;
- lignes 28-34 : [c1.andThen(c2)] rend un [Consumer] qui exécute [c1] puis [c2] sur la même valeur. C'est la façon la plus simple, en Java, de faire exécuter plusieurs traitements par un seul appel. Ligne 30, [journal::add] est une référence à la méthode [add] de l'objet [journal] ;
- ligne 39 : [Comparator.comparing(f)] construit un comparateur qui compare les valeurs de [f] (ici l'âge) ; [thenComparing] indique le critère à utiliser en cas d'égalité (ici le nom). Le record [Personne] (lignes 60-65) fournit les accesseurs [age()] et [nom()] ; nous avons redéfini sa méthode [toString] pour abréger les affichages ;
- lignes 41-42 : [comparingInt] évite la conversion de l'âge en [Integer], [reversed] inverse l'ordre obtenu jusque-là, et la seconde forme de [thenComparing] reçoit, en plus du critère, le comparateur à utiliser pour ce critère : [Comparator.reverseOrder()], l'ordre naturel inversé ;
- lignes 45-46 : [BinaryOperator.minBy] transforme un comparateur en une fonction qui rend le plus petit de ses deux paramètres ;
- lignes 50-58 : la méthode [filtrer] utilisée pour ces essais. Son paramètre est de type [Predicate<? super T>] plutôt que [Predicate<T>] : elle accepte ainsi, pour filtrer une liste de [String], un [Predicate<String>] mais aussi un [Predicate<Object>] ou un [Predicate<CharSequence>]. C'est la règle suivie par toute la bibliothèque Java : un paramètre fonctionnel qui consomme des [T] est déclaré avec [? super T], un paramètre qui en produit avec [? extends T].
Ces méthodes permettent d'écrire des traitements complexes en assemblant des fonctions simples, sans écrire une seule classe. Nous les retrouverons avec l'API Stream.
7.3.4. Capture de variables et fermetures
Une lambda peut utiliser les variables locales de la méthode dans laquelle elle est écrite. On dit qu'elle capture ces variables. Java impose sur ce point une règle importante, qui distingue le langage de beaucoup d'autres (C#, JavaScript, Python) : une variable locale capturée doit être effectivement finale (« effectively final »), c'est-à-dire qu'elle n'est jamais modifiée après son initialisation, ni dans la méthode ni dans la lambda. Le programme [Lambdas06.java] montre ce que cette règle permet et interdit :
Résultats :
Commentaires :
- ligne 9 : la lambda utilise la variable locale [seuil] déclarée ligne 8. [seuil] n'est pas un paramètre de la lambda : c'est une variable capturée. La variable n'est jamais modifiée : elle est effectivement finale, sans qu'il soit nécessaire de la déclarer [final] (c'était obligatoire jusqu'à Java 7, pour les classes anonymes) ;
- ligne 11 : si l'on ajoutait [seuil = 10;] après la création de la lambda, le compilateur refuserait la lambda de la ligne 9 : [local variables referenced from a lambda expression must be final or effectively final]. Pour changer de seuil, on crée une autre variable et une autre lambda (lignes 12-13). En contrepartie, la question « la lambda voit-elle la valeur de la variable au moment de sa création, ou sa valeur au moment de son exécution ? » ne se pose pas en Java : les deux sont identiques. Concrètement, la lambda reçoit une copie de la valeur de la variable lorsqu'elle est créée ;
- ligne 17 : pour la même raison, une lambda ne peut pas modifier une variable locale capturée : [n -> total += n] est refusé, avec le même message ;
- lignes 19-23 : la variable [total] capturée ne change pas : elle référence toujours le même objet [AtomicInteger]. Mais rien n'empêche la lambda de modifier l'état de cet objet. La classe [AtomicInteger] du paquetage [java.util.concurrent.atomic] encapsule un entier modifiable ([addAndGet], [incrementAndGet], [get]…), utilisable sans risque par plusieurs threads (chapitre 8). On rencontre aussi l'astuce d'un tableau d'un élément ([int[] total = {0};] puis [total[0] += n]), qui n'a pas cette dernière qualité ;
- lignes 4 et 25-28 : la règle ne concerne que les variables locales. Une lambda peut lire et modifier les champs de l'objet courant : elle capture alors la référence [this], qui, elle, ne change pas. Le champ [nbAppels] est un champ de la classe implicite du fichier source compact ; la méthode [main] étant une méthode d'instance, la lambda peut l'incrémenter ;
- lignes 58-62 : la méthode [creerCompteur] rend une lambda qui capture sa variable locale [valeur]. Normalement, une variable locale disparaît quand la méthode se termine. Ici, la lambda rendue a conservé une copie de la référence [valeur], et l'objet [AtomicInteger] référencé survit à [creerCompteur] tant que la lambda existe. L'ensemble formé par la lambda et les valeurs qu'elle capture s'appelle une fermeture (« closure »). Pour la réaliser, la machine virtuelle range les valeurs capturées dans des champs de l'objet qu'elle crée pour la lambda ;
- lignes 31-34 : chaque appel de [creerCompteur] crée un nouvel objet [AtomicInteger], donc un nouveau compteur, indépendant des autres. Les résultats (lignes 6-7) le confirment ;
- lignes 38-42 : dans une boucle [for] classique, la variable [i] est modifiée à chaque tour ([i++]) : elle n'est pas effectivement finale, et la lambda de la ligne 39 serait refusée. Dans d'autres langages, cette capture est permise et réserve une mauvaise surprise : les trois lambdas partagent la même variable [i] et affichent toutes 3, sa valeur à la fin de la boucle. Java élimine ce piège à la compilation. La solution consiste à déclarer dans le corps de la boucle une variable [copie], nouvelle à chaque tour et jamais modifiée (ligne 40) : chaque lambda capture la sienne ;
- lignes 49-51 : avec la boucle [for] « pour chaque élément », le problème ne se pose pas : la variable [j] est une nouvelle variable à chaque tour, jamais modifiée, donc effectivement finale ;
- lignes 44 et 53 : la méthode [forEach] des collections attend un [Consumer] et l'exécute sur chaque élément. Ici chaque élément est lui-même un [Runnable], et la référence [Runnable::run] (un objet quelconque de type [Runnable], sur lequel on appelle [run]) l'exécute.
La règle des variables effectivement finales peut sembler contraignante. Elle a pourtant une justification solide : une lambda peut être exécutée plus tard, ou dans un autre thread (chapitre 8). Si elle pouvait modifier les variables locales d'une méthode peut-être déjà terminée, ou les lire pendant qu'un autre thread les modifie, on aurait des comportements difficiles à prévoir. En imposant des variables qui ne changent pas, Java rend ces situations impossibles ; quand un état modifiable partagé est vraiment nécessaire, on le rend explicite avec un objet ([AtomicInteger], collection…) dont on choisit le comportement vis-à-vis des threads.
7.4. 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. Prenons l'exemple [Lambdas07.java], proche de [Lambdas02.java] :
Résultats :
Ligne 15, la méthode [executer] attend un paramètre du type de l'interface fonctionnelle [Operation] définie ligne 34. Cela permet de lui passer différentes fonctions : des méthodes statiques de notre classe [Methodes] (lignes 5 et 7), une méthode de la bibliothèque Java ayant la bonne forme (ligne 9 : parmi les surcharges de [Math.max], le compilateur choisit [int max(int a, int b)]), et même une lambda (ligne 11) : il n'a pas été nécessaire d'écrire une méthode de multiplication. Ce polymorphisme est celui des interfaces, étudié au chapitre 4 : le programme [Lambdas08.java] le montre en implémentant la même interface avec des classes :
Résultats :
Commentaires :
- lignes 31-33 : l'interface [Operation] définit une méthode [calculer]. Elle n'est pas annotée [@FunctionalInterface] : l'annotation n'est qu'une vérification, et toute interface ayant une seule méthode abstraite est fonctionnelle ;
- lignes 41-47 et 50-56 : les classes [Ajouter] et [Soustraire] implémentent l'interface [Operation] ;
- lignes 26-28 : la méthode [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 5 et 7), puis une lambda (ligne 9). Pour [executer], ces trois objets sont de même nature : des objets qui implémentent [Operation] ;
- lignes 36-38 : l'interface [OperationNommee] hérite de la méthode [calculer] et ajoute une méthode [nom]. Avec deux méthodes abstraites, elle n'est plus fonctionnelle : on ne peut plus l'implémenter par une lambda, il faut une classe, ici une classe anonyme (lignes 11-21).
Ces deux exemples montrent qu'en Java, on n'a pas à choisir entre deux mécanismes : une méthode qui attend une interface à une seule méthode accepte à la fois des objets de classes écrites pour l'occasion, des références de méthodes et des lambdas. La vraie question est donc : faut-il que l'interface soit fonctionnelle, avec une seule méthode, ou qu'elle en ait plusieurs ?
- une interface à une seule méthode, prédéfinie ([Function], [Predicate]…) ou non, se montre plus souple d'utilisation : toute méthode 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 (l'API Stream, la programmation asynchrone du chapitre 9, Spring) utilisent massivement les interfaces fonctionnelles ;
- une interface à plusieurs méthodes 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'une méthode, [List<TrancheImpot> getTranchesImpot()], 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 [Supplier<List<TrancheImpot>>] aurait techniquement suffi, mais il aurait été beaucoup moins parlant. On réserve donc les interfaces fonctionnelles 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 méthodes.
7.5. Gestion d'événements
7.5.1. Le modèle des écouteurs
Les interfaces fonctionnelles servent à mettre en place des événements. Java n'a pas de mot clé dédié aux événements : il utilise un ensemble de conventions, appelé le modèle des écouteurs (« listener model »), établi dès Java 1.1 pour les composants graphiques et les JavaBeans. Une classe [C1] qui veut signaler un événement [Xxx] procède ainsi :
- une classe [XxxEvent], dérivée de [java.util.EventObject], encapsule les informations sur l'événement. [EventObject] mémorise l'objet à l'origine de l'événement, qu'on appelle la source et qu'on obtient par [getSource()] ;
- une interface [XxxListener], qui étend l'interface vide [java.util.EventListener] (une simple marque), définit la ou les méthodes qui seront appelées quand l'événement se produit. Avec une seule méthode, c'est une interface fonctionnelle : un écouteur pourra être une lambda ;
- la classe [C1] gère une liste d'écouteurs et offre deux méthodes publiques pour s'y inscrire et s'en désinscrire : [addXxxListener(XxxListener l)] et [removeXxxListener(XxxListener l)] ;
- lorsqu'une instance [c1] de [C1] veut signaler l'événement, elle crée un objet [XxxEvent] et appelle la méthode de chacun des écouteurs inscrits en lui passant cet objet. Par convention, cette notification est confiée à une méthode protégée [fireXxx(XxxEvent e)] (« 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 ;
- une interface d'écoute qui définit la forme des méthodes 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 un champ privé 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.5.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 implémenteront l'interface [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].
Toutes ces classes sont regroupées dans le fichier source compact [Lambdas09.java], que nous présentons en trois parties. Voici d'abord la classe de l'événement, l'interface d'écoute et la classe [Emetteur] :
Commentaires :
- lignes 1-28 : la classe [TemperatureTropHauteEvent] dérive de [EventObject], comme le veut la convention. Son constructeur (lignes 10-13) transmet la source de l'événement au constructeur de [EventObject] ([super(source)]) et mémorise la température qui a provoqué l'événement. Nous avons ajouté l'heure de l'événement, [heure] (ligne 8). Les champs sont [final] et la classe n'a que des accesseurs en lecture : un écouteur ne doit pas pouvoir modifier les informations de l'événement pour les écouteurs suivants ;
- lignes 3-4 : [EventObject] implémente l'interface [Serializable] (pour que les événements puissent être enregistrés ou transmis sur un réseau). Le compilateur, avec l'option [-Xlint:all], demande alors à chaque classe dérivée de déclarer un numéro de version [serialVersionUID]. L'annotation [@Serial] (Java 14) signale ce champ particulier au compilateur ;
- lignes 16-19 : la méthode [getSource] d'[EventObject] rend un [Object]. On la redéfinit avec un type de résultat plus précis, [Emetteur], ce que Java autorise (type de retour covariant) : les écouteurs n'auront pas à transtyper la source. On ne peut pas utiliser ici un [record] : un record ne peut hériter d'aucune classe, et donc pas d'[EventObject] ;
- lignes 30-34 : l'interface d'écoute. Elle étend [EventListener] et déclare une seule méthode, [temperatureTropHaute], qui reçoit l'objet événement : c'est une interface fonctionnelle ;
- ligne 39 : le seuil de température au-delà duquel l'événement [TemperatureTropHaute] est signalé ;
- ligne 41 : l'émetteur a un nom pour être identifié ;
- ligne 45 : la liste des écouteurs inscrits. 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 59-65 : la méthode [setTemperature] enregistre la nouvelle température et signale l'événement si elle dépasse le seuil ;
- ligne 63 : 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 ;
- lignes 68-70 : l'inscription d'un écouteur ajoute celui-ci à la liste. [Objects.requireNonNull] refuse immédiatement un écouteur [null], qui provoquerait sinon une [NullPointerException] bien plus tard, lors de la notification ;
- lignes 73-75 : la désinscription retire l'écouteur de la liste. La méthode [remove] recherche l'objet à retirer avec [equals] : il faut donc lui passer le même objet que celui qui a été inscrit ;
- lignes 78-82 : la notification appelle la méthode [temperatureTropHaute] de chaque écouteur inscrit, dans l'ordre de leur inscription. S'il n'y a aucun écouteur, la boucle ne fait rien : il n'y a aucun cas particulier à traiter.
Voici la classe [Souscripteur] :
Commentaires :
- lignes 4-6 : chaque souscripteur est identifié par un nom et reçoit le nombre maximal d'alertes qu'il accepte ([nbAlertesMax]) ;
- ligne 10 : l'écouteur du souscripteur est une référence à sa méthode privée [evtTemperatureTropHaute]. Nous avons vu avec [Lambdas04.java] que deux évaluations de [this::evtTemperatureTropHaute] produisent deux objets différents. Si l'on écrivait [emetteur.addTemperatureTropHauteListener(this::evtTemperatureTropHaute)] dans [abonner] puis [emetteur.removeTemperatureTropHauteListener(this::evtTemperatureTropHaute)] dans [desabonner], la désinscription ne retirerait rien, sans aucun message d'erreur ! On crée donc l'écouteur une seule fois et on le conserve dans un champ ;
- lignes 18-20 : l'inscription auprès d'un émetteur. Elle est à la charge du souscripteur, qui connaît l'objet à retirer pour se désinscrire ;
- lignes 23-25 : la désinscription retire le même objet [ecouteur]. Chaque souscripteur a son propre objet [ecouteur] : [remove] ne retire que celui du souscripteur concerné ;
- ligne 28 : le gestionnaire de l'événement. Il a la forme imposée par l'interface [TemperatureTropHauteListener]. Il est privé : seul le souscripteur peut l'inscrire auprès d'un émetteur. Une autre façon de faire, très répandue, consiste à faire implémenter l'interface d'écoute par la classe [Souscripteur] elle-même ([class Souscripteur implements TemperatureTropHauteListener]) et à s'inscrire avec [this]. Elle a l'inconvénient de rendre publique la méthode [temperatureTropHaute], que n'importe qui pourrait alors appeler ;
- ligne 29 : grâce à la redéfinition de [getSource], la source de l'événement est directement de type [Emetteur] ;
- lignes 30-31 : 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 ;
- lignes 33-37 : 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 :
Résultats :
Commentaires :
- ligne 5 : création de l'émetteur ;
- lignes 7-10 : création de deux souscripteurs, [s0] (3 alertes au plus) et [s1] (1 alerte), que l'on inscrit auprès de l'émetteur ;
- lignes 12-13 : un troisième écouteur, une lambda, 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 la passer à [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 16-31 : boucle de saisie des températures au clavier. [IO.readln(invite)] affiche l'invite et lit une ligne ; elle rend [null] si l'entrée standard est fermée, cas que l'on traite comme une saisie vide (ligne 19) ;
- ligne 24 : [Double.parseDouble] 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, [parseDouble] lance une [NumberFormatException], interceptée ligne 27 ;
- ligne 26 : si la température saisie est correcte, elle est transmise à l'émetteur [e1], qui signale l'événement [TemperatureTropHaute] si elle est supérieure à 19 °C.
Les résultats montrent (l'entrée standard avait été alimentée par un fichier, ce qui explique que toutes les heures soient identiques) :
- ligne 2 : 17 °C ne dépasse pas le seuil : aucun événement ;
- lignes 3-7 : 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 10-12 : [s1] n'est plus averti ;
- lignes 13-16 : [s0] reçoit sa troisième alerte et se désinscrit à son tour ;
- ligne 18 : il ne reste plus que le journal.
Que se passerait-il avec une simple [ArrayList] à la ligne 45 du premier extrait, 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.5.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. La bibliothèque Java fournit pour ce cas une classe prête à l'emploi, [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 programme [Lambdas10.java] l'utilise :
Résultats :
Commentaires :
- lignes 3-4 : le paquetage [java.beans] appartient au module [java.desktop], et non au module [java.base] : il faut l'importer explicitement, même dans un fichier source compact ;
- ligne 33 : 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 62-72 se contentent de lui transmettre les inscriptions et désinscriptions ;
- lignes 45-49 et 55-59 : chaque modificateur mémorise l'ancienne valeur, enregistre la nouvelle et appelle [firePropertyChange] avec le nom de la propriété et les deux valeurs. [firePropertyChange] n'avertit les écouteurs que si les deux valeurs sont différentes : la ligne 22 ne produit aucun affichage ;
- lignes 9-11 : un premier écouteur, une lambda rangée dans une variable pour pouvoir être désinscrite (ligne 25). Il est inscrit pour toutes les propriétés de l'objet. [e.getSource()] rend un [Object], qu'il faut transtyper. Les valeurs sont des objets : les [double] ont été convertis en [Double], dont la méthode [toString] utilise le point décimal ;
- lignes 13-17 : un second écouteur, inscrit pour la seule propriété [temperature]. Il émet une alerte au-delà de 19 °C ;
- lignes 19-26 : les modifications de propriétés. Le changement de nom (ligne 23) n'est signalé qu'au journal. Ligne 5 des résultats, le journal affiche le nouveau nom de la source, [séjour] : l'événement est signalé après la modification ;
- lignes 26-27 : après la désinscription du journal, seule l'alerte est affichée, et il ne reste qu'un écouteur.
Sans écrire de classe événement ni d'interface d'écoute, on obtient ainsi un mécanisme complet. C'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.
7.6. Les lambdas et l'API Stream
Au chapitre 5, nous avons utilisé les méthodes de l'API Stream ([filter], [map], [sorted]…) en leur passant des lambdas. Nous savons maintenant ce que sont ces paramètres : la méthode [filter] est déclarée ainsi dans l'interface [java.util.stream.Stream<T>] : [Stream<T> filter(Predicate<? super T> predicate)].
Son paramètre est un [Predicate] : toute méthode, ou toute lambda, qui reçoit un élément et rend un booléen convient. De même, [map] attend une [Function<? super T, ? extends R>], [sorted] un [Comparator], [forEach] un [Consumer], [reduce] un [BinaryOperator]… Pour bien comprendre le fonctionnement de l'API, écrivons nous-mêmes des opérations de filtrage et de transformation. C'est le programme [Lambdas11.java] :
Résultats :
Commentaires :
- lignes 7-9 : la lambda de [filter] est d'abord rangée dans une variable de type [Predicate<Integer>], pour faire apparaître son type. [map] reçoit, lui, une [Function<Integer, Integer>] qui transforme chaque élément : on obtient les carrés des nombres pairs ;
- lignes 36-44 : notre méthode [filtrer]. Elle est générique : elle filtre une liste d'éléments de n'importe quel type [T], avec un prédicat de type [Predicate<? super T>]. Elle parcourt la liste, appelle le prédicat (ligne 39) sur chaque élément et rend la liste des éléments retenus ;
- ligne 13 : appel de [filtrer] avec une lambda. Le type [T] est déduit de la liste : [Integer]. Ligne 14, on lui passe une référence de méthode : [estImpair] (ligne 46) reçoit un [int], le compilateur ajoute la conversion depuis [Integer] ;
- lignes 17-20 : un prédicat bavard, qui affiche chaque élément qu'il teste. Le résultat (lignes 5-6) montre que la définition du flux n'exécute rien : le message de la ligne 21 s'affiche avant le premier test. Ce n'est que lorsque l'opération terminale [forEach] (ligne 22) 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 tout le flux avant que le suivant n'y entre. C'est l'exécution différée (ou paresseuse) des flux. Notre méthode [filtrer], elle, construit toute la liste résultat avant de la rendre ;
- lignes 51-58 : pour écrire nos propres opérations intermédiaires, paresseuses comme celles de l'API, nous utilisons les collecteurs intermédiaires ([Gatherer], Java 24), le pendant, pour les opérations intermédiaires, des collecteurs ([Collector]) utilisés par [collect]. [Gatherer.of] reçoit une lambda à trois paramètres, appelée pour chaque élément du flux : un état (inutile ici, d'où le paramètre anonyme [_]), l'élément, et l'étage suivant du flux ([aval]), auquel on transmet un élément par [aval.push(...)]. La lambda rend [false] pour arrêter le flux ; les nôtres ne le décident jamais elles-mêmes : elles rendent le résultat de [push], qui ne vaut [false] que si l'aval ne veut plus d'éléments (ou [true] quand le filtre écarte un élément). Le filtre (ligne 52) ne transmet que les éléments qui vérifient le prédicat, la transformation (ligne 57) transmet le résultat de la fonction appliquée à chaque élément ;
- ligne 56 : [transformer] est doublement générique : [T] est le type des éléments reçus, [R] celui des éléments produits. Le paramètre [Function<? super T, ? extends R>] suit la règle déjà rencontrée : la fonction consomme des [T] et produit des [R] ;
- lignes 27-30 : la méthode [gather] des flux insère nos opérations dans la chaîne, qui s'écrit comme avec les opérations de l'API : on garde les villes de 5 lettres au plus, puis on les met en majuscules.
Toute l'API Stream est construite sur ce modèle : des opérations qui reçoivent des interfaces fonctionnelles et produisent de nouveaux flux, dont les éléments ne sont calculés qu'à la demande de l'opération terminale.
7.7. 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.getLimite() > 20000] et la traduire, par exemple, en une clause SQL [WHERE limite > 20000] exécutée par le serveur de bases de données.
Java n'a pas d'équivalent. Une lambda Java est toujours compilée en code exécutable : on peut l'appeler, mais on ne peut pas examiner ce qu'elle fait. Les bibliothèques d'accès aux bases de données utilisent donc d'autres moyens pour exprimer une requête. Hibernate, que nous étudierons au chapitre 11, propose deux solutions : 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 Java.
7.8. Conclusion
Nous retiendrons de ce chapitre :
- une interface fonctionnelle est une interface qui n'a qu'une méthode abstraite. Les interfaces prédéfinies du paquetage [java.util.function] ([Function], [Supplier], [Consumer], [Predicate], leurs variantes à deux paramètres et leurs versions primitives), avec [Runnable], [Callable] et [Comparator], dispensent le plus souvent de définir les siennes ; quand on en définit une, on l'annote [@FunctionalInterface] ;
- une interface fonctionnelle peut être implémentée par une classe (nommée ou anonyme), par une expression lambda, ou par une référence de méthode ([Classe::statique], [objet::methode], [Classe::methode], [Classe::new]). Une lambda n'a pas de type par elle-même : c'est le type cible qui le fixe ;
- les lambdas capturent les variables locales qui les entourent, à condition qu'elles soient effectivement finales ; un état modifiable passe par un objet ou un champ ;
- les méthodes [andThen], [compose], [and], [negate], [comparing], [thenComparing]… construisent de nouvelles fonctions à partir de fonctions existantes ;
- en Java, une lambda est l'instance d'une interface : on choisit une interface fonctionnelle pour un comportement court et ponctuel, une interface à plusieurs méthodes (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] dérivée d'[EventObject], une interface [XxxListener], des méthodes [addXxxListener] / [removeXxxListener] et une liste d'écouteurs ([CopyOnWriteArrayList]) parcourue par une méthode [fireXxx]. Pour désinscrire un écouteur, il faut conserver l'objet inscrit. [PropertyChangeSupport] fournit ce mécanisme tout prêt pour les changements de propriétés ;
- l'API Stream est construite sur des opérations qui reçoivent des interfaces fonctionnelles ; Java 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 un [Runnable] et un [ExecutorService] un [Callable] (chapitre 8), un [CompletableFuture] enchaîne des fonctions (chapitre 9), et les services web de Spring Boot traitent les requêtes HTTP dans des méthodes que le framework appelle comme des écouteurs (chapitre 13).