Java 8 et les Lambda


La prochaine version de Java, prévue pour 2013, apportera le lot de nouveautés le plus important depuis Java 5 : expressions lambda, "public defender methods", références de fonctions...

Aujourd'hui, je vous propose de découvrir la nature, la syntaxe et les cas d'utilisation des expressions lambda (une forme de "closure"), ainsi que leur impact sur notre façon de coder.

Lambda ?

Le problème

En Java, il n'existe que deux types de références : des références vers des valeurs primitives, et des références vers des instances d'objets.

int i = 42;       // Référence vers une valeur primitive
String s = "42";  // Référence vers une instance

Dans d'autres langages (Groovy, Scala, Haskell...), il est également possible d'établir des références vers des closures, c'est-à-dire des blocs de code anonymes.
Une référence de ce type peut alors, comme toutes les autres, être utilisée en tant que champ d'une classe ou en paramètre d'une méthode.

Ce dernier usage est très répandu dans les langages fonctionnels (ou "orientés fonctionnel"). En particulier,la possibilité de passer une fonction en argument d'une autre permet leur composition, favorise leur découpage atomique, simplifie leur test, et améliore leur réutilisabilité.

En Java, qui ne dispose pas de cette facilité, la technique qui s'en rapproche le plus consiste à définir une interface décrivant la fonctionnalité souhaitée, puis à instancier une classe (souvent anonyme) implémentant la fonctionnalité. L'instance obtenue peut alors être affectée à une référence et/ou être passée en paramètre d'une méthode.

Mais cette façon de faire est très verbeuse, comme nous allons nous en rendre compte à l'aide de l'exemple suivant.

Comparaison Groovy / Java

Nous souhaitons afficher tous les éléments d'une liste qui satisfont un certain critère arbitraire. La méthode d'affichage étant générique, il est nécessaire de lui passer en paramètre l'algorithme de filtrage.

Comparons les implémentations en Groovy, qui dispose des closures, et en Java, qui n'en dispose pas (encore).

Groovy :

def names = ["un", "deux", "trois", "quatre"]
 
// names : la collection
// filter : l'algorithme de filtrage
def printNames(names, filter) {
    println names.findAll(filter)
}
 
// Critère de filtrage, sous forme de closure
// On ne conserve que les noms courts (5 caractères max)
def isShortName = {name -> name.size() <= 5}
 
printNames (names, isShortName)

La syntaxe est claire et lisible. Notez la façon dont un bloc de code anonyme (closure) est directement affecté à la référence isShortName, puis passé en paramètre de la méthode printNames().

Maintenant, en Java :

public class PrintNames {
 
    // Encapsule la définition de la méthode implémentant le critère de sélection
    private interface Predicate<T> {
        public boolean keep(T element);
    }
 
    public static <T> void printNames(List<T> elements, Predicate<T> filter) {
        for (T elt : elements) {
            if (filter.keep(elt)) {
                System.out.println(elt);
            }
        }
    }
 
    public static void main(String[] args) {
 
        List<String> names = Arrays.asList("un", "deux", "trois", "quatre");
 
        // Critère de sélection, sous forme de classe anonyme
        Predicate<String> isShortName = new Predicate<String>() {
            @Override
            public boolean keep(String element) {
                // La seule ligne réellement utile !
                return element.length() <= 5;
            }
        };
 
        printNames(names, isShortName);
    }
 
}

Je pense que la différence saute aux yeux. Le code Java est nettement plus verbeux, et noie la fonctionnalité métier au sein d'une masse importante de code purement technique.
Voyons quelle solution Java 8 propose.

Les Lambda en Java 8

Domaine d'application

Sous la pression des langages "alternatifs" et de la communauté Java, Oracle s'est enfin décidé à intégrer les closures dans le langage.

Enfin... pas tout à fait.
Pour des raisons de rétrocompatibilité avec le type-system existant, Java 8 limitera sévèrement leur domaine d'application. Les closures ne serviront en réalité qu'à simplifier l'implémentation et l'utilisation des "Interfaces SAM" ("Single Abstract Method") ou "Interfaces fonctionnelles", c'est-à-dire les interfaces ne définissant qu'une seule méthode[1].

Certes, ces interfaces sont nombreuses en Java : Runnable, Callable, Comparator, ActionListener... Et de nombreux frameworks orientés événements (en particulier les frameworks graphiques comme Swing ou GWT) les utilisent pour déclarer des callbacks.
Mais tout de même, on est loin de la souplesse et de la puissance des closures présentes dans les autres langages.

Sous le capot

A la compilation, les closures sont tout simplement compilées sous la forme de simples classes anonymes.
Par exemple, le code suivant...

public class CompilationTest {
 
    interface Doubler {
        int timesTwo(int x);
    }
 
    public static void main(String[] args) {
        Doubler d = (n) -> { return n * 2;};
    }
 
}

... est compilé sous la forme de 3 classes :

CompilationTest           # La classe de test       
CompilationTest$Doubler   # L'interface Doubler
CompilationTest$1         # La closure implémentant Doubler

Si nous décompilons la classe CompilationTest$1, nous obtenons :

Compiled from "CompilationTest.java"
class CompilationTest$1 implements CompilationTest$Doubler {
  CompilationTest$1();
  public int timesTwo(int);
}

En descendant au niveau du bytecode, nous retrouvons bien l'opération de multiplication par deux :

public int timesTwo(int);
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=2, args_size=2
         0: iload_1       
         1: iconst_2      
         2: imul          
         3: ireturn       

Etudions maintenant la syntaxe.

Syntaxe

Une closure peut se concevoir comme une méthode anonyme. A ce titre, elle peut accepter des paramètres et retourner un résultat.

Après moult tergiversations et guerres des tranchées sur la mailing-list dédiée, la syntaxe retenue est inspirée de celle de C#[2]. Elle peut prendre deux formes :

  • (paramètres) -> expression_simple
  • (paramètres) -> { bloc_d'instructions }

Exemples :

(int x) -> x * 2                           #1
(int x) -> { return x * 2; }               #2
(String s) -> { System.out.println(s); }   #3
() -> 42                                   #4

Explications :

  1. La première expression prend un paramètre x de type int, et renvoie le double de sa valeur. Notez l'absence du mot-clé return : la valeur de l'expression est automatiquement renvoyée.
  2. La seconde est une variante de la première, qui utilise un bloc d'instructions. Cette fois, le mot-clé return est nécessaire.
  3. La troisième expression accepte un paramètre de type String mais ne renvoie rien.
  4. Enfin, la quatrième expression ne prend aucun paramètre, et renvoie la constante 42.
Syntaxe simplifiée

Cette syntaxe peut être encore simplifiée dans certains cas :

  • Si les types des paramètres peuvent être inférés, il n'est pas nécessaire de les préciser.
  • Les parenthèses sont optionnelles si la closure n'attend qu'un seul paramètre (elles sont par contre obligatoires pour zéro paramètres).

Les trois expressions suivantes sont donc équivalentes :

(String s) -> s.length()
(s) -> s.length()
s -> s.length()
Capture de variables

Actuellement, les classes anonymes ne peuvent accéder aux variables de leur environnement d'exécution que si celles-ci sont déclarées final.

Il est toutefois prévu de relâcher quelque peu cette contrainte en Java 8, et d'autoriser également la capture des variables "effectivement finales", c'est-à-dire qui ne sont pas explicitement déclarées final, mais dont la valeur n'est jamais modifiée après leur première initialisation. Les closures bénéficieront également de cette facilité.

Exemple : récupération des nombres inférieurs à 3

int max = 3;  // Non final, mais "effectivement final"
List<Integer> nums = Arrays.asList(1, 2, 3, 4, 5);
Iterable<Integer> smallNums = nums.filter(n -> n < max);

Les closures en action

Les closures servant avant tout à faciliter l'implémentation des "interfaces SAM", on peut les affecter à des références de type interface.

Runnable

Prenons l'exemple d'un Runnable, qui définit une unique méthode run(), n'acceptant aucun argument et ne renvoyant aucun résultat.

Traditionnellement, nous l'implémentons comme ceci :

Runnable job = new Runnable() {
    @Override
    public void run() {
        System.out.println("Hello world");
    }
};

Avec les closures, nous pourrons le définir comme cela :

Runnable job = () -> {
    System.out.println("Hello world");
};

Notez que le code est considérablement simplifié, et recentré sur l'algorithme métier. La lisibilité est également meilleure.

ActionListener

Prenons un second exemple, un peu plus complexe : l'interface ActionListener, qui permet de réagir au clic sur un bouton Swing. Elle définit une méthode actionPerformed(ActionEvent event) - notez la présence du paramètre de type ActionEvent.

Au lieu de l'affecter à une référence, nous allons cette fois la passer directement en paramètre de la méthode JButton.addActionListener().

Sans les closures :

JButton greeterButton = new JButton("Click me !");
greeterButton.addActionListener(new ActionListener() {
    @Override
    public void actionPerformed(ActionEvent event) {
        JOptionPane.showMessageDialog(null, "Hello !");
    }
});

Avec les closures :

JButton greeterButton = new JButton("Click me !");
greeterButton.addActionListener( event -> {
    JOptionPane.showMessageDialog(null, "Hello !");
});
Comparator

Un dernier exemple pour la route, avec l'interface Comparator<T>, qui expose la méthode int compare(T x, T y), qui accepte deux paramètres :

List<String> strings = Arrays.asList("hello", "world", "!");
Collections.sort(strings, (s1, s2)-> s1.compareTo(s2));
System.out.println(strings);

Principaux impacts

On l'a vu, le domaine d'application des expressions lambda en Java sera relativement limité.
Essayons d'en faire le tour...

Le JDK

Evidemment, le premier bénéficiaire sera le JDK lui-même. Les classes pré-existantes ne seront sans doute pas adaptées, mais l'on peut d'ores et déjà voir les lambda en action dans certaines nouvelles classes ou méthodes.

Dans la dernière "developer preview", j'ai dénombré les usages suivants :

  • java.lang.MapStream (nouvelle classe)
  • java.util.Arrays (nouvelle méthode iterable(), et nouvelle classe interne ArraySplittable)
  • java.util.ParallelIterables (nouvelle classe, fonctionne avec Fork/Join pour réaliser des opérations en parallèle sur les éléments d'une collection)

Ainsi que dans le tout nouveau package java.util.functions, dédié à la programmation fonctionnelle :

  • java.util.functions.Blocks (nouvelle classe utilitaire pour manipuler des Blocks)
  • java.util.functions.Mappers (nouvelle classe utilitaire pour manipuler des Mappers)
  • java.util.functions.Predicates (nouvelle classe utilitaire pour manipuler des Predicates)

Les frameworks orientés événements

Les interfaces à méthode unique sont très utilisées, dans les frameworks orientés événements (en particulier des frameworks graphiques), pour implémenter des callbacks. On pourrait donc s'attendre à ce que les lambda y trouvent un terrain d'application naturel.

AWT /Swing

Le package java.awt.event regroupe toutes les interfaces permettant de répondre aux événements graphiques.

Sur les 18 interfaces, 8 seulement ne définissent qu'une seule méthode, et sont donc éligibles au titre d'"Interface SAM" (dont ActionListener déjà vu plus haut). Les autres interfaces définissent plusieurs méthodes, et ne seront donc pas implémentables sous forme d'expression lambda.

Côté Swing, l'étude du package javax.swing.event aboutit au même constat : seulement 9 interfaces sur 23 sont éligibles.

Un intérêt mitigé côté AWT/Swing, donc.

Un exemple d'utilisation :

JButton greeterButton = new JButton("Click me !");
greeterButton.addActionListener( (event) -> {
    JOptionPane.showMessageDialog(null, "Hello !");
});
Wicket

Wicket est un framework web orienté composants, ressemblant à Swing par bien des aspects.

Malheureusement, son modèle de callback est complètement différent : ceux-ci sont définis comme des méthodes internes aux composants graphiques, que le développeur doit surcharger.

Par exemple, pour réagir au clic sur un lien :

Link link = new Link("linkId") {
    public void onClick() {
        // ...
    }
);

A moins d'un changement majeur dans l'architecture du framework, il sera impossible d'utiliser les expressions lambda pour simplifier le déveoppement d'applications avec Wicket.

GWT

Le cas GWT est intéressant.

Contrairement à Wicket, ce framework de RIA utilise bien des interfaces pour gérer les callbacks :

Button button = new Button("Click me !");
button.addClickListener(new ClickListener() {
    public void onClick(Widget sender) {
       Window.alert("Hello, world");
    }
});

GWT pourrait donc être un excellent candidat pour les expressions lambda :

Button button = new Button("Click me !");
button.addClickListener( sender -> {
   Window.alert("Hello, again");
});

Mais le code GWT n'est pas exécuté tel quel : il est d'abord traduit en Javascript. La capacité d'utiliser des expressions lambda dépendra donc directement de leur prise en compte dans le traducteur Java vers Javascript...

Vu le travail que cela représente et le gain somme toute modéré qu'on peut en attendre, je doute que cette fonctionnalité soit jamais implémentée. Wait & see...

Dans nos projets

Pour finir, il sera naturellement possible d'intégrer le support des expressions lambda dans nos projets, partout où nous aurons défini des interfaces compatibles SAM.

Certains use-cases s'y prêteront plus naturellement, comme les callbacks ou handlers. Certains usages plus créatifs émergeront certainement avec le temps[3].

Conclusion

Le support des closures en Java était attendu impatiemment. Prévues pour Java 7 à l'origine, elles seront finalement disponibles en Java 8, dont la sortie est prévue pour l'année prochaine.

Leur implémentation, sous forme d'expressions lambda, me paraît toutefois très décevante.
Leur domaine d'application, strictement limité à l'implémentation d'interfaces à méthode unique, est en définitive très restreint.

Les changements les plus significatifs sont à attendre du côté des collections, qui gagneront des méthodes empruntées à la programmation fonctionnelle. Pour le reste du code, les gains semblent moins évidents.

Encore une fois, je ne doute pas que la communauté s'emparera de ce nouvel outil, et saura en tirer la quintessence. Vivement Java 8 !

Références

Quelques références utiles :

Notes

[1] Une seule méthode propre, c'est-à-dire hors méthodes héritées d'Object, et hors "defender methods". Par exemple, java.util.Comparable définit deux "defender methods" en plus de la méthode compare(), mais est bien considéré comme une "Interface SAM".

[2] A titre personnel, j'aurais préféré la syntaxe de Groovy. Mais de gustibus non est disputandum...

[3] Voir par exemple l'article de François Sarradin : Towards pattern matching in Java


Commentaires

1. Le jeudi 31 mai 2012, 09:40 par pgras

Merci pour l'article, enfin un texte simple pour comprendre ce sujet.
Il y a un petit mélange entre Filter et Predicate dans le premier exemple Java, et, après avoir cherché les sens du mot volatilité dans le dictionnaire je ne comprends toujours pas le sens de "volatilité des closures".

2. Le lundi 18 juin 2012, 09:56 par Olivier Croisier

Merci pour ces remarques, j'ai corrigé !

3. Le lundi 2 juillet 2012, 14:52 par Dominique De Vito

Au moins, c'est un premier pas ;-)

Reste que, vu ce qu'ils proposent pour le JDK 8, il était quand même possible d'obtenir cela dans le JDK 7. C'est là, je trouve, un des cotés regrettables de la chose.

Fait amusant, je me suis repenché sur ma "next JDK wishlist". Dans un post mi-2009 - http://www.jroller.com/dmdevito/ent... - j'imaginais dans un premier temps, un genre d'auto-boxing des méthodes statiques pour les transformer en une interface SAM, puis après (dans mon update du 4 juillet 2009), un auto-boxing des lambdas en interfaces SAM (soit, ne pas introduire des delegates dans Java, mais utiliser les SAM).

4 ans (voire plus) pour introduire ce genre d'auto-boxing dans Java, cela fait un peu long...

4. Le mercredi 4 juillet 2012, 11:28 par Dominique De Vito

En même temps, l'auto-boxing des lambdas en SAM a l'air simple coté utilisateur, mais la machinerie interne pour avoir de bonnes perfs a l'air plutôt compliqué ; Fredrik Öhrström indique : "The Java language is getting a lambda operator and a Mount Everest of technical improvements to assist this seemingly simple operator" dans http://www.infoq.com/presentations/...

5. Le jeudi 12 juillet 2012, 22:58 par doanduyhai

"effectivement finales, c'est-à-dire qui ne sont pas explicitement déclarées final, mais dont la valeur n'est jamais modifiée après leur première initialisation"

Attention Olivier, dire que "dont la valeur n'est jamais modifiée après leur première initialisation"  n'est pas tout à fait exact. On peut la modifier, mais c'est la valeur (référence) assignée au moment de la déclaration du lambda qui est gardée.
J'ai fait quelques expérimentations pour clarifier la chose ici: [http://doanduyhai.wordpress.com/2012/07/12/java-8-lambda-in-details-part-ii-scoping-of-this-and-effectively-final-variable-semantic/]
On apprend au passage quelques renseignements intéressants sur l'implémentation de la capture des variables. Mon petit doigt me dit que ça va donner beaucoup de bugs ce genre de subtilité...
6. Le mercredi 18 juillet 2012, 09:14 par Nicolas Rémond

Pour conclure le billet, ça aurait intéressant de voir l'exemple PrintNames traduit en Java8, non?

Fil des commentaires de ce billet

Ajouter un commentaire

Le code HTML est affiché comme du texte et les adresses web sont automatiquement transformées.