Programmation Fonctionnelle

Introduction

La programmation fonctionnelle est un paradigme de programmation basé sur la notion de fonction et d'expression lambda. Elle est apparue en Java 1.8.

Pour mettre en œuvre la programmation fonctionnelle en Java, nous utiliserons les concepts suivants :

  • Les interfaces fonctionnelles
  • Les méthodes par défauts des interfaces
  • Les expressions lambda.

Interface fonctionnelle

Une interface fonctionnelle est une interface qui ne possède qu'une seule méthode abstraite. Mais elle peut posséder plusieurs méthodes concrète par défaut. Elle porte également l'annotation @FunctionalInterface.

Exemple :

@FunctionalInterface
public interface MyFunction<T>{

   void apply(T element);
}

De nombreuse interfaces fonctionnelles génériques sont déjà présentent dans Java pour couvrir les cas courants:

Interface Type de retour Type en entrée Commentaire
Consumer void T Consomme une donnée
Supplier T void Produit une donnée
Predicate boolean T Pour construire une condition
Function R T Fonction générique
BiConsumer void T, U Variante du Consumer pour deux paramètres
BiPredicate boolean T, U Variante du Predicate pour deux paramètres
BiFunction R T, U Variante de Function pour deux paramètres

Pour une liste plus exhaustive, voir le package java.util.function.

Expression lambda

Vous pouvez voir l'écrire d'une expression lambda comme une façon plus simple d'utiliser des classes anonymes. Mais son fonctionnement en interne est radicalement différent.

// Classe anonymes:
// Cette écriture est ni élégante, ni épurée.
// A EVITER !
Function<String, String> upper = new Function<>() {

  @Override
  public String apply(String t){
    return t.toUpperCase();
  }
};

// Usage:
var result = upper.apply("Hello world");
// Sortie console:
// HELLO WORLD

Dans le code ci-dessus, la seule information réellement utile le traitement de la fonction apply. On peut donc réduire l'écriture ainsi:

// Expression lambda multi ligne.
Function<String, String> upper0 = (String t) -> {
                                  ^^^^^^^^^^--------// 
                                  // Paramètre de la fonction 'apply'.
                                  // L'instanciation n'est plus nécessaire.
                                  // Le nom de la fonction n'est plus nécessaire également.

  return t.toUpperCase();   // Le traitement est sur une ligne, on peut simplifier l'écriture.
};

// Expression lambda en une ligne.
Function<String, String> upper1 = t -> t.toUpperCase();
                                 ^^^-------------------//
                                 // Paramètre de la fonction 'apply'.
                                 // Le type de 't' est inféré, il est porté par le membre de gauche
                                 // on peut donc omettre le typage.
                                 // Les parenthèses sont optionnelles car il n'y a qu'un seul paramètre.
                                 // Les accolades ne sont plus nécessaires car le traitement tient en une ligne.

// Passage par référence
Function<String, String> upper2 = String::toUpperCase;
                                        ^^^^^^^^^^^^^----//
                                        // La fonction 'toUpperCase' est transtyper en 'apply'.
                                        // Le paramètre n'est plus nécessaire, il sera résolu
                                        // à l'appel de la fonction.
                                        // C'est le principe d'application partielle.

Lors de l'écriture d'une expression lambda privilégier le passage par référence de fonction ou l'écriture sur une ligne. Les écritures en blocs sont à éviter, cela signifie que vous pouvez extraire le bloc dans une autre fonction.

Note

Toutes les fonctions d'une classe peuvent être passées par référence. Pour le constructeur la syntaxe est la suivante : MyClass::new.

Concept

Voici les principes fondamentaux de la programmation fonctionnelle :

  • Fonction pure
  • Transparence référentielle
  • Fonction d'ordre supérieur
  • Évaluation paresseuse
  • Application partielle
  • Structure de données immuables

Note

En Java nous ne ferons pas de distinction entre une méthode et une fonction. Ce sont des synonymes.

Fonction pure

Une fonction pure, est une fonction qui ne produit pas d'effet de bord. Elle ne modifie pas son environnement. C'est-à-dire, pour des paramètres d'entrées données, la fonction produira toujours le même résultat sans influencer son environnement.

Exemple :

// Fonction pure
public int getUniversalAnswer(){

  return 42;
  // Retourne toujours la valeur '42'.
}

// Fonction pure avec paramètres
public int sum(final int a, final int b){

  return a + b;
  // Retourne toujours la même valeur
  // pour des mêmes paramètres d'entrée.
}

// Fonction impure
public int doSomething(int n){

  var badDay = LocalDate.now().getDayOfWeek() == DayOfWeek.MONDAY;
  if(badDay){
    throw new IllegalStateException("RAGE QUIT!");
  }
  // Effet de bord:
  // Cette fonction retourne un résultat différent
  // en fonction du jour où elle est appelée.

  return n * n;
}

// Fonction impure
public void showUniversalAnswer(){

  System.out.print(42);
  // Effet de bord:
  // Cette fonction modifie son environnement,
  // en l'occurrence l'état de la console.
  //
  // Sortie console au premier appel: 42
  // Sortie console au deuxième appel: 4242
  // Sortie console au troisième appel: 424242
  // ...
}

Note

L'effet de bord n'est pas quelque chose de fondamentalement mauvais, il faut simplement maîtriser son impact. Dans une application toutes les fonctions ne sont pas tenues d'être des fonctions pures. Si une application ne produit pas d'effet de bord, c'est qu'elle ne fait rien.

Transparence référentielle

La transparence référentielle permet de considérer des fonctions pures comme des valeurs dans un algorithme.

// Fonction pure
public int getUniversalAnswer(){
  return 42;
}

// Fonction pure
public int sum(final int a, final int b){
  return a + b;
}

// Fonction de traitement:
public int doSomething(){
                                 // Vision de la transparence référentielle:
  var x = getUniversalAnswer();  // Peut être remplacé par: 42
  var y = sum(x, x);             // Peut être remplacé par: 84
  return x + y;                  
}

Note

On peut directement remplacer une fonction par le résultat de celle-ci sans que cela ne perturbe l'algorithme.

Fonction d'ordre supérieur

Une fonction d'ordre supérieur est une fonction qui prend en paramètre une fonction et/ou qui retourne une fonction.

Exemple :

// Fonction d'ordre supérieur
// Le paramètre 'Supplier' est une interface fonctionnelle Java (donc une fonction)
public void doSomething(final Supplier<String> message){
  // ...
}

// Fonction d'ordre supérieur
// Le type de retour 'Consumer' est une interface fonctionnelle Java (donc une fonction)
public Consumer<String> doSomething(){
  // ...
}

Évaluation paresseuse

Ce concept va de pair avec les fonctions d'ordre supérieur, il permet d'obtenir le résultat d'une fonction le plus tard possible dans l'algorithme.

Exemple :

// Cas de la journalisation
public void log(final Level level, Supplier<String> message){
                                   ^^^^^^^^^^^^^^^^^^^^^^^^-----//
                                   // Fonction qui contient 
                                   // une chaîne de caractères (potentiellement énorme).
                                   // Cette fonction n'est pas encore évaluée,
                                   // ce qui permet de ne pas consommer de la ressource inutilement
                                   // notamment si le niveau de journalisation 
                                   // ne permet pas d'enregistrement.


  if(isLoggable(level)){
     ^^^^^^^^^^---//
     // Vérification du niveau de journalisation
     // Est-ce que ce niveau permet l'enregistrement ?

     // Création de l'enregistrement
     // pour la journalisation.
     var record = new LogRecord(level, message.get());
                                               ^^^^^-----// Évaluation de la fonction:
                                               // Cette fonction est invoquée à ce moment-là.
                                               // La ressource est effectivement utilisée ici,
                                               // car le besoin est avéré.

     doLog(record);  // Faire l'enregistrement.
  }

  // Sinon:
  // Ce niveau de journalisation ne permet pas d'enregistrement
  // Pas besoin d'évaluer la fonction.
  // Pas de consommation de la ressource.

}

Application partielle

Ce concept permet de manipuler des fonctions en résolvant implicitement ou tardivement des paramètres.

Exemple :

  // Cas nominal:
  Stream
      .of("a", "b", "c", "d")
      .forEach(e -> System.out.println(e));
                               ^^^^^^^^^^----// La fonction 'forEach' prend en paramètre
                               // un 'Consumer' peut être résolu avec une expression lambda.
                               // On passe 'e' l'élément d'itération de la fonction à 'println'.


  // Application partielle:             
  Stream
      .of("a", "b", "c", "d")
      .forEach(System.out::println); 
                           ^^^^^^^-----------// On passe cette fois directement la fonction 'println'
                           // par référence.
                           // On ne passe pas l'élément d'itération de la fonction à 'println'.
                           // L'application partielle permet de résoudre le paramètre automatiquement.   

Note

Le passage par référence de fonction en Java s'effectue avec la syntaxe ::. La fonction forEach prend en paramètre un type Consumer, Java est en mesure de transtyper ce paramètre avec la fonction println. Pour se faire la signature de la fonction Customer.accept et System.out.println doivent correspondre. Ce qui bien entendu le cas ici. Le nom de la fonction n'entre pas en compte lors du transtypage fonctionnel, seuls les paramètres d'entrée et de sortie compte.

Structure de données immuables

Comme une fonction pure ne doit pas produire d'effet de bord, il est donc logique que les objets passés en paramètres ne puissent pas être modifiés par référence. Il faut à présent considérer les objets manipulés par ces fonctions comme des valeurs et non plus comme des références. Pour se faire les objets seront immuables.

Exemple :

// Classe de données immuables.
// Impossible de changer l'état d'un objet après instanciation.
public final class Cutomer {

  // La classe 'java.lang.String' est immuable.
  // Les attributs sont initialisés une et une seule fois lors de l'instanciation.
  public final String givenName;
  public final String familyName;
  public final String email;

  public Customer(final String givenName, final String familyName, final String email){
    this.givenName = givenName;
    this.familyName = familyName;
    this.email = email;
  }

  // equals, hashCode et toString sur tous les attributs
  // ...

  // Accesseurs
  // ...

  // PAS DE MUTATEURS !

  // Si on souhaite changer l'état de cet objet, il faut en instancier un nouveau à chaque fois
  // Solution possible à la place des mutateurs:

  public Customer withGivenName(final String givenName){
    return new Customer(givenName, this.familyName, this.email);
  }

  public Customer withFamilyName(final String familyName){
    return new Customer(this.givenName, familyName, this.email);
  }

  public Customer withEmail(final String email){
    return new Customer(this.givenName, this.familyName, email);
  }

}
  • Un objet immuable ne produit pas d'effet de bord lorsqu'il est manipulé.
  • Un objet immuable n'est pas soumis au problème d'accès concurrentiel (thread-safe).

Note

Effectivement, la JVM est obligée de faire une nouvelle allocation mémoire pour chaque instanciation. Généralement ce type d'objet immuable possède un cycle de vie assez court, ce qui n'est pas un problème. Le mécanisme de ramasse miette sait très bien gérer ce cas de figure.

Cas pratique avancé

Objectif

On souhaite réaliser un validateur d'objet de données générique en pure Java avec l'approche fonctionnelle.

Structure de données

// Exemple complet.
// Classe de données immuables.
public final class Cutomer {

  public final String givenName;
  public final String familyName;
  public final String email;

  public Customer(final String givenName, final String familyName, final String email){
    this.givenName = givenName;
    this.familyName = familyName;
    this.email = email;
  }

  public int hashCode(){
    return Objects.hash(givenName, familyName, email);
  }

  public boolean equals(Object obj){
    boolean eq;
    if (this == obj){
      eq = true;
    }else if(!(obj instanceof Customer)){
      eq = false;
    }else{
      var other = (Customer) obj;
      eq = Objects.equals(this.givenName, other.givenName)
              && Objects.equals(this.familyName, other.familyName)
              && Objects.equals(this.email, other.email);
    }
    return eq;
  }

  public String toString(){
    return String.format(
        "%s{givenName=%s, familyName=%s, email=%s}", 
        getClass().getName(),
        this.givenName,
        this.familyName,
        this.email
    );
  }

  public String getGivenName() {
    return givenName;
  }

  public Customer withGivenName(final String givenName){
    return new Customer(givenName, this.familyName, this.email);
  }

  public String getFamilyName() {
    return familyName;
  }

  public Customer withFamilyName(final String familyName){
    return new Customer(this.givenName, familyName, this.email);
  }

  public String getEmail() {
    return email;
  }

  public Customer withEmail(final String email){
    return new Customer(this.givenName, this.familyName, email);
  }

}

Note

Pour ce cas pratique, n'importe quelle classe de données peut faire l'affaire.

Si un objet n'est pas valide, la validation produira une exception.

Classe d'exception

public class ValidationException extends RuntimeException {

    private static final long serialVersionUID = 1L;

    ValidationException(String message) {
        super(message);
    }

}

Classe du Validateur

// On applique la généricité pour que cette classe travail avec n'importe quel type d'instance.
// Patron de conception: 'Monad'.
public final class Validator<T> {

  // Instance d'objet de données à valider.
  private final T target;

  // Liste de toutes les erreurs de validation.
  private final List<ValidationException> exceptions;

  // Constructeur privé
  // Ce constructeur sera encapsulé dans une méthode fabrique.
  private Validator(final T target) {
        this.target = target;
        this.exceptions = new ArrayList<>();
 }

  // Méthode de fabrique.
  // Plus élégant pour chaîner les appels de méthodes.
  public static <T> Validator<T> of(final T target) {
        return new Validator<>(target);
  }

  // Méthode de validation
  // 1er paramètre: Clause conditionnelle de validation.
  // 2nd paramètre: Message d'erreur si la condition échoue.
  public Validator<T> validate(final Predicate<T> predicate, final String message) {

    // Exemple de prédicat:
    // ???.getEmail() != null
    // Le résultat de cette opération renvoie bien une valeur booléenne.
    // Où ??? doit être un objet de type T. (confère généricité)

    // Évaluation paresseuse du prédicat.
    // Si le test échoue,
    // on crée une exception et on l'ajoute à la liste.
    // On ne lève pas l'exception maintenant
    // car on souhaite tester l'intégralité de l'objet avant la levée d'exception.
    if (!predicate.test(target)) {
            exceptions.add(new ValidationException(message));
    }

    // Pour chaîner les appels, on retourne l'instance du validateur courant.
    return this;
  }

  // Méthode de validation
  // C'est fois-ci, on utilise le concept d'application partielle.
  // 1er paramètre: Fonction générique permettant d'accéder à une propriété de l'objet.
  // 2e paramètre: Clause conditionnelle de validation.
  // 3e paramètre: Message d'erreur si la condition échoue.
  public <R> Validator<T> validate(
            final Function<T, R> projection,
            final Predicate<R> predicate,
            final String message) {

    // Exemple de projection:
    // ???.getEmail()
    // On récupère ici simple une fonction de l'objet à valider.
    // Où ??? doit être un objet de type T. (confère généricité)

    // Exemple de prédicat:
    // ??? != null
    // Une simple condition, ici doit être différent de nul.
    // Où ??? doit être un objet de type R. (confère généricité)

    // Ci-dessous, avec l'application partielle, on réalise également de la composition de fonction
    // Ce qui permet d'associer la projection et le prédicat:
    // ???.getEmail() != null
    // Or ce résultat est lui-même un prédicat
    // On peut donc rappeler la fonction 'validate' ci-dessus.

    return validate(projection.andThen(predicate::test)::apply, message);
  }

  // Enfin, on peut dés-encapsuler l'objet initial et lever les exceptions si existantes.
  public T get() {

    // Si l'objet possède des exceptions...
    if (!exceptions.isEmpty()) {

      // On crée une exception racine
      ValidationException exception = new ValidationException("Validation failed");

      // Puis on concatène les autres exceptions à la racine
      // Cela permet d'avoir les messages de tous les champs invalides
      // dans une seule trace.
      exceptions.forEach(exception::addSuppressed);
      throw exception;
    }

    // On retourne l'objet initial.
    return target;
  }
}

Approche par prédicat

Mise en œuvre :


  // Expression régulière simple pour adresse de courriel.
  String emailRegEx = "^[\\w-\\+]+(\\.[\\w]+)*@[\\w-]+(\\.[\\w]+)*(\\.[a-z]{2,})$";
  Pattern pattern = Pattern.compile(emailRegEx, Pattern.CASE_INSENSITIVE);

  Customer enity = new Customer("John", "DOE", "john.doe@sample.org");

  // Approche par prédicat:
  Validator
    .of(entity)
    .validate(e -> Objects.nonNull(e.getGivenName()), "Le prénom est obligatoire")
    .validate(e -> Objects.nonNull(e.getFamilyName()), "Le nom de famille est obligatoire")
    .validate(e -> Objects.nonNull(e.getEmail()), "L'adresse de courriel est obligatoire")
    .validate(e -> pattern.matcher(e.getEmail()).matches(), "Format d'adresse de courriel invalide")
    .get();

Approche par référence de fonction

Mise en œuvre :


  Customer enity = new Customer("John", "DOE", "john.doe@sample.org");

  // Approche par référence de fonction:
  Validator
    .of(entity)
    .validate(Customer::getGivenName, Objects::nonNull, "Le prénom est obligatoire")
    .validate(Customer::getFamilyName, Objects::nonNull, "Le nom de famille est obligatoire")
    .validate(Customer::getEmail, Objects::nonNull, "L'adresse de courriel est obligatoire")
    .validate(Customer::getEmail, this::checkEmail, "Format d'adresse de courriel invalide")
    .get();  


  // Cette petite méthode est pour faciliter l’écriture.
  private boolean checkEmail(String email){
    boolean check;
    if(Objects.isNull(email)){
      checked = false;
    }else{
      String emailRegEx = "^[\\w-\\+]+(\\.[\\w]+)*@[\\w-]+(\\.[\\w]+)*(\\.[a-z]{2,})$";
      Pattern pattern = Pattern.compile(emailRegEx, Pattern.CASE_INSENSITIVE);
      checked = pattern.matcher(email).matches();
    }
    return checked;
  }

Cas d'exception

Mise en œuvre :

  // Cas d'un objet invalide:
  Customer fake = new Customer(null, null, "john.doe");

  Validator
    .of(fake)
    .validate(Customer::getGivenName, Objects::nonNull, "Le prénom est obligatoire")
    .validate(Customer::getFamilyName, Objects::nonNull, "Le nom de famille est obligatoire")
    .validate(Customer::getEmail, Objects::nonNull, "L'adresse de courriel est obligatoire")
    .validate(Customer::getEmail, this::checkEmail, "Format d'adresse de courriel invalide")
    .get();  

// Sortie console:
//> Statck Trace
//
// Exception in thread "main" fr.zelmoacademy.util.validation.ValidationException: Validation failed
// at fr.zelmoacademy.util.validation.Validator.get(Validator.java:XX)
// at fr.zelmoacademy.Launcher.main(Launcher.java:XX)
// Suppressed: fr.zelmoacademy.validation.ValidationException: Le prénom est obligatoire
//   at fr.zelmoacademy.util.validation.Validator.validate(Validator.java:XX)
//   at fr.zelmoacademy.Launcher.main(Launcher.java:XX)
// Suppressed: fr.zelmoacademy.validation.ValidationException: Le nom de famille est obligatoire
//   at fr.zelmoacademy.util.validation.Validator.validate(Validator.java:XX)
//   at fr.zelmoacademy.Launcher.main(Launcher.java:XX)
// Suppressed: fr.zelmoacademy.validation.ValidationException: Format d'adresse de courriel invalide
//   at fr.zelmoacademy.util.validation.Validator.validate(Validator.java:XX)
//   at fr.zelmoacademy.Launcher.main(Launcher.java:XX)

Axe d'amélioration

On peut créer une classe utilitaire avec plein de méthode statique pour les prédicats tel que checkEmail. Ceci permettrait de créer du code réutilisable facilement.

Si vous examinez attentivement la classe Validator<T> vous constaterez que les fonctions ne sont pas pures. À votre avis comment corriger cela ?

Note

Malgré le passage par référence et l'application partielle, le compilateur est en mesure de résoudre le typage statique de Java par inférence. Pas de problème de ce côté-là.