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 dev.gayerie;

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 dev.gayerie;

@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 dev.gayerie;

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 dev.gayerie;

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