JavaScript, les bons éléments, façon 2020 : Déstructuration, itérateurs et générateurs

JavaScript, comme de très nombreux langages, est centré autour de l’itération : de tableaux comme de propriétés d’objets.

Les différents types de boucles

Je sais pas si vous avez remarqué, mais JavaScript proposait dès sa version originale un nombre conséquent de structures de contrôles liées à l’itération : while, do-while, for classique et for in.

L’ensemble des solutions proposées permettaient de manipuler les structures du langages.

for in, pour les objets

Rappelons le, en JS, avant 2009, il n’y avait aucun moyen de récupérer les clés d’un objets simplement.

Pour accéder à celles-ci, on n’avait que for in, l’itérateur sur propriétés (héritées ou non).

N’importe quel objet JS peut être utilisé avec for in, même les primitives (celles-ci héritant d’Object, rappelons le, même si leurs propriétés sont non-extensibles).

for (const key in { a: 2, b: 3 }) {
  console.log(key); // "a", puis "b"
}

// Les codes suivants sont valides

for (const key in true) {
  console.log(key); // affiche rien, aucune propriété de `true` est énumérable
}

for (const key in 3) {
  console.log(key); // affiche rien, aucune propriété de `3` est énumérable
}

// Les tableaux sont des objets
for (const key in [113, 21397, 913]) {
  console.log(key); // 0, puis 1, puis 2 (indexs du tableau)
}

for, (généralement) pour les tableaux

En JavaScript, les tableaux sont des objets. Pourtant, il est peu commun d’utiliser for in pour itérer dessus (surtout si le prototype a été modifié, bonjour les dégats).

À la place on utilise un for "classique", entendez par là que c’est le même que celui présent en C, par exemple. La condition d’arrêt se trouve dans la propriété .length des tableaux.

const arr = [18, 183, 35];

for (let i = 0; i < arr.length; i++) {
  const value = arr[i];
  // ...
}

while et do-while, pour les boucles non conventionnelles

Le titre peut être étonnant mais il est néanmoins réel : Dans les langages modernes, les boucles en while sont relativement peu communes et même évitées — étant facilement suspectibles d’être infinies en cas d’erreur de programmation.

Elles ne sont pas forcément utilisées avec des structures, mais plus pour des actions agrégeant des tâches : itération entre plusieurs structures, ré-essai après erreur, etc.

La variante do-while permet de faire la vérification d’itération à la fin de celle-ci et non au début — ceci engendre que le code de la boucle s’effectuera forcément une fois.

let tries = 3;

while (tries) {
  try {
    doRiskyThing();
    // Réussi, casse la boucle
    break;
  } catch (e) {
    console.error('Failed.');
  }
  tries--;
}

// Variante do-while
let failed = false;
do {
  try {
    doRiskyThing();
    failed = false;
  } catch (e) {
    console.error('Failed.');
    // Tant que la chose échoue, on recommence
    failed = true;
  }
} while (failed);

Mieux itérer sur les tableaux

On l’a vu, le langage ne fournit pas de manière intégrer d’itérer directement sur les valeurs d’un tableau, on doit forcément passer par l’index, que ce soit via for in ou via for.

Il a donc été réfléchi à plusieurs manières d’accéder directement au contenu du tableau.

La première d’entre elles fut .forEach(), une méthode ajoutée sur les tableaux permettant d’exécuter une fonction sur chaque élément de celui-ci, dans l’ordre naturel du tableau. Je ne vais pas détailler cette méthode ici, qui est pour moi une fausse bonne idée : Introduite en 2009/2010, elle permettait certes de créer un nouveau scope (à l’échelle d’une fonction) là où let et const n’existaient pas encore. En revanche, impossible d’utiliser break : faire un return au sein du callback valait l’équivalent d’un continue, il restait la possibilité de lancer des erreurs… On a vu plus propre. De plus, le coût de l’appel à une fonction à chaque itération est assez prohibitif, surtout sur des très gros tableaux.

L’utilisation était la suivante (j’insiste sur le était : sauf si vous travaillez sur du vieux code, il n’y a plus aucune raison d’utiliser .forEach()) :

[1, 2, 3].forEach(function (item, index) {
  console.log('Just received item', item, 'at index', index); 
  // 'Just received item 1 at index 0', etc...
});

La solution trouvée : les itérateurs

Finalement, avec l’ES6, en 2015, une solution a été trouvée, permettant non seulement d’itérer simplement sur les tableaux au sein d’une structure de contrôle, mais également de permettre l’itération native d’objets créés par l’utilisateur : Symbol.iterator.

Nous n’avons pas encore parlé de Symbol, considérez le juste pour le moment comme un nouvel objet global exposant une propriété iterator.

Symbol.iterator doit être le nom de la clé d’un objet renvoyant un itérateur.

Un itérateur dispose d’une seule méthode, next, qui renvoie une valeur se composant de deux propriétés : value, done.

Lorsque l’itérateur est terminé, done vaut true et value vaut généralement undefined.

Mon premier itérateur

Expliquer tout ça avec du texte peut sembler assez abstrait, alors un petit exemple s’impose : construisons un itérateur autour d’un tableau :

const my_array = [3, 18, 72];

// On crée une fonction iterator sur les tableaux
Object.defineProperty(Array.prototype, 'iterator', {
  value: function () {
    // La méthode next() d'un itérateur doit renvoyer { value: any, done: bool }
    const len = this.length;
    const that = this;
    let i = 0;

    // Le premier appel ne calcule rien,
    // renvoie juste next pour accéder au premier élément
    return {
      next: function () {
        const value = that[i];
        const done = i >= len;

        // Incrémentation de i pour le prochain appel à next()
        i++;
        
        return { value: value, done: done };
      }
    };
  },
});

const it = my_array.iterator();
it.next(); // { value: 3, done: false }
it.next(); // { value: 18, done: false }
it.next(); // { value: 72, done: false }

// L'itérateur est fini !
it.next(); // { value: undefined, done: true }

Utiliser les "vrais" itérateurs, via Symbol.iterator

Là, on a construit notre propre itérateur pour tableau, mais il existe déjà en standard depuis 2015 !

La convention pour "caser" les itérateurs sur les objets est la propriété Symbol.iterator. Un certain nombre d’objets standards possède déjà cette propriété, les tableaux en font partie.

const my_array = [3, 1, 18];
const it = my_array[Symbol.iterator]();

it.next(); // { value: 3, done: false }
it.next(); // { value: 1, done: false }
it.next(); // { value: 18, done: false }
it.next(); // { value: undefined, done: true }

On peut s’en servir comme ça dans une boucle for classique, façon C++ :

const my_array = [3, 1, 18];

for (
  let it = my_array[Symbol.iterator](), data = it.next(); 
  !data.done; 
  data = it.next()
) {
  const value = data.value;
  console.log(value);
}

Cependant, là vous êtes en capacité de vous poser la question : Super, on a un itérateur, et ? En quoi ça résout notre problème de boucle ? En plus, c’est hyper moche à utiliser dans un for, c’est ça ton universalité ?

Et bien, dans l’ES6, ce n’est pas pour rien que Symbol.iterator a été standardisé : Cette propriété "spéciale" est utilisée dans un nouveau type de boucle for : la boucle for of.

À ne pas confondre avec la boucle for in, la boucle for of est capable d’itérer sur les objets implémentant la méthode Symbol.iterator.

for (const value of my_array) {
  console.log(value); // Affiche 3, 1 puis 18
}

Je tiens à insister, for of appelle Symbol.iterator automatiquement : Si vous lui passez un itérateur déjà construit, cette structure ne fonctionnera pas.

/// Le code suivant NE marche PAS
// On utilise l'itérateur construit à la main avant
const it = my_array.iterator();

// TypeError: it is not iterable
for (const item of it) {}

/// Le code suivant FONCTIONNE
// Pour le fixer, on peut faire en sorte que l'itérateur se renvoie lui-même
// C'est ce qui est fait sur les itérateurs de tableaux
it[Symbol.iterator] = function () { return this; };

for (const item of it) {
  console.log(item); // 3, 1 puis 18
}

Les objets implémentant Symbol.iterator nativement

Les tableaux ne sont pas les seuls à implémenter le protocole d’itération. Les structures comme Map, Set sont supportées, et les structures du DOM comme les NodeList ou autres structures exotiques comme les HTMLCollection commencent à l’implémenter aussi.

Le mieux reste de vérifier sur la doc MDN si l’objet que vous souhaitez utiliser le supporte.

Les générateurs

Introduction aux générateurs

Nous allons parler de la fonctionnalité la plus magique d’ES6.

Comment ça, "magique" ?

Pour les débutants, cette fonctionnalité est si différente de ce qui existe qu’elle peut paraître un peu surprenante à première vue. D’une certaine façon, elle peut complètement renverser le comportement du langage ! Si ce n’est pas de la magie, je ne sais pas ce que c’est.

Est-ce que j’en fais suffisamment assez ? Allons-y et jugez-en par vous-même.

Que sont les générateurs?

Commençons par en observer un :

function* quips(nom) {
  yield "bonjour " + nom + " !";
  yield "j'espère que vous appréciez ces billets";
  if (nom.startsWith("X")) {
    yield "Tiens, votre nom commence par un X, " + nom;
  }
  yield "À la prochaine !";
}

Ceci est un morceau de code pour simuler un chat qui parle, probablement un type d’application crucial sur Internet aujourd’hui. (essayez ce bout de code dans la console de votre navigateur !)

Cela ressemble à une fonction, n’est-ce pas ? C’est ce qu’on appelle une fonction génératrice (ou générateur) et ça possède beaucoup de liens avec les fonctions. Dès le premier coup d’œil, on peut toutefois observer deux différences :

  • Les fonctions classiques commencent par function. Les fonctions génératrices commencent par function*. Dans une fonction génératrice, yield est un mot-clé, avec une syntaxe similaire à return. La différence est que, tandis qu’une fonction (même un générateur) ne peut utiliser return qu’une seule fois, un générateur peut utiliser yield plusieurs fois. L’expression yield suspend l’exécution du générateur, qui peut donc être reprise plus tard.

Voici donc la principale différence entre une fonction classique et une fonction génératrice. Les fonctions normales ne peuvent pas être mises en pause. Les générateurs peuvent être interrompus puis repris.

Ce que font ces générateurs

Que se passe-t-il lorsqu’on appelle la fonction génératrice quips() ?

> const iter = quips("alki");
  [object Generator]
> iter.next();
  { value: "bonjour alki!", done: false }
> iter.next();
  { value: "j'espère que vous appréciez ces billets", done: false }
> iter.next();
  { value: "À la prochaine !", done: false }
> iter.next();
  { value: undefined, done: true }

Vous êtes sans doute familier des fonctions classiques et de leur comportement.

Lorsqu’elles sont appelées, elles démarrent immédiatement et ne s’arrêtent que lorsqu’elles rencontrent le mot-clé return ou throw.

Un appel à un générateur ressemble à un appel à une fonction classique : quips("alki").

Cependant, quand on appelle un générateur, il ne démarre pas immédiatement. En fait, il renvoie un objet générateur en pause (nommé iter dans l’exemple ci-dessus). On peut considérer cet objet générateur comme un appel de fonction, gelé dans le temps. En particulier, il est mis en pause tout au début de la fonction génératrice, juste avant de démarrer la première ligne de code.

À chaque appel de la méthode .next() de l’objet générateur, l’appel de la fonction se remet en route jusqu’au yield suivant.

C’est pour cette raison qu’à chaque fois que nous avons appelé iter.next() dans l’exemple ci-dessus nous avons obtenu une valeur différente (sous la forme d’une chaîne de caractères).

Lors du dernier appel à iter.next(), nous avons finalement atteint la fin de la fonction génératrice, le champ .done vaut donc true. Atteindre la fin d’une fonction revient à renvoyer undefined, c’est pour cela que la propriété .value du résultat vaut undefined.

OK, nous savons maintenant ce que sont les générateurs. Nous avons vu un générateur fonctionner, s’arrêter puis reprendre. Arrive maintenant la grande question : en quoi cette chose bizarre pourrait-elle nous être utile ?

Les générateurs sont des itérateurs

On a vu juste précédemment que les itérateurs sont une interface renvoyée par l’appel de la méthode Symbol.iterator qui implémente .next().

Malgré tout, implémenter cette interface demande toujours un minimum de travail.

Nous avons déjà vu l’implémentation d’un itérateur pour un tableau dans la partie précédente, et on voit bien que ce n’est déjà pas trivial pour quelque chose d’aussi simple qu’un tableau.

À tout hasard, n’auriez vous pas idée d’une toute nouvelle structure de contrôle du flux en JavaScript, fonctionnant étape par étape, et qui rendraient les itérateurs beaucoup plus simples à construire ? Puisque nous avons les générateurs, pourquoi ne pas les utiliser ici ?

Essayons :

function* arrayIterator(array) {
  for (let i = 0; i < array.length; i++) {
    yield array[i];
  }
}

Ces 5 lignes remplacent les 20 lignes nécessaires pour écrire notre fonction .iterator() sur Array.prototype vue plus haut.

Comme les tableaux, l’itérateur renvoyé par les générateurs contient une référence sur lui-même dans la méthode Symbol.iterator ; cela de permet de se servir d’un générateur dans un for of.

for (const item of arrayIterator([1, 35, '1,13,40'])) {
  console.log(item); // 1, 35 puis '1,13,40'
}

Une des forces des générateurs est qu’il est n’est obligatoire de le terminer : une fonction génératrice peut très bien débuter son exécution sans jamais la finir, si jamais l’entièreté des appels à .next() ne sont pas faits.

À quoi les générateurs peuvent-ils encore servir ?

  • À rendre n’importe quel objet itérable. Il suffit d’écrire une fonction génératrice qui parcourt this, et qui utilise yield sur chaque valeur rencontrée. La fonction obtenue peut être définie comme la méthode [Symbol.iterator] de l’objet.

  • À simplifier les fonctions de construction de tableaux. Si votre fonction retourne un tableau de résultats à chaque appel, comme ceci :

function doubleArray(array) {
  const res = [];
  for (const item of array) {
    res.push(item * 2);
  }
  return res;
}

Les générateurs permettent de faire la même chose de manière plus concise :

function* doubleArray(array) {
  for (const item of array) {
    yield item * 2;
  }
}

La seule différence de comportement est le fait qu’au lieu de travailler sur tous les résultats à la fois et de renvoyer un tableau les contenant, ceci renvoie un itérateur et les valeurs de retour sont calculées l’une après l’autre, à la demande.

  • À produire des résultats de grande taille. Il est impossible de construire un tableau infini. Mais vous pouvez renvoyer un générateur qui produit une séquence sans fin, chaque appelant pourra récupérer autant de valeurs que nécessaire à partir de ce générateur.

  • À créer des outils afin de manipuler les itérables. JavaScript ne fournit pas (encore) de bibliothèque complète pour filtrer, mapper et bidouiller les ensembles de données itérables. Vous pouvez créer votre propre fonction pour faire ces opérations sur des itérables n’implémentant pas les méthodes de Array.prototype.

function* filter(iterable, callback) {
  for (const item of iterable) {
    if (callback(item)) {
      yield item;
    }
  }
}

Déstructurer un objet itérable

Revenons aux bases : un objet itérable est n’importe quel objet implémentant Symbol.iterator (qui doit renvoyer un itérateur).

Lorsqu’on dispose d’un itérable, il peut être intéressant de récupérer certaines de ses valeurs et des stocker dans des variables (par exemple, récupérer les x premières valeurs d’un tableau).

JavaScript propose désormais de déstructurer des variables contenant des objets itérables simplement !

const arr = [18, 32, 66];

// Crée les variables {first} et {second} avec respectivement
// le premier et le second élément de l'itérateur de {arr}.
const [first, second] = arr;

console.log(first, second); // 18 32

// ---------------------
/// Totalement égal à :
// ---------------------
const it = arr[Symbol.iterator]();
const first = it.next().value;
const second = it.next().value;

console.log(first, second); // 18 32

La déstructuration fonctionne avec n’importe quel itérable : vous pouvez déstructurer vos propres objets itérables, des générateurs, des NodeList… Tout ce qui dispose d’un Symbol.iterator est éligible à la déstructuration.

Il est possible de capturer une partie d’un tableau dans un autre tableau lors de la déstructuration, via le nouvel opérateur ....

const arr = [1, 2, 3, 4, 5, 6];

const [first, second, ...rest] = arr;

console.log(first); // 1
console.log(second); // 2
console.log(rest); // [3, 4, 5, 6] : Reste du tableau !

L’explosion d’un objet itérable via ... fonctionne également lorsque vous construisez des nouveaux tableaux :

const arr_2 = [1, 2, ...rest]; // [1, 2, 3, 4, 5, 6] !
// {rest} doit implémenter {Symbol.iterator} pour que ce code fonctionne

La fin

Cette partie est terminée !

La suivante parlera de l’ensemble des nouvelles méthodes intéressantes disponibles sur les tableaux !

À bientôt 😀 !

Article précédent : Des objets plus puissantsArticle suivant : Tableaux boostés

Laisser un commentaire