Il y a 4 ans -
Temps de lecture 10 minutes
La curryfication et ses applications
Si on compare beaucoup la programmation fonctionnelle et les mathématiques, ce n’est pas pour rien. Derrière les principes fondamentaux de la programmation fonctionnelle se cache un mathématicien, Haskell Curry. Il a notamment travaillé sur une technique qui porte son nom : la curryfication.
Mais qu’est-ce que la curryfication ? Bonne question, voyons ça avec un langage de programmation qui s’y prête bien : Scala.
La Curryfication
Le but de la curryfication est de passer d’une fonction à N arguments qui retourne une valeur à une fonction à 1 argument qui retourne une fonction à N-1 arguments qui retourne une valeur. Tout de suite, vous devez vous demander si on peut curryfier cette nouvelle fonction retournée, eh bien oui ! Mais voyons d’abord ce que veut dire cette phrase beaucoup trop complexe.
Prenons un premier exemple simple, une fonction addition
.
def addition(x: Int, y: Int): Int = x + y
C’est une fonction à 2 arguments qui retourne une valeur. Si j’applique la définition de la curryfication, je veux obtenir une fonction à 1 argument qui retourne une fonction à 1 argument qui retourne notre valeur. Essayons :
def additionCurr(x: Int): (Int => Int) = { def addPartial(y: Int): Int = { x + y } addPartial // on retourne la fonction directement, pas sa valeur }
Comme notre en-tête l’indique, notre fonction prend un paramètre entier et retourne une fonction qui prend un paramètre entier et retourne un entier. À l’intérieur du corps de la fonction, on voit qu’une autre fonction appelée addPartial
est définie. Celle-ci prend en paramètre un entier et retourne un entier. Au final, la valeur retour de notre fonction addition est bien la fonction addPartial
, du type désiré Int => Int
. Autre chose importante, lorsque j’ai curryfié ma fonction, je n’ai à aucun moment modifié son comportement.
Ok, c’est bien joli tout ça, mais je suis passé d’une fonction qui faisait 1 ligne à une fonction qui en fait 6... J’y gagne quoi ? Surtout que je ne sais même pas comment utiliser ce truc.
Imaginons que nous ayons besoin d’ajouter 5 très souvent. Voici ce que l’on peut faire :
def add5 = additionCurr(5) val sept = add5(2) val huit = add5(3) val deux = add5(-3)
Dans ce cas là, on appelle add5
une application partielle. C’est le fait d’avoir curryfié notre fonction qui a rendu cela possible. On dit partielle car nous n’avons pas fini d’exécuter la fonction additionCurr
.
Mouais… C’est mignon, mais toujours pas pratique à définir. Bon c’est vrai, cette fonction curryfiée à 6 lignes est horrible, mais en Scala comme dans tous les langages fonctionnels que j’ai vus (Haskell, Caml…), il existe un sucre syntaxique qui permet de rendre la définition plus jolie. En Scala, il est plutôt simple à comprendre.
Si vous essayez d’appeler la fonction addition directement pour obtenir une valeur, vous allez devoir écrire ceci :
val sept = additionCurr(5)(2)
Pourquoi ? Comme on l’a vu juste au dessus, additionCurr(5)
renvoie une fonction. Cette fonction qu’on avait appelé add5
s’appelle comme toute fonction, avec son paramètre entre parenthèse. Donc si on veut tout simplement oublier l’étape de définir une variable qui contient le résultat de notre application partielle, c’est à dire notre fonction, il suffit de directement rappeler notre fonction en mettant son argument dans un deuxième groupe de parenthèses. Le rapport avec le sucre syntaxique de la curryfication en Scala ? Et bien on peut définir de la même façon notre fonction :
def additionCurrSugar(x: Int)(y: Int): Int = x + y
TADAAA ! Pour voir si c’est bien compris, voici un autre exemple de fonction à 3 arguments cette fois-ci. La réponse est en fin d’article.
def subString(str: String, begin: Int, end: Int): String
Faire de la function factory avec la curryfication et l’application partielle
En reprenant notre exemple de add5
, nous pourrions très bien imaginer toute une librairie de template de fonction basée sur la même technique. Ce cas d’utilisation nous est proposé dans le standard Scala avec les fonctions fold
, foldLeft
et foldRight
, présentent dans les collections comme Seq
ou List
. Ces fonctions produisent le même résultat, mais diffèrent dans l’exécution. Pour simplifier, on dit souvent que les 3 fonctions sont identiques. Voyons ensemble le cas de foldLeft
.
val liste = List(1, 2, 3) liste.foldLeft(0)((acc, y) => acc + y)
La fonction foldLeft
est curryfiée. Elle prend 2 arguments : une valeur et une fonction, et retourne une seule valeur qui correspond au type de valeur qu’il y a dans la collection. Dans notre cas, un entier. C’est une fonction qui va agréger les valeurs de notre collection entre elles. Pour agréger les valeurs, elle va appliquer sur chaque élément la fonction passée en argument.
Cette fonction prend 2 paramètres du même type que les éléments de notre collection. Le premier argument représente un accumulateur, qui permet à chaque étape du foldLeft
de stocker notre résultat. Le deuxième argument est un élément de notre collection. Dans notre exemple, nous additionnons à notre accumulateur les éléments de notre collection un à un pour obtenir à la fin la somme des éléments de notre liste.
Si vous avez bien suivi, à ce moment là vous devriez vous demander « mais au début, notre accumulateur vaut combien ? ». C’est avec le premier argument de la fonction foldLeft
que vous définissez sa valeur initiale. Dans notre cas, 0. Si vous écrivez précisément cette ligne dans un IDE comme IntelliJ, vous verrez qu’il vous suggère de plutôt utiliser une fonction comme sum
, qui produit le même résultat.
Jusque là, le fait que cette fonction soit curryfiée ne nous a pas apporté une grande valeur. Alors pourquoi cette fonction est-elle curryfiée ? Imaginons que je souhaite calculer la somme, la somme des carrées, la somme des cubes, la moyenne, l’écart-type… sur une même collection.
val liste = List(1, 2, 3) val somme = liste.foldLeft(0)((acc, y) => acc + y) val sommeDesCarres = liste.foldLeft(0)((acc, y) => acc + y*y) val sommeDesCubes = liste.foldLeft(0)((acc, y) => acc + y*y*y) val moyenne = liste.foldLeft(0)((acc, y) => acc + y/liste.length) val moyenneDesCarres = liste.foldLeft(0)((acc, y) => acc + y*y/liste.length) val ecartType = moyenne*moyenne - moyenneDesCarres
On retrouve sur toutes les lignes l’instruction liste.foldLeft(0)
, et vous me voyez venir, qui dit répétition d’instruction dit variable !
val liste = List(1, 2, 3) // de type ((Int, Int) => Int) => Int val reduireListeDepuis0 = liste.foldLeft(0) val somme = reduireListeDepuis0((acc, y) => acc + y) val sommeDesCarres = reduireListeDepuis0((acc, y) => acc + y*y) val sommeDesCubes = reduireListeDepuis0((acc, y) => acc + y*y*y) val moyenne = reduireListeDepuis0((acc, y) => acc + y/liste.length) val moyenneDesCarres = reduireListeDepuis0((acc, y) => acc + y*y/liste.length) val ecartType = moyenne*moyenne - moyenneDesCarres
Notre variable reduireListeDepuis0
est une fonction qui prend en paramètre une fonction prenant en paramètre 2 entiers et retournant un entier, et retourne un entier. Et parce que c’est devenu une variable, nous avons pu lui donner un nom et donc facilité la lecture du code pour la suite. Cette variable est une nouvelle fonction créée à partir d’une fonction proposée dans le standard scala. D’où la notion de function factory. C’est parce que la fonction foldLeft
est curryfiée que cela nous est rendu possible.
Curryfier pour composer
Avec la curryfication, on peut également faire de la composition de fonction.
En mathématiques, la composition de fonction se note ainsi :
g o f(x) (lire "g rond f") ou encore g(f(x)
Quel est le rapport avec la programmation ? En programmation fonctionnelle, les fonctions sont l’équivalent des fonctions mathématiques. Ça prend des paramètres en entrée, et ça renvoie une valeur. Donc si on peut composer des fonctions en mathématique, on peut le faire en programmation. Et première nouvelle, vous le faites déjà tous les jours.
def addition(x: Int, y: Int): Int = x + y // composition de add et add addition(addition(1, 4), 3)
J’ai appliqué la fonction add
au résultat de la fonction add
. J’ai composé les deux fonctions. Bon, c’est pas sorcier. Pas besoin de s’amuser à curryfier ma fonction add
pour composer. Voyons un autre exemple :
addition(7, addition(4, addition(8, addition(2, addition(3, addition(1, 5))))))
Houlà c’est moche ! Et encore, heureusement qu’il n’y a que des add
sinon ça serait illisible en plus. Bon ok, ça ressemble à quoi la composition une fois son code curryfié ?
Déjà, il faut savoir qu’en Scala, une fonction est un type d’objet. Et comme tout objet, il a des méthodes. Notamment les méthodes compose
et andThen
. Compose
est exactement ce que nous cherchons à faire. Voici comment l’utiliser sur le même exemple :
def addition(x: Int)(y: Int): Int = { println(s"x = $x") println(s"y = $y") x + y } (addition(7)_) // étape 6, le _ est pour expliciter au compilateur que c'est une fonction .compose(addition(4)) // étape 5 .compose(addition(8)) // étape 4 .compose(addition(2)) // étape 3 .compose(addition(3)) // étape 2 .compose(addition(1))(5) // étape 1 et première valeur de y // étape 1 : x = 1 et y = 5 // étape 2 : x = 3 et y = 6 // étape 3 : x = 2 et y = 9 // étape 4 : x = 8 et y = 11 // étape 5 : x = 4 et y = 19 // étape 6 : x = 7 et y = 23 // résultat : 30
Ce qu’on observe, c’est que la fonction compose
résout en premier la dernière opération qu’on lui a décrit. Exactement comme la composition mathématique. À l’inverse, la fonction andThen
effectue dans l’ordre les opérations. Exemple :
def subtract(x: Int)(y: Int): Int = y - x (subtract(4)_) // étape 1 : 10 - 4 .andThen(subtract(3)) // étape 2 : 6 - 3 .andThen(subtract(2)) // étape 3 : 3 - 2 .andThen(subtract(1))(10) // étape 4 : 1 - 1 et première valeur de y utilisé en étape 1 // résultat : 0
Faut-il curryfier son code tout le temps ?
Si on récapitule, nous avons vu deux cas d’usage où il était intéressant de curryfier son code. Pour proposer une librairie de function factory en utilisant l’application partielle et pour faire de la composition de fonction. Dans les deux cas, le résultat final ne change pas. Avoir curryfié son code et utilisé la composition de fonction ou avoir créé une nouvelle fonction n’apporte rien au runtime. Le gain se fait sur la qualité du code. Pouvoir donner un nom business à un morceau de code réutilisé plusieurs fois grâce à l’application partielle, simplifier sa structure de code pour les tests…
Pour la composition de fonction, on passe d’une notation de « stacking » – une fonction, qui est appelée dans une fonction, qui est appelée dans une fonction, etc. – à une notation « en chaine », où on voit plus clairement un ordre d’exécution.
À titre personnel, j’ai beaucoup utilisé la composition de fonction, mais très peu l’application partielle. C’est principalement en utilisant Spark, un framework Scala qui expose une API assez peu fonctionnelle, que j’ai compris l’intérêt de la curryfication. En Scala, on utilise des monades et pour simplifier la composition sur ce type d’objet, il existe les for comprehension. Avec Spark, il est assez compliqué de réussir à travailler avec des monades sans rendre le code trop lourd. D’où l’intérêt de curryfier son code et d’utiliser la composition.
La curryfication est très intéressante dans certains cas, mais ne doit pas être systématique. Mon conseil, commencer par coder au plus vite sa fonctionnalité, et seulement pendant la phase de refacto réfléchir à l’intérêt ou non de curryfier son code.
P.-S. : Bon anniversaire Haskell Curry
Commentaire