Depuis la version 8 de Java, on peut mettre en pratique le paradigme de la programmation fonctionnelle notamment grâce à la grammaire des Lambda expressions, comme nous allons le découvrir dans ce cours.
En ce qui concerne le mot programmation, pas besoin de le définir, par contre, nous allons tenter une explication pour le mot fonctionnelle, même si vous avez certainement une petite idée… fonction, fonc… tion, mais oui, bien sûr, on manipule des fonctions !
Mais on le fait déjà, me direz-vous ! Vous avez parfaitement raison, allez c’est parti…
Nous verrons un peu plus loin avec quelques exemples que Java est un langage de programmation impérative, nous sommes dans l’univers objet, la POO.
La programmation fonctionnelle est un paradigme de programmation qui se base sur l’appel et la composition de fonctions.
On se focalise sur la question : Que veut-on faire où obtenir ? (le résultat, déclaratif) plutôt que sur le Comment je vais faire ? (l’algorithme, l’impératif).
En PF (Programmation Fonctionnelle), une fonction est considérée comme un être autonome, une sorte d’entité (souvent nommée entité de première classe) à laquelle on peut appliquer les mêmes traitements (opérations) que pour les autres éléments du langage.
Cela signifie qu’une fonction peut être passée en paramètre d’une autre fonction, voire d’être retournée par une autre fonction. En tant que fonction, elle peut aussi recevoir des arguments. D’ailleurs une fonction peut recevoir plusieurs fonctions en paramètres.
Question : Que veut-on faire (obtenir) ?
Réponse : Je veux obtenir une liste avec des chaînes de caractères triées par ordre alphabétique.
Depuis Java 8, nous avons la chance de pouvoir utiliser des expressions lambda prédéfinies et utilisables facilement… enfin, presque. Cela peut être déroutant au début. Le changement de paradigme nécessite une autre manière de penser et d’aborder un problème.
import java.util.ArrayList; import java.util.List; import java.util.Arrays; public class TestFormat { public static void main(String[] args) { List<String> listeDePrenoms = Arrays.asList("Zoé", "Léo", "Maëlis", "Paul", "Julie", "Mélanie", "Joachim"); listeDePrenoms.sort( (a , b) -> a.compareTo(b)); System.out.println(listeDePrenoms); // affichera [Joachim, Julie, Léo, Maëlis, Mélanie, Paul, Zoé] } }
Résultat :
[Joachim, Julie, Léo, Maëlis, Mélanie, Paul, Zoé]
Dans cet exepmle, nous utilisons une fonction lambda (ou expression lambda) qui prend 2 arguments, prenomA et prenomB et retourne le résultat de la comparaison entre les 2 arguments en utilisant la méthode compareTo() qui est elle-même appelée par la fonction sort() pour trier les éléments de notre liste.
prenomA
prenomB
// on va utiliser une ArrayList pour stocker des nombres ArrayList<Integer> listeDeNombres = new ArrayList<Integer>(); listeDeNombres.add(2); listeDeNombres.add(4); listeDeNombres.add(7); listDeNombres.add(8); listeDeNombres.add(9); listeDeNombres.add(12); // multiplier chacun des nombres par 10 et afficher le résultat listeDeNombres.forEach( (valeur) -> System.out.println(valeur * 10)); // trier la liste et mettre nombres pairs avant les nombres impairs // voir documentation de la méthode sort dans la javadoc listeDeNombres.sort((v1, v2) -> (v1 % 2 ) - (v2 % 2)); System.out.println(listeDeNombres);
20 40 70 80 90 120 [2, 4, 8, 12, 7, 9]
List<String> prenoms = new ArrayList<>(); // On stocke des prénoms prenoms.add("Joachim"); prenoms.add("Agnès"); prenoms.add("Mathieu"); List<String> salutList = new ArrayList<>(); prenoms.forEach(unElement -> salutList.add("Salut " + unElement + ", bienvenue sur la planète Java Lambda !\n")); // pour faire plus joli for( String personne : salutList) { System.out.println(personne); }
Salut Joachim, bienvenue sur la planète Java Lambda ! Salut Agnès, bienvenue sur la planète Java Lambda ! Salut Mathieu, bienvenue sur la planète Java Lambda !
import java.util.Arrays; import java.util.Collections; import java.util.List; public class Main { public static void main(String[] args) { List<String> prenoms = Arrays.asList("Zoé", "Léo", "Maëlis", "Paul", "Julie", "Mélanie", "Joachim"); Collections.sort(prenoms, new java.util.Comparator<String>() { @Override public int compare(String a, String b) { return a.compareTo(b); } }); System.out.println(prenoms); } }
Ce code utilise une classe anonyme qui implémente l’interface java.util.Comparator avec une méthode compare() qui fait la même chose que la fonction lambda dans l’exemple précédent. La méthode sort() de la classe Collections utilise cet objet Comparator pour trier les éléments de la liste.
λ
Une Lambda est une fonction anonyme (ça tombe bien pour mettre en pratique la PF) : Cette fonction n’a pas de nom !
Voir l’origine du Lambda sur wikipédia : Le lambda-calcul, un système formel inventé par Alonzo Church dans les années 1930.
Les Lambdas vont nous permettre de mettre en pratique la programmation fonctionnelle. Un des principes fondamentaux à connaître est la notion de closure (fermeture).
notion de closure
Une fermeture est créée lorsqu’une fonction est définie dans le corps d’une autre fonction et utilise des paramètres ou des variables locales de cette dernière.
Nous verrons ce que cela implique dans les exemples ultérieurs.
Définition : Une expression lambda est un petit bloc de code qui prend des paramètres et retourne une valeur. Elle est anonyme et peut être implémentée dans le corps d’une méthode.
()
{ }
return
// Déclaration de l'interface fonctionnelle Runnable @FunctionalInterface public interface Runnable { void run(); } // Utilisation de lambda pour créer une instance de Runnable sans paramètre // le compilateur va inférer l'exécution de la méthode run() // il va se dire, ah, il n'y a qu'une méthode dans cette interface, alors je l'exécute // tout en la redéfinissant... voir le cours sur les Threads Runnable r1 = () -> System.out.println("Salut l'univers !"); // Utilisation de lambda avec 2 paramètres BiFunction<String, String, Integer> comparator = (s1, s2) -> s1.length() - s2.length(); // Utilisation de lambda avec un bloc de code Consumer<String> printer = s -> { System.out.println("Notre chaîne est : " + s); System.out.println("Sa longeur est : " + s.length()); };
Dans ces exemples, nous utilisons des lambda expressions pour créer des instances d’interfaces fonctionnelles :
Runnable
BiFunction
Consumer
Comme nous l’avons vu,
Les lambdas expressions permettent de définir une méthode abstraite de manière concise en utilisant la syntaxe suivante :
(parameters) -> expression // ou (parameters) -> { // bloc de code }
Nous pourrions utiliser notre Functional Interface Runnable de la manière suivante :
// Utilisation de lambda dans une méthode process public void process(Runnable r) { r.run(); } // Appel de la méthode avec l'écriture lambda process(() -> System.out.println("Salut l'univers !");
Dans cet exemple, nous passons une lambda expression comme argument de la méthode process(). La lambda expression est convertie en une instance de l’interface Runnable et est passée à la méthode comme un objet Runnable classique.
Vous pouvez également utiliser des lambda expressions pour les filtres et transformations de données avec les API Stream :
List<String> strings = Arrays.asList("abc", "def", "ghi"); // Filtrage avec lambda List<String> longStrings = strings.stream() .filter(string -> string.length() > 2) // on ne prend que les chaînes de caractères de longueur > à 2 .collect(Collectors.toList()); // // Transformation de données avec lambda List<String> upperCaseStrings = strings.stream() .map(s -> s.toUpperCase()) .collect(Collectors.toList());
Dans ces exemples basiques, nous utilisons des lambda expressions pour filtrer une liste de chaînes de caractères en ne conservant que les chaînes de longueur supérieure à 2, puis pour les transformer en majuscules.
Les lambda expressions sont un outil puissant pour réaliser du traitement de données de manière concise et lisible en Java. Elles sont particulièrement utiles avec les interfaces fonctionnelles et les API Stream.
La syntaxe du langage Java ne supporte pas la notion de fonction. Il n’est donc pas possible de déclarer des fonctions en Java et encore moins des fonctions anonymes pour les passer en paramètre des méthodes.
Pour ajouter des éléments de programmation fonctionnelle en Java, on utilise une interface.
Les lambdas correspondent en fait à une implémentation d’une interface qui ne déclare qu’une seule méthode. Le corps de la lambda expression correspond au code d’implémentation de cette unique méthode.
On appelle interface fonctionnelle une interface qui déclare une seule méthode abstraite et dont l’implémentation peut être fournie par une lambda.
On déclare notre interface fonctionnelle ci-dessous :
public interface OperationMultiplierParDeux { int calculer(int i); }
Là où le programme attend une implémentation de cette interface, il est possible de fournir une lambda expression :
OperationMultiplierParDeux operation = i -> i * 2; int resultat = operation.calculer(300); System.out.println(resultat); // affichera 600
Dès lors, il est aussi facile de déclarer une méthode qui accepte une lambda en paramètre. Il suffit de spécifier un paramètre correspondant à une interface fonctionnelle.
public class DemoSimple { private int valeur; // Constructeur 1 argument public DemoSimple(int valeur) { this.valeur = valeur; } // méthode qui utilise l'interface fonctionnelle public void executer(OperationMultiplierParDeux operation) { valeur = operation.calculer(valeur); } public int getValeur() { return valeur; } }
Dans la classe DemoSimple, la méthode appliquée attend en paramètre une instance d’un objet implémentant une interface fonctionnelle. Il est donc possible de fournir une lambda selon nos besoins (Que veut-on obtenir ?) :
DemoSimple ds = new DemoSimple(10); ds.executer(v -> v * v); System.out.println(vs.getValeur()); // Affiche 100
L’annotation @FunctionalInterface peut être utilisée lors de la déclaration de l’interface : Elle signale au compilateur qu’il doit vérifier que cette interface peut être implémentée par des lambdas.
Il vérifie que l’interface ne possède qu’une seule méthode abstraite et génère une erreur dans le cas contraire (Si vous en doutez, essayez d’ajouter une deuxième méthode).
@FunctionalInterface public interface OperationSimple { int calculer(int i); }
Il est possible d’introduire des expressions lambdas même pour des bibliothèques et des applications développées avant la version 8 de Java.
Remarque : En ce qui concerne la méthode sort() vue précédemment, sa signature n’a pas changé. Cette méthode attend toujours en paramètre un objet qui implémente l’interface Comparator. L’interface Comparator ne déclarant qu’une seule méthode abstraite, il s’agit donc d’une interface fonctionnelle !
Afin d’éviter aux développeurs de créer systématiquement leurs interfaces, le package java.util.function déclare les interfaces fonctionnelles les plus utiles.
Par exemple, l’interface java.util.function.IntUnaryOperator déclare une méthode applyAsInt() qui accepte un entier en paramètre et qui retourne un autre entier. Nous pouvons nous en servir pour définir un régulateur de vitesse dans une classe Vehicule.
import java.util.function.IntUnaryOperator; //... public class Vehicule { private int vitesse; private IntUnaryOperator regulateurDeVitesse = v -> v; public void accelerer(int deltaVitesse) { this.vitesse = regulateurDeVitesse.applyAsInt(this.vitesse + deltaVitesse); } public void setRegulateurDeVitesse(IntUnaryOperator regulateur) { this.regulateurDeVitesse = regulateur; } public int getVitesse() { return vitesse; } }
Application :
Vehicule v = new Vehicule(); v.setRegulateurDeVitesse(vitesse -> vitesse > 110 ? 110 : vitesse); v.accelerer(90); System.out.println(v.getVitesse()); // 90 v.accelerer(90); System.out.println(v.getVitesse()); // 110
Le package java.util.function fournit de nombreuses interfaces. Il y a une convention particulière concernant la nommination des interfaces qui nous permet de comprendre son rôle, le suffixe nous donne une information importante.
Si le nom de la functional interface se termine par :
T
U
Voici un exemple d’utilisation de l’interface BiFunction :
BiFunction<String, Integer, String> function = (s, i) -> s + i; String result = function.apply("Louis", 14); // "Louis14"
Dans cet exemple, on créé une instance de BiFunction qui concatène une chaîne de caractères et un entier lorsque la méthode apply() est appelée. On appelle ensuite la méthode avec 2 arguments pour obtenir le résultat de la concaténation.
L’interface BiFunction est souvent utilisée avec les méthodes de l’API Stream, comme avec Stream.reduce() qui réduit les éléments d’un flux en un résultat unique en utilisant une BiFunction. Voici un dernier exemple :
List<Integer> integers = Arrays.asList(1, 2, 3, 4, 5, 6); BiFunction<Integer, Integer, Integer> function = (i1, i2) -> i1 + i2; Integer sum = integers.stream().reduce(0, function); // 21
Ci-dessus, la BiFunction calcule la somme de deux entiers et permet avec Stream.reduce() de calculer la somme de tous les éléments de la liste.
Comment feriez-vous avec l’écriture du style impérative (POO) ?
List<Integer> integers = Arrays.asList(1, 2, 3, 4, 5, 6); int sum = 0; for (int i : integers) { sum = sum + i; }
Finalement, c’est pas beaucoup plus long !)
Voici un exemple :
import java.util.function.Predicate; //... Predicate<String> predicate = chaine -> chaine.length() > 5; boolean test1 = predicate.test("agnès"); // false boolean test2 = predicate.test("anticonstitutionnellement"); // true System.out.println("test1 = "+test1+ " test2 = "+test2);
test1 = false test2 = true
On teste si une chaîne de caractères a une longueur supérieure à 5. Nous appelons ensuite la méthode test() sur deux chaînes de caractères pour vérifier si elles satisfont ou non au prédicat.
Exemple :
import java.util.function.Operator; //... Operator<Integer> operator = i -> i * 1000; int resultat1 = operator.apply(1); // 1000 int resultat2 = operator.apply(2); // 2000 System.out.println("resultat1 = "+resultat1+ " resultat2 = "+resultat2);
Consumer<String> consumer = chaîneAAfficher -> System.out.println(chaîneAAfficher); consumer.accept("Bravo"); // affiche "Bravo" consumer.accept("Trop facile !"); // affiche "Trop facile !"
Exemple d’utilisation avec Stream.forEach() :
import java.util.function.Consumer; //... List<String> chaines = Arrays.asList("Je", "crois", "que", "je", "suis", "Consumer"); Consumer<String> consumer = chaine -> System.out.println(chaine); chaines.stream().forEach(consumer);
Franchement, je ne suis pas certain que l’utilisation de ce Consumer soit d’un grand intérêt ! ;)
Supplier<String> supplier = () -> "Bonjour des rois et reines du Lambda !"; String s = supplier.get();
Dans cet exemple, nous créons une instance de Supplier qui renvoie la chaîne de caractères “Bonjour des rois et reines du Lambda !” lorsque la méthode get() est appelée. PAr conséquent, nous appelons ensuite la méthode get() pour récupérer la chaîne de caractères résultat.
L’interface Supplier est souvent utilisée avec les méthodes de l’API Stream de Java 8 qui acceptent des Supplier comme argument, comme Stream.generate() qui crée un flux infini à partir d’un Supplier.
Voici un dernier exemple d’utilisation de Stream.generate() avec Supplier :
Supplier<Integer> supplier = new Random()::nextInt; Stream<Integer> stream = Stream.generate(supplier);
Dans cet exemple, nous créons un Supplier qui utilise la méthode nextInt() de la classe Random pour générer un nombre aléatoire à chaque appel de la méthode get(). Nous utilisons ensuite ce Supplier pour créer un flux infini de nombres aléatoires avec Stream.generate().
::
Un aspect important de la programmation fonctionnelle est de pouvoir référencer les fonctions, notamment pour pouvoir les passer comme paramètres dans un appel à une autre fonction.
En Java, la notion de fonction n’existe pas en tant que telle et une méthode n’est pas une entité de première classe. Néanmoins, il est possible de référencer une méthode en utilisant l’opérateur ::. La référence de méthode rend le code plus lisible en évitant de déclarer une lambda.
Prenons un exemple : nous souhaitons écrire une lambda qui attend une chaîne de caractères en paramètre et qui retourne la valeur de la chaîne de caractères en lettres majuscules. Du point de vue des interfaces fonctionnelles, il s’agit d’un opérateur unaire. Nous pouvons donc écrire :
UnaryOperator<String> f = s -> s.toUpperCase();
Mais nous pouvons également considérer que la méthode toUpperCase agit comme un opérateur unaire : elle s’applique sur un objet de type String puisqu’elle est une méthode de la classe String et elle retourne une valeur de type String. Donc, il est possible de se passer complètement de la lambda pour référencer directement la méthode toUpperCase :
UnaryOperator<String> f = String::toUpperCase;
Notez que dans l’exemple ci-dessus, nous n’appelons pas la méthode toUpperCase mais nous la référençons avec l’opérateur ::.
Il est également possible de référencer une méthode d’un objet particulier. Dans ce cas, la méthode est nécessairement invoquée sur l’objet à partir duquel on référence la méthode. En programmation, on appelle cela un binding.
Si nous reprenons un exemple vu précédemment :
Collection<String> collection = new ArrayList<>(); collection.add("un"); collection.add("deux"); collection.add("trois"); collection.forEach(e -> System.out.println(e));
La méthode forEach() attend en paramètre une instance qui implémente l’interface fonctionnelle Consumer. L’interface Consumer déclare la méthode accept qui prend un type T en paramètre et ne retourne rien. Si maintenant nous comparons cette signature avec celle la méthode println appelée sur l’objet System.out, cette dernière attend un objet en paramètre et ne retourne rien. La signature de println est compatible avec celle de la méthode de l’interface fonctionnelle Consumer. Donc, plutôt que de déclarer une lambda, il est possible d’utiliser l’opérateur :: pour passer la référence de la méthode println :
Collection<String> collection = new ArrayList<>(); collection.add("un"); collection.add("deux"); collection.add("trois"); collection.forEach(System.out::println); // passage de la référence de la méthode
Il est également possible de référencer les constructeurs d’une classe. Cela aboutira à la création d’un nouvel objet à chaque appel. Les constructeurs sont référencés grâce à la syntaxe :
NomDeLaClasse::new
Par exemple, nous pouvons utiliser l’interface fonctionnelle Supplier. Cette interface fonctionnelle peut être implémentée en utilisant un constructeur sans paramètre. Ainsi, si nous définissons une classe Voiture avec un constructeur sans paramètre :
public class Voiture { public Voiture() { // ... } }
Nous pouvons utiliser la référence de ce constructeur pour créer une implémentation de l’interface fonctionnelle Supplier :
Supplier<Voiture> garage = Voiture::new; Voiture v1 = garage.get(); // crée une nouvelle instance Voiture v2 = garage.get(); // crée une nouvelle instance
De la même manière, nous pouvons référencer un constructeur avec des paramètres. Par exemple, la classe SimpleDateFormat déclare un constructeur qui attend une chaîne de caractères en paramètre pour définir le format d’une date. Un tel constructeur s’apparente à une interface fonctionnelle de type Function :
Function<String, SimpleDateFormat> f = SimpleDateFormat::new; SimpleDateFormat sdf = f.apply("dd/MM/YYYY");
La référence de méthode permet de simplifier l’écriture du code mais elle permet aussi d’étendre le support de la programmation fonctionnelle au-delà des fonctions anonymes. Si vous devez écrire un traitement complexe, alors l’utilisation d’une lambda risque de complexifier la lecture de votre code. Dans ce cas, il est recommandé d’écrire le traitement sous la forme d’une méthode et d’utiliser l’opérateur de référence de méthode pour passer cette méthode en paramètre.
Pour donner un aperçu du style de programmation fonctionnelle en Java, nous allons prendre l’exemple de la classe générique Optional introduite en Java 8.
Dans beaucoup de langages de programmation, nous avons la possibilité d’affecter à des variables, des paramètres ou des attributs une référence nulle.
String s = null;
Il faut bien reconnaître que cela n’a pas vraiment de sens puisque nous affectons ainsi à quelque chose, quelque chose qui n’est rien ! Et surtout cela peut conduire à toutes sortes de bugs et à l’apparition inopinée de NullPointerException en Java.
Java 8 introduit la classe Optional qui permet de représenter la possibilité qu’il existe ou non une valeur sans avoir besoin d’utiliser null.
La classe Optional fournit les méthodes de construction statiques of et ofNullable ainsi que la méthode isPresent pour vérifier si une valeur est présente et la méthode get pour obtenir sa valeur.
Optional<String> v = Optional.ofNullable("test"); if (v.isPresent()) { System.out.println(v); } else { System.out.println("valeur non trouvée"); }
L’intérêt de la classe Optional est de l’utiliser avec un style de programmation fonctionnelle.
Optional<String> v = Optional.ofNullable("Super"); // retourne la valeur de l'optional si elle est définie ou la valeur par // défaut passée en paramètre. System.out.println(v.orElse("valeur non trouvée !")); // invoque la méthode passée en référence uniquement si une valeur est définie // par l'optional v.ifPresent(System.out::println); // retourne la valeur de l'optional si elle est définie ou lance une exception // construite à partir du constructeur fourni en paramètre. String resultat = v.orElseThrow(ValeurNonTrouveeException::new);
Et voici l’équivalent en style impératif comme on le fait habituellement (car plus évident à écrire pour les vieux développeurs ou développeuses) :
String v = "Super"; String result; if (v != null) { System.out.println(v); result = v; } else { System.out.println("valeur non trouvée !"); result = "valeur non trouvée"; } if (v != null) { System.out.println(v); } if (v != null) { result = v; } else { throw new ValeurNonTrouveeException(); }
Function | ------------|------------ / \ UnaryOperator BinaryOperator | ----------------|---------------- / \ BiFunction ToDoubleBiFunction | ----------------|---------------- / \ ToLongBiFunction ToIntBiFunction
Comme vous pouvez le voir, la classe Function est la classe de base de la hiérarchie des functions. Elle définit une fonction qui prend un argument et renvoie un résultat.
Les classes UnaryOperator et BinaryOperator sont des sous-classes de Function qui fournissent des implémentations spécialisées pour des fonctions à un ou deux arguments. Elles définissent également une méthode andThen qui permet de chaîner des fonctions de manière fonctionnelle.
Les classes BiFunction, ToDoubleBiFunction, ToLongBiFunction et ToIntBiFunction sont des sous-classes de BinaryOperator qui fournissent des implémentations spécialisées pour des fonctions à deux arguments de types différents. Elles définissent également des méthodes de conversion vers des types primitifs (double, long, int).
Vous pouvez utiliser ces classes de la même manière que vous utiliseriez Function, en fonction de vos besoins en matière de types et de conversions. Par exemple, si vous avez besoin d’une fonction qui prend deux arguments et renvoie un résultat de type long, vous pouvez utiliser ToLongBiFunction. Si vous avez besoin d’une fonction qui prend un argument et renvoie un résultat de type int, vous pouvez utiliser UnaryOperator.
Le code en style impératif utilise des instructions if et des boucles pour contrôler le flux d’exécution et effectuer des actions conditionnelles.
Le style impératif est moins concis que le code utilisant Optional, mais peut être plus facile à comprendre pour certains et certaines d’entre-nous !
Cependant, il est recommandé d’utiliser Optional dans la plupart des cas, car il permet de réduire les erreurs de codage liées aux valeurs null et rend le code plus lisible et maintenable.
Bravo ! Vous avez découvert des notions de bases de la programmation fonctionnelle, c’est plus ça rend le code plus concis, plus joli, plus fiable mais cela demande un peu d’entraînement.