Accueil / Articles / Explication des générateurs JavaScript : fonctions poussables et itération différée

Explication des générateurs JavaScript : fonctions poussables et itération différée

Apprenez comment les fonctions générateurs JavaScript suspendent et reprendent l’exécution, mettent en œuvre le protocole d’itérateur, et permettent un flux de données différé et économe en mémoire.

1084 mots

Toute fonction que vous avez écrite jusqu’à présent obéit à une règle : vous l’appeliez, elle s’exécute du début à la fin, elle renvoie une seule fois des résultats, et c’est tout. Les générateurs enfreignent discrètement cette règle, et cette infraction s’avère très importante en pratique : une fonction générateur peut se suspendre à un moment donné, renvoyer une valeur à celui qui l’a appelée, puis reprendre son exécution exactement à cet endroit, comme si aucun temps n’avait passé entre-temps.

Le mécanisme de base

Vous déclarez un générateur avec function*, et au lieu de return, il utilise yield pour transmettre des valeurs une par une :

function* countUp() {
  console.log("starting");
  yield 1;
  console.log("resumed after first yield");
  yield 2;
  console.log("resumed after second yield");
  yield 3;
  console.log("done");
}
const counter = countUp();
console.log(counter.next()); // "starting" logs, then { value: 1, done: false }
console.log(counter.next()); // "resumed after first yield" logs, then { value: 2, done: false }
console.log(counter.next()); // "resumed after second yield" logs, then { value: 3, done: false }
console.log(counter.next()); // "done" logs, then { value: undefined, done: true }

L’appel à countUp() ne met en exécution aucune partie du corps de la fonction. Elle renvoie un objet générateur, une structure inactive qui n’a pas encore commencé à fonctionner. Chaque appel ultérieur à .next() reprend l’exécution là où elle s’était arrêtée, continue jusqu’au prochain yield, puis s’arrête à nouveau en renvoyant la valeur produite. Tout l’état interne de la fonction, ses variables, sa position à l’intérieur d’une boucle, en bref tout reste intact pendant ces pauses, comme si la fonction n’avait jamais été interrompue.

Pourquoi c’est vraiment différent de simplement renvoyer un tableau

Une objection naturelle est la suivante : pourquoi ne pas simplement créer un tableau et le retourner à la place ? C’est là que les générateurs se révèlent utiles, car ils génèrent le résultat de manière différée, élément par élément, uniquement sur demande, plutôt que de calculer l’ensemble du résultat à l’avance.

function* readLargeFileLineByLine(filePath) {
  const fileHandle = fs.openSync(filePath, "r");
  let position = 0;
  let leftover = "";
  while (true) {
    const buffer = Buffer.alloc(1024);
    const bytesRead = fs.readSync(fileHandle, buffer, 0, 1024, position);
    if (bytesRead === 0) break;    position += bytesRead;
    const lines = (leftover + buffer.toString("utf8", 0, bytesRead)).split("\n");
    leftover = lines.pop();
    for (const line of lines) yield line;
  }
  fs.closeSync(fileHandle);
}for (const line of readLargeFileLineByLine("access.log")) {
  if (line.includes("500")) console.log(line);
  // only reads and holds one small chunk of the file in memory at a time
}

Si l’on avait écrit ce code pour créer à l’avance un tableau contenant chaque ligne, il aurait fallu lire tout le fichier de journal de plusieurs gigaoctets en mémoire avant même de pouvoir accéder à une seule ligne, ce qui est exactement le problème que les approches par flux visent à éviter. Un générateur évite ce problème grâce à un mécanisme différent : chaque ligne n’est calculée que au moment où quelqu’un la demande via .next() ; si la boucle for...of s’arrête prématurément, par exemple dès qu’elle a trouvé ce qu’elle cherchait, le générateur ne se donne même pas la peine de calculer quoi que ce soit au-delà de ce point.

Les générateurs implémentent le protocole d’itérateur, c’est pourquoi for...of fonctionne simplement

Des constructeurs tels que for...of, la déstructuration d’array et l’opérateur de diffusion fonctionnent tous sur tout ce qui satisfait le protocole d’itérateur, c’est-à-dire tout objet exposant une méthode next() qui retourne { value, done }. Un objet générateur correspond automatiquement à cette structure sans aucun effort de votre part, ce qui explique pourquoi on peut l’itérer directement sans aucune configuration supplémentaire :

function* pageThroughResults(fetchPage) {
  let page = 1;
  while (true) {
    const results = fetchPageSync(fetchPage, page);
    if (results.length === 0) return;
    yield* results; // delegates to another iterable, yielding each of its values
    page++;
  }
}

La syntaxe yield* délègue à un autre itérable, en transmettant chacune de ses valeurs séquentiellement. C’est ainsi qu’un générateur peut transformer une série de résultats paginés en un flux continu d’éléments individuels, sans que le code qui les consomme ait besoin de savoir qu’une pagination avait lieu en arrière-plan.

Communication bidirectionnelle : .next() peut envoyer des valeurs, pas seulement les récupérer

Il existe un aspect moins connu des générateurs : yield n’est pas seulement un moyen d’émettre des valeurs, c’est aussi une expression, et tout ce que vous passez à l’appel suivant de .next() devient le résultat de cette expression. Cela signifie que les données peuvent revenir dans une fonction en pause, et non seulement en sortir.

function* priceNegotiation() {
  const offer1 = yield "What's your offer?";
  const offer2 = yield `I can't do ${offer1}, how about a counter?`;
  return `Final: ${offer2}`;
}
const negotiation = priceNegotiation();
console.log(negotiation.next().value);        // "What's your offer?"
console.log(negotiation.next(50).value);       // "I can't do 50, how about a counter?"
console.log(negotiation.next(80).value);       // "Final: 80"

Chaque fois que vous appelez .next(value), le générateur se réveille et la ligne yield en pause est évaluée avec la valeur que vous avez fournie. Disposer d’une fonction capable de suspendre son exécution, d’attendre des données nouvelles de la part de l’appelant, puis de reprendre son travail avec ces données est une capacité inhabituelle dans la conception classique des fonctions. C’est précisément ce mécanisme que les premières bibliothèques asynchrones de JavaScript utilisaient pour simuler async/await avant que le langage ne les prenne en charge nativement : un planificateur résolvait une promesse, puis transmettait son résultat au générateur via .next(), en répétant ce processus à chaque appel à yield jusqu’à ce que toutes les étapes asynchrones soient terminées.

Pourquoi c’est la véritable base à connaître

Les générateurs sont loin d’être une anomalie syntaxique que l’on peut ignorer sans risque. Ce sont le mécanisme sur lequel async/await a fini par être construit, car « suspendre l’exécution ici pour la reprendre plus tard avec une valeur » correspond exactement au comportement requis pour attendre une promesse. Ils expliquent également pourquoi for...of, la syntaxe de déploiement et le déstructuration se comportent de manière cohérente avec les tableaux, les chaînes de caractères, les instances Map et les objets personnalisés, puisque le protocole d’itérateur mis en œuvre par les générateurs est identique à celui sur lequel reposent déjà ces types intégrés. Une fois que l’on a internalisé le véritable modèle mental — une fonction capable de s’arrêter et de reprendre tout en échangeant des valeurs à chaque point d’arrêt, l’évaluation différée, une logique d’itération personnalisée, ainsi que le fonctionnement interne de async/await — ces éléments ne semblent plus être trois fonctionnalités indépendantes. En réalité, ils forment un tout unique.

le schéma sous-jacent qui apparaît dans trois contextes différents.

Lectures complémentaires