Introduction à la curryfication en JavaScript

Implémentations et exemples pratiques

Hicham Benjelloun
Le JavaScript
8 min readJul 22, 2020

--

Photo by JJ Ying on Unsplash

Introduction

Nous souhaitons spécialiser une fonction JavaScript à plusieurs paramètres en la transformant d’abord en une composition de fonctions d’arités inférieures.

Dans un premier temps, considérons une fonction de la forme suivante :

const fn = (p1, p2, ..., pN) => f(p1, p2, ..., pN);

Dans sa forme la plus simple, ce problème consiste à transformer cette fonction en une nouvelle fonction de la forme suivante :

const curried = p1 => (p2, ..., pN) => fn(p1, p2, ..., pN);

Nous pouvons également généraliser ce processus pour obtenir la forme suivante :

const curried = p1 => p2 => ... => pN => fn(p1, p2, ..., pN);

Cette dernière transformation est aussi appelée curryfication (ou currying) en hommage au mathématicien américain Haskell Curry dont les travaux ont posé les bases de la programmation fonctionnelle.

Dans cet article, nous proposons d’étudier différents aspects de la curryfication en JavaScript. En particulier, nous verrons :

  • un exemple simple pour comprendre ce qu’est la curryfication en pratique.
  • plusieurs implémentations permettant de réaliser les transformations ci-dessus.
  • une variante générique et flexible consistant à transformer une fonction JavaScript en une composition de fonctions d’arités variables.
  • un exemple illustrant les limites des implémentations proposées au regard de la curryfication de fonctions d’arité variable.

Un exemple simple de curryfication

Nous souhaitons calculer le prix toutes taxes comprises (TTC) de produits appartenant à différentes catégories à l’aide d’une fonction TTC :

const TTC = (taux, prix) => (1 + taux) * prix;const prix_tv = TTC(0.2, 299);
const prix_pommes = TTC(0.055, 1.99);
const prix_tomates = TTC(0.055, 2.99);

Réécrivons maintenant notre fonction TTC sous sa forme curryfiée :

const TTC = taux => prix => (1 + taux) * prix;

Cela nous permet en particulier de définir des fonctions de calcul du prix TTC par catégorie :

const TTC_service = TTC(0.2);
const TTC_première_nécessité = TTC(0.055);

Nous pouvons ainsi réécrire nos calculs de prix TTC sans répéter la valeur du taux utilisé :

const TTC_tv = TTC_service(299);
const TTC_pommes = TTC_première_nécessité(1.99);
const TTC_tomates = TTC_première_nécessité(2.99);

Il s’agit là d’un exemple très simple permettant d’utiliser la curryfication pour pouvoir ensuite spécialiser une fonction en en fixant le premier paramètre.

Même si ce n’est pas le sujet de cet article, notez que nous pouvons également réécrire nos fonctions TTC_service et TTC_première_nécessité directement de la façon suivante :

const TTC_service = prix => TTC(0.2, prix);
const TTC_première_nécessité = prix => TTC(0.055, prix);

Cette transformation revient à créer une application partielle. Nous dirons également que nous avons projeté la fonction TTC sur le reste des paramètres qui n'ont pas été fixés. L'avantage de cette technique et de pouvoir fixer certains paramètres et de retourner une nouvelle fonction d’arité inférieure.

Même si, comme l’exemple ci-dessus le montre, la projection de la fonction TTC sur le paramètre prix donne une fonction similaire à la fonction curryfiée, il ne faut pas confondre curryfication et application partielle :

  • la curryfication permet de transformer une fonction à plusieurs paramètres en une composition de fonctions unaires.
  • une application partielle permet de fixer un certain nombre de paramètres d’une fonction et retourne une fonction d’arité inférieure.

L’objet de cet article est d’implémenter une fonction curry générique permettant de réaliser un processus de curryfication de façon automatique, et ce, quel que soit le nombre de paramètres, fixé, de la fonction considérée.

Ce serait user de bien grands moyens dans le cas de cet exemple qui se veut simple, mais nous voudrions pouvoir obtenir, par exemple, la deuxième fonction TTC à partir de la première de la façon suivante :

const TTC_curried = curry(TTC);

Premières fonctions de curryfication

Curryfication simple

Considérons une fonction de la forme suivante :

const fn = (p1, p2, ..., pN) => f(p1, p2, ..., pN);

Nous souhaitons la transformer en une nouvelle fonction de la forme suivante :

const curried = p1 => (p2, ..., pN) => fn(p1, p2, ..., pN);

Une première solution

Voici une première solution possible :

const curryOne = fn => first => (...args) => fn(first, ...args);

Ici, la fonction curryOne prend en paramètre une fonction fn et retourne une nouvelle fonction prenant un paramètre first. Lorsque nous appelons la fonction unaire ainsi produite, nous obtenons une nouvelle fonction ayant mémorisé le premier argument passé au paramètre first, et appliquable au reste des paramètres de la fonction fn.

Une solution plus concise

Enfin, notez que la fonction curyOne peut se réécrire de la façon plus concise suivante :

const curryOne = fn => first => fn.bind(null, first);

En effet, la méthode Function.prototype.bind() renvoie ici une nouvelle fonction disposant d'un argument lié qui sera automatiquement placé avant les autres arguments qui lui seront passés, ce qui est très commode pour réaliser la transformation attendue.

Curryfication générale

Considérons maintenant la forme générale de la fonction curryfiée que nous souhaitons obtenir :

const curried = p1 => p2 => ... => pN => fn(p1, ..., pN);

Avant de nous attaquer à l’implémentation, reprenons la solution du cas de la curryfication simple :

const curryOne = fn => first => (...args) => fn(first, ...args);

Et essayons ensuite d’aller un peu plus loin en appliquant à la première fonction retournée une nouvelle curryfication simple :

const curryTwo = fn =
first =>
second =>
(...args) =>
fn(first, second, ...args);

Pour pouvoir généraliser cette solution, nous devons d’abord observer que dans tous les cas, c’est la dernière fonction qui se charge d’appeler la fonction initiale fn sur l'ensemble des arguments. Par ailleurs, les arguments rencontrés lors du parcours des fonctions unaires successives s'empilent de gauche à droite dans l'appel final : il faut donc trouver un mécanisme pour que cette fonction puisse mémoriser dans le bon ordre tous les arguments passés successivement aux fonctions unaires.

Une première solution

Voici une première solution possible correspondant à l’observation précédente :

const curry = fn => {
const curried =
(...acc) =>
acc.length === fn.length ?
fn(...acc) :
p => curried(...acc, p);

return curried();
};

Nous construisons ici une fonction curryfiée curried à l'aide d'une récursion ascendante correspondant à l'accumulation dans acc des arguments passés successivement aux fonctions unaires :

  • Cas terminal : tous les arguments ont été consommés et la longueur de l’accumulateur d’arguments est égale à l’arité fn.length de la fonction.
  • Si tous les arguments n’ont pas encore été consommés, nous retournons alors une fonction unaire p => curried(...acc, p). En particulier, nous observons qu'à chaque appel de curried, la longueur de l'accumulateur est incrémentée de un et notre récursion se termine bien lorsque sa longueur atteint l'arité de la fonction fn.

Une solution plus concise

Enfin, notez que de la même façon que dans la section précédente, nous pouvons écrire une fonction plus concise en utilisant la méthode Function.prototype.bind() qui accumule naturellement les arguments à gauche.

const curry = fn =>
fn.length === 0 ?
fn() :
p => curry(fn.bind(null, p));

Curryfication flexible

Il se peut que nous ayons besoin d’un peu plus de flexibilité lors de la curryfication d’une fonction.

Imaginons que nous souhaitions pouvoir spécialiser une fonction en spécifiant plusieurs arguments d’un coup au lieu de réaliser plusieurs appels fonctionnels :

const sum = curry((a, b, c) => a + b + c);

sum(1)(2)(3); // => 6
sum(1, 2)(3); // => 6
sum(1, 2, 3); // => 6

Une première solution

Nous pouvons simplement reprendre la fonction de curryfication générale vue précédemment et remplacer les fonctions unaires par des fonctions d’arités variables :

const curry = fn => {
const curried =
(...acc) =>
acc.length === fn.length ?
fn(...acc) :
(...args) => curried(...acc, ...args);

return curried();
};

Nous pouvons simplifier cette solution. En effet, nous utilisions une constante intermédiaire curried pour nous assurer que nous retournions initialement une fonction unaire lorsque nous retournions l'appel curried(). Maintenant, comme notre fonction curryfiée peut prendre n'importe quelle nombre d'arguments en paramètres, nous pouvons réécrire curry sans fonction intermédiaire :

const curry = fn =>
(...acc) =>
acc.length === fn.length ?
fn(...acc) :
(...args) => curry(fn)(...acc, ...args);

En place de curried, nous écrirons simplement l'appel récursif principal avec curry(fn).

Une solution plus concise

L’usage de la méthode Function.prototype.bind() permet d'écrire la fonction précédente de façon plus concise, en accumulant de façon implicite les arguments dans la fonction retournée à chaque appel récursif :

const curry = fn =>
fn.length === 0 ?
fn() :
(...args) => curry(fn.bind(null, ...args));

Limites des implémentations proposées

Voici un exemple de fonction générique astucieuse nous permettant de calculer une somme de nombres de plusieurs façons :

const sum = (...args) =>
Object.assign(
sum.bind(null, ...args),
{valueOf: () => args.reduce((acc, cur) => acc + cur, 0)}
);
+sum(1)(2)(3); // => 6
+sum(1, 2)(3); // => 6
+sum(1, 2, 3); // => 6

Essayons maintenant de réutiliser la fonction curry générique écrite précédemment pour rendre plus lisible ce code :

const add = (a, b) => a + b; 
const sum = curry((...args) => args.reduce(add, 0));

sum(1)(2)(3); // TypeError: sum is not a function

Oups ! Vous vous en doutiez probablement, mais la ligne faisant appel à curry ne produit pas le bon résultat : cela est dû au fait que pour une fonction fn acceptant un nombre variable d'arguments, la valeur de fn.length qui définit le cas terminal de notre récursion vaut… 0 !

Ainsi, nous tombons directement dans le cas terminal de notre fonction récursive et notre tentative de curryfication revient à faire :

const sum = ((...args) => args.reduce(add, 0))(); // sum = 0

Cela n’est bien sûr pas ce que nous cherchons à faire mais ce problème nous permet d’insister sur le fait que notre fonction de curryfication s’applique seulement à une fonction ayant une arité fixe.

Dans le cas où vous auriez quand même besoin d’avoir des fonctions d’arités variables à curryfier, vous pouvez toujours modifier le code de la fonction curry pour prendre en compte un paramètre supplémentaire en plus de la fonction et qui indiquerait le nombre de paramètres attendus au total : il faut bien que notre fonction sache quand s'arrêter et retourner une valeur.

Cela étant dit, nous pouvons généraliser notre fonction curry dans certains cas. Par exemple, en généralisant la fonction sum vue au début de cette section, nous pouvons écrire :

const add = (a, b) => a + b;const curryWithBinaryOp = (op, initialValue) => {
const reduced = (...args) =>
Object.assign(
reduced.bind(null, ...args),
{ valueOf: () => args.reduce(op, initialValue) }
);

return reduced;
};
const sum = curryWithBinaryOp(add, 0);+sum(1)(2)(3); // => 6
+sum(1, 2)(3); // => 6
+sum(1, 2, 3); // => 6

Voilà donc une fonction de curryfication astucieuse de fonctions d’arités variables. Notez toutefois qu’il est nécessaire de préciser à JavaScript que nous souhaitons extraire la valeur ainsi obtenue étant donné que comme nous ne savons pas à l’avance où nous allons nous arrêter, la valeur retournée est toujours une fonction ! Ainsi, l’astuce consistant à utiliser la propriété valueOf nous permet d'extraire la valeur qui nous intéresse lorsque nous en avons besoin.

--

--