Programmation fonctionnelle : définition et explications
Le paradigme de programmation fonctionnelle est applicable au langage JavaScript. Cela permet d’écrire des programmes plus fiables et surtout plus faciles à maintenir.
Vous êtes-vous déjà demandé quelle pouvait être la cause majeur de l'apparition de bugs dans un programme ? Et bien la réponse est simple et il s'agit, la plupart du temps, d'une mauvaise gestion des variables. Réfléchissez, par définition, une variable est une donnée qui peut changer à l'inverse des constantes qui sont censées être immutables. Mais voilà, en javascript, rien n'est vraiment immutable, nous le verrons plus loin dans cet article. Une donnée qui change pendant l'execution d'un programme est tout à fait normal mais une variable accessible par plusieurs fonctions et qui peut être modifiée par plusieurs fonctions est un vrai fléau pour votre programme !
Dans un programme informatique, qu'est-ce qui peut venir altérer le bon fonctionnement des fonctions si ce n'est les variables. Imaginez qu'on puisse supprimer ces variables, vos fonctions seraient alors prévisibles et rien ne pourrait altérer leur bon fonctionnement. Imaginez une fonction que vous pourriez executer autant de fois que vous le voulez et qui vous retourne toujours le même bon résultat quel que soit le context d'exécution !
Quelle solution nous apporte le paradigme de programmation fonctionnelle face à un tel casse-tête ?
La solution est simple, que diriez-vous si je vous disais qu'il suffit de supprimer toutes les variables ?
C'est là toute la puissance de ce paradigme. Voyons un peu de théorie pour entrevoir le gain que l’on peut obtenir en appliquant le concept de programmation fonctionnelle à des langages impératifs tel que le JavaScript. Nous commencerons par un peu de pratique en appliquant ce concept sur les manipulations de tableaux car la programmation fonctionnelle excelle dans ce domaine et nous pousseront la refacto de code à l'extrême pour arriver à un code sans "variable".
Synthèse des concepts de la programmation fonctionnelle => Cheat sheet "Programmation fonctionnelle"
JavaScript : Les fonctions
Vous le savez tous, les fonctions sont la base de la programmation, elles permettent de regrouper les portions de code qui se répètent. D'autre part, avec un bon nommage, elles permettent une meilleure lisibilité/compréhension du code.
En d’autres termes, elles permettent de découper un besoin fonctionnel complexe en un ensemble de petites fonctions moins complexe, et donc, plus facile à implémenter.
Définition d'une fonction en programmation fonctionnelle
Pour faire simple, c'est un ensemble de ligne de code mis bouts à bouts dans le but unique de faire ce pour quoi on l'a créée ET RIEN D'AUTRE !
function add(a, b) {
return a + b;
}
// Ou, l'équivalent avec la syntaxe ES6
const add = (a, b) => a + b;
// add(3, 2) => 5
Cette fonction a été crée pour additionner 2 nombres. Celle-ci retourne donc l’addition des deux paramètres “a” et “b” passés en arguments et rien d'autre.
On peut dire que si on appelle cette fonction plusieurs fois de suite avec les mêmes paramètres en entrée, alors le résultat en sortie sera toujours identique ! Et en affirmant cela, on se retrouve au cœur du paradigme de programmation fonctionnelle.
On appelle ce genre de fonction, une fonction pure car rien ne viendra altérer son comportement. Cette fonction n'utilise que des variables passés en paramètres et surtout pas de variable globale.
Maintenant, jetons un coup d’œil à la fonction suivante :
var a = 3; // ne pas utiliser “var” ! uniquement à des fins de démo
function add(b) {
return a + b;
}
add(2); // renverra 5.
// Si pour une raison ou une autre, la valeur de "a" était amenée à
// changer, notre fonction renverrait une valeur différente !
var a = 2;
// alors, cette fois
add(2); // renverra 4
Dans ce cas, vous voyez bien qu’il y a un problème car on ne peut en aucun cas garantir le résultat de la fonction “add” puisqu'un programme/fonction externe peut altérer/modifier la variable globale et ainsi modifier le comportement de cette fonction.
Autrement dit, une autre fonction à été en mesure d’accéder au même espace mémoire où est stockée la variable globale.
Cette fonction peut donc avoir un comportement inattendu, c’est ce que l’on appelle en programmation fonctionnelle, les effets de bord.
En conclusion, une fonction avec des effets de bord (utilisant des variables en dehors de son champ d'action) est une fonction qui n’est pas pure. Cela nous causera des soucis inévitables dans la vie et l’évolution du programme à venir.
Les notions importantes de la programmation fonctionnelle
L’immutabilité
L’immutabilité est la notion qui consiste à dire qu’une variable ne peut être, en aucun cas modifiée par qui que ce soit.
L’utilisation de var et let est donc interdite en programmation fonctionnelle. De même que la mutation d’objet. En effet, même en utilisant “const” pour déclarer un objet, rien ne vous empêchera de modifier une propriété de cet objet.
Il est crucial de comprendre ce qu’est l’immutabilité mais grâce au paradigme de programmation fonctionnelle, cette notion deviendra peu à peu transparente et vous n’y ferez même plus attention.
Inutile de vous munir de librairie telle que “Immutable” ou d’utiliser des “Object.freeze” à tout va puisque la programmation fonctionnelle vous évitera de faire des bêtises. Ou plutôt, la programmation fonctionnelle changera votre manière de penser et donc de concevoir votre programme qui fait que vous n'aurez plus à vous souciez de l'immutabilité d'une variable.
Si vous voulez adopter le paradigme à la lettre, alors vous n'aurez même plus le droit de faire des affectations. Vous ne ferez plus que de la composition de fonctions. Mais nous verrons plus loin en quoi cela consiste car avant d'aborder la composition de fonctions, il est indispensable de comprendre les notions de transparence référentielle et les effets de bords.
La transparence référentielle et effets de bords
En programmation fonctionnelle, vous entendrez certainement parler de “transparence référentielle”. Cette notion un peu barbare et pas du tout évidente à comprendre peut être expliquée de la manière suivante :
Dites vous bien que c'est parce que la fonction est pure que la transparence référentielle est possible. Lorsqu'une fonction est pure, les variables utilisées à l'intérieur restent dans le champ d'action (ou scope) de cette fonction. De plus, cette fonction ne fait que ce pour quoi elle a été prévue et rien d'autre ! On dit qu'il n'y a donc pas d'effets de bords. Et s'il n'y a pas de responsabilité autre que la raison d'être de cette fonction, alors on peut remplacer cette même fonction par une valeur. On ne le fera pas, mais c'est pour expliquer la notion de transparence référentielle.
Dans un programme développé avec des fonctions pures, il est possible de remplacer les appels des fonctions directement par leurs valeurs respectives sans risque de modifier le résultat final et surtout sans risque de régression dans ce programme.
// reprenons notre fonction pure du début
function add(a, b) {
return a + b;
}
console.log( add(2, 3) ); // retourne 5
// aura le même comportement que
console.log( add( add(1, 1) , 3 ) ); // retourne 5 également
console.log( add( add(1, 1) , add(2, 1) ) ); // retourne 5
console.log( 5 ); // encore et toujours 5 !
Remarquez-vous la manière dont il est possible de remplacer une fonction par une valeur ? Et bien dites-vous bien que cela est possible uniquement si vous respectez le fait que votre fonction est pure. La notion de fonction pure est très certainement la notion la plus importante du paradigme de programmation fonctionnelle.
Dans le cas inverse, prenons l'exemple d'une fonction "addTwoNumbers" ayant un effet de bord. Une fonction ayant une double responsabilité. Celle d'additionner 2 nombres, et de notifier un autre programme comme quoi l'addition est en cours (ou a été effectuée). On voit bien que si on cherche à remplacer cette fonction par une valeur, alors l'effet de bord en question ne sera plus exécuté. Par conséquent, la notification ne sera plus envoyée, ce qui causera très certainement un bug dans le programme.
C’est grâce à ce concept de transparence référentielle qu’il est pour nous, développeurs, possible de factoriser un programme sans risquer de tout casser.
Les fonctions d’ordre supérieur (ou High Order Function => HOF)
Pour faire simple, une fonction d’ordre supérieur est une fonction qui prend au moins une fonction en paramètre et/ou qui retourne une fonction plutôt qu’une valeur de type "string", "object" ou "number".
// Mon objet "car" initial
const myCar = {
brand: "Audi",
model: "A7 sportback",
};
// Fonction d'ordre supérieur qui retourne une fonction
function enrichCarWithProp (obj1) {
console.log("obj1", obj1); // { brand: "Audi", model: "A7 sportback" }
return function(prop, value) {
return {
...obj1,
[prop]: value, // ici => color: "Bleu lunaire"
};
};
}
const enrichCar = enrichCarWithProp(myCar);
console.log( enrichCar("color", "Bleu lunaire") )
/*
{
brand: "Audi",
model: "A7 sportback",
color: "Bleu lunaire"
}
*/
// En ES6, on peut simplifier de la sorte
// Mon object "car" initial
const myCar = {
brand: "Audi",
model: "A7 sportback",
};
// Fonction d'ordre supérieur qui retourne une fonction
const enrichCarWithProp = obj => (prop, value) => ({
...obj,
[prop]: value,
});
const enrichCar = enrichCarWithProp(myCar);
console.log( enrichCar("color", "Bleu lunaire") )
/*
{
brand: "Audi",
model: "A7 sportback",
color: "Bleu lunaire"
}
*/
Grâce aux fonctions d’ordre supérieur, il est possible de créer des fonctions génériques et surtout réutilisables. C’est, en partie, grâce à ce concept que l’on peut séparer notre programme en plusieurs petites fonctions ayant chacune leur responsabilité. Cela nous permet également de créer des modules réutilisable et qui pourraient être utiles pour d'autres programmes.
Essayons d’appliquer ce concept sur un exemple plus concret grâce à la manipulation de tableaux.
Itérer sur les tableaux en JavaScript
Un tableau représente une liste ordonnée de valeurs. Peu importe le type de ces valeurs. En revanche, je vous conseille vivement de ne pas mélanger les types au sein de votre tableau car il devient très difficile, voire impossible d’industrialiser un traitement sur chacune des occurrences de votre tableau si leur type change. En effet, vous seriez obligé de tester le type de votre variable (typeof <myVar> ou Array.isArray(myVar) ) afin d'appliquer le traitement ou la transformation adéquate.
Javascript : itérateur "forEach" (Array.prototype.forEach)
L'itérateur "forEach" exécute une fonction sur chaque éléments de votre tableau.
var globalCars = [
{ brand: "Honda", model: "civic", power: "140cv" },
{ brand: "Audi", model: "A7 sportback", power: "252cv" },
];
/*
* Extract number from a given string
* @param {string}
* @return {number}
*/
const getNumericValue = txt => txt.match(/\d/g).join("");
function formatCars(globalCars) {
globalCars.forEach((car, idx) => {
globalCars[idx].power = Number(getNumericValue(car.power));
});
}
formatCars(globalCars);
console.log( "globalCars", globalCars );
Le code ci-dessus fonctionne très bien, alors pourquoi aller chercher plus loin ?
Et bien tout simplement parce que la fonction “formatCars” (ainsi que celle à l’intérieur du “forEach”) n’est pas pure et que vous vous exposez à des bugs certains ! Il est donc impossible de connaître avec certitude le contenu de la variable “globalCars”.
En effet, la fonction utilise la variable “globalCars” qui se situe en dehors de son scope. Il y a donc de forte chance que d’autres fonctions fassent de même puisque rien n’interdit l’accès à l’espace mémoire où est stockée la variable “globalCars”.
D’autre part, il est important pour la compréhension du code, de bien séparer la logique métier de celle de votre programme (regex, boucle, etc). Cela vous permettra de tester très facilement vos fonctions de manière unitaire en vous concentrant sur le résultat final et non la manière dont vous avez implémenté la fonctionnalité.
Souvenez-vous, une fonction ne doit faire que ce pour quoi elle a été créée. Pour simplifier et améliorer la qualité de votre programme, séparez votre code en plein de petites fonctions simple ayant chacune UNE seule responsabilité.
L’itérateur “forEach” est à bannir de vos prochaines lignes de code !
Ne connaissez-vous pas une fonction qui permettrait d’itérer sur chacun des éléments du tableau et qui, en même temps, retourne un tableau ? La fonction “map”, et oui !
Une meilleure solution consisterait donc à utiliser l’itérateur “map” qui nous permettra de créer un nouveau tableau sans changer le tableau d’origine.
Javascript : itérateur "map" (Array.prototype.map)
L'itérateur "map" applique (ou exécute) une fonction sur chaque éléments de votre tableau et vous renvoi ainsi un tableau ayant le même nombre d'éléments que le tableau d'origine.
Voyons comment faire :
const cars = [
{ brand: "Honda", model: "civic", power: "140cv" },
{ brand: "Audi", model: "A7 sportback", power: "252cv" },
];
/*
* Extract number from a given string
* @param {string}
* @return {number}
*/
const getNumericValue = txt => txt.match(/\d/g).join("");
/*
* @param {array} cars - liste des voitures
* @return {array}
*/
function formatCars(cars) {
// déclaration d'un nouveau tableau pour respecter l'immutabilité.
// c'est inutile de créer une variable pour la retourner juste après, retournez directement comme ceci => return cars.map(...)
const formattedCars = cars.map(car => {
// Remarque : pour un code à destination d'un environnement de production, il faudrait tester l'objet "car" pour s'assurer que la propriété "power" existe !
const { power, ...rest } = car;
const powerAsNumber = getNumericValue(power);
return {
...rest,
power: Number(powerAsNumber)
}
});
return formattedCars;
}
const formattedCars = formatCars(cars);
console.log( "formattedCars", formattedCars )
/*
[
{ brand: "Honda", model: "civic", power: 140 },
{ brand: "Audi", model: "A7 sportback", power: 252 },
]
*/
Pour faciliter la lecture, on peut sortir la fonction qui se trouve à l'intérieur de l’itérateur “map” et simplifier en utilisant la syntaxe es6 :
const cars = [
{ brand: "Honda", model: "civic", power: "140cv" },
{ brand: "Audi", model: "A7 sportback", power: "252cv" },
];
/*
* Extract number from a given string
* @param {string}
* @return {number}
*/
const getNumericValue = txt => txt.match(/\d/g).join("");
const formatCar = car => {
// Objet "car" à valider avant d'extraire la propriété "power"
const { power, ...rest } = car;
const powerAsNumber = getNumericValue(power);
return {
...rest,
power: Number(powerAsNumber)
}
};
const formatCars = cars => cars.map(formatCar);
/* l'équivalent es5 pour ceux qui ne seraient pas encore familier avec la syntaxe es6
function formatCars(cars) {
// déclaration d'un nouveau tableau pour respecter l'immutabilité.
return cars.map(formatCar);
}
*/
const formattedCars = formatCars(cars);
console.log( "formattedCars", formattedCars )
/*
[
{ brand: "Honda", model: "civic", power: 140 },
{ brand: "Audi", model: "A7 sportback", power: 252 },
]
*/
Javascript : itérateur "filter" (Array.prototype.filter)
Comme son nom l'indique, l'itérateur "filter" permet de filtrer et donc de réduire le tableau d'origine. Il exécute une fonction de filtre sur chacun des éléments du tableau d'origine et renvoi un nouveau tableau dont chaque élément vérifie la condition implémentée dans la fonction de callback.
Imaginons le besoin métier suivant :
Créer une fonction permettant de filtrer les voitures afin que seules celles qui se situent entre 130 et 200 chevaux soient sélectionnées pour participer à la course et ainsi garantir un équilibrage acceptable entre les voitures qui vont s’affronter.
// Liste des voitures candidates pour la course
const cars = [
{ brand: "Honda", model: "civic", power: "140cv" },
{ brand: "BMW", model: "320 i worldline", power: "163cv" },
{ brand: "Audi", model: "A7 sportback", power: "252cv" },
{ brand: "Audi", model: "A5 TFSI", power: "cv" }, // bad parameter type
{ brand: "Renault", model: "twingo" }, // missing power property !
];
// Regex permettant d'extraire un nombre à partir d'une string
const getNumericValue = txt => txt.match(/\d/g).join("");
// Fonction de formattage d'un objet "car"
// Destructuration immédiate de l'objet "car"
const formatCar = ({ power, ...rest }) => {
try {
const powerAsNumber = getNumericValue(power);
return {
...rest,
power: Number(powerAsNumber)
}
} catch(e) {
console.log("ERROR", e.message);
return {
...rest,
power: 0,
error: "Bad or missing property 'power'",
}
}
};
// Itération sur chaque voiture pour appliquer le formattage à chacune
// d'entres elles
const formatCars = cars => cars.map(formatCar);
// Fonction de comparaison pour la sélection des voitures
// Notez que cette fonction sert à implémenter une règle métier. Il serait
// pertinent de la sortir dans un module à part (ex: racingCarbusinessRules.js)
const filterCarLowerThan200CV = car => car.power > 130 && car.power < 200;
// Itération sur chaque voiture pour appliquer la fonction de filtre
// sur chacune d'entres elles
const getElligibleCars = cars => cars.filter(filterCarLowerThan200CV);
// Appel de la fonction de formattage des voitures afin de pouvoir
// exploiter/comparer les puissances
const formattedCars = formatCars(cars);
console.log( "formattedCars", formattedCars )
/*
formattedCars [
{
"brand": "Honda",
"model": "civic",
"power": 140
},
{
"brand": "BMW",
"model": "320 i worldline",
"power": 163
},
{
"brand": "Audi",
"model": "A7 sportback",
"power": 252
},
{
"brand": "Audi",
"model": "A5 TFSI",
"power": 0,
"error": "Bad or missing property 'power'"
},
{
"brand": "Renault",
"model": "twingo",
"power": 0,
"error": "Bad or missing property 'power'"
}
]
*/
// Récupération des voitures elligibles à la course
const elligibleCars = getElligibleCars(formattedCars)
console.log( "elligibleCars", elligibleCars );
/*
elligibleCars [
{
"brand": "Honda",
"model": "civic",
"power": 140
},
{
"brand": "BMW",
"model": "320 i worldline",
"power": 163
}
]
*/
Javascript : itérateur "reduce" (Array.prototype.reduce)
"Reduce", itérateur sans nul doute, le plus puissant en JavaScript - il est possible d'implémenter les fonctions "map", "find", "filter", "every" ou "some" avec cet unique itérateur !
Pour comprendre le fonctionnement du “reduce”, il faut d’abord comprendre la notion d’accumulateur.
En quelques mots, un accumulateur est une variable (de type number, string, object ou array) qui va nous permettre de passer le résultat de la 1ère itération en paramètre de la seconde itération et ainsi de suite.
// Liste des personnages
const brawlers = [
{ pseudo: "Nita", nbTrophy: 148 },
{ pseudo: "Bartaba", nbTrophy: 140 },
{ pseudo: "Colt", nbTrophy: 103 },
{ pseudo: "Penny", nbTrophy: 56 },
{ pseudo: "El Costo", nbTrophy: 324 },
{ pseudo: "Jessie", nbTrophy: 215 },
{ pseudo: "Shelly", nbTrophy: 183 },
{ pseudo: "Bull", nbTrophy: 43 },
{ pseudo: "Rosa", nbTrophy: 0 },
];
// Fonction de calcul du nombre total de trophés
const getTotalTrophy = brawlers =>
brawlers.reduce(
(accumulator, brawler) => accumulator + brawler.nbTrophy,
0
);
// Appel de la fonction pour récupérer la somme des trophés
console.log("sumTrophy", getTotalTrophy(brawlers)); // => sumTrophy 1212
Vous voyez qu’on utilise un accumulateur afin de récupérer le résultat de l’itération précédente.
je vous entends déjà me dire “et oui, mais la 1 ère itération alors ?”
Pas d’inquiétude, le "reduce" est prévu pour cela, c’est d’ailleurs la raison qui fait qu’il y a un second paramètre passé en argument, ici la valeur “0”.
<monArray>.reduce(myFunction, initialValue)
Le second paramètre est optionnel. Si vous ne l'utilisez pas, alors l'argument n°1 représente le 1er élément du tableau et l'argument n°2 représente le 2ème élément du tableau.
Unary function VS binary function
L'itérateur "reduce" utilise une fonction de callback à 2 paramètres contrairement aux autres itérateurs tels que "map", "find" et "filter" *. On parle alors de fonction binaire (ou binary function) s'opposant aux fonctions unaires (ou unary function). On peut également parler de l'arité d'une fonction. En mathématique, l'arité d'une fonction représente le nombre d'argument de celle-ci.
* je ne parle pas ici des paramètres optionels tels que l'index ou le tableau lui-même. Ces données sont très souvent inutiles en programmation fonctionnelle.
La composition de fonction
Cette notion n'est pas si compliquée qu'il n'y paraît. Composer deux fonctions ensembles revient à appliquer le traitement de 2 fonctions sur la même donnée de départ, mais l'une après l'autre. Plus simplement, on applique la fonction n°1 et on passe le résultat de cette fonction à la fonction n°2 et on récupère le résultat final. Evidemment, on peut composer avec un très grand nombre de fonction et pas nécessaire de se limiter à 2 fonctions.
Je vous propose l'exemple ci-dessous pour illustrer mes propos.
const add = (a, b) => a + b;
const mult = (a, b) => a * b;
const sous = (a, b) => a - b;
// Exemple n°1
const addResult = add(2, 3); // 2 + 3 => 5
const multResult = mult(addResult, 2); // 5 * 2 => 10
const sousResult = sous(multResult, 2); // 10 - 2 => 8
console.log("sousResult", sousResult);
// => 8
// Exemple n°2
const result = sous(mult(add(2, 3), 2), 2);
console.log("result", result);
// => 8
Vous noterez au passage que toutes mes fonctions sont pures. Il n’y a pas de variable globale ni d'effets de bords. C'est-à-dire, pas de responsabilités multiples au sein d'une fonction ni de fonction accédant au même espace mémoire qu’une autre fonction. Autrement dit, deux fonctions différentes n’accèdent jamais à une même variable.
Dans l’exemple 1, j’utilise le résultat de la 1ère fonction (add) pour le passer en paramètre de la seconde fonction (mult) et ainsi de suite.
Dans l’exemple 2, plutôt que de déclarer des variables pour les utiliser juste après et nulle part ailleurs, je passe directement la 1ère fonction en paramètre de la seconde fonction.
En effet, puisque “addResult” est équivalent à “add(2, 3)”
const addResult = add(2, 3);
Alors, grâce à la transparence référentielle, je peux remplacer le résultat de la 1ère fonction directement par la fonction elle-même.
Et dans ce cas, l’appel de fonction “mult” ci-dessous
const multResult = mult(addResult, 2);
Devient alors
const multResult = mult(add(2, 3), 2);
On économise alors la déclaration d’une constante qui a une durée de vie très courte et n’a donc aucune utilité en dehors du scope de la fonction.
Cette syntaxe de composition de fonctions est complexe à lire. Alors, ne pourrait-on pas se créer une fonction dont l'objectif serait de faire de la composition mais de manière plus lisible. Une fonction dont l'objectif serait de faire transiter une donnée au travers de nos 2 fonctions (mult2 et add1) passées en arguments.
const add1 = (v) => v + 1;
const mult2 = (v) => v * 2;
// Fonction permettant la composition
const compose = (fn2, fn1) => (v) => fn2(fn1(v));
const pipeline = compose(mult2, add1);
pipeline(1); // resultat => 4
ATTENTION : remarquez la signature (nombre de paramètre ou arité de la fonction) des fonctions ! Les fonctions "add1" et "mult2" n'acceptent qu'un seul paramètre (arité de 1) en entrée (unary function) donc comment on fait si on a des fonctions qui ont, de base, plusieurs paramètres en entrée, deux (binary function) ou plus (arité de 2 ou plus) ?
=> Réponse : le currying ! Et oui, on currifie nos fonctions. Le currying permet de transformer une fonction binaire en fonction unaire. C'est-à-dire, une fonction n'ayant qu'un seul paramètre.
Voici un avant goût de comment on utilise la currification :
const add = (x, y) => x + y;
const mult = (x, y) => x * y;
/*
Ne vous inquitez pas, nous verrons dans le prochain
chapitre en quoi consiste la fonction "curry".
*/
const addCurried = curry(add);
const multCurried = curry(mult);
/*
Maintenant que nos fonctions sont currifiées, on peut
les rendres spécifiques et nous créer
les fonctions add1 et mult2 de la sorte
*/
const add1 = addCurried(1);
const mult2 = multCurried(2);
// Fonction permettant la composition
const compose = (fn2, fn1) => (v) => fn2(fn1(v));
const pipeline = compose(mult2, add1);
pipeline(1); // resultat => 4
Remarque : notre fonction de composition n'accepte que 2 fonctions. Mais rassurez-vous, les librairies de programmation fonctionnelle fournissent un tas de fonctions utilitaires dont les fonctions de composition telles que pipe (qui prend les paramètres de gauche à droite) et compose (qui prend les arguments de droite à gauche).
Bon aller, je vous conseil d'utiliser les fonctions de composition de Ramda mais regardez, ce n'est pas si compliqué que ça à écrire :
const pipe = (...fns) => (x) => fns.reduce((acc, fn) => fn(acc), x);
// la fonction compose est identique à ceci près que les paramètres sont inversés
const compose = (...fns) => pipe(...fns.reverse());
La curryfication (ou le Currying)
Le principe de currying est relativement simple mais il est plus ardu de comprendre vraiment à quoi cette notion peut bien servir.
Définition du currying
Le currying consiste à transformer une fonction acceptant "n" paramètres en une série de fonction ne traitant qu'un seul paramètre à la fois.
Vous me direz, mais pourquoi ?
Le currying au service des applications partielles et de la composition de fonctions
Et bien, dans certain cas, il pourrait être intéressant d’exécuter votre fonction en partie et non totalement. En effet, avec le currying (ou currification), tant que tous les paramètres n'ont pas été passés en argument, alors la fonction currifiée renverra une fonction qui pourra, à son tour, être exécutée plus tard lorsque le(s) paramètre(s) manquants seront connus. Ce genre de besoin est extrêmement intéressant dans le cadre du développement d'application partielle ou encore dans lorsqu'on utilise la composition de fonction comme vu précédemment.
Mais comme d'habitude, un exemple vaut mieux qu'un long discours. Le currying transforme la fonction suivante :
const add = (a, b, c) => a + b + c;
En ceci :
curry(add)(a)(b)(c);
Pour arriver à ce résultat, il faut que la fonction "curry(add)" renvoie une fonction de manière à pouvoir l’exécuter en lui passant le 1er paramètre "a" qui à sont tour, devra retourner une fonction de manière à pouvoir l’exécuter avec le 2nd paramètre et ainsi de suite.
Une version simplifiée de la fonction curry pourrait ressembler à ceci :
// Fonction "add" d'origine
const add = (a, b, c) => a + b + c;
// Fonction de curryfication simplifiée
// Cette version "simplifiée" ne fonctionne qu'avec 3 paramètres
const currySimple = fn => {
return function(a) {
return function(b) {
return function(c) {
// Une fois les 3 paramètres enregistrés dans les différents scopes,
// on retourne la fonction d'origine avec ses 3 paramètres en arguments.
return fn(a, b, c);
}
}
}
};
/*
Et avec la syntaxe es6, cela donne :
const currySimple = fn => a => b => c => fn(a, b, c);
*/
// Fonction "add" curryfiée
const curriedAddVersion = currySimple(add);
// Execution de la fonction "add" curryfiée
console.log( "curriedAddVersion", curriedAddVersion(1)(2)(3) );
/*
curriedAddVersion 6
*/
Est-ce que ce pattern ne vous dit rien ? Aller... Une fonction qui accepte au moins une fonction en paramètre et/ou retourne une fonction... Et oui, il s'agit d'une HOF !
Exemple à tester dans votre console Chrome:
const curry = (fn, arity = fn.length) =>
(function recursiveFunc(prevArgs) {
return function(nextArgs) {
const args = [
...prevArgs,
nextArgs,
];
if (args.length >= arity) {
return fn( ...args );
}
return recursiveFunc(args);
};
})([]);
const add = (a, b, c) => a + b + c;
console.log( "call add function", add(1, 2, 3) )
// call add function 6
console.log( "call curried version", curry(add)(1)(2)(3) );
// call curried version 6
A quoi sert le currying en JavaScript - Exemple d'application
Si cette notion se révèle très intéressante, il est certain que les cas d'applications ne sont pas toujours évident à trouver pour un jeune développeur. Là où il est possible d'exploiter toute la puissance du currying se trouve être, comme déjà expliqué précédemment, pour les applications partielles mais aussi dans les traitements appliqués en boucle et plus précisément sur les occurrences d'un tableau ou encore dans la composition de fonctions. Les librairies telles que Ramda ou Lodash (version FP) permettent une souplesse extrême dans le passage des paramètres car leurs fonctions sont "auto" currifiées.
Imaginons un tableau pour lequel nous souhaiterions appliquer une opération, par exemple, multiplier par 2, sur tous les éléments du tableau tout en s'assurant que la variable sur laquelle nous appliquons l'opération est bien un nombre (de type Number).
// Fonction curry qu'on peut trouver un peu partout sur le net.
// Notez qu'il existe également la fonction curryRight pour inverser l'ordre
// de prise en compte des paramètres
const curry = (fn, arity = fn.length) =>
(function recursiveFunc(prevArgs) {
return function(nextArgs) {
const args = [
...prevArgs,
nextArgs,
];
if (args.length >= arity) {
return fn( ...args );
}
return recursiveFunc(args);
};
})([]);
// Mon tableau tout pourri :D
const myArray = [2, "4", 6, '8', {count: 3}, 10, "douze", "14s"];
// Ma fonction générique permettant de multiplier un nombre par un coef
const multiply = (coef, num) => num * coef;
/*
La version curryfiée est la fonction générique :
const curriedMultiply = (coef) => num => num * coef;
Les fonctions qui en découlent deviennent éligibles pour un module d'application partielle :
const multiplyBy2 = curriedMultiply(2);
const multiplyBy4 = curriedMultiply(4);
const multiplyBy10 = curriedMultiply(10);
etc...
Et pour l'utilisation, on utilise :
const result = multiplyBy10(3);
// result => 30;
*/
// Ma fonction générique "multiply" rendu spécifique pour mon besoin du moment
// c'est à dire, multiplier par 2
const double = curry(multiply)(2);
/*
ou avec la version curryfié, cela devient plus lisible
const double = curriedMultiply(2);
*/
// Ma fonction de filtre des valeurs de type "number"
const isNumberFilter = num => !isNaN(num)
const doubleArray = myArray
.map( double )
.filter( isNumberFilter );
console.log( "doubleArray", doubleArray );
/*
doubleArray [4,8,12,16,20]
*/
Le "Point Free"
Définition de "Point free" en programmation fonctionnelle
C'est le fait de déclarer une fonction sans avoir besoin de mentionner les arguments de manière explicite.
On peut aller vraiment très loin en programmation fonctionnelle en appliquant cette notion à l'extrême. Le gros avantage est de diminuer de manière drastique la complexité du code. Cela réduit le nombre de ligne de votre programme et facilite ainsi la lecture. Même si pour la plupart des développeurs cela demande un effort supplémentaire au début pour la compréhension du code, cette syntaxe reste de loin la plus concise et la plus sûre (produit peu de bug voir pas du tout).
Exemple de factorisation de code en utilisant la notion de "Point Free"
L'exemple le plus basique et le plus facile à comprendre est la fonction de callback qu'on passe à l'interieur de l'itérateur "map" :
const arrayDoubled = [1, 2, 3, 4, 5].map((num) => num * 2);
On peut sortir la callback "(num) => num * 2" et déclarer une fonction qu'on appellerait "multBy2"
const multBy2 = (num) => num * 2;
const arrayDoubled = [1, 2, 3, 4, 5].map(multBy2); // Vous voyez ici qu'on ne passe pas l'argument "num"
Voici, cette fois, un autre exemple mais qui nécessite l'implémentation d'une nouvelle fonction permettant ainsi la syntaxe "Point Free" :
const isOdd = (x) => x % 2 === 1;
const isEven = (x) => !isOdd(x);
En l'état, il est impossible d'aller plus loin. Mais à l'aide d'une petite fonction utilitaire, on pourrait factoriser la fonction "isEven". Crééons nous une fonction utilitaire dont l'objectif est de nous renvoyer l'inverse du résultat d'une fonction :
const not = (fn) => (...args) => !fn(...args);
// isEvent devient alors
const isEven = not(isOdd
);
Et le tour est joué. Sympa non ?
Comme vu précédemment, le "Point Free" associé au "currying" nous permet de faire de la composition de fonctions, rappelez-vous l'exemple suivant du paragraphe "La composition de fonction" :
const pipeline = compose(mult2, add1);
pipeline(1); // resultat => 4
Notez l'absence d'arguments dans la déclaration de la fonction "pipeline" ! C'est seulement au moment où on a besoin d'exécuter la fonction qu'on passe la valeur "1" en argument et que cette donnée transite au sein des fonctions "mult2" et "add1" déclarées dans la fonction "compose" !
ATTENTION : souvenez-vous, la fonction "compose" exécute les fonctions de droite à gauche. On commence donc par la fonction "add1" puis on termine par la fonction "mult2". D'où le résultat obtenu => 4.
Aller, un dernier exemple pour la route :)
On peut réécrire la fonction "isOdd" à l'aide de fonctions utilitaires ainsi que de la fonction "compose" :
// Pour réduire le nombre de ligne, j'ai currifié moi-même les fonctions suivantes
const mod = (y) => (x) => x % y;
const eq = (y) => (x) => x === y;
const mod2 = mod(2);
const eq1 = eq(1);
const isOdd = compose(eq(1), mod(2));
isOdd(2); // false
isOdd(1); // true
Commencez-vous à appréhender la puissance de la programmation fonctionnelle ?
Si la réponse est : "non" ou "pas encore tout à fait", alors sachez que les librairies telles que Ramda ou Lodash sont un ensemble de fonctions utilitaires répondant à des besoins algorithmiques standards. Boucles, filtres, groupBy, tri - bref, toutes les opérations classiques sont déjà fournis par ces librairies vous permettant ainsi de vous concentrer sur le code métier, évitant ainsi tout risque de bugs au niveau de l'implémentation
Par conséquent, il y a de grande chance pour que vous soyez en mesure de développer un programme entier composé uniquement de fonction Ramda mais attention à ne pas tomber dans l'excès au risque de rendre votre code illisible et inmaintenable par une personne tierce.
Conclusions
Voilà, j'espère que vous aurez trouvé la motivation suffisante pour lire et tester les différents exemples ci-dessus. Mais surtout, j'espère vous avoir aidé (au moins un peu) dans la compréhension des concepts de la programmation fonctionnelle.
Si vous souhaitez améliorer la qualité de votre code de manière significative, alors adoptez le paradigme de programmation fonctionnelle. Séparez les bouts de code qui peuvent l’être dans des fonctions très simples. Privilégiez toujours les fonctions pures, c’est-à-dire, n’utilisez que des paramètres passés en arguments et ne modifiez jamais les paramètres eux-mêmes car vous risquez de modifier une variable appartenant au "scope" de la fonction parente !
Petit à petit, factorisez votre code en adoptant la syntaxe "Point Free". Vous verrez qu'il y a toujours un moyen d'aller plus loin dans votre refacto :)
JavaScript : les fonctions à bannir définitivement de vos programmes
- while
- do...while
- for
- for...of
- for...in
A bannir également :
- var et let
- object mutation (ex: car.power = 140)
Les fonctions de mutation des tableaux (array mutator) :
- copyWithin
- fill
- pop
- push
- reverse
- shift
- sort
- splice
- unshift
Programmation fonctionnelle avancée en JavaScript
Vous souhaitez aller encore plus loin en programmation fonctionnelle ? Vous en avez marre de coder toujours les mêmes algorithmes ? Itérer sur les éléments d'un tableau, transformer un tableau d'objet ou toutes sortes de transformations diverses et variées... Concentrez vous sur le code métier pour répondre à un besoin fonctionnel en un temps record ! Choisissez une librairie telle que Ramda ou Radash
JavaScript monads
Sinon, si vous n'en avez pas encore eu assez et que vous ne connaissez pas encore les monades, alors lisez mon prochain article à propos des Monades dont vous trouverez le lien juste en dessous.
Autre article sur le même thème :
Auteur : Gael Cadoret - Senior Lead Développeur backend chez CHANEL
Sources