Programmation fonctionnelle : quel intérêt ?

  La plupart des développeurs aujourd’hui ont commencé par apprendre à coder en programmation impérative, avec le C ou le Java par exemple. On donne une séquence d’instructions qui modifient l’état du programme, jusqu’à atteindre l'état final. On décrit le “comment”, le déroulement. C’est en principe plus facile à apprendre, mais plus verbeux, lent à coder, et on perd en clarté du fait de la quantité importante d’informations écrites.

  On est naturellement attirés par cette approche grâce à sa simplicité initiale. Mais plus le programme va grossir, plus cette simplicité apparente va nous amener vers son opposé ; la complexité va augmenter et on va se retrouver avec un code difficilement maintenable et testable. La vélocité qu’on pouvait délivrer initialement va progressivement diminuer jusqu’à ce qu’une tâche même mineure devienne laborieuse.

  La programmation fonctionnelle est une alternative à la programmation impérative qui va tenter d’adresser ce problème. Explorons-la avec Java.

Principes de la programmation fonctionnelle

  En mathématiques, une fonction est une expression qui relie une entrée avec une sortie : f(x) = y. La valeur de sortie ne dépend que de la valeur d’entrée ; la fonction n’a aucun autre effet observable et on peut en assembler plusieurs pour en faire une nouvelle.

  La programmation fonctionnelle est un style d’écriture de programmes qui traite les calculs comme des fonctions mathématiques. Elle est une sous-catégorie de la programmation déclarative, qui s’oppose à la déclaration impérative.

Voyons d’abord un exemple (un peu caricatural, certes) de programmation impérative :

  var couleurs = new ArrayList<String>();
  couleurs.add("vert");
  couleurs.add("rouge");
  couleurs.add("bleu");

  for (int i=0; i<couleurs.size(); i++) {
    System.out.println(couleurs2.get(i));
  }

  Par exemple, ici on va jusqu’à détailler le fonctionnement de l’itération : déclaration d’une variable i, incrémentation par +1, etc… En programmation fonctionnelle, on se concentre directement sur le “quoi”, l’objectif recherché, ce qui nous permet d’avoir un code plus concis :

  List.of("vert", "rouge", "bleu").forEach(System.out::println);

  En dehors de cette différence syntaxique, voici les principes clés de la programmation fonctionnelle :

  • Fonctions de première classe : Comme en mathématiques, les fonctions doivent supporter toutes les opérations, comme les assigner à des variables ou les passer en arguments d’autres fonctions. En Java, des outils comme les lambdas nous permettent de s’en approcher.
  • Fonctions pures : Le retour d’une fonction ne doit dépendre que de ses arguments, sans effets de bord. Une fonction pure doit également toujours retourner le même résultat si ses arguments n’ont pas changé.
  • Transparence Référentielle :  Pouvoir interchanger une expression avec sa valeur sans modifier le comportement du programme. On expliquera son importance plus bas.
  • Immuabilité : impossibilité de modifier une entité après son instanciation. Son rôle est important : on va ainsi limiter les bugs car nos entités ne pourront pas être modifiées ailleurs.

Effets de bord, pureté des fonctions, transparence référentielle et immuabilité sont 4 concepts liés, et ils donnent à la programmation fonctionnelle toute sa valeur. Explorons-les pour mieux comprendre comment ils peuvent nous aider à résoudre nos problèmes de complexité.

Les effets de bord

  C’est lorsqu’une fonction fait autre chose que simplement retourner une valeur à partir de ses paramètres d’entrée, par exemple :

  • Elle interagit avec l’extérieur
  • Elle modifie une structure de donnée
  • Elle appelle un setter pour changer un champ d’un objet

Par exemple :

  public int add(int a, int b) {
    var result = a + b;
    resultService.save(result);
    return result;
  }

  Ici le  resultService.save(result) n’est pas lié au retour de la fonction, et interagit avec l’extérieur : on peut supposer qu’il enregistre notre résultat en base de données. Mais ça pourrait aussi être un sendResult(result) à une autre application par exemple, un Instant.now() qui récupère l’heure, un System.out.println()... Tous ces exemples interagissent avec l’extérieur d’une manière ou d’une autre.

Les Fonctions Pures

Une fonction pure est une fonction :

  • qui n’a pas d’effet de bord
  • dont le résultat ne dépend que des paramètres qu’on lui donne
  • qui doit toujours renvoyer le même résultat si on l’appelle avec les mêmes paramètres.

Par exemple :

  public int pureAdd(int a, int b) { return a + b; }

  Les fonctions pures sont explicites : puisque le résultat ne dépend que de ses paramètres et qu’elle n'interagit pas avec le reste du programme, sa signature donne toutes les informations nécessaires pour la comprendre.
Une fonction pure public B maFonction(A) ne fait que transformer A en B. Rien d’autre.

Note : La “pureté” des fonctions est déjà une propriété des “fonctions”, comme en mathématiques. L’expression “fonction pure” est redondante,  elle est simplement utilisée pour insister sur cette caractéristique. Toute “fonction” qui n’a pas cette caractéristique est en fait mal nommée, et devrait plutôt être appelée “procédure” : un enchaînement d’instructions qui peut contenir des effets de bord. Mais on continuera à parler de “fonctions pures” pour éviter toute confusion.

  Pour mieux saisir le problème que posent les effets de bord et l’intérêt des fonctions pures, il faut comprendre l’intérêt de la transparence référentielle.
La pureté d’une fonction n’est pas un objectif en soi, mais simplement un moyen d’atteindre la transparence référentielle.

La transparence référentielle

  Une expression référentiellement transparente (on dira “RT”) peut être remplacée par sa valeur sans modifier le comportement du programme. Reprenons l’exemple de notre fonction pure :

  public int pureAdd(int a, int b) { return a + b; }

Si on exécute cette ligne :

  System.out.println(pureAdd(1, 2));     // on obtient 3

Et si on la remplace par sa valeur :

  System.out.println(3);   // 3 aussi

  Facile, le résultat est le même ; notre fonction pureAdd est donc RT. Essayons maintenant avec la fonction add qui a un effet de bord :

  public int add(int a, int b) {
    var result = a + b;
    resultService.save(result);
    return result;
  }

Si on exécute :

  System.out.println(add(1, 2));  // on obtient 3, et un save(result)

Par contre :

  System.out.println(3);  // 3 mais aucun save(result)

  Tout ce qui est fait en dehors du return est ignoré si on remplace une fonction par son résultat. Si c’est une déclaration de variable dans le scope de la fonction ce n’est pas gênant, mais si c’est un effet de bord comme ici, ça l’est. Notre résultat n’est plus enregistré en BDD.

  Notre fonction add est non-RT : l’effet de bord save(result) nous empêche de remplacer la fonction par sa valeur sans casser le programme.

  Si on récapitule :  effet de bord -> fonction impure -> fonction non RT
Pourquoi est-ce que c’est problématique ?

Le problème des fonctions non référentiellement transparentes

  Mettons-nous en situation pour mieux comprendre le problème. On a cette fonction non-RT add, écrite par un ancien développeur de notre équipe par exemple, qui est appelée plusieurs fois dans une méthode qu’on doit faire évoluer dans le cadre d’une nouvelle User Story.

  public int myMethod() {
    …
    var result1 = add(1, 2);
    …
    var result2 =  add(1, 2));
    …
    return result1 + result2;	// 6
  }

  Dans un souci de refactoring on décide, pour optimiser et simplifier de code, de ne l’appeler qu’une seule fois et d’utiliser son résultat deux fois.

  public int myMethod() {
    …
    var result = add(1, 2);
    …
    return result + result;   // 6
  }

  Le résultat retourné est le même, notre code est plus efficace, et peut-être même que les tests unitaires passent si on a testé le retour mais pas le nombre exact d’appels sur un mock, avec verify de Mockito. Cependant, on vient de casser votre programme sans le savoir, car le deuxième save(result) n’est plus fait.

  Si on avait un sendResult(result) à la place d’un save(result) dans notre fonction non-RT add, le problème serait similaire : le résultat ne serait envoyé qu’une fois au service externe, à la place des deux attendues. Dans le cas d’un Instant.now() on aurait l’heure du premier appel mais pas du deuxième, ce qui porterait à confusion, surtout si l’écart de temps entre les deux appels est important. Même dans le cas d’un log, on pourrait induire en erreur quelqu’un qui les consulterait, ou fausser les metrics.

  Imaginons maintenant que votre application est remplie de fonctions non-RT, qui ont des effets de bord cachés partout et que vous aimeriez refactorer ou simplement la faire évoluer… bonne chance. Pour éviter ce problème, il faut donc privilégier les fonctions référentiellement transparentes, c’est-a-dire qui n’ont pas d’effet de bord et qui sont pures. On garde alors des fonctions modulables, qui sont plus facilement réutilisables, testables, parallélisables, et compréhensibles.

Séparation des préoccupations

  Mais alors comment enregistrer notre résultat en BDD, comme dans l’exemple au dessus, si une fonction pure ne peut pas interagir avec l’extérieur ? Ou appeler un autre service ?

  C’est pourquoi l’idée n’est pas d'interdire les fonctions impures, mais de les séparer du reste de notre application. En programmation fonctionnelle, on va séparer le “coeur pur” de notre application, qui sera sans effet de bord, pur et RT (et qui contiendra la logique du programme), d’une fine couche extérieure composée de fonctions qui se chargeront spécifiquement des effets de bord. Il seront alors davantage isolés et maîtrisés.

Immuabilité et transparence référentielle

  La mutabilité rend le code difficilement prédictible, puisque les variables ou attributs d’objets peuvent être modifiés n’importe où et n’importe quand. D’autant plus que dans le cycle de vie d’une application, celle-ci subit beaucoup d’évolutions et on peut vite perdre la trace des utilisations d’un setter sur un objet.

  Si une fonction dépend d’un objet mutable en paramètre, ou d’une variable partagée mutable, une modification de ces éléments entraînera un changement du retour de la fonction, cassant ainsi le principe de transparence référentielle : deux appels identiques retourneront des résultats différents, si une variable a été changée entre temps. Le comportement du programme deviendra alors plus compliqué à prévoir.

  A l’opposé, une fonction opérant uniquement sur des données immuables aura davantage tendance à être RT (bien que ce soit pas une condition suffisante à elle seule). De plus, il suffit de regarder où un objet est instancié pour connaître toutes ses caractéristiques, sans mauvaise surprise au runtime.

  Les vrais langages de programmation fonctionnelle sont immuables par défaut. En Java, il faut choisir volontairement de créer des structures de données immuables :

  • L'utilisation systématique de final pour la déclaration des paramètres de fonction ou des attributs de classe
  • La présence d’accesseurs uniquement (get), sans les mutateurs (set) dans nos classes

  Lombok propose des outils pour faciliter l’immuabilité en java et d'éviter le boilerplate, comme le val (qui équivaut à final var) ou le @With qui remplace les setters en créant un clone de l’objet avec une nouvelle valeur plutôt que de modifier l'état de l’objet existant.

Conclusion

Finalement, on peut dégager quelques leçons de nos exemples.
A propos des effets de bords :

  • Ils brisent la transparence référentielle de nos fonctions.
  • Ils se cachent ; on ne les voit pas dans la signature de la méthode
  • Ils sont la première source de complexité dans les programmes

Et par opposition, la transparence référentielle permet au code d'être :

  • Indépendant du contexte d'exécution, et donc d'être facilement réutilisable et testable
  • Plus facilement compréhensible puisque la signature de la fonction nous donne une vision claire de son rôle.
  • Beaucoup plus facile à refactorer.

  Les langages de programmation fonctionnelle nous donnent des outils pour favoriser la transparence référentielle de nos expressions qui ne sont pas toujours intuitifs à prendre en main : les lambdas, la composition de fonctions, l’immuabilité ou encore les monades…. Il ne sera pas simple pour quelqu’un qui a appris à développer de manière impérative de changer sa manière de raisonner pour adopter une écriture plus fonctionnelle. Cela dit, les effets de bord et la mutabilité faisant partie des problèmes principaux auxquels nous faisons face en tant que développeurs, s’en débarrasser rendra ses programmes plus concis, faciles à comprendre, à tester et à maintenir.