Programmation fonctionnelle et lambdas¶
La programmation fonctionnelle est un paradigme de programmation basé sur l’appel et la composition de fonctions. À l’origine, elle apparaît comme une approche alternative à la programmation impérative à laquelle appartient la programmation objet. Néanmoins, de plus en plus de langages de programmation impératifs incorporent dans leur grammaire des éléments de programmation fonctionnelle. C’est également le cas en Java depuis sa version 8.
Un trait important de la programmation fonctionnelle est de considérer une fonction comme une entité de première classe (first class citizen), c’est-à-dire qu’on peut lui appliquer les mêmes opérations que n’importe quel autre élément du langage. Ainsi une fonction doit posséder une identité intrinsèque lui permettant d’être passée en paramètre d’une autre fonction ou retournée par une autre fonction.
Note
Une fonction qui accepte une ou plusieurs autres fonctions en paramètres et qui retourne une fonction est appelée une fonction d’ordre supérieur (higher-order function).
Une approche fonctionnelle va favoriser la représentation d’une application comme
un appel chaîné de fonctions pouvant prendre elles-mêmes des fonctions en paramètres.
Ce type de représentation à plusieurs avantages. D’abord, il favorise le découpage
du traitement en une suite de processus simples représentés chacun par une fonction.
Cela améliore la lisibilité et la testabilité d’un programme. Pour les langages
impératifs comme Java, cela offre une manière alternative d’implémenter certains
traitements en limitant l’usage des structures de contrôle comme if
ou les
structures de boucle comme while
et for
qui sont alors implémentées sous
la forme d’appels de fonctions. Là encore, l’objectif est d’améliorer la lisibilité
du code en évitant l’imbrication de blocs de traitement et d’accolades.
L’introduction d’éléments de programmation fonctionnelle en Java a été un processus complexe car la grammaire du langage ne permet pas la déclaration de fonction. Comme nous le verrons, les fonctions anonymes ou les références de fonction en Java sont en fait un sucre syntaxique : il s’agit d’une simplification d’écriture qui mobilise en arrière-plan des notions purement objet comme l’interface fonctionnelle.
Les fonctions anonymes : les lambdas¶
Une lambda est une fonction anonyme, c’est-à-dire une fonction qui est déclarée sans être associée à un nom. Le terme lambda est emprunté à la méthode formelle du lambda-calcul. Les fonctions lambda (ou plus simplement les lambdas) sont couramment utilisées dans la programmation fonctionnelle. Elles permettent d’écrire des programmes plus concis et elles permettent de créer des closures (fermetures).
Depuis la version 8, il est possible de déclarer des lambdas en Java.
Syntaxe des lambdas¶
En Java, les lambdas s’écrivent sous la forme :
(paramètres) -> { corps }
Les parenthèses ne sont pas obligatoires si la lambda n’a qu’un seul paramètre.
Si le compilateur peut inférer le type des paramètres alors il n’est pas obligatoire de déclarer ce type.
Les accolades peuvent être omises si la lambda n’a qu’une instruction et si le contexte le permet.
Si le corps de la lambda ne contient qu’une instruction, on peut omettre le point-virgule à la fin de l’instruction.
Si le corps de la lambda ne contient qu’une instruction, on peut omettre le mot-clé
return
et le compilateur infère que la fonction retourne le résultat de l’instruction.
Exemple d’une lambda qui attend deux nombres entiers comme paramètres et qui retourne la somme de ces nombres :
(int a, int b) -> { return a + b; }
Notez qu’il n’est pas nécessaire de fournir le type de retour de la fonction.
Le compilateur peut inférer qu’il s’agit d’un int
correspondant à la somme
des deux paramètres.
Cette fonction anonyme est équivalente à la déclaration d’une méthode statique dans la classe :
public static int somme(int a, int b) {
return a + b;
}
La forme lambda évite d’avoir à fournir un nom à la fonction dont l’implémentation
est évidente. On peut encore simplifier la lambda en supprimant les accolades,
le mot-clé return
et le point-virgule puisque cette fonction anonyme ne
contient qu’une seule instruction.
(int a, int b) -> a + b
Note
Le corps d’une lambda se limite le plus souvent à une seule instruction très simple. Même s’il est possible de déclarer une lambda ayant plusieurs lignes d’instruction, cela doit rester l’exception dans un programme.
D’autres exemples de lambdas :
Une fonction qui attend une chaîne de caractères pour l’afficher sur la console :
String s -> System.out.println(s)
Une fonction qui ne prend aucun paramètre et qui retourne le nombre 42 :
() -> 42
Usage des lambdas¶
Depuis Java 8, beaucoup de méthodes se comportent maintenant comme des fonctions de premier ordre en acceptant des fonctions en paramètre. Il est alors possible de déclarer une lambda au moment de l’appel.
Prenons l’exemple de la méthode sort. Cette méthode déclarée par l’interface List permet de trier les éléments de cette liste. Mais pour cela, cette méthode a besoin d’appliquer une comparaison sur les éléments deux à deux pour connaître la relation d’ordre. L’algorithme de comparaison peut être donné par une lambda :
List<Integer> liste = new ArrayList<>();
liste.add(1);
liste.add(2);
liste.add(3);
liste.add(4);
// trie la liste en plaçant en premier les nombres pairs
liste.sort((e1, e2) -> (e1 % 2) - (e2 % 2));
// [2, 4, 1, 3]
System.out.println(liste);
Notez qu’il n’est pas nécessaire de préciser le type des paramètres e1
et e2
pour la déclaration de la lambda. Le compilateur peut les inférer car les éléments
de la liste sont de type Integer
.
Beaucoup de méthodes ont également été ajoutées à partir de Java 8 comme la méthode forEach déclarée par l’interface Iterable. On peut ainsi effectuer un traitement sur chaque élément d’une collection en passant une lambda en paramètre.
Collection<String> collection = new ArrayList<>();
collection.add("un");
collection.add("deux");
collection.add("trois");
collection.forEach(e -> System.out.println(e));
On voit dans cet exemple simple que la programmation fonctionnelle implique un style différent de programmation. Nous pourrions écrire la même portion de code en utilisant une structure de boucle typique de la programmation impérative :
Collection<String> collection = new ArrayList<>();
collection.add("un");
collection.add("deux");
collection.add("trois");
for(String e : collection) {
System.out.println(e);
}
Lambda et closure¶
Une lambda définit une closure (fermeture), c’est-à-dire qu’elle définit un environnement lexical constitué de toutes les variables et de tous les attributs qu’elle capture au moment de sa déclaration. Le corps d’une lambda peut donc accéder au contenu d’une variable déclarée dans le code englobant.
1 2 3 4 5 6 7 8 9 10 11 | List<String> prenoms = new ArrayList<>();
prenoms.add("Murielle");
prenoms.add("Jean");
prenoms.add("Michelle");
List<String> helloList = new ArrayList<>();
prenoms.forEach(e -> helloList.add("Hello " + e));
// [Hello Murielle, Hello Jean, Hello Michelle]
System.out.println(helloList);
|
Dans l’exemple ci-dessus, on déclare à la ligne 6 la variable helloList
correspondant à une nouvelle liste. À la ligne 8, la lambda a pour tache d’ajouter
un nouvel élément dans cette liste. Pour le compilateur, la variable helloList
utilisée dans la lambda correspond bien à celle déclarée précédemment. Cette
variable a été capturée par la closure définie par la lambda.
Comme pour la déclaration de classe anonyme, une lambda ne peut pas modifier le contenu d’un paramètre ou d’une variable issue d’une closure (le compilateur émettra une erreur dans ce cas). Par contre, il n’est pas nécessaire de déclarer comme final une variable ou un paramètre pour pouvoir y accéder dans une lambda.
List<Integer> liste = new ArrayList<>();
liste.add(1);
liste.add(2);
liste.add(3);
liste.add(4);
int i = 0;
liste.forEach(e -> i += e); // ERREUR DE COMPILATION : la variable i ne peut pas
// être modifiée car elle fait partie de la closure.
Les interfaces fonctionnelles¶
La grammaire 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 incorporer des éléments de programmation fonctionnelle, les concepteurs du langage ont dû recourir à une astucieuse manipulation. 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 correspond au code d’implémentation de cette unique méthode. Ainsi, on appelle interface fonctionnelle une interface déclarant une seule méthode abstraite et dont l’implémentation peut être fournie par une lambda.
Si nous déclarons l’interface ci-dessous :
package fr.epsi.b3;
public interface OperationSimple {
int calculer(int i);
}
Alors, partout où le programme attend une implémentation de cette interface, il est possible de fournir une lambda :
OperationSimple os = i -> 2 * i;
int resultat = os.calculer(10);
System.out.println(resultat); // Affiche 20
Ainsi, il est très facile de déclarer des méthodes qui acceptent une lambda en paramètre. Il suffit de spécifier un paramètre correspondant à une interface fonctionnelle.
public class ValeurSimple {
private int valeur;
public ValeurSimple(int valeur) {
this.valeur = valeur;
}
public void appliquer(OperationSimple os) {
valeur = os.calculer(valeur);
}
public int getValeur() {
return valeur;
}
}
Dans la classe déclarée ci-dessus, la méthode appliquer
attend en paramètre
une instance d’un objet implémentant une interface fonctionnelle. Il est donc
possible de fournir une lambda :
ValeurSimple vs = new ValeurSimple(10);
vs.appliquer(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. Le compilateur contrôle que l’interface ne comporte qu’une seule méthode abstraite et signale une erreur dans le cas contraire.
package fr.epsi.b3;
@FunctionalInterface
public interface OperationSimple {
int calculer(int i);
}
Il est donc très simple d’introduire des lambdas même pour des bibliothèques et des applications qui ont été développées avant Java 8.
Note
Concernant la méthode sort présentée précédemment, sa signature n’a pas évolué en Java 8. 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
Voiture.
package fr.epsi.b3;
import java.util.function.IntUnaryOperator;
public class Voiture {
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;
}
}
Voiture v = new Voiture();
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 beaucoup d’interfaces. Pour s’y retrouver, il suffit de garder à l’esprit la convention de nom utilisée :
Les interfaces fonctionnelles dont le nom se termine par Function
déclarent
une méthode qui prend un ou plusieurs paramètres et qui retourne un résultat.
Par exemple, l’interface BiFunction déclare la méthode apply
qui accepte
deux paramètres de types différents et retourne un paramètre d’un autre type.
Les interfaces fonctionnelles dont le nom se termine par Operator
déclarent
une méthode qui prend un ou plusieurs paramètres du même type et retourne un
paramètre du même type. Par exemple, l’interface DoubleUnaryOperator déclare la
méthode applyAsDouble
qui attend un paramètre de type double
et retourne
un résultat de type double
.
Les interfaces fonctionnelles dont le nom se termine par Consumer
déclarent
une méthode qui prend un ou plusieurs paramètres mais qui ne retourne aucun
résultat. Par exemple, l’interface LongConsumer déclare la méthode accept
qui attend un paramètre de type long
et qui ne retourne rien.
Les interfaces fonctionnelles dont le nom se termine par Supplier
déclarent
une méthode qui ne prend aucun paramètre mais qui retourne un résultat. Par
exemple, l’interface Supplier déclare la méthode get
qui n’attend aucun
paramètre et qui retourne un objet du type générique de l’interface.
Les interfaces fonctionnelles dont le nom se termine par Predicate
déclarent
une méthode qui prend un paramètre et qui retourne un résultat de type booléen.
L’opérateur :: de référence de méthode¶
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 :
package fr.epsi.b3;
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");
Astuce
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.
La classe Optional¶
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");
}
Si on utilise une instance de la classe Optional à la manière des langages impératifs, on peut obtenir du code simple à comprendre mais assez inutilement long. L’intérêt de la classe Optional est de l’utiliser avec un style de programmation fonctionnelle.
Optional<String> v = Optional.ofNullable("test");
// 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 jette une exception
// construite à partir du constructeur fourni en paramètre.
String resultat = v.orElseThrow(ValeurNonTrouveeException::new);
Nous pourrions écrire le même code en style impératif mais au prix de l’utilisation
de plusieurs structures de contrôle if
… else
.