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 parfunction*
. 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 utiliserreturn
qu’une seule fois, un générateur peut utiliseryield
plusieurs fois. L’expressionyield
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 utiliseyield
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 puissants • Article suivant : Tableaux boostés