Publié par

Il y a 6 jours -

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.

fonction addition non curryfiée
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 :

fonction addition curryfiée

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 :

exemple curryfication add5

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 :

appeler une fonction curryfiée
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 :

sucre syntaxique curryfication
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.

fonction substring
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.

Somme avec 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.

Exemple de function factory avec foldLeft

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 !

Exemple de function factory avec foldLEft
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.

Exemple composition fonction

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 :

Exemple de composition compliquée
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 :

Exemple composition scala

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 :

Exemple de composition andThen

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

 

 
Réponse
def subString(str: String)(begin: Int, end: Int): String

// ou encore, si on veut appliquer la curryfication une deuxième fois :
def subString(str: String)(begin: Int)(end: Int): String

Publié par

Commentaire

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Nous recrutons

Être un Xebian, c'est faire partie d'un groupe de passionnés ; C'est l'opportunité de travailler et de partager avec des pairs parmi les plus talentueux.