Lambda-expressions, fermetures, références de codes

images/brownbelt.png

Pour permettre des évolutions de Java ses concepteurs ont souvent à adopter des compromis entre facilités d'écriture (implicites) et codes explicites (par exemple un excès d’implicites rend les codes de langages comme C++ difficile à maintenir, d’un autre point de vue certains programmeurs reprochent à Java son aspect "bavard").

Prenons un exemple de code avec une classe anonyme:

this.bouton.addActionListener( new ActionListener() {
   public void actionPerformed(ActionEvent evt){
      clics++ ;
      message.setText(" nombre de clics = " + clics ) ;
         } ) ;

Dans ce code le compilateur pourrait automatiquement faire un certain nombre d’inférences:

Depuis Java 8 on peut réécrire ce code avec une lambda-expression :

this.bouton.addActionListener( evt -> {
      clics++ ;
      message.setText(" nombre de clics = " + clics ) ;
         } ) ;

Les conditions précédentes étant réunies le compilateur sait parfaitement ce qu’il a à faire avec le symbole evt et le code associé après l’accolade. (On notera ici une situation exceptionnelle en Java: un symbole - ActionEvent evt - a un typage implicite!)

Ce code n’est pas simplement une ellipse pour une classe anonyme (ce n’est pas une classe anonyme qui est implicitement générée) mais permet de créer des expressions qui englobent un code qui sera exécuté (plus tard) dans un autre contexte. On notera par exemple que certaines ambiguités liées à l’utilisation des classes anonymes (comme la portée de la référence this dans le code à exécuter) n’ont plus lieu.

Principes des lambda-expressions

Une lambda-expression permet de créer un code dont l’exécution sera déférée par rapport à l’exécution du code qui la crée .

Dans le code précédent c’est un clic sur le bouton qui déclenchera le code de la lambda-expression enregistrée par addActionListener.

Autres exemples:

List<Client> mesClients ;
.......
// un peu simpliste puisque ceci se ferait très bien avec une boucle for ...
// mais le mécanisme permet de faire plus compliqué
mesClients.forEach(client-> {
   client.getMessager().sendMessage("meilleurs voeux pour 2048!") ;
} ) ;
// on trie les clients par ordre du nombre d'achats
mesClients.sort((clientA,clientB) ->
   clientA.getNombreAchats() - clientB.getNombreAchats()) ;

Regardons les opérations effectuées par le compilateur:

  • La nature de l’opération est définie par une interface qui ne dispose que d’une seule méthode abstraite :

    • L’opération forEach (définie par l’interface Iterable) prend en paramètre une interface java.util.function.Consumer<T> qui ne connait qu’une méthode abstraite accept<T> (il y a une autre méthode andThen mais c’est une méthode par défaut!).

      Une telle interface est annotée @FunctionalInterface : seules les interfaces dotées d’une seule méthode peuvent induire une lambda-expression, l’annotation est ici un simple renforcement de cette contrainte. Dans le deuxième exemple on a un Comparator qui présente la même caractéristique.

    • La lambda-expression n’est utilisable qu’en tout point du code où le compilateur peut déterminer le type ciblé : affectation, paramètre de méthode ou de constructeur, valeur de retour, certaines expressions (comme des transtypages, des expressions conditionelles condition?resSiVrai: resSiFaux), …
  • Le nombre et le type des paramètres sont définis par cette simple méthode. La définition des symboles client, clientA, clientB ont donc un type implicite.

    On notera que dans les deux cas le compilateur a réalisé un contrôle de type en s’appuyant sur un paramètre-type. List<T> est Iterable<T> et la méthode utilisée est définie comme forEach<Consumer<? super T>> (ce qui signifie que la méthode accept de cette interface est utilisable avec un type pour lequel T est affectable - par ex. une super-classe de T - ).

  • La syntaxe à gauche de la flêche dépend des caractéristiques de la méthode implicite:

    () -> : méthode sans paramètre ( burger arrow en argot de programmeur!)

    x -> : un seul paramètre avec typage implicite

    (x, y) -> : plusieurs paramètres avec typage implicite. A partir de java 11 il est possible d’utiliser la notation var à la place du type quand le typage des paramètres est implicite (c’est une facilité permettant des constructions syntaxiques comme les annotations que nous verrons ultérieurement).

    (var x, var y) -> x.process(y)

    (int x, int y) -> : paramètres avec typage explicite. Le compilateur va vérifier si ce typage est compatible. Exemple:

       List<Integer> listValues = .... ;
       listValues.forEach((Number x) -> .... ) ;
  • En partie droite de la flêche on peut trouver:

    • un bloc (plusieurs instructions entre accolades)
    • une seule instruction sans accolade si:

      • c’est une expression rendant un résultat compatible avec le résultat attendu par la méthode (ex. ci-dessus avec sort et la méthode compareTo du Comparator )
      • la méthode est procédurale (type retour void)

java.util.function

Ce package définit un certain nombre d’interfaces fonctionnelles caractéristiques que l’on retrouve dans de nombreuses bibliothèques de codes. Notons pas exemple:

  • Consumer: marque souvent une "visite" d’un certain objet
  • Predicate: permet de produire le résultat d’un test liée à un objet passé en paramètre.
  • Supplier : fournisseur d’un objet (souvent utilisé pour permettre une évaluation paresseuse d’un paramètre)
  • Function : le prototype des représentations de fonctions.

Bien entendu on ne trouve pas tous les prototypes possibles et il faut savoir en créer soi-même (par ex. recherche à réaliser sur le WEB : "currying", "curryfication" - un sujet chaud ! - ). Les transformations d’interfaces fonctionnelles sont loin d'être triviales et doivent être utilisées avec parcimonie :

// transformation non triviale
// pour ceintures noires uniquement!
public static  <T,X,R> Function<T,R> de2ArgsVers1(X arg2, BiFunction<T,X,R> bif) {
        return (T t) -> bif.apply(t,arg2) ;
}

Un cas particulier à traiter avec soin est celui des méthodes propageant un exception controlée (un traitement avancé de ce problème sort du périmètre de ce cours … mais sachez qu’il y a des solutions)

Exemple de code réalisant ("visiteurs" dans un arbre) :

interface NoeudArbre {
   ...
   List<NoeudArbre> listeBranches() ....
   ...
   public default void parcours(Consumer<NoeudArbre> avant , Consumer<NoeudArbre> après) {
        if(avant != null) {
            avant.accept(this);
        }
        for(NoeudArbre branche: this.listeBranches()) {
                branche.parcours(avant, après);
        }
        if(après != null) {
            après.accept(this);
        }
    }
}

Le code permet de parcourir un arbre et de déclencher des codes associés à la visite de chaque noeud de l’arbre: un code avant de parcourir les branches, un code après le parcours des branches.

Code appelant:

// on pourrait rajouter un code pour l'indentation (voir fermeture ci-dessous)
arbre.parcours(noeud-> System.out.println(noeud), null) ;

Un point important souligné dans les documentations est la présence potentielle d’effets de bord : voir ci-après.

Fermetures (Closures)

Considérons le code suivant :

listeClient.forEach( client ->
   totalAchats += client.getNombreAchats()) ;

Quel est le statut de cette variable totalAchats ?

  • Elle est définie dans le contexte du code appelant : la lambda-expression est une "fermeture" ( En anglais Closure). Le code exécutant opérera sur une donnée qui est dans le contexte du code appelant!
  • Cette variable étant modifiée ce ne peut être une variable locale. Les variables locales qui sont "capturées" par une lambda-expression sont implicitement finales. (Avant java8 les variables locales placées dans une classe anonyme devaient être etiquetées final: ce n’est plus nécessaire , en particulier pour une lambda expression, mais il reste qu’une variable locale ainsi capturée devient implicitement final et ne peux donc pas être modifiée dans le contexte du code appelant).
  • Ce peut être une variable d’instance (ou une variable static). Les conventions de désignation classiques d’appliquent : this.totalAchats désigne bien la variable d’instance dans le contexte englobant (au contraire de ce qui se passe pour les classes anonymes!).
public class Clientele {
   List<Client> listeClients ;
   int adresseVérifiées;

   // la variable locale "message" est implicitement final
   public void envoyerMessageDeTest(String message) {
      this.adresseVérifiées = 0 ;
      listeClients.forEach(client -> {
         try {
            client.getMessager().envoiMessage(message) ;
            this.adresseVérifiées++ ;
         } catch(Exception exc) {
            client.setAdresseVérifiée(false) ;
         }

      }) ;
   }

}

Problématique des effets de bord

En théorie une "fonction" prend en entrée des données et produit un résultat. Il y a "effet de bord" lorsque cette fonction (non pure) produit des modifications sur ses paramètres.

Il faut faire extrèmement attention aux effets de bords: le code réalisant opére alors des modifications dans le contexte du code appelant. Certains de ces effets peuvent être pertinents : on met à disposition du code réalisant un "service" (par exemple dans le cas d’un "collecteur": le parcours d’une liste permet de mettre à jour un décompte). D’autres ont des effets imprévisibles (par exemple en terme de parallélisme) et rendent le comportement global du programme difficile à analyser.

Il convient donc de bien analyser les effets de toute fermeture pour éviter des conceptions fragiles ou erronées.

La documentation des interfaces de java.util.function signale les quelques points où des effets de bords sont a priori tolérables dans les termes du contrat d’interface.

Références de codes

Soit le code suivant :

public class Clientele {
   ...
    public static int comparaisonClient(Client clientA, Client clientB) {
        return clientA.getNbAchats() - clientB.getNbAchats() ;
    }
    ....
}

Un code de tri de la clientèle pourrait donc s'écrire :

   listeClients.sort((client1, client2) -> Clientele.comparaisonClient(client1,client2)) ;

Un niveau supplémentaire dans l’ellipse est possible en écrivant;

   listeClients.sort(Clientele::comparaisonClient) ;

On dispose ici d’une "référence de méthode".

C’est un mécanisme délicat basé uniquement sur des inférences du compilateur:

  • Une référence de code n’a pas de type en soi! Mais le contexte permet d'écrire quelque chose comme Comparator<Client> comparator = Clientele::comparaisonClient ; parceque la méthode a une signature (Client,Client) int qui autorise à l’induire comme résultat du compareTo implicite d’une lambda-expression.
  • Le code écrit ci-dessus avec la référence de méthode génère en fait la lambda-expression précédente.

On peut ainsi invoquer des références de code:

  • MaClasse::méthodeStatique ainsi String::valueOf génère une lambda-expression qui ressemble à : number → String.valueOf(number) dans le contexte d’une Function<Number,String> (mais aussi à number → { String.valueOf(number);} dans le contexte d’un Consumer<Number> !)
  • instance::méthodeInstance (y compris this.methodInstance ) ainsi client::toString génère une lambda-expression de la forme : ()→ client.toString() dans le contexte d’un Supplier<String>
  • plus complexe MaClass::méthodeInstance ainsi Object::toString génère une lambda-expression de la forme : objet → objet.toString() dans le contexte d’une Function<Object,String> ou Function<Number,String> (même remarque que ci-dessus dans le contexte d’un Consumer<Object> !)
  • référence de constructeur MaClasse::new ainsi Client::new génère une lambda-expression de la forme : nom → new Client(nom) dans le contexte d’une Function<String,Client> (même remarque que ci-dessus dans le contexte d"un Consumer<String> !)

Exemple:

//
arbre.parcours(System.out::println, null) ;
[Note]

Dans toutes les manipulations de lambda-expression et de références de code se méfier des surcharges : la levée des ambiguités est d’une complexité difficile à appréhender.

--exercice--