mai
2012
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.
(Cet article est également publié sur le blog Zenika).
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 :
- La première expression prend un paramètre
x
de typeint
, et renvoie le double de sa valeur. Notez l'absence du mot-cléreturn
: la valeur de l'expression est automatiquement renvoyée. - La seconde est une variante de la première, qui utilise un bloc d'instructions. Cette fois, le mot-clé
return
est nécessaire. - La troisième expression accepte un paramètre de type
String
mais ne renvoie rien. - 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éthodeiterable()
, et nouvelle classe interneArraySplittable
)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 desBlock
s)java.util.functions.Mappers
(nouvelle classe utilitaire pour manipuler desMapper
s)java.util.functions.Predicates
(nouvelle classe utilitaire pour manipuler desPredicate
s)
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 :
- Java 8 developer preview
- State of the lambda, par Brian Goetz.
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
J'ai l'impression que l'exemple de la classe PrintNames a été réalisé en deux coups, il y a un mismatch avec Predicate<String> isShortName = new Predicate<String>() qui devrait probablement être Filter<String> isShortName = new Filter<String>(). De même la condition if (filter.accept(elt)) devrait probablement être if (filter.keep(elt)) (ou l'inverse).
Ceci étant, merci pour ce résumé sur cette future feature plus qu'intéressante
Super article, clair et concis... Vivement Java 8 !
En attendant, on pourra toujours se familiariser avec les concepts en utilisant Guava ;)
Article sympa par contre tu oublis un point important je trouve : les implementations par défauts dans les interfaces, ce qui introduit grosso modo le polymorphysme!
Je n'en ai pas encore parlé, cela fera peut-être l'objet d'un prochain article.
Par contre le problème des "public defender methods", ce n'est pas le polymorphisme (qui existe déjà en Java), mais l'héritage en diamant, problème bien connu des développeurs C++ et que Java avait pris grand soin d'éviter jusque-là.
J'ai bien aimé l'article. Pas seulement parce que je suis cité dedans ;) , mais en particulier pour les différents tests sur les frameworks orientés événements. Ça montre bien certaines limites avec les lambdas ou les problèmes de conceptions qui peuvent émerger si on souhaite bénéficier des lambdas.
Après, je ne pense pas qu'il faille avoir un point de vue aussi négatif sur les lambda. Certes, moi aussi, je me suis dit "et c'est tout?". Mais à l'usage, la syntaxe proposée est très pratique et ouvre en plus grand les portes (pas complètement) à la programmation fonctionnelle. Ainsi, il est beaucoup plus simple de déclarer une closure (avant il fallait créer toute une inner class) ; on peut plus facilement faire de la curryfication (a -> b -> c -> ...) ; et l'évaluation retardée, et certains types de flux immutables/lazy à la Haskell, etc. Tout ça peut se faire avec un confort visuel largement accru par rapport aux précédentes versions de Java. Et que dire de l'intégration des lambda dans les collections !
Quelques remarques supplémentaires :
1/ Si actuellement la compilation d'une lambda est identique à celle d'une inner classe, il est prévu à terme différentes stratégies de compilation pour d'une lambda, notamment grace aux bénéfices de l'invokedynamic /JSR292 (voir http://cr.openjdk.java.net/~briango...).
2/ Je pense que les public defender methods (aka virtual extension method) peuvent aussi avoir un impact important sur notre façon de concevoir et développer le code en Java. Preuve en est faite avec la transformation de l'API collection. Les public defender method représentent en quelques sortes des mixins limités à l'extension du comportement et non à celui de l'état (voir http://blog.xebia.fr/2011/10/05/les...)
3/ Pour l'héritage en diamant des public defender method, la solution proposée est simple => erreur de compilation en cas de collision.
4/ Je compte bien développer ces usages créatifs ;)
Les defender methods je connais pas, va faloir que je regarde ce que c'est ;)
Merci pour l'article très clair.
Ne risque t-on pas de voir pondre des codes exécrables en terme de performance la ou les langages fonctionnels agissent avec intelligence.
Je m'explique imaginons nous avons
Collection<String> startByFR = maCollection.map(s-> s.toUppercase()).map(s -> s.substring(0,2)).filter(s -> s.equals("FR"));
Un langage fonctionnel bien foutu factoriserais le tout mais la, sauf erreur, on va faire trois allocations de collection alors qu'on en a besoin que d'une (ça serait vrai aussi pour guava).
J'ai un peu peur qu'a force de vouloir imiter le fonctionnel sans remettre en cause le language on va arriver a faire des choses qui seraient des hérésies si on les regardait procéduralement (chose qui est fait par la JVM au final).
Les closures dans ces conditions ne sont qu'un simple argument marketing pour le langage (On tape pas trop de code gràce à java 8... ).
Bref il est temps de passer à scala
Dommage que comme pour les Generics, Sun/Oracle ait fait agir son département marketing...il faudrait qu'à un moment ou à un autre ils se décident à casser vraiment la compatibilité niveau source pour ne garder que la compatibilité binaire...comme ça on reste compatible avec l'existant et on se simplifie la vie pour le reste...je trouve que Java ressemble de plus en plus à un zombie rapiécé de toutes parts...
Sinon, par pure curiosité, y a t il une raison technique pour avoir limité les closures à l'implémentation d'interfaces à méthode unique ?
Si l'interface possédait plusieurs méthodes, comment le compilateur déterminerait-il celle dont on fournit l'implémentation ?
Certes, on pourrait imaginer que la signature de la closure suffirait à sélectionner la bonne, mais l'inférence de type a ses limites. Et en cas d'ambigüité, il faudrait lever une erreur de compilation.
Oracle a donc décidé de se simplifier la vie en n'autorisant l'utilisation de closures que pour les interfaces à méthode unique.
Ravi de savoir que je vais pouvoir continuer à justifier d'utiliser Groovy.