ES6 a introduit une nouvelle façon de travailler avec les fonctions et les itérateurs sous la forme de générateurs (ou fonctions génératrices). Un générateur est une fonction qui peut s’arrêter à mi-chemin, puis reprendre là où elle s’est arrêtée. En bref, un générateur semble être une fonction mais il se comporte comme un itérateur.
Fait amusant : async/await
peut être basé sur des générateurs. Lisez plus ici.
Les générateurs sont intimement liés aux itérateurs. Si vous ne connaissez pas les itérateurs, voici un article pour mieux les comprendre.
Voici une analogie simple pour avoir une intuition des générateurs avant de passer aux détails techniques.
Imaginez que vous lisez un techno-thriller à suspense. Tout absorbé dans les pages du livre, vous entendez à peine la sonnette de votre porte. C’est le livreur de pizza. Vous vous levez pour ouvrir la porte. Mais avant de le faire, vous placez un signet à la dernière page que vous avez lue. Vous enregistrez mentalement les événements de l’intrigue. Puis, vous allez chercher votre pizza. De retour dans votre chambre, vous commencez le livre à partir de la page où vous avez placé le signet. Vous ne le recommencez pas à partir de la première page. En un sens, vous avez agi comme une fonction génératrice.
Voyons comment nous pouvons utiliser les générateurs pour résoudre certains problèmes courants lors de la programmation. Mais avant cela, définissons ce que sont les générateurs.
Que sont les générateurs ?
Une fonction normale comme celle-ci ne peut pas être arrêtée avant qu’elle ne termine sa tâche c’est-à-dire que sa dernière ligne soit exécutée. Elle suit quelque chose appelé modèle run-to-completion.
function normalFunc() {
console.log('I')
console.log('cannot')
console.log('be')
console.log('stopped.')
}
La seule façon de sortir de la normalFunc
est en return
sortant de celle-ci, ou en throw
sortant une erreur. Si vous appelez à nouveau la fonction, elle recommencera l’exécution depuis le début.
A l’inverse, un générateur est une fonction qui peut s’arrêter à mi-chemin et reprendre là où elle s’est arrêtée.
Voici d’autres définitions courantes des générateurs –
- Les générateurs sont une classe spéciale de fonctions qui simplifient la tâche d’écriture des itérateurs.
- Un générateur est une fonction qui produit une séquence de résultats au lieu d’une seule valeur, c’est-à-dire que vous générez une série de valeurs.
En JavaScript, un générateur est une fonction qui renvoie un objet sur lequel vous pouvez appeler next()
. Chaque invocation de next()
retournera un objet de forme –
{
value: Any,
done: true|false
}
La propriété value
contiendra la valeur. La propriété done
est soit true
, soit false
. Lorsque le done
devient true
, le générateur s’arrête et ne générera plus de valeurs.
Voici une illustration de la même –
Notez la flèche en pointillés qui ferme la boucle rendement-résumé-rendement juste avant Terminer dans la partie Générateurs de l’image. Il est possible qu’un générateur ne se termine jamais. Nous verrons un exemple plus tard.
Création d’un générateur
Voyons comment nous pouvons créer un générateur en JavaScript –
function * generatorFunction() { // Line 1
console.log('This will be executed first.');
yield 'Hello, '; // Line 2 console.log('I will be printed after the pause');
yield 'World!';
}const generatorObject = generatorFunction(); // Line 3console.log(generatorObject.next().value); // Line 4
console.log(generatorObject.next().value); // Line 5
console.log(generatorObject.next().value); // Line 6// This will be executed first.
// Hello,
// I will be printed after the pause
// World!
// undefined
Focus sur les parties en gras. Pour créer une fonction de générateur, nous utilisons la syntaxe function *
au lieu de simplement function
. Un nombre quelconque d’espaces peut exister entre le mot-clé function
, le *
et le nom de la fonction. Puisque c’est juste une fonction, vous pouvez l’utiliser partout où une fonction peut être utilisée c’est-à-dire à l’intérieur des objets, et des méthodes de classe.
À l’intérieur du corps de la fonction, nous n’avons pas de return
. Au lieu de cela, nous avons un autre mot-clé yield
(ligne 2). C’est un opérateur avec lequel un générateur peut se mettre en pause. Chaque fois qu’un générateur rencontre un yield
, il « retourne » la valeur spécifiée après lui. Dans ce cas, Hello,
est retournée. Cependant, nous ne disons pas « retourné » dans le contexte des générateurs. On dit « le générateur a donné Hello,
« .
On peut aussi retourner d’un générateur. Cependant, return
définit la propriété done
à true
après quoi le générateur ne peut plus générer de valeurs.
function * generatorFunc() {
yield 'a';
return 'b'; // Generator ends here.
yield 'a'; // Will never be executed.
}
À la ligne 3, nous créons l’objet générateur. Il semble que nous invoquons la fonction generatorFunction
. En effet, nous le faisons ! La différence est qu’au lieu de renvoyer une valeur quelconque, une fonction de générateur renvoie toujours un objet générateur. L’objet générateur est un itérateur. Vous pouvez donc l’utiliser dans des boucles for-of
ou d’autres fonctions acceptant un itérable.
A la ligne 4, nous appelons la méthode next()
sur le generatorObject
. Avec cet appel, le générateur commence à s’exécuter. D’abord, il console.log
le This will be executed first.
Puis, il rencontre un yield 'Hello, '
. Le générateur cède la valeur en tant qu’objet { value: 'Hello, ', done: false }
et suspend/pausse. Maintenant, il attend la prochaine invocation.
À la ligne 5, nous appelons à nouveau next()
. Cette fois, le générateur se réveille et commence à exécuter à partir de là où il s’est arrêté. La prochaine ligne qu’il trouve est une console.log
. Il enregistre la chaîne I will be printed after the pause
. Un autre yield
est rencontré. La valeur est cédée comme l’objet { value: 'World!', done: false }
. Nous extrayons la propriété value
et l’enregistrons. Le générateur dort à nouveau.
À la ligne 6, nous invoquons à nouveau next()
. Cette fois, il n’y a plus de lignes à exécuter. Rappelez-vous que chaque fonction renvoie implicitement undefined
si aucune instruction de retour n’est fournie. Par conséquent, le générateur renvoie (au lieu de céder) un objet { value: undefined, done: true}
. Le done
est mis à true
. Cela signale la fin de ce générateur. Maintenant, il ne peut pas générer plus de valeurs ou reprendre à nouveau puisqu’il n’y a plus d’instructions à exécuter.
Nous devrons faire nouveau un autre objet générateur pour exécuter à nouveau le générateur.
Utilisations des générateurs
Il y a beaucoup de cas d’utilisation impressionnants des générateurs. Voyons-en quelques-uns.
Implémentation des itérables
Lorsque vous implémentez un itérateur, vous devez manuellement faire un objet itérateur avec une méthode next()
. De plus, vous devez manuellement sauvegarder l’état. Souvent, il devient vraiment difficile de le faire. Puisque les générateurs sont également des itérables, ils peuvent être utilisés pour mettre en œuvre des itérables sans le code passe-partout supplémentaire. Voyons un exemple simple.
Problème : Nous voulons faire un itérable personnalisé qui renvoie This
, is
, et iterable.
. Voici une implémentation utilisant des itérateurs –
const iterableObj = {
() {
let step = 0;
return {
next() {
step++;
if (step === 1) {
return { value: 'This', done: false};
} else if (step === 2) {
return { value: 'is', done: false};
} else if (step === 3) {
return { value: 'iterable.', done: false};
}
return { value: '', done: true };
}
}
},
}for (const val of iterableObj) {
console.log(val);
}// This
// is
// iterable.
Voici la même chose en utilisant des générateurs –
function * iterableObj() {
yield 'This';
yield 'is';
yield 'iterable.'
}for (const val of iterableObj()) {
console.log(val);
}// This
// is
// iterable.
Vous pouvez comparer les deux versions. Il est vrai que c’est un exemple un peu artificiel. Mais il illustre les points –
- Nous n’avons pas à nous soucier de
Symbol.iterator
- Nous n’avons pas à implémenter
next()
. - Nous n’avons pas à faire manuellement l’objet de retour de
next()
c’est-à-dire{ value: 'This', done: false }
. - Nous n’avons pas à sauvegarder l’état. Dans l’exemple de l’itérateur, l’état était sauvegardé dans la variable
step
. Sa valeur définissait ce qui était sorti de l’itérable. Nous n’avions rien à faire de ce genre dans le générateur.
Meilleure fonctionnalité asynchrone
Le code utilisant des promesses et des callbacks tels que –
function fetchJson(url) {
return fetch(url)
.then(request => request.text())
.then(text => {
return JSON.parse(text);
})
.catch(error => {
console.log(`ERROR: ${error.stack}`);
});
}
peut être écrit comme (avec l’aide de bibliothèques telles que co.js)-
const fetchJson = co.wrap(function * (url) {
try {
let request = yield fetch(url);
let text = yield request.text();
return JSON.parse(text);
}
catch (error) {
console.log(`ERROR: ${error.stack}`);
}
});
Certains lecteurs ont pu remarquer qu’il est parallèle à l’utilisation de async/await
. Ce n’est pas une coïncidence. async/await
peut suivre une stratégie similaire et remplacer le rendement par await
dans les cas où des promesses sont impliquées. Il peut être basé sur des générateurs. Voir ce commentaire pour plus d’infos.
Flux de données infinis
Il est possible de créer des générateurs qui ne se terminent jamais. Considérez cet exemple –
function * naturalNumbers() {
let num = 1;
while (true) {
yield num;
num = num + 1
}
}const numbers = naturalNumbers();console.log(numbers.next().value)
console.log(numbers.next().value)// 1
// 2
Nous faisons un générateur naturalNumbers
. A l’intérieur de la fonction, nous avons une boucle while
infinie. Dans cette boucle, nous yield
le num
. Lorsque le générateur cède, il est suspendu. Lorsque nous appelons à nouveau next()
, le générateur se réveille, continue à partir de là où il a été suspendu (dans ce cas yield num
) et s’exécute jusqu’à ce qu’un autre yield
soit rencontré ou que le générateur se termine. Comme l’instruction suivante est num = num + 1
, elle met à jour num
. Ensuite, il passe au début de la boucle while. La condition est toujours vraie. Il rencontre la ligne suivante yield num
. Elle rend la mise à jour num
et suspend. Cela continue aussi longtemps que vous le souhaitez.
Les générateurs en tant qu’observateurs
Les générateurs peuvent également recevoir des valeurs en utilisant la fonction next(val)
. Alors le générateur est appelé observateur puisqu’il se réveille lorsqu’il reçoit de nouvelles valeurs. En un sens, il continue d’observer les valeurs et agit lorsqu’il en reçoit une. Vous pouvez lire plus sur ce pattern ici.
Avantages des générateurs
Comme on le voit avec l’exemple des flux de données infinis, c’est possible uniquement grâce à l’évaluation paresseuse. L’évaluation paresseuse est un modèle d’évaluation qui retarde l’évaluation d’une expression jusqu’à ce que sa valeur soit nécessaire. Autrement dit, si nous n’avons pas besoin de la valeur, elle n’existe pas. Elle est calculée au fur et à mesure que nous la demandons. Voyons un exemple –
function * powerSeries(number, power) {
let base = number;
while(true) {
yield Math.pow(base, power);
base++;
}
}
Le powerSeries
donne la série du nombre élevé à une puissance. Par exemple, la série de puissance de 3 élevé à 2 serait 9(3²) 16(4²) 25(5²) 36(6²) 49(7²). Lorsque nous faisons const powersOf2 = powerSeries(3, 2);
, nous créons juste l’objet générateur. Aucune des valeurs n’a été calculée. Maintenant, si nous appelons next()
, 9 serait calculé et retuné.
Efficace en mémoire
Une conséquence directe de l’évaluation paresseuse est que les générateurs sont efficaces en mémoire. Nous ne générons que les valeurs qui sont nécessaires. Avec les fonctions normales, nous devions pré-générer toutes les valeurs et les conserver au cas où nous les utiliserions plus tard. Cependant, avec les générateurs, nous pouvons différer le calcul jusqu’à ce que nous en ayons besoin.
Nous pouvons créer des fonctions combinateurs pour agir sur les générateurs. Les combinateurs sont des fonctions qui combinent des itérables existants pour en créer de nouveaux.Un tel combinateur est take
. Il prend les premiers n
éléments d’un itérable. Voici une implémentation –
function * take(n, iter) {
let index = 0;
for (const val of iter) {
if (index >= n) {
return;
}
index = index + 1;
yield val;
}
}
Voici quelques cas d’utilisation intéressants de take
–
take(3, )// a b ctake(7, naturalNumbers());// 1 2 3 4 5 6 7take(5, powerSeries(3, 2));// 9 16 25 36 49
Voici une implémentation de la bibliothèque cyclique (sans la fonctionnalité d’inversion).
function * cycled(iter) {
const arrOfValues =
while (true) {
for (const val of arrOfValues) {
yield val
}
}
}console.log(...take(10, cycled(take(3, naturalNumbers()))))// 1 2 3 1 2 3 1 2 3 1
Caveats
Il y a quelques points dont vous devez vous souvenir lorsque vous programmez en utilisant des générateurs.
- Les objets des générateurs sont à accès unique. Une fois que vous avez épuisé toutes les valeurs, vous ne pouvez plus itérer dessus. Pour générer les valeurs à nouveau, vous devez faire un nouvel objet générateur.
const numbers = naturalNumbers();console.log(...take(10, numbers)) // 1 2 3 4 5 6 7 8 9 10
console.log(...take(10, numbers)) // This will not give any data
- Les objets générateurs ne permettent pas un accès aléatoire comme cela est possible avec les tableaux. Puisque les valeurs sont générées une par une, l’accès à une valeur aléatoire entraînerait le calcul des valeurs jusqu’à cet élément. Par conséquent, ce n’est pas un accès aléatoire.
Conclusion
Il reste encore beaucoup de choses à couvrir dans les générateurs. Des choses telles que yield *
, return()
et throw()
. Les générateurs rendent également les coroutines possibles. J’ai listé quelques références que vous pouvez lire pour mieux comprendre les générateurs.
Vous pouvez vous rendre sur la page itertools de Python, et voir certains des utilitaires qui permettent de travailler avec les itérateurs et les générateurs. Comme exercice, vous pouvez implémenter les utilitaires vous-même.