Java : Les expressions lambda
Depuis la version 8, Java propose une nouvelle manière de programmer des applications en introduisant un paradigme assez ancien tout compte fait : La programmation fonctionnelle.
Ce paradigme remonte à la création de Lisp en 1958. La programmation fonctionnelle fait partie de la programmation déclarative qui s'oppose à la programmation impérative.
Dans les versions antérieures, Java proposait une manière de programmer impérative.
Programmation déclarative vs impérative
Tout d'abord, la programmation impérative est un paradigme qui utilise des instructions qui modifient l'état d'un programme. À l'opposé, la programmation déclarative ne change jamais l'état d'un programme. En programmation fonctionnelle, lorsque le programme appel une fonction avec un argument elle renverra toujours le même résultat pour le même argument.
En mathématiques, la fonction f pour le paramètre x renverra toujours le même résultat.
La programmation impérative décrit comment obtenir le résultat étape par étape.
Integer[] collectionDeNombres = new Integer[] { 1, 2, 3, 4, 5 };
// Programmation impérative
System.out.println("Programmation itérative");
List nombresPairs = new ArrayList();
for (int nombre : collectionDeNombres)
if (nombre % 2 == 0)
nombresPairs.add(nombre);
for (int nombre : nombresPairs)
System.out.println(nombre);
// Programmation fonctionnelle avec un expression lambda
System.out.println("Programmation fonctionnelle");
Arrays.asList(collectionDeNombres)
.stream()
.filter(nombre -> nombre % 2 == 0)
.forEach(System.out::println);
Le programme impératif se compose de la manière suivante :
- Création d'une liste vide de nombres pairs
- Pour chaque nombre dans la liste
- Si le nombre est pair
- On l'ajoute Ă la liste des nombres pairs
- Pour chaque nombre dans la liste des pairs
- On affiche le nombre Ă l'Ă©cran
Pour le programme déclaratif :
Imprime les nombres pairs de la liste Ă l'Ă©cran.
Donc ici on ne sait pas comment le programme fait pour avoir les nombres pairs mais on sait qu'on aura tous les nombres pairs Ă l'Ă©cran.
De plus, le programme dĂ©claratif est immuable. Aucun Ă©lĂ©ment externe peut le modifier. On ne peut rien modifier de l’extĂ©rieur.
Quant au programme déclaratif, je peux tout à fait modifier des variables. Je pourrais très bien ajouter une ligne de code qui ajoute un nombre à la liste des pairs avant de les afficher à l'écran.
La programmation déclarative permet d'éviter les "effets de bord".
La programmation déclarative permet d'éviter les "effets de bord".
Et le modificateur final ?
On pourrait programmer de manière déclarative et utiliser le mot-clé final pour éviter tous changements de variable et il faudrait aussi utiliser des champs d'objet private final et ne jamais utiliser de setter. On peut tout de même modifier la variable avec l'API de réflexion de Java.
public static void main(String[] args) {
// Je peux modifier fruit
final Fruit fruit = new Fruit("Pomme");
fruit.setNom("Poire");
System.out.println(fruit.getNom());
// Affiche "Poire"
// Je ne peux pas modifier fruit sans réflexion
final FruitPresqueImmuable fruitImmuable = new FruitPresqueImmuable("Pomme");
System.out.println(fruitImmuable.getNom());
// Affiche Pomme
//Je peux modifier avec la réflexion
Field declaredField = null;
try {
declaredField = FruitPresqueImmuable.class.getDeclaredField("nom");
//On rend le champs accessible
declaredField.setAccessible(true);
//On change sa valeur
declaredField.set(fruitImmuable, "Poire");
} catch (Exception e) {
e.printStackTrace();
}
System.out.println("Après Reflection :");
System.out.println(fruitImmuable.getNom());
//Affiche Poire
}
}
final class Fruit {
private String nom;
public Fruit(String nom) {
this.nom = nom;
}
public String getNom() {
return nom;
}
public void setNom(String nom) {
this.nom = nom;
}
}
final class FruitPresqueImmuable {
private final String nom;
public FruitPresqueImmuable(String nom) {
this.nom = nom;
}
public String getNom() {
return nom;
}
L'API de réflexion casse l'immuabilité de l'objet mais ce n'est pas aussi simple que de modifier une variable avec un setter. On ne peut pas utiliser API par inadvertance. Donc cette méthode est retenue pour créer des objets immuables. Il ne faut pas oublier de rendre les collections immuables aussi. On peut utiliser l'objet Collections et les fonctions commençant par unmodifiable dans le constructeur.
Pour une liste, ça donnerait :
this.fruits = Collections.unmodifiableList(new ArrayList(fruits));
La programmation fonctionnelle permet aussi d'utiliser les traitements parallèles (ou simultanés). Cela permet d'avoir de meilleures performances. Il est important de rappeler que les processeurs multi-cœur sont très répandues aujourd'hui.Syntaxe
Le principe de l'expression lambda est de donner une implémentation à une interface avec une classe anonyme.
Ainsi on peut Ă©crire :
public static void main(String[] args) {
MaFonctionLambda maFonction = new MaFonctionLambda() {
public void direBonjour() {
System.out.println("Bonjour !");
}
};
maFonction.direBonjour();
}
interface MaFonctionLambda {
void direBonjour();
}
La meilleure façon de comprendre et de retenir la syntaxe est de convertir une fonction classique en expression lambda.Prenons la fonction direBonjour, le modificateur public n'est pas utile dans cette situation puisque la fonction est isolée. De plus, le mot-clé public n'est pas nécessaire lorsque la fonction est en effet publique. Par défaut une fonction est publique. D'après les spécifications du langage de programmation Java les termes abstract, public peuvent et doivent être omis pour éviter les répétitions.
« It is permitted, but discouraged as a matter of style, to redundantly specify the public and/or abstract modifier for a method declared in an interface. »
Ensuite, le nom de la fonction n'est plus utile puisque nous allons nous référer au type MaFonctionLambda avec la variable maFonction.
Le terme void n'est plus utile puisque le compilateur connait déjà le type de retour de la fonction avec l'interface.
Enfin, nous n'avons pas besoin de préciser new MaFonctionLambda puisque le compilateur connait déjà l'interface. Il suffit ensuite d'ajouter la flèche -> entre les parenthèses et les crochets.
Donc on peut Ă©crire :
public static void main(String[] args) {
MaFonctionLambda maFonction = new MaFonctionLambda()-> {
public void direBonjour() {
System.out.println("Bonjour !");
}
};
maFonction.direBonjour();
}
interface MaFonctionLambda {
void direBonjour();
}
L'expression lambda est la suivante :
public static void main(String[] args) {
MaFonctionLambda maFonction = () -> {
System.out.println("Bonjour !");
};
maFonction.direBonjour();
}
interface MaFonctionLambda {
void direBonjour();
}
On peut aussi enlever les accolades quand il n'y a qu'une ligne de code dans l'expression lambda. Cela donne :
MaFonctionLambda maFonction = () -> System.out.println("Bonjour !");
Pour passer des arguments on peut Ă©crire :
public static void main(String[] args) {
MaFonctionLambda maFonction = (String prenom) -> {
return "Bonjour " + prenom + " !";
};
System.out.println(maFonction.direBonjour("Ronan"));
}
interface MaFonctionLambda {
String direBonjour(String prenom);
}
Préciser le type String n'est pas nécessaire et si la fonction fait une ligne on peut enlever le mot-clé return. De plus, si un seul argument est passé dans la fonction on peut enlever les parenthèses.
Ce qui donne :
public static void main(String[] args) {
MaFonctionLambda maFonction = prenom -> "Bonjour " + prenom + " !";
System.out.println(maFonction.direBonjour("Ronan"));
}
interface MaFonctionLambda {
String direBonjour(String prenom);
}
Inférence de types
L'inférence de types permet d'associer les types non indiqués dans le code source à des expressions lambda. On peut donc passer en argument différents comportements dans la même fonction de l'interface.
public static void main(String[] args) {
double nb1 = 10;
double nb2 = 20;
// Somme 10 + 20 = 30.0
Operation somme = (a, b) -> nb1 + nb2;
System.out.println(nb1 + " + " + nb2 + " = " + somme.calculer(nb1, nb2));
// Soustraction 10 - 20 = -10.0
Operation soustraction = (a, b) -> nb1 - nb2;
System.out.println(nb1 + " - " + nb2 + " = " + soustraction.calculer(nb1, nb2));
// Multiplication 10 x 20 = 200.0
Operation multiplication = (a, b) -> a * b;
System.out.println(nb1 + " x " + nb2 + " = " + multiplication.calculer(nb1, nb2));
// Division 10 / 20 = 0.5
Operation division = (a, b) -> a / b;
System.out.println(nb1 + " / " + nb2 + " = " + division.calculer(nb1, nb2));
}
interface Operation {
double calculer(double a, double b);
}
On peut donc se resservir d'interface et sans créer de nouvelles implémentations. Ce qui m'amène au point suivant...
Interfaces Fonctionnelles
Une interface fonctionnelle est une interface qui possède une fonction générique. Par exemple, dans la liste des interfaces fonctionnelles de java, il existe une fonction appelée BiFunction qui prend deux valeurs génériques et retourne un résultat générique.
Si on prend le code précédent cela donnerait :
double nb1 = 10;
double nb2 = 20;
// Somme 10 + 20 = 30.0
BiFunction < Double, Double, Double > somme = (a, b) -> nb1 + nb2;
System.out.println(nb1 + " + " + nb2 + " = " + somme.apply(nb1, nb2));
// Soustraction 10 - 20 = -10.0
BiFunction < Double, Double, Double > soustraction = (a, b) -> nb1 - nb2;
System.out.println(nb1 + " - " + nb2 + " = " + soustraction.apply(nb1, nb2));
// Multiplication 10 x 20 = 200.0
BiFunction < Double, Double, Double > multiplication = (a, b) -> a * b;
System.out.println(nb1 + " x " + nb2 + " = " + multiplication.apply(nb1, nb2));
// Division 10 / 20 = 0.5
BiFunction < Double, Double, Double > division = (a, b) -> a / b;
System.out.println(nb1 + " / " + nb2 + " = " + division.apply(nb1, nb2));
Nous n'avons plus besoin de créer une interface grâce à la liste des interfaces fonctionnelles.
La nouvelle boucle foreach
Avant la version 8, il existait deux sortes de boucles for.la classique avec la variable d'initialisation, de terminaison et d’incrĂ©mentation :
System.out.println("Boucle for classique");
for (int i = 0; i < nombres.size(); i++)
System.out.println(nombres.get(i));
la boucle for each :
System.out.println("Boucle for each");
for (int nombre: nombres)
System.out.println(nombre);
et la boucle for each lambda :
System.out.println("Boucle for each lambda");
nombres.forEach(System.out::println);
Imaginons que nous voulons doubler toutes les valeurs dans une liste. Avec une boucle classique nous ferions quelque chose comme ça :
for (int i = 0; i < nombres.size(); i++)
nombres.set(i, nombres.get(i) * 2);
System.out.println(nombres);
On multiplie par 2 le nombre à l'index i et on l'ajoute à la liste à l'index i. Avec les expressions lambda, on ne peut pas modifier une liste : Ce serait modifier l'état de ma liste et donc de mon programme. En revanche, Je peux créer une autre liste après avoir multiplié les valeurs par deux.
System.out.println(
nombres
.stream()
.map(n -> n * 2)
.collect(Collectors.toList()));
La fonction stream nous permet d'obtenir les éléments de la liste via un transfert d'opérations. La fonction map nous permet de multiplier le nombre par deux. Elle renvoie un stream avec les résultats des opérations. Enfin, la fonction collect nous permet de récupérer les résultats du stream dans une liste.
Les Streams
Un stream est créé à partir d'une source de données tel que les collections par exemple. Un stream ne modifie pas les données. Il crée un nouveau stream. Par exemple, lorsque la fonction map est appelée sur un stream, celle-ci créer un nouveau stream avec de nouvelles données et retourne le nouveau stream. Cela permet de paralléliser les tâches.Par exemple, nous voulons prendre tous les fruits commençant par "P" dans la liste. Il faut appeler la fonction filter qui va créer un nouveau stream avec seulement les fruits commençant par "P". Il ne reste plus qu'à collecter ce stream dans une liste.
List fruits = Arrays.asList(new String[] {"Pomme","Banane", "Poire","Pruneau", "Abricot"});
System.out.println(
fruits.stream()
.filter(fruit -> fruit.toLowerCase().startsWith("p"))
.collect(Collectors.toList()));
Un stream peut être borné avec la fonction limit. Nous voulons prendre les trois premiers fruits de la liste.
List fruits = Arrays.asList(new String[] {"Pomme","Banane", "Poire","Pruneau", "Abricot"});
System.out.println(fruits.stream().limit(3).collect(Collectors.toList()));
Les références de méthodes
Dans l'article, j'ai utilisé à plusieurs reprises une notation de type :
Objet::nomDeLaFonction
Cette notation est une référence de méthode. Imaginons, nous voulons connaitre la longueur des nombres d'une liste et les afficher à l'écran. Sans les références nous ferions :
List nombres = Arrays.asList(new Integer[] { 10, 404, 12, 3, 50000 });
System.out.println("Sans les références :");
nombres
.stream()
.map(nombre -> String.valueOf(nombre))
.map(nombre -> nombre.length())
.forEach(nombre -> System.out.print(nombre));
Remarquez qu'Ă plusieurs endroits (en gras) je passe simplement la variable dans la fonction directement et Ă un endroit j’exĂ©cute une fonction sur l'objet String "nombre" (soulignĂ©). Il existe un moyen pour juste utiliser la fonction sans prĂ©ciser la variable ou l'objet.
List nombres = Arrays.asList(new Integer[] { 10, 404, 12, 3, 50000 });
System.out.println("Avec les références :");
nombres
.stream()
.map(String::valueOf)
.map(String::length)
.forEach(System.out::print);
Notez que les références ne peuvent être utilisées que pour remplacer une fonction dans l'expression lambda.
Les bonnes pratiques en vidéo
Découvrez les bonnes pratiques pour utiliser les expressions lambda en vidéo.Conclusion
Avec les processeurs multi-cœur, il devient de plus en plus intéressant de faire du multithreading. Les expressions lambda nous permettent d'écrire du code fonctionnel et donc d'exécuter des taches en parallèle et d'utiliser efficacement les processeurs multi-cœur. De plus, les expressions lambda permettent d'écrire du code plus lisible et donc plus compréhensible.Enfin, la programmation objet n'est pas incompatible avec la programmation fonctionnelle. Un programme fonctionnel peut très bien se servir d'objet en tant que structure de données.
Commentaires
Enregistrer un commentaire