Vous aimez ce que vous lisez sur ce blog ?
Envie d'aller plus loin avec véritable formation d'expertise en Java ?
Venez suivre ma formation Masterclasse Expertise Java !

"Même un développeur experimenté a besoin de continuer à apprendre. Et dans cette formation... j'ai appris beaucoup !" - A.G., Java Champion

Sessions intra-entreprises sur demande : contact[at]mokatech.net.
Inscrivez-vous vite !

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 :

  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 mercredi 30 mai 2012, 18:09 par Tome

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

2. Le mercredi 30 mai 2012, 22:10 par jlrigau

Super article, clair et concis... Vivement Java 8 !

En attendant, on pourra toujours se familiariser avec les concepts en utilisant Guava ;)

3. Le mercredi 30 mai 2012, 23:17 par ybonnel

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!

4. Le mercredi 30 mai 2012, 23:24 par Olivier Croisier

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à.

5. Le jeudi 31 mai 2012, 06:29 par fsarradin

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 ;)

6. Le jeudi 31 mai 2012, 06:54 par ybonnel

Les defender methods je connais pas, va faloir que je regarde ce que c'est ;)

7. Le jeudi 31 mai 2012, 09:16 par Benjamin

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

8. Le lundi 9 juillet 2012, 21:57 par Charles Casadei

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 ?

9. Le lundi 9 juillet 2012, 22:49 par Olivier Croisier

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.

10. Le jeudi 19 juillet 2012, 10:08 par Guillaume Balaine

Ravi de savoir que je vais pouvoir continuer à justifier d'utiliser Groovy.

Ajouter un commentaire

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