ES6 en détails est une série d’articles décrivant les nouvelles fonctionnalités ajoutées au langage de programmation JavaScript avec la sixième édition du standard ECMAScript (ES6 en abrégé).
Je suis vraiment enthousiasmé par l’article d’aujourd’hui. 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.
Mais ce n’est pas tout : cette fonctionnalité peut repousser les frontières de « l’enfer des callbacks » aux limites du surnaturel.
Est-ce que j’en fais suffisamment assez ? Allons-y et jugez-en par vous-même.
Introduction aux générateurs ES6
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-la, cliquez sur le lien et jouez avec le chat. Quand vous serez perdu, revenez ici pour quelques explications.)
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 les générateurs
Que se passe-t-il lorsqu’on appelle la fonction génératrice quips()
?
> var iter = quips("jorendorff"); [object Generator] > iter.next() { value: "bonjour jorendorff!", 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
. Élémentaire pour tout programmeur en JavaScript.
Un appel à un générateur ressemble à un appel à une fonction classique : quips("jorendorff")
. 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
.
C’est sans doute le bon moment pour revenir à la page de démo du chat parlant et manipuler le code pour de bon. Essayez par exemple d’ajouter un yield
dans une boucle, que se passe-t-il ?
D’un point de vue technique, chaque fois qu’un générateur utilise yield
, le cadre de sa pile (stack frame) — qui contient les variables locales, les arguments, les valeurs temporaires ainsi que la position actuelle de l’exécution dans le générateur — est ôté de la pile. Cependant, le générateur conserve une référence vers ce cadre afin que le prochain appel à .next()
puisse réutiliser ce cadre et continuer l’exécution du code.
Il est important de préciser que les générateurs ne sont pas des threads. Dans les langages utilisant des threads, on peut rencontrer plusieurs parties de code qui fonctionnent en même temps, entraînant habituellement des accès concurrents, un certain indéterminisme et de meilleures performances.
Les générateurs n’agissent pas du tout de cette façon. Quand un générateur fonctionne, il se situe dans le même thread que son appel. L’ordre des exécutions est séquentiel et déterministe, il n’y a pas d’accès concurrents. Contrairement aux systèmes utilisant des threads, un générateur est uniquement suspendu aux endroits correspondants aux yield
contenus dans son code.
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
La semaine dernière nous avons vu que les itérateurs ES6 ne sont pas seulement une classe native. Ils représentent une extension du langage dans son ensemble. Vous pouvez créer vos propres itérateurs, il suffit d’implémenter deux méthodes : [Symbol.iterator()]
et next()
.
Malgré tout, implémenter une interface demande toujours un minimum de travail. Voyons à quoi ressemble réellement l’implémentation d’un itérateur. Par exemple, créons un itérateur basique qui compte d’un nombre à un autre, comme le ferait une bonne vieille boucle for(;;)
en C.
// Cela devrait sonner 3 fois for (var value of range(0, 3)) { alert("Ding ! à l'étage numéro " + value); }
Voici une solution qui utilise une classe ES6 (si cette syntaxe vous semble un peu obscure, pas de panique, ce sera l’objet d’un prochain billet).
class RangeIterator { constructor(start, stop) { this.value = start; this.stop = stop; } [Symbol.iterator]() { return this; } next() { var value = this.value; if (value < this.stop) { this.value++; return {done: false, value: value}; } else { return {done: true, value: undefined}; } } } // On renvoie un nouvel itérateur qui compte de 'start' à 'stop'. function range(start, stop) { return new RangeIterator(start, stop); }
Note : il est nécessaire d’utiliser Firefox Nightly ou Chrome avec une version supérieure ou égale à 42 pour que cet exemple fonctionne.
C’est pour cela qu’implémenter un itérateur se fait comme avec Java ou Swift, ce n’est pas si compliqué ! Mais ce n’est pas vraiment trivial non plus ! Est-ce que ce code contient des erreurs ? Pas facile à dire. Il ne ressemble pas du tout à la boucle for(;;)
habituelle que l’on essaie de remplacer : le protocole des itérateurs nous oblige à démanteler la boucle.
À ce stade, vos sentiments concernant les itérateurs sont sans doute mitigés. Ils sont sûrement super à utiliser mais ils semblent difficiles à implémenter.
À 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* range(start, stop) { for (var i = start; i < stop; i++) yield i; }
Les 4 lignes du générateur ci-dessus remplacent exactement les 23 lignes utilisées précédemment pour implémenter range()
, y compris l’intégralité de la classe RangeIterator
. Ceci est possible parce que les générateurs sont des itérateurs. Tous les générateurs possèdent une implémentation native de .next()
et de [Symbol.iterator]()
. Il suffit simplement d’écrire le comportement de la boucle.
Implémenter des itérateurs sans disposer de générateurs, c’est équivalent à devoir écrire un courrier en utilisant uniquement la voie passive. Lorsqu’il est impossible d’exprimer directement ce qu’on a besoin, on se retrouve à formuler des phrases de façon alambiquée. RangeIterator
était long et étrange car on l’utilisait pour décrire une boucle sans pouvoir utiliser une syntaxe de boucle. Les générateurs permettent de répondre à ce problème.
Comment peut-on utiliser les générateurs comme des itérateurs ?
En rendant 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.En simplifiant les fonctions de construction de tableaux. Si votre fonction retourne un tableau de résultats à chaque appel, comme ceci :
// Divise un tableau à une dimension 'icons' // en tableaux de taille fixe 'rowLength' function splitIntoRows(icons, rowLength) { var rows = []; for (var i = 0; i < icons.length; i += rowLength) { rows.push(icons.slice(i, i + rowLength)); } return rows; }
Les générateurs permettent de faire la même chose de manière plus concise :
function* splitIntoRows(icons, rowLength) { for (var i = 0; i < icons.length; i += rowLength) { yield icons.slice(i, i + rowLength); } }
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.
Pour des résultats de grande taille, cela peut également être utile. 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.
Pour « refactorer » des boucles complexes : vous avez une fonction énorme et moche ? Vous souhaitez la scinder en éléments plus simples ? Les générateurs sont des couteaux que vous pouvez ajouter à votre boîte à outils. Lorsque vous êtes face à une boucle complexe, vous pouvez sortir la partie qui produit les données dans une fonction génératrice indépendante. Puis modifier la boucle en utilisant
for(var toto of monNouveauGenerateur(args))
.Pour créer des outils afin de manipuler les itérables. ES6 ne fournit pas de bibliothèque complète pour filtrer, mapper et bidouiller les ensembles de données itérables. Cependant, les générateurs sont très bien adaptés à la création de ces outils dont vous pourriez avoir besoin, en écrivant simplement quelques lignes de code. Par exemple, supposons que vous ayez besoin d’un équivalent de
Array.prototype.filter()
qui fonctionne sur lesNodeList
du DOM et pas uniquement sur les objetsArray
. Facile !function* filter(test, iterable) { for (var item of iterable) { if (test(item)) yield item; } }
Alors, est-ce que les générateurs sont utiles ? Évidemment. Ils représentent une façon étonnament simple d’implémenter des itérateurs personnalisés et les itérateurs représentent le nouveau standard pour les données et les boucles dans ES6.
Mais ce n’est pas la seule chose que peuvent faire les générateurs. Ce n’est peut-être même pas la plus importante chose qu’ils peuvent faire !
Générateurs et code asynchrone
Voici du code JS que j’ai écrit il y a quelque temps :
}; }) }); }); }); });
Cela vous rappelle peut-être quelques lignes de votre code. Les API asynchrones demandent généralement une fonction de rappel (callback), ce qui signifie qu’il faut écrire une fonction anonyme supplémentaire chaque fois qu’on fait quelque chose. Ainsi, si vous avez un code qui fait 3 choses, plutôt que 3 lignes de code, vous aurez 3 niveaux d’indentation dans votre code.
Voici un autre fragment de code JS que j’ai écrit :
}).on('close', function () { done(undefined, undefined); }).on('error', function (error) { done(error); });
Les API asynchrones possèdent certaines conventions concernant la gestion des erreurs plutôt que d’utiliser des exceptions. Différentes API possèdent différentes conventions. Dans la plupart des cas, les erreurs sont ignorées par défaut. Dans certains cas, même les cas de terminaisons normales sont ignorés par défaut.
Jusqu’à maintenant, ces problèmes ont été le prix à payer pour utiliser la programmation asynchrone. Nous avons fini par accepter que le code asynchrone n’est pas aussi propre et simple que la version synchrone correspondante.
Les générateurs nous font espérer que ce ne soit plus le cas.
Q.async() est un tentative expérimentale pour utiliser les générateurs avec des promesses afin de produire du code asynchrone qui ressemble à du code synchrone. Par exemple :
// Code synchrone pour créer une fonction qui fait du bruit function makeNoise() { shake(); rattle(); roll(); } // Code asynchrone pour faire du bruit. // Renvoie un objet Promise qui est résolu // quand nous avons fini de faire du bruit. function makeNoise_async() { return Q.async(function* () { yield shake_async(); yield rattle_async(); yield roll_async(); }); }
La principale différence est que, dans la version asynchrone, il faut ajouter le mot-clé yield
à chaque appel d’une fonction asynchrone.
Ajouter un détail comme une condition if
ou un bloc try-catch
dans la version Q.async
se fait exactement de la même façon que dans la version synchrone. Comparé à d’autres façons d’écrire du code asynchrone, cela ressemble beaucoup moins à l’apprentissage d’un nouveau langage.
Si vous êtes arrivé jusqu’ici, vous apprécierez sans doute l’article très détaillé de James Long sur ce sujet.
Les générateurs illustrent donc un nouveau modèle de programmation asynchrone qui semble mieux adapté au cerveau humain. Les travaux autour de ces concepts sont toujours en cours. Entre autres choses, une meilleure syntaxe devrait aider. Pour la version ES7, il existe une proposition pour construire des fonctions asynchrones basées à la fois sur les promesses (promises) et les générateurs, inspirées de fonctionnalités similaires existant en C#.
Quand puis-je commencer à utiliser cette fonctionnalité ?
Côté serveur, vous pouvez utiliser les générateurs ES6 dès aujourd’hui avec io.js (et avec Node en utilisant l’option —harmony)
Côté navigateur, seuls Firefox 27+ et Chrome 39+ supportent les générateurs ES6 pour le moment. Pour les utiliser à travers le web, vous devrez utiliser un compilateur tel que Babel ou Traceur pour traduire votre code ES6 en code ES5, plus largement compatible avec les anciens navigateurs.
Quelques remerciements à qui de droit : les générateurs ont d’abord été implémentés dans JS par Brendan Eich ; sa conception suivait de près les générateurs Python, inspirés par Icon. Ils sont apparus dans Firefox 2.0 dès 2006. Le chemin vers la standardisation a été chaotique et la syntaxe ainsi que le comportement ont évolué au cours de cette période. Les générateurs d’ES6 ont été implémentés dans Firefox et Chrome par la même personne, Andy Wingo, un professionnel du compilateur. Ce travail a été sponsorisé par Bloomberg.
yield;
Il y a encore à dire sur les générateurs. Nous n’avons pas évoqué les méthodes .throw()
et return()
, les arguments optionnels de .next()
ou encore la syntaxe de l’expression yield*
. Cependant, je pense que ça suffira pour ce billet, déjà suffisamment déconcertant. Comme le font les générateurs, il vaut mieux faire une pause et reprendre une autre fois.
La semaine prochaine, nous changerons d’allure. Nous avons abordé deux sujets assez conséquents, l’un après l’autre. Ne pourrions-nous pas parler d’une fonctionnalité ES6 qui ne changera pas votre vie ? Quelque chose de simple et de manifestement utile ? Quelque chose qui vous fera sourire ? ES6 possède aussi quelques fonctionnalités de ce genre.
À venir : une fonctionnalité qui va immédiatement trouver sa place dans le code que vous écrivez tous les jours. Rejoignez-nous la semaine prochaine pour une explication détaillée sur les patrons de chaînes (template strings) d’ES6 !