Fermer

avril 10, 2019

Composition de fonction en JavaScript avec Array.prototype.reduceRight –


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 de getFullName
  • acceptant un paramètre unique, personne
  • renvoyant une chaîne calculée de personne.firstName et de personne.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 de égal à 2 sur le deuxième appel [19659031] le résultat renvoyé par cet appel ultérieur, 2 + 3 sera fourni au paramètre total 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 de tableau [0] à tableau [array.length - 1] tout au long de l'invocation de Array.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)));

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