Le Modèle d'Itération en JavaScript
Dans les versions antérieures d'ECMAScript, effectuer des itérations nécessitait l'utilisation de boucles ou d'uatres structures auxiliaires. Pour résoudre ce problème, le modèle d'itération a été introduit. JavaScript prend en charge ce modèle depuis ECMAScript 6.
Le modèle d'itération décrit un schéma où certaines structures sont appelées « objets itérables » car elles implémentent l'interface Iterable formelle. Ces objets peuvent être consommés via un itérateur. Fondamentalement, un objet itérable est un type de collection, comme un tableau ou un ensemble, avec des éléments limités et un ordre de parcours non ambigu.
Un itérateur est un objet jetable créé à la demande. Chaque itérateur est associé à un objet itérable et expose son API d'itération. L'itérateur n'a pas besoin de connaître la structure de données de l'objet itérable ; il doit seulement savoir comment obtenir des valeurs successives.
Protocole des Objets Itérables
Pour implémenter l'interface Iterable (protocole itérable), une structure de données doit posséder deux capacités : l'auto-identification pour l'itération et la capacité à créer un objet implémentant l'interface Iterator. En ECMAScript, cela signifie qu'il faut exposer une propriété comme « itérateur par défaut », et cette propriété doit utiliser la clé spéciale Symbol.iterator. Cette propriété doit référencer une fonction d'usine à itérateurs, et l'appel de cette fonction doit retourner un nouvel itérateur.
const collection = new Set().ajouter('alpha').ajouter('beta').ajouter('gamma');
console.log(collection[Symbol.iterator]); // [Function: valeurs], fonction d'usine
console.log(collection[Symbol.iterator]()); // [Set Iterator] { 'alpha', 'beta', 'gamma' }, itérateur généré
En pratique, il n'est pas nécessaire d'appeler explicitement la fonction d'usine. Tous les types implémentant le protocole itérable sont automatiquement compatibles avec toute fonctionnalité du langage qui attend un objet itérable. Ces fonctionnalités natives appelent en arrière-plan la fonction d'usine fournie pour créer un itérateur. Tant qu'un type de données implémente l'interface Iterable, il peut être utilisé partout où un objet itérable est attendu.
const ensemble = new Set().ajouter('premier').ajouter('deuxieme').ajouter('troisieme');
for (const element of ensemble) {
console.log(element);
}
const [a, b, c] = ensemble; // Déstructuration
console.log(a, b, c); // premier deuxieme troisieme
const tableau = [...ensemble]; // Opérateur de spread
console.log(tableau); // ['premier', 'deuxieme', 'troisieme']
const tableauViaFrom = Array.from(ensemble);
console.log(tableauViaFrom); // ['premier', 'deuxieme', 'troisieme']
const paires = tableau.map((item, index) => [item, index]);
console.log(paires); // [['premier', 0], ['deuxieme', 1], ['troisieme', 2]]
const carte = new Map(paires);
console.log(carte); // Map(3) { 'premier' => 0, 'deuxieme' => 1, 'troisieme' => 2 }
Si une classe parente dans la chaîne de prototypes d'un objet implémente l'interface Iterator, cet objet l'implémente également.
Protocole des Itérateurs
L'API d'itérateur utilise la méthode next() pour parcourir les données d'un objet itérable. Chaque appel réussi à next() retourne un objet IteratorResult avec deux propriétés : done (un booléen indiquant si d'autres appels sont possibles) et valeur (la prochaine valeur de l'itérable, ou undefined si done est true).
- Dès qu'un itérateur atteint l'état
done:true, les appels suivants ànext()retournent toujours la même valeur (undefined). - Un objet itérable peut avoir plusieurs instances d'itérateurs indépendantes.
- Un itérateur utilise un curseur pour suivre la progression. Si l'objet itérable est modifié pendant l'itération, l'itérateur reflète ces changements.
Exemple d'implémentation explicite d'un itérateur :
class MaClasse {
[Symbol.iterator]() {
return {
next() {
return { done: false, valeur: 'test' };
}
};
}
}
const instance = new MaClasse();
const iterateur = instance[Symbol.iterator]();
console.log(iterateur); // { next: [Function: next] }
console.log(iterateur.next()); // { done: false, valeur: 'test' }
console.log(iterateur.next()); // { done: false, valeur: 'test' }
Créer des Itérateurs Personnalisés
Tout objet implémentant l'interface Iterator peut être utilisé comme itérateur.
class Compteur {
constructor(limite) {
this.limite = limite;
}
[Symbol.iterator]() {
let compteur = 1;
const max = this.limite;
return {
next() {
if (compteur <= max) {
return { done: false, valeur: compteur++ };
} else {
return { done: true, valeur: undefined };
}
}
};
}
}
const compteurInstance = new Compteur(3);
console.log(compteurInstance[Symbol.iterator]()); // { next: [Function: next] }
for (const nombre of compteurInstance) {
console.log(nombre); // 1 2 3
}
Les itérateurs créés de cette manière implémentent également l'interface Iterable. La propriété Symbol.iterator référençant la fonction d'usine retourne le même itérateur :
const liste = ['un', 'deux', 'trois'];
const iter1 = liste[Symbol.iterator]();
const iter2 = iter1[Symbol.iterator]();
console.log(iter1 === iter2); // true
Arrêt Précoce des Itérateurs
La méthode optionnelle return() définit la logique exécutée lorsqu'un itérateur est fermé prématurément. Cela peut se produire lors d'une sortie anticipée d'une boucle for-of, d'une déstructuration incomplète, etc.
class CompteurAvecReturn {
constructor(limite) {
this.limite = limite;
}
[Symbol.iterator]() {
let compteur = 1;
const max = this.limite;
return {
next() {
if (compteur <= max) {
return { done: false, valeur: compteur++ };
} else {
return { done: true, valeur: undefined };
}
},
return() {
console.log("Sortie anticipée");
return { done: true };
}
};
}
}
const compteur = new CompteurAvecReturn(5);
for (const num of compteur) {
if (num > 2) {
break; // Appelle return()
}
console.log(num);
}
// 1
// 2
// Sortie anticipée
for (const num of compteur) {
console.log(num);
}
// 3
// 4
// 5
const [val1, val2] = compteur; // Déstructuration incomplète
// Sortie anticipée
Les Générateurs en JavaScript
Les générateurs permettent de suspendre et de reprendre l'exécution d'un bloc de code. Bien que les itérateurs personnalisés soient utiles, ils nécessitent une gestion explicite de l'état interne. Les fonctions généra-trices offrent une alternative puissante : elles permettent de définir une fonction avec un algorithme d'itération intégré, tout en gérant automatiquement son état. Les générateurs peuvent être utilisés pour créer des itérateurs personnalisés et implémenter des coroutines.
Fondamantaux des Générateurs
Un générateur est une fonction précédée d'un astérisque (*). L'astérisque peut être placé n'importe où où une fonction peut être définie, et les espaces autour n'ont pas d'importance.
function* fonctionGeneratrice(); // Déclaration de fonction généra-trice
let genExpr = function*() {}; // Expression de fonction généra-trice
const objet = {
*methodeGeneratrice() {} // Méthode dans un objet littéral
};
class Classe {
*methodeGeneratrice() {} // Méthode d'instance de classe
static *methodeStatique() {} // Méthode statique de classe
}
Les objets générateurs implémentent l'interface Iterator, donc ils possèdent une méthode next() :
function* genFunc() {};
const generateur = genFunc(); // Appel de la fonction généra-trice produit un objet générateur
console.log(generateur); // Object [Generator] {}
console.log(generateur.next); // [Function: next]
console.log(generateur.next()); // { valeur: undefined, done: true }
La fonction généra-trice ne s'exécute qu'au premier apel de next() :
function* genFunc() {
console.log('exécution');
};
const gen = genFunc(); // Aucune exécution à ce moment
console.log(gen.next()); // 'exécution' puis { valeur: undefined, done: true }
Les objets générateurs implémentent également l'interface Iterable, avec un itérateur par défaut autoréférent :
function* genFunc() {};
console.log(genFunc); // [GeneratorFunction: genFunc]
console.log(genFunc()); // Object [Generator] {}
console.log(genFunc()[Symbol.iterator]); // [GeneratorFunction: genFunc]
console.log(genFunc()[Symbol.iterator]()); // Object [Generator] {}
Interruption avec yield
Le mot-clé yield permet d'arrêter et de reprendre l'exécution. La fonction généra-trice s'exécute normalement jusqu'à rencontrer yield, puis l'exécution est suspendue et l'état de la fonction est préservé. La reprise nécessite un appel à next() sur l'objet générateur.
function* genFunc() {
yield;
}
const gen = genFunc();
console.log(gen.next()); // { valeur: undefined, done: false }
console.log(gen.next()); // { valeur: undefined, done: true }
Les valeurs générées par yield apparaissent dans l'objet retourné par next(). Un générateur sorti via yield a done:false ; via return, il a done:true.
function* genFunc() {
yield 'alpha';
yield 'beta';
return 'gamma';
}
const gen = genFunc();
console.log(gen.next()); // { valeur: 'alpha', done: false }
console.log(gen.next()); // { valeur: 'beta', done: false }
console.log(gen.next()); // { valeur: 'gamma', done: true }
L'exécution d'un générateur est spécifique à chaque instance. Appeler next() sur un générateur n'affecte pas les autres.
function* genFunc() {
yield 'un';
yield 'deux';
return 'trois';
}
const g1 = genFunc();
const g2 = genFunc();
console.log(g1.next()); // { valeur: 'un', done: false }
console.log(g2.next()); // { valeur: 'un', done: false }
Le mot-clé yield ne peut être utilisé que directement dans la définition d'une fonction généra-trice ; l'utiliser dans une fonction imbriquée non généra-trice provoque une erreur de syntaxe.
Générateurs comme Objets Itérables
function* genFunc() {
yield 'foo';
yield 'bar';
return 'baz';
}
for (const x of genFunc()) {
console.log(x);
}
// foo
// bar (la valeur de return n'est pas itérée)
function* nFois(n) {
while (n--) {
yield;
}
}
for (let _ of nFois(3)) {
console.log('exécution');
}
// exécution
// exécution
// exécution
Utilisation de yield pour l'Entrée et la Sortie
yield peut servir d'argument intermédiaire. Le yield qui a suspendu l'exécution reçoit la première valeur passée à next(). Notez que le premier appel à next() ne prend pas en compte la valeur passée, car il démarre l'exécution.
function* genFunc(initiale) {
console.log(initiale);
console.log(yield);
console.log(yield);
}
const gen = genFunc('départ');
gen.next('ignoré'); // Affiche 'départ', la valeur 'ignoré' n'est pas utilisée
gen.next('valeur1'); // Affiche 'valeur1'
gen.next('valeur2'); // Affiche 'valeur2'
yield peut être utilisé à la fois pour l'entrée et la sortie :
function* genFunc() {
return yield 'sortie';
}
const gen = genFunc();
console.log(gen.next()); // { valeur: 'sortie', done: false }
console.log(gen.next('entrée')); // { valeur: 'entrée', done: true }
Exemple de compteur infini :
function* compteurInfini() {
for (let i = 0; ; ++i) {
yield i;
}
}
Délégation avec yield*
L'astérisque après yield permet de déléguer l'itération à un autre itérable.
function* genFunc() {
yield* [1, 2, 3];
yield* [4, 5, 6];
}
for (const val of genFunc()) {
console.log(val);
}
// 1 2 3 4 5 6
yield* sérialise un itérable en une série de valeurs individuelles. Sa valeur de retour est la valeur de la propriété valeur lorsque l'itérateur associé atteint done:true (par défaut undefined).
function* genFunc() {
console.log('valeur de yield* :', yield* [1, 2, 3]);
}
for (const x of genFunc()) {
console.log('valeur :', x);
}
// valeur : 1
// valeur : 2
// valeur : 3
// valeur de yield* : undefined
yield* peut aussi être suivi d'un autre générateur, permettant la récursivité :
function* interne() {
yield 'a';
return 'b';
}
function* externe() {
const resultat = yield* interne();
console.log('résultat :', resultat);
}
for (const val of externe()) {
console.log(val);
}
// a
// résultat : b
Générateurs comme Itérateurs par Défaut
Étant donné que les objets générateurs implémentent l'interface Iterable et que les fonctions généra-trices produisent des itérateurs, ils conviennent parfaitement comme itérateurs par défaut.
class Collection {
constructor() {
this.elements = [10, 20, 30];
}
*[Symbol.iterator]() {
yield* this.elements;
}
}
const col = new Collection();
for (const elem of col) {
console.log(elem); // 10 20 30
}
Arrêt Précoce des Générateurs
Les générateurs supportent la fermeture via les méthodes return() et throw().
function* genFunc() {};
const gen = genFunc();
console.log(gen.return); // [Function: return]
console.log(gen.throw); // [Function: throw]
Méthode return()
return() force la fermeture du générateur. La valeur passée devient la valeur de l'itérateur terminé. Une fois fermé, le générateur ne peut pas être réactivé.
function* genFunc() {
for (const item of ['a', 'b', 'c']) {
yield item;
}
};
const gen = genFunc();
console.log(gen.return('fin')); // { valeur: 'fin', done: true }
console.log(gen.next()); // { valeur: undefined, done: true }
Les structures comme for-of ignorent les valeurs retournées par un itérateur terminé.
Méthode throw()
throw() injecte une erreur dans le générateur lorsqu'il est suspendu. Si l'erreur n'est pas gérée, le générateur se ferme.
function* genFunc() {
for (const item of [1, 2, 3]) {
yield item;
}
};
const gen = genFunc();
try {
gen.throw('erreur');
} catch (e) {
console.log(e); // 'erreur'
}
console.log(gen); // Generator {<closed>}
Si l'erreur est gérée à l'intérieur du générateur, celui-ci peut reprendre son exécution, en sautant le yield correspondant.
function* genFunc() {
for (const item of [1, 2, 3]) {
try {
yield item;
} catch (e) {
console.log('Erreur gérée :', e);
}
}
};
const gen = genFunc();
console.log(gen.next()); // { valeur: 1, done: false }
gen.throw('erreur_test'); // Affiche 'Erreur gérée : erreur_test'
console.log(gen.next()); // { valeur: 3, done: false }