Application partielle et curryfication [FR]
Préambule
Cet article utilise TypeScript
mais les concepts abordés sont applicables à de nombreux langages
Voici une courte explication de la syntaxe de TypeScript
const areTheSame = (a: number, b: string): boolean => a.toString() === b;
Cette instruction
- crée une fonction appelée
areTheSame
- la fonction
areTheSame
déclare deux paramètresa
etb
- le paramètre
a
est de typenumber
- le paramètre
b
est de typestring
- la fonction
areTheSame
retourne unboolean
- la partie derrière le
=>
est le corps de la fonction
Il est possible de créer des fonctions qui renvoient des fonctions de cette manière
const areTheSameInTwoFunctions = (a: number) => (b: string): boolean => a.toString() === b;
Cette instruction
- crée une fonction appelée
areTheSameInTwoFunctions
- la fonction
areTheSameInTwoFunctions
déclare un paramètresa
- le paramètre
a
est de typenumber
- le corps de
areTheSameInTwoFunctions
va renvoyer une nouvelle fonction anonyme (qui n'a pas de nom) - cette fonction anonyme déclare un paramètre
b
- le paramètre
b
est de typestring
- la fonction anonyme retourne un
boolean
Les deux fonctions peuvent être exécutées ainsi
const theyAreTheSame = areTheSame(1, "1");
const theyAreNotTheSame = areTheSameInTwoFunctions(1)("2");
Différence entre paramètre
et argument
Un paramètre
se trouve dans la déclaration d'une fonction pour indiquer de quoi elle a besoin pour être exécutée
Un argument
est une valeur concrète passée à un paramètre
const increment = (a: number): number => a + 1;
increment(5);
a
est un paramètre
5
est un argument
Définition du terme arité
Nombre d’arguments dont une fonction a besoin pour être exécutée
const addThree = (a: number, b: number, c: number): number => a + b + c;
La fonction addThree
a besoin qu'on attribue des valeurs à ses 3
paramètres a
, b
et c
pour être exécutée
const six = addThree(1, 2, 3);
Son arité est donc 3
Définition du terme unaire
Fonction dont l’arité est 1
const increment = (n: number): number => n + 1;
La fonction increment
a besoin qu'on attribue une valeur à son paramètre n
pour être exécutée
const two = increment(1);
Son arité est donc 1
Définition du terme application partielle
Produire une nouvelle fonction B
à partir d'une fonction A
. L'arité de la fonction B
doit être inférieure à l'arité de la fonction A
const addThreeNumbers = (a: number, b: number, c: number): number => a + b + c;
const addTenToTwoNumbers = (b: number, c: number): number => addThreeNumbers(10, b, c);
La fonction addThreeNumbers
a une arité de 3
La fonction addTenToTwoNumbers
, créée à partir de addThreeNumbers
, a une arité de 2
Il s’agit donc d’application partielle
Le fait de donner une valeur à un paramètre afin d’effectuer une application partielle s’appelle un fix
ou un bind
D’ailleurs, en JavaScript
et TypeScript
, il est possible d'appliquer partiellement une fonction avec bind
const addTenToTwoNumbers = (b: number, c: number): number => addThreeNumbers.bind(null, 10);
Cette technique a le désavantage de vous obliger à fournir un contexte pour votre nouvelle fonction (null
dans l'exemple précédent) et ne fonctionne qu'avec le premier argument de votre fonction (il n'aurait pas été possible de créer un application partielle sur b
ou c
)
C’est pourquoi en JavaScript
et TypeScript
, on trouve plus souvent des fonctions qui retournent d'autres fonctions pour effectuer une application partielle, ou encore des fonctions dédiées à l’application partielle comme nous le verrons plus tard
A quoi ça sert ?
Une fonction appliquée partiellement a certains de ses paramètres fixés et est en attente d’un ou de plusieurs arguments pour pouvoir s’exécuter
Par conséquent, une fonction appliquée partiellement est une fonction qui contient de l’information
Cette fonction peut ensuite être utilisée pour créer de nouvelles fonctions, avec de l’information déjà fixée en argument
Par conséquent, une fonction appliquée partiellement est capable de créer de nouvelles fonctions qui contiennent déjà de l’information
const buildUrl = (protocol: string, domain: string, path: string) => protocol + "://" + domain + "/" + path;
const buildSecureUrl = (domain: string, path: string): string => buildUrl("https", domain, path);
Je peux réutiliser buildSecureUrl
pour construire des valeurs ou des fonctions qui partageront le fait que le protocole à utiliser est https
const buildWebsiteUrl = (path: string): string => buildSecureUrl("website.com", path);
const buildAppUrl = (path: string): string => buildSecureUrl("app.website.com", path);
const urlToInternshipOffer = buildSecureUrl("careers.website.com", "offer?id=10");
On utilise ainsi l’application partielle pour éviter de se répéter ou pour éviter d’effectuer des calculs coûteux plusieurs fois
const myFunction = (arg1, arg2) => {
const complexResult = functionThatTakesTime(arg1);
return complexResult + arg2;
}const otherFunction = myFunction(1, 3);
const thirdFunction = myFunction(1, 2);
Le calcul qui prend du temps est effectué deux fois alors qu’avec de l’application partielle, on pourrait ne l’effectuer qu’une seule fois
const myFunction = (arg1) => {
const complexResult = functionThatTakesTime(arg1);
return (arg2) => complexResult + arg2;
}const alreadyDone = myFunction(1);
const otherFunction = alreadyDone(3);
const thirdFunction = alreadyDone(2);
Le calcul qui prend du temps n’est effectué qu’une seule fois
On pourrait passer par une variable intermédiaire qui contiendrait le résultat mais ce serait se priver de la composabilité des fonctions, un vaste sujet qui dépasse le cadre de cet article
Ainsi, l’intérêt principal de l’application partielle est de permettre de créer des fonctions qui vont permettre de créer d’autres fonctions
Définition du terme curryfication
Prenons l’exemple de la fonction suivante
const add = (a: number, b: number): number => a + b;
add
n'est pas unaire parce que son arité est de 2
Curryfier add
signifierait
- obtenir à partir de
add
une nouvelle fonction - qui aurait pour but d’effectuer la même action que
add
- qui serait unaire
- qui retournerait une fonction elle-même currifiée pour gérer les arguments restants
const add = (a: number, b: number): number => a + b;
const addCurried = (a: number) => (b: number): number => a + b;
La fonction addCurried
est une fonction unaire, elle a pour seul argument a
Elle retourne une fonction anonyme unaire, qui a pour seul argument b
et qui effectue le calcul initial
const five = addCurried(2)(5);
Ainsi, une fonction currifiée
- prend un seul argument
- retourne soit une fonction currifiée, soit le résultat attendu
A quoi ça sert ?
Une fonction curryfiée est une fonction appliquée partiellement dont vous savez à l’avance le fonctionnement
Pas besoin d’avoir à vous préoccuper du nombre d’arguments à fournir, elle prendra toujours un seul argument et renverra une fonction qui en prendra un aussi, ou un résultat final
Ainsi, si vous créez une fonction currifiée, vous savez qu’elle est en attente d’un argument
- soit pour retourner une fonction currifiée
- soit pour qu’elle s’exécute
En informatique, savoir à quoi ressemble quelque chose de manière certaine, c’est pouvoir effectuer des actions très puissante sur cette chose
const rawIds = ["id-35", "id-53", "id-657"];
const numericPartOfMyIds = rawIds.map(id => id.split("-")[1]);
Je sais à l’avance à quoi ressemblent mes identifiants, ce sont des chaînes de caractères qui commencent par id-
et qui contiennent ensuite un chiffre
Je peux ainsi facilement extraire la partie numérique avec un simple split
const rawIds = ["29437", 32, "id-15"];
Avec cette convention d’identifiants là, il m’est beaucoup plus compliqué d’extraire la partie numérique
Le même principe de prédictibilité s’applique pour les fonctions
Comme une fonction ne retourne qu’une seule valeur, si une autre fonction ne prend qu’un argument, alors je peux facilement enchaîner le résultat d’une fonction pour le fournir en paramètre à une autre fonction
const increment = (_: number): number => _ + 1;
const isOver = (boundary: number) => (toTest: number): boolean => toTest > boundary;[-2, 2, 3]
.map(increment)
.filter(isOver(0))
Les fonctions increment
, isOver
et isOver(0)
sont très faciles à réutiliser dans d'autres contextes
Ce ne serait pas le cas avec une fonction qui prend plus d’un argument, comme il me faudrait une valeur pour les paramètres qui ne sont pas le premier
const add = (a: number, b: number): number => a + b[-2, 2, 3]
.map(/* je ne peux pas utiliser "add" directement ici */)
Ainsi, l’intérêt principal de la curryfication est de permettre de créer des fonctions qui vont permettre de créer d’autres fonctions, faciles à utiliser sans les connaître à l’avance
L’autocurryfication
Ecrire manuellement des fonctions unaires pour curryfier une fonction peut être chronophage
const addFiveNumbers = (a: number) => (b: number) => (c: number) => (d: number) => (e: number): number => a + b + c + d + e;
Il existe des bibliothèques qui se chargent d’effectuer cette action manuellement, par exemple Ramda
const addFiveNumbers = R.curry(a: number, b: number, c: number, d: number, e: number): number => a + b + c + d + e);
const addTenToNumber = addFiveNumbers(1, 2, 3, 6);
const twelve = addTenToNumber(2);
A noter que cette fonction ne renvoie pas une nouvelle fonction curryfiée mais une fonction qui permet l’application partielle de manière très libre
const addTenToNumber = addFiveNumbers(1, 2)(3, 6);
La curryfication dans d’autres langages
Les langages orientés fonctionnels essaient de reproduire le comportement des fonctions mathématiques
Or, une fonction mathématique ne peut avoir qu’un seul argument
Par conséquent, les fonctions dans certains langages fonctionnels ne peuvent avoir qu’un seul paramètre
Il est possible de déclarer des fonctions avec plusieurs paramètre mais le compilateur curryfiera automatiquement la fonction
let add a b = a + b
Cet exemple en F#
montre une fonction appelée add
qui a deux paramètres a
et b
pour les additionner
Au moment de la compilation, la fonction générée ressemblera à quelque chose comme
let add a =
let addSubFuction b =
a + b
addSubFuction // retourne la fonction interne
Il est ensuite possible d’utiliser la fonction ainsi
let inc b = add 1 b // création d'une fonction "add" avec deux arguments
let incOther = add 1 // on peut aussi écrire la déclaration de "incOther" ainsi, le "b" est implicite
let three = add 1 2
let four = inc 3
Le même raisonnement s’applique en Haskell
add :: Num a => a -> a -> a
add a b = a + b -- création d'une fonction "add" avec deux argumentsinc :: Num a => a -> a
inc b = add 1 b -- création d'une nouvelle fonction "inc" en appliquant partiellement "add"inc = add 1 -- on peut aussi écrire la déclaration de "inc" ainsi, le "b" est implicite
Conclusion
L’application partielle et la curryfication sont deux mécanismes pour créer des fonctions spécialisées à partir de fonctions génériques
Elles permettent ainsi d’avoir moins de duplication et d’améliorer la lisibilité de votre code
Encore mieux, en utilisant des techniques de développement orienté fonctionnel, elles permettent d’écrire du code en exploitant la composabilité des fonctions, surtout la curryfication