Marmelab Blog

Comprendre les générateurs en JavaScript

Les générateurs, késako ?

Les générateurs sont une fonctionnalité introduite dans ES6 qui permet de créer des fonctions spéciales avec la capacité de mettre en pause leur exécution en retournant un résultat intermédiaire. Ils sont déclarés avec le mot clef function *, et utilisent le mot clef yield pour retourner un résultat intermédiaire.

function * countGenerator(i) {
    while(i < 100) {
        yield i++;
    }

    return i++;
}

Un générateur retourne un itérateur

Lorsqu'il est appelé, countGenerator ne va pas s'exécuter. À la place, il va retourner un objet spécial appelé itérateur.

const iterator = countGenerator(0);

Remarquez qu'à ce stade, rien n'est encore exécuté dans le générateur.

iterator.next

L'itérateur posséde une fonction next, qui va exécuter le générateur jusqu'au premier yield. Il va ensuite reprendre le genérateur là ou il s'était arrêté sur les appels suivants, et ainsi de suite.

next retourne un objet contenant deux clefs :

  • value, indiquant la valeur retournée par yield.
  • done: un booléen indiquant si le générateur est arrivé à la fin de son éxéctution.
iterator.next(); // { value: 0, done: false }
iterator.next(); // { value: 1, done: false }
iterator.next(); // { value: 2, done: false }
// ... 97 calls later
iterator.next(); // { value: 99, done: false }
iterator.next(); // { value: 100, done: true }
iterator.next(); // { value: 100, done: true }

Une fois que le générateur a été totalement exécuté, next continue de retourner le dernier résultat.

Remarque: Un générateur se termine toujours par la valeur retournée par return, ou undefined si aucun return n'est spécifié.

function* gen() {
   yield 5;
}

équivaut à

function* gen() {
   yield 5;
   return undefined;
}

Et

function* gen() {
    return yield 5;
}

ne retourne pas 5 mais la valeur passée au dernier next.

Communiquer avec un générateur

Nous avons donc une fonction capable de produire des valeurs sur demande. Mais, les générateurs, ne s'arrêtent pas là. En plus de donner des valeurs, ils peuvent en recevoir.

function* addGenerator(i) {
    while (true) {
        i += yield i;
    }
}

Ok, qu'est ce que l'on a là ? Un indice: yield fonctionne dans les deux sens et next accepte un paramètre.

const iterator = addGenerator(0);
iterator.next(); // { value: 0, done: false }
iterator.next(2); // { value: 2, done: false }
iterator.next(5); // { value: 7, done: false }

La ligne i += yield i; se lit de droite à gauche. D'abord, i est retourné. Le générateur attend alors que next soit appelé. Puis il reçoit la valeur passée à next et résume son exécution, en l'additionnant à i.

Nous avons donc la possibilité de communiquer avec le générateur, il nous donne des valeurs, et on lui en passe d'autres en réponses.

Remarque:

La valeur passée au premier next, est toujours ignorée, c'est la valeur passée au générateur qui est prise en compte.

function* gen(arg) {
    return arg;
}

const it = gen('foo');
it.next('bar'); // { value: 'foo', done: true }

iterator.throw

Mais comment faire si le générateur nous donne une valeur que l'on juge incorrecte ? On doit pouvoir lui dire qu'il y a une erreur.

Reprenons l'exemple précédent. Si l'on appelle next avec une chaîne de caractères, par exemple :

iterator.next('hello'); // { value: NaN, done }
iterator.next(5); // { value: NaN, done }
iterator.next(1); // { value: NaN, done }

Le générateur se retrouve dans un état où il est devenu complètement inutile, 'hello' + 7 donnant NaN, i est devenu NaN. Et ensuite peu importe l'opération, il reste NaN.

Comment signaler au générateur qu'il est dans un état incorrect ? En lui passant une erreur grâce à la méthode throw.

iterator.throw(new Error('yielded value is not a number'));
// Error: yielded value is not a number';

Ok, iterator.throw nous a rejeté l'erreur directement, pas très utile. Cependant, si on rappelle iterator.next (après avoir catché l'erreur bien sûr) :

iterator.next(); // { value: undefined, done: true }

On constate que l'itérateur est maintenant terminé : done: true.

Mais l'idéal serait que le générateur soit capable de gérer l'erreur. Ca tombe bien, il peut, avec try catch.

function* addGeneratorV2() {
    let i = 0;
    while (true) {
        try {
            i += yield i;
        } catch (error) {
            i = 0;
        }
    }
}

En résumé, yield transmet la valeur sur sa droite à iterator.next. Et retourne la valeur qu'il reçoit de iterator.next. Sauf si c'est iterator.throw qui est appelé, auquel cas il jette l'erreur reçue.

yield *

Imaginons que vous ayez à exécuter plusieurs fois le même code dans plusieurs générateurs. Plutôt que de copier coller ce code x fois, vous en faites un autre générateur, que vous appelez ensuite dans un générateur.

function* gen1() {
    yield 1;
    yield 2;
}
function* gen() {
    yield gen1();
}

Problème : next ne retourne pas la première valeur de gen1, mais l'itérateur retourné par celui ci.

const it = gen();
const n = it.next(); // { value: {}, done: false }
n.value.next(): // { value: 1, done: false }

Logique, mais pas pratique.

Pour ce cas de figure, il existe yield *. Il prend un générateur, et qui l'exécute dans le contexte courant.

function* gen() {
    yield*  gen1();
    const it = gen();
    it.next(); // { value: 1, done: false }
    it.next(); // { value: 2, done: false }
}

Notez que si yield * est appelé avec autre chose qu'un itérateur, il aura le même effet que yield.

Générateur récursif (oui, mais non)

Grâce à yield*, il devrait être possible de créer un générateur récursif, c'est à dire qui se rappelle lui même. Ainsi :

function * countGenerator(i) {
    while (i >= 100) {
        yield i++;
    };
}

Peut se réécrire en :

function* countGenerator(i) {
    if (i >= 100) {
        return i;
    }
    return yield* countGenerator(++i);
}

Sauf que :

const it = countGenerator(0);
it.next(); // { value: 100, done: true }

On constate que l'intégralité du générateur est exécutée en une fois, et on ne récupère que le dernier résultat. Pourquoi ? Je dois bien avouer que je n'en ai pas la moindre idée.

Autre chose à noter, les générateurs ne supportent pas le tail call optimization. En résumé cela signifie que l'on peut très vite se retrouver avec un dépassement de pile stack overflow. Bref, ne faites pas de récursion avec les générateurs.

Conclusion

Voilà en ce qui concerne le fonctionnement des générateurs. Dans un prochain post, je vais vous parler de ce que l'on peut faire avec.