Un gros morceau aujourd'hui : les itérateurs et les fonctions qui les génèrent, appelées "générateurs". ES6/2015 apporte énormément de "sucres syntaxiques", même s'ils sont terriblement pratiques. Les valeurs par défaut, l'affectation par déstructuration, les fonctions flêchées… Tout ça n'apporte pas de réelle nouvelle fonctionnalité. C'est un peu différent pour les générateurs qui vont permettre de prendre le contrôle sur l'éxécution d'une fonction depuis l'extérieur.
Un nouveau mot-clé fait son apparition : function*
, une fonction marquée par
l'astérisque n'est jamais exécutée directement, à la place, elle retourne un
itérateur. Un générateur est donc capable de s'interrompre (d'ailleurs, il
l'est par défaut). Il est également capable de reprendre là où il s'était arrêté
: l'itérateur retourné est un objet exposant une méthode next
qui lorsqu'elle
est appelée demande au générateur de reprendre là où il en était.
function* idleFunction() {
console.log("World");
}
const iterator = idleFunction();
// L'exécution de la fonction est interrompue en attente d'être "débloquée"
console.log("Hello");
iterator.next(); // L'éxécution reprend et on affiche "World"
Notez qu'il ne s'agit pas de code bloquant : la fonction est mise en pause, son traitement sera repris plus tard, pendant ce temps l'event-loop continue sa petite vie.
yield
La méthode next()
de l'itérateur retourne un objet possédant les propriétés
suivantes :
done
vaut true
quand le générateur a terminé son exécutionvalue
est la valeur émise par le générateur dans cette portion de codeComment émettre une valeur ? Le mot-clé yield
a le double rôle de fournir une
valeur et de remettre en pause l'exécution de la fonction. Le principe étant
qu'on va émettre plusieurs valeurs, sinon on utiliserait simplement
return
.
function* numbers() {
yield 1;
yield 2;
}
const iterator = numbers();
iterator.next(); // { value: 1, done: false }
iterator.next(); // { value: 2, done: false }
iterator.next(); // { value: undefined, done: true }
Note : si notre générateur return
une valeur, elle sera affectée à la
propriété value
de la dernière itération.
Un premier use case à ce stade est la possibilité de générer des listes de longueur non définie à l'avance. On peut parcourir une suite dont on ne sait pas à l'avance combien d'éléments on veut, par exemple récupérer tous les éléments de la suite de Fibonacci inférieurs à 100 :
function* fibo() {
let [a, b] = [1, 1];
while (true) {
// Who can stop me?
[a, b] = [b, a + b];
yield a;
}
}
const iterator = fibo();
for (let n of iterator) {
if (n >= 100) {
break; // *I* can stop you
}
console.log(n);
}
// 1 2 3 5 8 13 21 34 55 89
Note : l'opérateur for … of
sera vu plus en détail dans un prochain article.
On a vu que yield
permettait d'émettre une valeur depuis le générateur vers le
code contrôleur. Mais le sens inverse est également possible : la méthode next
de l'itérateur accepte une valeur en paramètre, qui sera alors retournée par
l'appel correspondant à yield
. Exemple :
function* math() {
// Le premier appel à next() permet de "démarrer" le générateur
const x = yield; // la valeur de la première itération sera undefined
// x = le paramètre du second appel à next()
const y = yield x + 1; // valeur de la seconde itération : x + 1
// y = paramètre du troisième appel à next()
yield y; // valeur de la troisième itération : y
// le 4e appel (et +) à next() retournent { value: undefined, done: true }
}
const iterator = math();
iterator.next(42); // { value: undefined, done: false }
// Passer un paramètre au premier appel à next() n'est pas utile : cette valeur
// n'est pas accessible dans le générateur car aucun "yield" correspondant
iterator.next(33); // { value: 34, done: false }, x = 33 dans le générateur
iterator.next(27); // { value: 27, done: false }, y = 27 dans le générateur
iterator.next(); // { value: undefined, done: true }
Ça ne semble pas très utile vu comme ça, mais on peut passer à next()
n'importe quel type de donnée : une fonction, un objet, un autre itérateur… Les
possibilités sont infinies. On va en explorer une rapidement avec les promesses.
Le code du générateur lui-même ne peut être réellement asynchrone : les appels à
yield
se suivent de manière synchrone. Le code contrôleur par contre, est
libre d'appeler next()
à loisir, et peut donc le faire de manière asynchrone.
On a donc des fonctions dont on peut choisir quand elles sont interrompues, et quand elles peuvent reprendre leur traitement. Et si… notre générateur émettait des promesses ? Histoire d'expliquer à son code contrôleur quand il est sûr de reprendre le traitement. Et si ce code contrôleur, voyant qu'il récupère une promesse, attendait que cette dernière soit résolue pour transmettre au générateur en retour la valeur résolue ? Dans ce cas le générateur pourrait disposer de manière synchrone mais non bloquante de résultats de traitements asynchrones :
execAsync(function*() {
console.log("Ajax request…");
var rows = yield fetch("http://my.api/get");
console.log("Work…");
console.log("Save…");
yield fetch("http://my.api/post");
console.log("OK.");
}); // Ajax request… Work… Save… OK.
Ne serait-ce pas merveilleux ? C'est le use case le plus intéressant pour nous au quotidien, et c'est assez simple en fait :
function execAsync(promiseGenerator) {
const iter = promiseGenerator(); // en pause…
function loop(iteration) {
if (iteration.done) {
// Le générateur a return'é, fin du game
return iteration.value;
}
// c'est un générateur de promesse, dont on attend la résolution ici
return iteration.value.then(result => {
// La promesse est résolue, on peut repasser sa valeur au générateur
const nextIteration = iter.next(result); // cette valeur est return'ée par
// le même "yield" qui a émis la promesse, ça tombe bien :)
// Puis on relance notre boucle, et on continue récursivement
return next(nextIteration);
});
}
const promiseIteration = iter.next(); // exécution reprise jusqu'au prochain "yield"
// le générateur est remis en pause jusqu'au prochain appel à "iter.next"
// Première itération de la boucle
return loop(promiseIteration);
}
Les erreurs, tout comme les valeurs, peuvent être émises dans les deux
directions. Le générateur peut throw
vers le code contrôleur (le code est
synchrone) :
function* fail() {
yield 1;
throw new Error("oops");
yield 2;
}
const iterator = fail();
iterator.next(); // { value: 1, done: false }
try {
iterator.next(); // throws
} catch (e) {
e; // Error('oops')
}
Mais le code contrôleur peut également émettre une erreur vers le générateur
avec la méthode throw
de l'itérateur :
function* fail() {
try {
yield 1;
} catch (e) {
console.error(e);
}
yield 2;
}
const iterator = fail();
iterator.next(); // { value: 1, done: false }
iterator.throw(new Error("nope")); // affiche "[Error: nope]"
iterator.next(); // { value: 2, done: false }
iterator.next(); // { value: undefined, done: true }
Note : il faut bien se souvenir que le premier next
sert à débloquer
l'exécution du générateur, qui va alors jusqu'au premier yield
, évalue
l'expression émise, la transmet en retour de next()
, et remet la fonction en
pause. C'est au second yield
seulement que l'exécution reprend à partir de
yield 1
. C'est une partie que je trouve contre-intuitive et que j'ai eu du
mal à assimiler.
L'opérateur yield*
permet d'émettre les valeurs d'un autre itérateur, par
exemple :
function* oneToThree() {
yield 1;
yield 2;
yield 3;
}
function* zeroToFour() {
yield 0;
yield* oneToThree();
yield 4;
}
Cela fonction bien sûr avec tous
les itérables : yield * [1, 2, 3]
est
valide par exemple.
Il est possible de terminer le traitement d'un générateur depuis le code
contrôleur avec la méthode return
de l'itérateur. Tout se passera comme si le
générateur se terminait immédiatement avec la valeur de retour fournie.
function* numbers() {
yield 1;
yield 2;
yield 3;
}
const iterator = numbers();
iterator.next(); // { value: 1, done: false }
iterator.return(4); // { value: 4, done: true } → yield 2 and yield 3 are skipped
iterator.next(); // { value: undefined, done: true }
Un habituel petit coup d'œil sur la compatibilité :
Les générateurs amènent tout un nouveau panel de fonctionnalités qui permettent d'inverser la responsabilité : c'est le code appelant qui prend le pouvoir sur la façon dont va s'exécuter la fonction appelée. Ils représentent le premier pas vers d'autres concepts qui bouleverseront probablement votre façon de coder dans quelques mois/années : fonctions asynchrones, observables… prennent leurs racines dans les générateurs. Les comprendre permettra de mieux appréhender de futures fonctionnalités.