Définition générale
Pour commencer, la première question à se poser est : Qu’est ce que la programmation fonctionnelle ?
L’objectif global de la programmation fonctionnelle est de réduire, tant que possible, les parties mouvantes et dangereuses de votre programme.
Je la définirais comme :
> Un paradigme, une façon de programmer, qui vise à éviter, autant que possible, les effets de bords en utilisant le plus possible des fonctions pures manipulant des données immuables.
Ainsi, la majeure partie de votre base de code (idéalement la totalité à part votre main) est dite « pure » et des propriétés intéressantes en découlent comme nous allons le voir.
Si votre main est la seule partie « impure », vous avez déjà drastiquement réduit la portion la plus dangereuse de votre code.
La définition de la programmation fonctionnelle que j’ai donné tout à l’heure introduit les termes effets de bord, fonctions pures ou données immuables que je vais maintenant expliquer.
Effets de bord
Une fonction ne devrait avoir qu’un seul effet : celui de calculer la valeur qu’elle renvoie.
Tout autre effet déclenché par une fonction est un effet de bord (générer une exception, logger, afficher quelque chose à l’écran, écrire sur disque, échanger sur le réseau, etc.).
La programmation fonctionnelle n’interdit pas ces actions mais elle encourage à les faire de façon explicite, plutôt que secrètement, caché au sein de fonctions qui ne déclarent pas haut et fort réaliser ces effets de bords.
Fonctions pures
>Les fonctions pures sont ce qui se rapprocherait le plus des fonctions mathématiques.
– Règles de pureté
Une fonction, pour être pure, doit respecter un certain nombre de règles simples :
- Déterminisme : une fonction pure à laquelle on donne les mêmes entrées donnera toujours la même sortie, sans autres impacts sur le programme. En conséquence, elle ne peut utiliser de variables globales, d’états mutables, d’I/O, etc.
def add(a: Int, b: Int): Int = a + b
est déterministe, elle renverra toujours la même sortie si on lui envoie les mêmes entrées
def rand(a: Int): Int = Random.nextInt(a)
ne l’est pas, chaque appel peut recevoir un retour différent
Une fonction retournant Unit
devrait être un énorme indice de mauvais design. Cette fonction ne revoie « rien », sa seule utilité doit donc être de produire des effets de bord. C’est le Némésis du déterminisme !
- Totale : une fonction pure de type
A => B
(A
est appelée le domaine de la fonction etB
le codomaine) doit être définie pour toutes les valeurs de son domaine, ici pour les valeurs de typeA
.
def divide(a: Int, b: Int): Int = a / b
n’est pas totale, le programme plante pour b == 0
def safeDivide(a: Int, b: Int): Option[Int] = Try(a / b).toOption
est totale car il gère le cas limite par le renvoi de None
pour b == 0
- Transparence référentielle : la transparence référentielle s’applique non seulement aux fonctions mais aussi à un programme plus vaste, car, pour une fonction seule, la transparence référentielle revient au déterminisme vu plus haut. Un programme référentiellement transparent doit être entièrement remplaçable par le résultat que donne son évaluation même si celui-ci fait partie intégrante d’un autre programme plus vaste et sans altérer le reste de ce programme.
def launchTheMissiles: Unit = ???
def formattingMyStuff(s: String): String = {
launchTheMissiles
s.toUpperCase
}
Ici on ne peut pas remplacer l’appel formattingMyStuff("hi")
par "HI"
sans altérer le reste du programme, car formattingMyStuff
effectue un effet de bord, launchTheMissiles
. Si l’on remplace formattingMyStuff("hi")
par "HI"
, les missiles ne seront pas lancés. L’appel formattingMyStuff("hi")
n’est pas référentiellement transparent.
Avec def purelyFormattingMyStuff(s: String): String = s.toUpperCase
en revanche, tout appel peut être remplacé directement par l’argument passé en majuscule, et l’on sait que le reste du programme n’en sera pas altéré.
Pour enfoncer le clou, un code référentiellement transparent doit être remplaçable par une table de correspondance argument(s) / résultat qui ferait correspondre automatiquement le ou les arguments à leur résultat associé.
def boolToString(b: Boolean): String = if (b) "That's true !" else "That's wrong..."
est référentiellement transparente. Elle est remplaçable par la table de correspondance suivante, sans aucune altération pour le reste du programme:
Input | Output |
true |
« That’s true ! » |
false |
« That’s wrong… » |
En comprenant bien ces règles, vous pouvez vous rendre compte comment, en utilisant des fonctions qui les respectent, vous réduisez considérablement les pièces mouvantes de votre programme.
Ces fonctions font ce que leurs signatures annoncent, et elle ne font que ça.
– Comment fait-on de « vraies choses » alors ?
Les seuls effets que peuvent avoir les fonctions sont d’allouer de la mémoire et du temps de processeur pour calculer leurs résultats de retour et rien d’autre.
Sans la possibilité de faire des entrées / sorties, d’utiliser de l’aléa ou encore de rater, réaliser des programmes utiles s’annonce compliqué…
Évidement, la programmation fonctionnelle vous permet de faire tout ça, elle demande juste à ce que cela soit fait de façon explicite.
Voici quelques exemples :
- Une fonction retournant un
Option[Int]
retourne en fait juste unInt
mais ce type de retour ajoute explicitement à la fonction, l’effet de pouvoir rater, de ne pas être capable de renvoyer unInt
, et de renvoyerNone
à la place. - Une fonction qui retourne un
Either[String, Int]
retourne en fait juste uneInt
, mais ajoute l’effet de renvoyer unString
qui pourrait, par exemple, représenter la raison pour laquelle elle n’a pas pu retourner unInt
(ce n’est pas la seule utilisation d’Either
). - Une fonction retournant un
Task[Int]
ouIO[Int]
, etc. renvoie la liste des étapes à suivre, sans pour autant les avoir déjà exécutées, qui produiront unInt
(ou ratera), à terme, une fois exécutées. C’est la description d’un effet qui n’a pas encore eu lieu et non pas l’effet lui même.Beaucoup d’effets sont donc encodés de cette manière et aller plus en détail serait l’objet d’un article à part entière.
Philosophie de la donnée
– Relation donnée / comportement
La programmation orienté objet (OOP) et la programmation fonctionnelle (FP) ont deux approches différentes de la relation entre la donnée et les comportements sur cette donnée.
>L’OOP a tendance à combiner la donnée et les comportements au sein de classes qui :
- Stockent et cachent la donnée sous forme d’état interne mutable
- Exposent publiquement des méthodes qui permettent d’agir dessus et de la transformer
case class Player(nickname: String, var level: Int) {
def levelUp(): Unit = { level = level + 1 }
def sayHi(): String = s"Hi, I'm player $nickname, I'm lvl $level !"
}
La FP vise à séparer complètement la donnée des comportements en :
- Définissant des types ADT d’un côté, qui n’exposent aucun comportement, mais uniquement de la donnée
- Des fonctions, qui prennent des valeurs en arguments et renvoient des valeurs sans avoir changé les valeurs d’entrée au passage
case class Player(nickname: String, level: Int)
object PlayerOperations {
def levelUp(p: Player): Player = p.copy(level = p.level + 1)
def sayHi(p: Player): String = s"Hi, I'm player ${p.nickname}, I'm lvl ${p.level} !"
}
– L’expression problem
L’expression problem est souvent utilisé pour décrire comment un langage ou un paradigme se comporte lorsqu’il s’agit d’ajouter à une base de code déjà existante :
- De nouveaux représentants à des types existant.
- De nouveaux comportements à des types existant.
Et s’ils parviennent à le faire sans avoir à toucher au code existant.
L’idée derrière l’expression problem est de permettre de comparer la façon dont les langages ou les paradigmes répondent à ce genre de problématique.
L’OOP et la FP ont tous deux des façons différentes de répondre à ces problèmes.
>Paradigme de l’OOP
👍 : Ajouter de nouveaux représentant à un type déjà existant
Une nouvelle classe étendant la classe / interface existante.
👎 : Ajouter de nouveaux comportements à un type existant
Une nouvelle méthode doit être ajoutée sur la super classe ou interface appropriée, ce qui impacte l’implémentation de toutes les sous classes.
Exemple de code existant :
trait MyType { def behavior: String }
final case class A() extends MyType { override def behavior: String = "I'm A" }
final case class B() extends MyType { override def behavior: String = "I'm B" }
Ajouter un nouveau représentant à un type existant (MyType
)
final case class C() extends MyType { override def behavior: String = "I'm C" }
Ajouter un nouveau comportement à un type existant (MyType
)
trait MyType {
def behavior: String
def newBehavior: String
}
Il faut maintenant repasser sur toutes les classes étendant MyType
pour implémenter newBehavior
.
>Paradigme FP
👍 : Ajouter de nouveaux représentants à un type déjà existant
Ajouter un nouveau représentant à un sum type (cf: Anatomy of an algebra) impacte toutes les fonctions acceptant ce type en entrée (il faut implémenter le comportement pour ce nouveau type).
👎 : Ajouter de nouveaux comportements à un type existant
Une nouvelle fonction, rien de plus.
Exemple de code existant:
sealed trait MyType
final case class A() extends MyType
final case class B() extends MyType
def behavior(m: MyType): String = m match {
case A() ⇒ "I'm A"
case B() ⇒ "I'm B"
}
Ajouter un nouveau représentant à un type existant (`MyType`)
final case class C() extends MyType
Maintenant il faut revenir sur toutes les fonctions prenant en entrée une donnée de type MyType
pour en implémenter le nouveau comportement.
Ajouter un nouveau comportement à un type existant (MyType
)
def newBehavior(m: MyType): String = m match {
case A() ⇒ ???
case B() ⇒ ???
}
– Donnée immuable
C’est très simple, une donnée est dite immuable si, une fois évaluée, il n’y a aucun moyen d’en changer la valeur.
Voici de la donnée mutable dont l’utilisation doit être évitée en programmation fonctionnelle :
var meaningOfLife = 41
meaningOfLife = meaningOfLife + 1
Voici de la donnée immuable :
val meaningOfLife = 42
meaningOfLife = meaningOfLife + 0
//<console>:12: error: reassignment to val
// meaningOfLife = meaningOfLife + 0
S’il vous est impossible de ne pas utiliser de donnée mutable, par exemple pour des raisons d’optimisation fine, je vous encourage à le faire avec précaution et en isolation, dans un cadre maîtrisé, encapsulé dans une construction immuable :
val magic = {
var mutableMagic = 0
mutableMagic = mutableMagic + 42
mutableMagic
}
Ainsi vous êtes certain que la mutabilité ne se répandra pas au delà du cadre imposé et n’ajoutera pas une pièce mouvante, sans contraintes, à votre programme.
Intérêts de la programmation fonctionnelle
Pour l’instant, nous n’avons vu quasiment que des contraintes…
La programmation fonctionnelle n’est pas qu’une suite de contraintes sans but, mais comme l’explique Runar Bjarnason, contraindre vos implémentations vous offre beaucoup de liberté par la suite.
Cela peut paraître contre intuitif mais je vais vous expliquer pourquoi.
Raisonnement équationnel
Les fonctions pures, bien que restrictives vous permettent de raisonner à propos de vos programmes d’une façon qui serait impossible autrement. C’est ce que l’on appelle le raisonnement équationnel.
Cela signifie qu’une fois déterminé que f(x)
, évalué, vaut y
, vous pouvez simplement remplacer par y
toutes occurrences de f(x)
dans votre programme et réduire ainsi la complexité de votre raisonnement sans avoir à réévaluer f(x)
à chaque fois.
Le raisonnement équationnel vous permet de suivre la logique d’un programme en remplaçant, au fur et à mesure, les appels aux fonctions par leurs résultats, en réduisant la charge cognitive que représente le parcours de ce code, comme on le ferait lors de la résolution d’une équation mathématique :
Si vous aviez ces équations :
2x 3y = 12
y + 2x = 180
Vous pourriez isoler x
dans la première équation :
2x 3y = 12
2x = 12 + 3y
x = (12 + 3y ) / 2
Puis remplacer x
dans le reste pour raisonner simplement sur les autres équations :
y + 2x = 180
y + 2 * (12 + 3y) / 2 = 180
y + 12 + 3y = 180
4y = 168
y = 42
C’est une façon très puissante de raisonner à propos de problèmes complexes.
Sans la transparence référentielle, il faudrait analyser chaque appel de fonction, vérifier ce que font ces fonctions en plus de renvoyer leurs résultats, garder ces effets supplémentaires en tête, et continuer votre analyse sans les perdre de vue au risque de fausser le raisonnement.
C’est un bon exemple démontrant comment des contraintes peuvent vous donner des libertés en contrepartie.
De la donnée prédictible
Comme votre donnée est immuable, il est beaucoup plus simple d’en garder la valeur en tête, puisque cette valeur ne change pas.
La seule façon de créer la donnée et d’utiliser le constructeur du type (au sens large), ainsi, à nouveau, cela permet de réduire les parties mouvantes de votre programme.
Vous pouvez être sûr, en utilisant de la donnée, que celle-ci n’a pas été modifiée depuis un autre endroit. Vous pouvez l’utiliser sans risque.
> Si vous isolez les endroits où les changements de données peuvent avoir lieu en restreignant sévèrement la mutabilité, vous créez un espace beaucoup plus restreint dans lequel de potentielles erreurs peuvent arriver, et vous aurez moins d’endroits à tester. Neal Ford
De plus, cela permet d’obtenir la thread safety par design, comme votre donnée ne sera jamais dans un état inconnu ou indésirable, et c’est un énorme avantage dans nos contextes de plus en plus concurrents.
Jouer avec des Lego
En plus du raisonnement équationnel et de l’immuabilité, je vais essayer de vous montrer ce que la FP apporte de plus à votre code avec une analogie.
Vous souvenez-vous de vos Lego ? La FP permet de jouer avec votre code de la même manière qu’avec avec vos blocs de Lego.
>Vous aviez plusieurs blocs, tous n’allaient pas forcément ensemble, ils étaient solides, immuables et remplissaient tous un rôle unique.
Exactement comme le sont les fonctions pures et les données immuables.
Cela vous permet :
👉 De refactorer vos applicatifs sans heurts.
Vous savez que vous pouvez changer ce bloc rouge par ce bleu, s’il respecte le même contrat et qu’il n’impacte pas, d’une façon ou d’une autre le reste de votre construction (effets de bord, mutabilité…).
👉 Composabilité
Vous pouvez créer une construction d’un côté, une autre construction d’un autre, puis les assembler pour obtenir une construction nouvelle plus complexe qui se comporte comme vous l’attendiez, en assemblant les deux parties indépendantes.
C’est exactement ce que vous pouvez faire avec des fonctions pures. Vous connaissez les entrants, vous savez qu’elle ne font rien d’autre que produire des sortants que vous pouvez à nouveau passer à de nouvelles fonctions pures. Vous pouvez donc les composer et créer de la complexité sans craintes !
👉 Une meilleure testabilité
Vous pouvez beaucoup plus facilement tester une pièce quand vous savez à coup sûr que ce n’est qu’une pièce simple, indépendante, sans effets de bord.
La programmation fonctionnelle cristallise les design patterns
Pour en finir avec les avantages de la programmation fonctionnelle, il existe cette chose curieuse appelée correspondance de Curry–Howard qui est un lien direct entre la logique mathématiques et le calcul computationnel (ce que nous faisons, nous, programmeurs).
Cette correspondance signifie, dans les grandes lignes, que beaucoup de concepts découverts et démontrés en mathématiques depuis des décennies peuvent être transposés à la programmation (et vice versa), offrant énormément de nouveaux outils de modélisation, gratuitement !
En OOP, les design patterns sont énormément utilisés et peuvent être définis comme des façons idiomatiques de résoudre des problèmes dans des contextes spécifiques mais leurs existences ne vous sauveront pas d’avoir à les appliquer, les réimplémenter, encore et encore, à chaque fois que vous rencontrerez les problèmes qu’ils résolvent.
Les constructions de la programmation fonctionnelle, certains provenant directement de la théorie de catégorie (mathématiques), résolvent directement, par nature, ce que vous auriez essayé de résoudre à travers des design patterns bien connus.
La boîte à outils classique de la programmation fonctionnelle vous donne des constructions, des structures de données, permettant de gérer:
Les états globaux
La concurrence
La parallélisation
Les échecs de computation / exceptions
Les validations cumulative
L’asynchronisme
La séquentialité
Les combinaisons associatives
…
Vous trouverez ici une liste qui explique comment les constructions de la programmation fonctionnelle correspondent aux design patterns classiques : Lambda, the ultimate pattern factory.
Plutôt pratique !
Quelques ressources
Si vous souhaitez creuser, voici une liste de ressources intéressantes, qui peuvent être trouvées sur ma liste de ressources autour de la programmation fonctionnelle et en particulier :
What Referential Transparency can do for you — Luka Jacobowitz
Constraints Liberate, Liberties Constrain — Runar Bjarnason
Functional Design Patterns — Scott Wlaschin
Propositions as Types — Philip Wadler
Pour résumer, nous avons vu :
👉 Ce qu’est vraiment la programmation fonctionnelle et que cela signifie de manipuler de la donnée immuable et des fonctions pures.
👉 Ce que sont les effets de bord en détail, les fonctions pures et l’immuabilité.
👉 Les libertés que nous offrent les contraintes imposés par la FP (raisonnement équationnel, prédictibilité, refactoring, testabilité, etc.).
👉 Qu’il existe un pont entre le monde de la logique mathématiques et de la computation et comment celui-ci nous fournit gratuitement des constructions très utiles.
👉 Comment certaines de ces constructions nous permettent de tacler des problèmes du quotidien que nous aurions, sinon, résolus grâce à des designs patterns lourds et redondants.