La programmation fonctionnelle en JavaScript a gagné en popularité ces dernières années. Bien qu'une poignée de ses principes régulièrement promus, tels que l'immutabilité, nécessitent des solutions de contournement, le traitement de première classe des fonctions de la langue a prouvé son soutien du code composable conduit par cette primitive fondamentale. Avant de vous expliquer comment composer de manière dynamique des fonctions à partir d’autres fonctions, revenons un peu en arrière.
Définition d’une fonction
En réalité, une fonction est une procédure qui permet de réaliser un ensemble. des étapes impératives pour effectuer des effets secondaires ou pour renvoyer une valeur. Par exemple:
function getFullName (person) {
return `$ {person.firstName} $ {person.surname}`;
}
Lorsque cette fonction est appelée avec un objet possédant les propriétés firstName
et lastName
getFullName
renvoie une chaîne contenant les deux valeurs correspondantes:
const caractère = {
prenom: 'Homer',
nom de famille: 'Simpson',
};
const nom complet = getFullName (caractère);
console.log (nom complet); // => 'Homer Simpson'
Il est intéressant de noter que depuis ES2015, JavaScript prend désormais en charge la fonction de flèche :
const getFullName = (person) => {
return `$ {person.firstName} $ {person.surname}`;
};
Étant donné que notre fonction getFullName
a une arité de un (c'est-à-dire un seul argument) et une seule instruction return, nous pouvons rationaliser cette expression:
const getFullName = person => `$ {person. prenom} $ {person.surname} `;
Ces trois expressions, malgré des différences de moyens, atteignent toutes le même but en:
- créant une fonction portant un nom, accessible via la propriété
nommée
degetFullName
- acceptant un paramètre unique,
personne
- renvoyant une chaîne calculée de
personne.firstName
et depersonne.lastName
les deux étant séparés par un espace
Combinaison de fonctions via des valeurs de retour
En plus d'affecter des valeurs de retour de fonction aux déclarations (par exemple, const person = getPerson ();
), nous pouvons les utiliser pour renseigner les paramètres d'autres fonctions ou, d'une manière générale, pour fournir des valeurs où JavaScript les permet. Supposons que nous ayons des fonctions respectives qui effectuent la journalisation et les effets secondaires de sessionStorage
:
const log = arg => {
console.log (arg);
retour arg;
};
const store = arg => {
sessionStorage.setItem ('state', JSON.stringify (arg));
retour arg;
};
const getPerson = id => id === 'homer'
? ({prenom: 'Homer', nom de famille: 'Simpson'})
: {};
Nous pouvons effectuer ces opérations sur la valeur de retour de getPerson
avec les appels imbriqués:
const person = store (log (getPerson ('homer')));
// person.firstName === 'Homer' && person.surname === 'Simpson'; => vrai
Etant donné qu'il est nécessaire de fournir les paramètres requis aux fonctions telles qu'elles sont appelées, les fonctions les plus profondes seront d'abord invoquées. Ainsi, dans l'exemple ci-dessus, la valeur de retour de getPerson
sera transmise à log
et la valeur de retour de log
est transmise à store
. La construction d'instructions à partir d'appels de fonction combinés nous permet de construire des algorithmes complexes à partir de blocs de construction atomiques, mais l'imbrication de ces invocations peut devenir compliquée. si nous voulions combiner 10 fonctions, à quoi ressemblerait-il?
const f = x => g (h (i (j (k (l (m (n (0) (x))))))) )));
Heureusement, il existe une mise en œuvre générique élégante que nous pouvons utiliser: réduire un ensemble de fonctions en fonctions d'ordre supérieur.
Tableaux cumulatifs avec Array.prototype.reduce
Le ] La méthode du tableau
du prototype réduire
prend une instance de tableau et l'accumule en une seule valeur. Si nous souhaitons totaliser un tableau de nombres, on pourrait suivre cette approche:
const sum = numbers =>
numbers.reduce ((total, number) => total + number, 0);
somme ([2, 3, 5, 7, 9]); // => 26
Dans cet extrait, Numbersreduce
prend deux arguments: le rappel qui sera invoqué à chaque itération et la valeur initiale transmise à l’argument du total total
de ce dernier; la valeur renvoyée par le rappel sera transmise à total
à la prochaine itération. Pour approfondir ceci en étudiant l'appel ci-dessus à la somme sum
:
- notre rappel sera exécuté 5 fois
- puisque nous fournissons une valeur initiale,
le total
sera. ] 0
sur le premier appel - le premier appel retournera
0 + 2
ce qui donnera un total de2
sur le deuxième appel [19659031] le résultat renvoyé par cet appel ultérieur,2 + 3
sera fourni au paramètretotal
du troisième appel, etc.
. Le rappel accepte deux arguments supplémentaires qui respectivement l'index actuel et l'instance de tableau sur laquelle on a appelé Array.prototype.reduce
les deux premiers sont les plus critiques, et sont généralement appelés:
-
accumulateur
– la valeur renvoyée par le rappel lors de l'itération précédente. Lors de la première itération, la valeur initiale ou le premier élément du tableau sera résolu si aucun n’est spécifié -
currentValue
– la valeur du tableau de l’itération actuelle; comme il est linéaire, cela va passer detableau [0]
àtableau [array.length - 1]
tout au long de l'invocation deArray.prototype.reduce
Fonctions de composition avec Array.prototype.reduce
Maintenant que nous savons comment réduire les tableaux en une seule valeur, nous pouvons utiliser cette approche pour combiner des fonctions existantes dans de nouvelles fonctions:
const compose = (... funcs) =>
initialArg => funcs.reduce ((acc, func) => func (acc), initialArg);
Notez que nous utilisons la syntaxe rest params ( ...
pour contraindre un nombre quelconque d'arguments dans un tableau, libérant ainsi le consommateur de la création explicite d'une nouvelle instance de tableau. pour chaque site d'appel. compose
renvoie également une autre fonction, rendant composant
une fonction d'ordre supérieur qui accepte une valeur initiale ( initialArg
. Ceci est essentiel car nous pouvons par conséquent composer de nouvelles fonctions réutilisables sans les invoquer jusqu'à ce que cela soit nécessaire; on appelle cela lazy evaluation .
Comment composons-nous donc d'autres fonctions en une seule fonction d'ordre supérieur?
const compose = (... funcs) =>
initialArg => funcs.reduce ((acc, func) => func (acc), initialArg);
log const = arg => {
console.log (arg);
retour arg;
};
const store = clé => arg => {
sessionStorage.setItem (clé, JSON.stringify (arg));
retour arg;
};
const getPerson = id => id === 'homer'
? ({prenom: 'Homer', nom de famille: 'Simpson'})
: {};
const getPersonWithSideEffects = composer (
getPerson,
bûche,
Personne de magasin'),
)
const personne = getPersonWithSideEffects ('homer');
Dans ce code:
- la déclaration de
personne
passera à{prénom: 'Homer', nom de famille: 'Simpson'}
- la représentation ci-dessus de
personne
sera envoyé à la console du navigateur -
personne
sera numérotée en tant que JSON avant d'être écrite dans la mémoire de session sous la clépersonne
L'importance de l'invocation [19659003] La possibilité de composer n'importe quel nombre de fonctions avec un utilitaire composable garde notre code plus propre et mieux abstrait. Cependant, nous pouvons souligner un point important en réexaminant les appels en ligne:
const g = x => x + 2;
const h = x => x / 2;
const i = x => x ** 2;
const fNested = x => g (h (i (x)));
const g = x => x + 2;
const h = x => x / 2;
const i = x => x ** 2;
const fNested = x => g (h (i (x)));
On peut trouver naturel de reproduire cela avec notre fonction compose
:
const fComposed = composer (g, h, i);
Dans ce cas, pourquoi fNested (4) === fComposed (4)
résout-il en false
? Vous vous souviendrez peut-être de ma façon de souligner comment les appels internes sont interprétés en premier, ainsi compose (g, h, i)
est en réalité l'équivalent de x => i (h (g (x)))
ainsi fNested
renvoie 10
tandis que fComposed
renvoie 9
. Nous pourrions simplement inverser l'ordre d'invocation de la variante imbriquée ou composée de f
mais étant donné que compose
est conçu pour refléter la spécificité des appels imbriqués, nous avons besoin d'un moyen de réduire les fonctions dans l'ordre de droite à gauche; Heureusement, JavaScript le fournit avec Array.prototype.reduceRight
:
const compose = (... funcs) =>
initialArg => funcs.reduceRight ((acc, func) => func (acc), initialArg);
Avec cette implémentation, fNested (4)
et fComposed (4)
résolvent tous deux de 10
. Cependant, notre fonction getPersonWithSideEffects
est maintenant définie de manière incorrecte. Bien que nous puissions inverser l'ordre des fonctions internes, il existe des cas où une lecture de gauche à droite peut faciliter l'analyse syntaxique des étapes de procédure. Il s'avère que notre approche précédente est déjà assez commune, mais est généralement connue sous le nom de piping :
const pipe = (... funcs) =>
initialArg => funcs.reduce ((acc, func) => func (acc), initialArg);
const getPersonWithSideEffects = pipe (
getPerson,
bûche,
Personne de magasin'),
)
En utilisant notre fonction pipe
nous allons maintenir l'ordre ordonné de gauche à droite requis par getPersonWithSideEffects
. La tuyauterie est devenue un élément essentiel de RxJS pour les raisons exposées ci-dessous. il est sans doute plus intuitif de penser aux flux de données au sein de flux composés manipulés par les opérateurs dans cet ordre.
Composition de fonctions comme alternative à l'héritage
Nous avons déjà vu dans les exemples précédents comment combiner des fonctions à l'infini unités plus grandes, réutilisables, orientées vers un but. Un autre avantage de la composition des fonctions est de se libérer de la rigidité des graphes d'héritage. Supposons que nous souhaitions réutiliser les comportements de journalisation et de stockage basés sur une hiérarchie de classes; on peut exprimer ceci comme suit:
class Storable {
constructeur (clé) {
this.key = clé;
}
le magasin() {
sessionStorage.setItem (
this.key,
JSON.stringify ({... this, key: undefined}),
)
}
}
classe Loggable s'étend Storable {
log () {
console.log (this);
}
}
classe Personne étend Loggable {
constructeur (prénom, nom) {
super ('personne');
this.firstName = prénom;
this.lastName = lastName;
}
debug () {
this.log ();
this.store ();
}
}
Le problème immédiat avec ce code, outre sa verbosité, est que nous abusons de l'héritage pour obtenir une réutilisation. si une autre classe s'étend Loggable
il s’agit également d’une sous-classe de Storable
même si nous n’exigeons pas cette logique. Un problème potentiellement plus catastrophique réside dans la désignation des collisions:
le magasin() {
return fetch ('/ api / store', {
méthode: 'POST',
});
}
}
La classe MyState étend State {}
Si nous devions instancier MyState
et invoquer sa méthode store
nous n'invoquerions pas la méthode Storable
de store
sauf si nous ajoutons un appel à super.store ()
dans MyState.prototype.store
mais cela créerait alors un couplage étroit et fragile entre State
et Storable
. Cela peut être atténué par les systèmes d'entités ou le modèle de stratégie, comme je l'ai déjà expliqué ailleurs, mais, malgré la force de l'héritage d'exprimer la taxonomie plus large d'un système, la composition des fonctions fournit un moyen simple et succinct de partager du code sans dépendance. sur les noms de méthodes.
Résumé
Le traitement par JavaScript des fonctions en tant que valeurs, ainsi que des expressions qui les produisent, se prête à la composition triviale d'oeuvres beaucoup plus vastes et spécifiques au contexte. Traiter cette tâche comme une accumulation de tableaux de fonctions supprime le besoin d'appels imbriqués impératifs et l'utilisation de fonctions d'ordre supérieur entraîne la séparation de leur définition et de leur invocation. De plus, nous pouvons nous libérer des contraintes hiérarchiques strictes imposées par la programmation orientée objet.
Source link