Romain JOURDIER, Leader Technique, a assisté à DevoxxFR 2013 et nous a rapporté une vision à 360° de ce que la programmation fonctionnelle va apporter et comment elle s’intègre dans l’écosystème Java: maintenant et dans le futur.
1. Des fonctions d’ordre supérieur
Pour un programmeur Java, la programmation fonctionnelle peut sembler déroutante. La vue d’un programme écrit dans un langage fonctionnel n’aide généralement pas à sa compréhension pour un « non-initié ». Pourtant comme souvent, seul le premier pas est le plus difficile, et le gain que l’on peut en tirer peut valoir le coup d’œil.
1.1. Un exemple
Là où un programme Java décrit comment arriver à la solution via une succession d’états, un programme fonctionnel va décrire ce qu’est la solution (quoi plutôt que comment). Par exemple, pour récupérer les noms des personnes majeures à partir d’une liste de personnes, voici une version java et une version fonctionnelle :
Version Java :
public String getNomsPersonnesMajeures(List<Personne> personnes) { String result = ""; boolean isFirstResult = true; for (Personne p : personnes) { if (p.getAge() >= 18) { // Récupération des personnes majeures String nom = p.getNom(); // Récupération des noms if (nom.isNotBlank()) { // Retrait des noms vides if (isFirstResult) { isFirstResult = false; } else { result += ", "; // Concaténation } result += nom; // Concaténation } } } return result; }
Version fonctionnelle (pseudo-code) :
fonction getNomsPersonnesMajeures(personnes: List<Personne>): String = { pour les personnes qui vérifient age >= 18 je veux leurs noms qui ne sont pas vides et je les concatène }
Cette dernière version, bien plus lisible dès le premier coup d’œil, se décompose ainsi :
- Filtrage des personnes : seulement les personnes majeures
- Transformation des personnes : les personnes sont réduites à leurs noms
- Filtrage des noms : seulement les noms non vides
- Agrégation des noms : concaténation
1.2. En java aussi, c’est possible
Nous allons maintenant voir comment implémenter la version fonctionnelle en Java. Pour cela nous allons implémenter les fonctions filter (filtrage), map (transformation) et reduce (agrégation), qui seront les bases de la manipulation des listes.
Commençons par la fonction map. Il s’agit d’une fonction qui prend une liste en paramètre et applique une transformation sur chacun de ses éléments.
public <T, R> List<R> map(List<T> input, ? transformation) { List<R> output = new ArrayList<R>(); for (T elt : input) { output.add(transformation(elt)); } return output; }
La fonction filter quant à elle, prend une liste en paramètre et retourne une nouvelle liste ne comportant que les éléments de la première qui vérifient une condition.
public <T> filter(List<T> input, ? condition) { List<T> output = new ArrayList<T>(); for (T elt : input) { if (condition(elt)) { output.add(elt); } } return output; }
La fonction reduce peut être résumée ainsi : on applique un opérateur entre chaque élément de la liste.
public <T, R> reduce(List<T> input, R elementNeutre, ? agregat) { R result = elementNeutre; for (T elt : input) { result = agregat(result, elt); } return result; }
Pour que ces fonctions soient complètes, il ne nous reste plus qu’à définir la transformation, la condition et l’agrégat. Ce sont en fait trois fonctions :
• transformation: T -> R • condition: T -> boolean • agregat: (R, T) -> R
A partir de cette constatation, il est aisé d’écrire les interfaces Function et Function2, que ces fonctions devront implémenter :
public interface Function<T, R> { R apply(T input); } public interface Function2<TL, TR, R> { R apply(TL left, TR right); }
Les fonctions map, filter et reduce sont donc des fonctions qui prennent d’autres fonctions en paramètre : on les appelle des fonctions d’ordre supérieur. Voici les prototypes complets de ces fonctions :
public <T, R> List<R> map(List<T> input, Function<T, R> transformation); public <T> filter(List<T> input, Function<T, Boolean> filtre); public <T, R> reduce(List<T> input, R elementNeutre, Function2<R, T, R> agregat);
Nous y sommes ! Nous pouvons maintenant écrire notre fonction getNomsPersonnesMajeures en Java, mais à la sauce fonctionnelle.
public String getNomsPersonnesMajeures(FunctionalList<Personne> personnes) { return personnes // parmi personnes .filter(EST_MAJEURE) // qui vérifient age >= 18 .map(GET_NOM) // je veux leurs noms .filter(IS_NOT_BLANK) // qui ne sont pas vides .reduce1(CONCATENATION) // et je les concatène }
Et voici l’implémentation des fonctions passées en paramètre :
public static final Function<Personne, Boolean> EST_MAJEURE = new Function<Personne, Boolean>() { public Boolean apply(Personne p) { return p.getAge() >= 18; } } public static final Function<Personne, Boolean> GET_NOM = new Function<Personne, String>() { public String apply(Personne p) { return p.getNom(); } } public static final Function<Personne, Boolean> IS_NOT_BLANK = new Function<String, Boolean>() { public Boolean apply(String s) { return s.isNotBlank(); } } public static final Function2<String, String, String> CONCATENATION = new Function2<String, String, String>() { public Boolean apply(String left, String right) { return left + ", " + right; } }
Vous aurez peut-être remarqué l’utilisation de la fonction reduce1 plutôt que reduce. L’utilisation de reduce(« », CONCATENATION) aurait donné comme résultat « , Nom1, Nom2, Nom3″; reduce1 est une alternative à reduce qui prend comme élément neutre le premier élément de la liste, ainsi, reduce1(CONCATENATION) retourne bien « Nom1, Nom2, Nom3″.
2. Java 8 change la donne
2.1. La syntaxe
2.1.1. Lambda
Devoir déclarer des objets Function, que ce soient des variables ou des classes anonymes, est lourd et peu lisible en Java : dans les exemples précédents, seules les parties en gras sont réellement utiles. Java 8 introduit une nouvelle syntaxe pour représenter des fonctions anonymes (les lambdas), ainsi
(String a, String b) -> { return a + ", " + b; }
… est une fonction anonyme prenant en entrée deux entiers et retournant leur somme.
Les types des arguments peuvent être omis, ainsi cette expression peut être simplifiée en :
(a, b) -> { return a + ", " + b; }
Si l’expression se contente de retourner une valeur, les accolades et le mot-clef « return » peuvent être omis, ainsi :
(a, b) -> { return a + ", " + b }
Peut être écrit :
(a, b) -> a + ", " + b
Notre fonction CONCATENATION de départ est désormais bien plus simple à écrire, et surtout plus lisible !
Une fonction sans argument :
() -> "Hello world!"
Pour une fonction avec un seul argument, les parenthèses autour de ce dernier peuvent être omises, ainsi :
(n) -> n * n
Peut être écrit :
n -> n * n
2.1.2. Référence de fonction
Jusqu’à présent nous avons vu comment déclarer des fonctions anonymes. Parfois on peut vouloir utiliser une méthode déjà existante. C’est à cette fin que Java 8 introduit les références de fonctions.
Par exemple, la fonction
Math.max(int a, int b)
… peut être référencée par
Math::max
Et ainsi, pour récupérer la plus grande valeur d’une liste, on écrira simplement
public int getMax(List<Integer> liste) { return liste.reduce1(Math::max); }
Il existe 4 types de références de méthode :
| Type | Formalisme | Exemple |
|---|---|---|
| Référence vers une méthode statique | Classe::methode | Math::max |
| Référence vers une méthode d’instance d’un objet donné | objet::methode | « chaine »::matches |
| Référence vers une méthode d’instance d’une classe donnée | Classe::methode | String::matches |
| Constructeur | Classe::new |
2.1.3. Quand peut-on les utiliser ?
Les lambdas et références de méthode peuvent être utilisés partout où une classe SAM est attendue. Une classe SAM (pour Single Abstract Method) est une interface ou une classe abstraite exposant une unique méthode abstraite ; le lambda ou la référence de méthode devant alors avoir le même prototype que ladite méthode. Nous pouvons citer, comme classes SAM, les classes Runnable, ActionListener, Comparator, etc.
2.2. Mise à jour de la bibliothèque standard
Les collections s’enrichissent de nouvelles méthodes s’appuyant sur les lambdas. Nous pouvons citer, par exemple (pour liste = [1, 2, 3, 4]) :
• map : liste.map(n -> 2*n) => [2, 4, 6, 8] • reduce : liste.reduce(0, (a, b) -> a + b) => 10 • filter : liste.filter(n -> n%2 == 0) => [2, 4]
Voici l’implémentation Java 8 de notre fonction getNomsPersonnesMajeures :
public String getNomsPersonnesMajeures(List<Personne> personnes) { return personnes // parmi personnes .filter(p -> p.getAge() >= 18) // qui vérifient age >= 18 .map(p -> p.getNom()) // je veux leurs noms .filter(n -> n.isNotBlank()) // qui ne sont pas vides .reduce1((res, n) -> res + ", " + n) // et je les concatène }
Ainsi, plus besoin de déclarer nos méthodes EST_MAJEURE, GET_NOM, IS_NOT_BLANK et CONCATENATION !
En plus d’être plus lisible, cette implémentation peut être plus optimisée car les implémentations des fonctions filter, map, reduce peuvent être paresseuses (n’effectuer la transformation ou le filtrage que si c’est vraiment nécessaire : si la valeur résultante est effectivement appelée plus tard) et parallélisées.
Ainsi, via la fonction parallel, certaines de ces nouvelles opérations peuvent utiliser à leur avantage les architectures multi-cœurs de nos machines actuelles : la fonction précédente peut être parallélisée à moindre frais grâce à la fonction parallel :
public String getNomsPersonnesMajeures(List<Personne> personnes) { return personnes // parmi personnes .parallel() // calculer en parallèle .filter(p -> p.getAge() >= 18) // qui vérifient age >= 18 .map(p -> p.getNom()) // je veux leurs noms .filter(n -> n.isNotBlank()) // qui ne sont pas vides .reduce1((res, n) -> res + ", " + n) // et je les concatène }
Je vous laisse le soin d’écrire la version Java 7… Personnellement, le courage me manque.
3. Conclusion
Les fonctions d’ordre supérieur permettent de factoriser davantage le code et donc de :
- Réduire les risques d’erreur,
- Avoir une implémentation standard plus maintenable et optimisable,
- Se concentrer sur le quoi plutôt que sur le comment dans le reste du programme.
En offrant des facilités d’écriture et tout un panel de méthodes tirant parti des lambdas, Java 8 fait un pas vers la programmation fonctionnelle. Il n’est cependant pas nécessaire d’attendre Java 8 pour s’y mettre : des bibliothèques comme Guava offrent déjà des classes et des méthodes facilitant l’écriture de programmes fonctionnels en Java.
Et parce que la programmation fonctionnelle ne se résume pas aux fonctions d’ordre supérieur, il peut être intéressant de regarder ce qui se fait du côté de langages comme Scala, utilisant la JVM et interopérable avec Java, et proposant des fonctionnalités puissantes comme le filtrage par motif (pattern matching), les traits, une bibliothèque standard tournée vers la programmation fonctionnelle, etc.






