Les fonctions étoile (génératrices) en JS-TS
Pourquoi
Il arrive d'avoir besoin de répresénter un flux : une structure de données d'une taille qu'on ne connait pas à l'avance, et que l'on interroge élément par élément (à la façon d'une liste chaînée par exemple) pour effectuer des traitements dessus.
Les flux existent aussi sous formes de listes lazy-loadées, quand on fait un for
sur une liste pour chercher une valeur précise, on a pas besoin de charger toute la liste en mémoire, ce qui est essentiel pour ne pas surcharger la RAM, notamment si la liste est distante, comme si elle est issue d'une base de données.
Une génératrice peut aussi servir à générer des données à la demande dans des suites/séries (pensez Fibonacci par exemple), évitant de faire des gros calculs inutiles.
Dans d'autres langages
Python, C# ont un fonctionnement équivalent au Javascript, en utilisant le mot clef yield
.
Méfiez-vous, en Java le mot clef yield
sert dans les switchs qui assignent des variables, ce qui n'est pas du tout la même tambouille.
Les utiliser en JS
Une version transparente avec un for
function *generatrice() {
for (let i = 0; i < 10; i++)
yield `Numéro ${i}`;
}
for (let k of generatrice()) {
console.log(k)
}
// Numéro 1
// Numéro 2
// ...
// Numéro 9
Rien de très intéressant ici, on note l'important : les génératrices se notent avec un *
devant leur nom, et on donne les valeurs de la génératrice via yield
.
Un petit point à noter, il n'y a pas accès à la syntaxe en lambdas pour faire des génératrices (() => { du code ... }
)
Plonger dans les entrailles : types de retour et performances des génératrices
for
n'est qu'un wrapper transparent sur le type de retour d'une fonction étoile. Une génératrice est un objet qui expose une fonction next()
qui permet de calculer le prochain élément de la génératrice. Quand on appelle next()
, la génératrice reprend son exécution jusqu'à arriver à un yield
/return
.
Chaque yield
remonte un objet avec deux propriétés :
{value: T, done?: boolean}
done
est égal à true quand la fonction génératrice "retourne", soit car on a utilisé le mot clef return
dans le code, soit car la fonction arrive en fin d'exécution. Si on utilise le mot clef return
, le champ value
contiendra la valeur du return
, sinon, il sera undefined
(comme lorsqu'on essaie de récupérer la valeur de retour d'une fonction void
).
Une fois qu'une génératrice est done
, elle devient idempotente : cela veut dire que quelque soit le nombre d'appels effectués, sa valeur sera toujours la même.
Faire d'un objet/classe une génératrice : aplatir un arbre
Pour regarder comment utiliser des génératrices, on va utiliser un exemple relativement simple : aplatir un arbre binaire.
On rappelle rapidement le parcours infixe : on itère sur le sous-arbre qui est dans son fils gauche, puis on intègre la valeur, avant d'itérer sur le sous-arbre qui est dans le fils droit. Ces parcours sont récursifs.
Cet exemple est parfait pour nous : il utilise de la récursion, il se sert d'objets "complexes" qui exposent un itérateur/générateur et ils imbriquent ces générateurs, ce qui nous permet de découvrir la syntaxe yield*
.
class Noeud<T> {
val : T
filsG ?: Noeud<T>;
filsD ?: Noeud<T>;
constructor(val : T, fg ?: Noeud<T>, fd ?: Noeud<T>) {
this.val = val;
this.filsG = fg;
this.filsD = fd;
}
*[Symbol.iterator]() : Generator<T> {
if (this.filsG) yield* this.filsG;
yield this.val;
if (this.filsD) yield* this.filsD;
}
}
// Un petit arbre
const arbre = new Noeud(
1,
new Noeud(
2,
new Noeud(4, undefined, undefined),
new Noeud(5, undefined, undefined)
),
new Noeud(
3,
new Noeud(
6,
new Noeud(7, undefined, undefined),
new Noeud(8, undefined, undefined)
),
undefined
)
);
for (let valeurNumerique of arbre) {
console.log(valeurNumerique);
}
// 4,2,5,1,7,6,8,3
Dans cet exemple, quelques choses à noter :
- l'utilisation d'une fonction génératrice dont le nom est
[Symbol.iterator]
- La syntaxe
yield*
D'un pur point de vue syntaxe, la notation entre crochets permet d'utiliser une valeur qui n'est pas une string comme identifier là où une string serait attendue, c'est aussi utile lorsque vous utilisez des énumérations comme clefs d'un objet (comme avec {[MonEnum.CLEF_1] : "toto"}
).
Symbol.iterator
est la valeur prédéfinie qui permet aux moteurs d'exécution d'appeler la bonne fonction pour obtenir un itérateur. Il convient de noter qu'il existe aussi l'interface Iterable
qui permets de faciliter votre typage.
L'utilisation de yield*
permet de déréférencer un générateur, si on faisait un yield
d'un générateur, on aurait le droit à la fonction comme valeur, en faisant un yield*
d'un générateur, ses valeurs sont absorbées dans le nôtre, jusqu'à épuisement.
Un exemple "cas-réel" : avec une base de données
import Database from "better-sqlite3";
function* fetchUsersInBatches(batchSize = 10) {
const db = new Database("example.db");
const stmt = db.prepare("SELECT id, name, email FROM users");
const rows = stmt.all();
for (let i = 0; i < rows.length; i += batchSize) {
yield rows.slice(i, i + batchSize);
}
db.close();
}
// Comme ça, on ne va traiter les utilisateurs que 5 par 5 en mémoire, gros gain de place en RAM
for (const batch of fetchUsersInBatches(5)) {
for (const user of batch) {
// On pourrait faire un traitement plus gros ici
console.log(user);
}
}
Ce qu'on retient sur les fonctions étoiles
- C'est un super outil pour éviter de créer des grands arrays
- On peut l'utiliser pour faire une abstraction sur des structures de données complexes
yield
pour des valeurs simples,yield*
pour intégrer un autre générateur- On peut return pour marquer la fin du flux
- On met une étoile devant le nom de la fonction pour la transformer en génératrice
- Pas accès à la syntaxe
() => { faire des trucs... }
pour les génératrices