Il protocollo degli iteratori
Molte funzionalità di JavaScript, come for...of, lo spread (...), il destructuring, Array.from e Promise.all, condividono un unico meccanismo di base: il protocollo degli iteratori. Una volta che lo capisci, sembrano tutte variazioni della stessa idea.
Un iteratore è qualsiasi oggetto con un metodo next() che restituisce { value, done }:
Chiama next() più volte. Ogni chiamata restituisce il valore successivo e un flag done. Quando done è true, la sequenza è finita. Questo è tutto il protocollo: un metodo e un booleano.
Iterabile e iteratore
C'è un secondo concetto collegato. Un iterabile è qualsiasi cosa sappia produrre un iteratore. Lo fa tramite un metodo salvato sotto una chiave speciale: Symbol.iterator.
Gli array sono iterabili. Chiamare numbers[Symbol.iterator]() restituisce un iteratore nuovo. Anche stringhe, Map, Set e arguments sono iterabili, ed è per questo che for...of funziona con tutti loro.
La distinzione conta: l'iterabile è la collezione, l'iteratore è il cursore. Puoi chiedere a un iterabile tutti i cursori indipendenti che vuoi.
Perché for...of funziona
for...of è solo zucchero sintattico sopra il protocollo degli iteratori. Dietro le quinte chiama Symbol.iterator, poi next() finché done non diventa true:
Lo spread e il destructuring fanno la stessa cosa: percorrono un iteratore finché non ha finito.
Qualsiasi oggetto tu costruisca che implementi Symbol.iterator può partecipare gratis a tutte queste funzionalità.
Scrivere un iterabile personalizzato
Creiamo un oggetto range che produce i numeri da start a end:
Alcune cose da notare:
[Symbol.iterator]()usa un nome di metodo calcolato. La chiave è il simbolo stesso, non la stringa"Symbol.iterator".- Ogni chiamata a
[Symbol.iterator]()restituisce un iteratore nuovo con il propriocurrent. È questo che ti permette di scorrererangedue volte senza che si "esaurisca". - L'iteratore restituito ha bisogno solo di
next(). Nient'altro.
Funziona, ma è prolisso. Esiste un modo molto migliore.
Arrivano i generatori
Una funzione generatore si dichiara con function* (nota l'asterisco). Invece di arrivare fino in fondo, può mettersi in pausa su un'espressione yield e riprendere più tardi. Chiamarla non esegue il corpo: restituisce un oggetto generatore che è sia un iteratore sia un iterabile.
Ogni next() esegue il corpo finché non incontra uno yield, si sospende e restituisce { value, done: false }. Quando la funzione termina, ottieni { value: undefined, done: true }.
E siccome i generatori sono iterabili, funzionano con tutto ciò che hai visto nella sezione precedente:
Riscrivere range con un generatore
Confronta la versione prolissa di prima con questa:
Tutto qui. L'* davanti a [Symbol.iterator] lo trasforma in un metodo generatore. yield i sostituisce l'intero oggetto iteratore scritto a mano. Niente next, niente done, nessun rischio di errori di uno: solo un ciclo normale con yield al posto di push.
Ecco perché esistono i generatori. Trasformano "scrivi un iteratore" in "scrivi una funzione che fa yield".
yield contro return
yield sospende; return termina. Puoi usare yield tutte le volte che vuoi: il generatore riprende da dove si era fermato.
Un return dentro un generatore compare come { value: "done", done: true } nella chiamata che lo termina. for...of e lo spread ignorano quel valore restituito: consumano solo gli elementi in cui done è false. Quindi non usare return value per infilare un ultimo elemento in un ciclo: verrebbe saltato.
Sequenze lazy e infinite
I generatori producono valori su richiesta, uno alla volta. Questo significa che puoi rappresentare sequenze che sarebbero impossibili come array:
Il ciclo è letteralmente while (true), eppure il programma termina: il generatore avanza solo quando qualcuno chiede il valore successivo. Puoi prendere i primi N elementi, fermarti, e il resto non viene mai eseguito:
take è a sua volta un generatore che ne avvolge un altro. Comporre generatori in questo modo è una parte importante del loro fascino: piccoli pezzi, ognuno con un solo compito.
Delegare con yield*
Se un generatore deve produrre tutto il contenuto di un altro iterabile, yield* delega a quest'ultimo:
yield* funziona con qualsiasi iterabile (array, set, altri generatori) e inoltra ogni elemento uno alla volta. È l'equivalente dello spread per gli iteratori.
Generatori asincroni, in breve
Un generatore dichiarato con async function* può fare yield di valori che richiedono tempo: utile per leggere in streaming da un'API o a blocchi da un file. Lo consumi con for await...of:
async function* paginate(url) {
let next = url;
while (next) {
const res = await fetch(next);
const page = await res.json();
for (const item of page.items) yield item;
next = page.nextUrl;
}
}
for await (const item of paginate("/api/users")) {
console.log(item);
}
Questo frammento non si può eseguire qui (serve un endpoint reale), ma vale la pena sapere che questa forma esiste. Una volta capiti i generatori normali, quelli asincroni sono la stessa idea con qualche await in più.
Quando usare un generatore
Usane uno quando:
- La sequenza è infinita o potrebbe esserlo: ID, timestamp, ritardi tra i tentativi.
- Produrre tutti i valori è costoso e chi li consuma potrebbe fermarsi prima.
- Stai implementando
Symbol.iteratorsu un oggetto personalizzato. È quasi sempre più corto che scrivere a mano l'oggetto{ next() }. - Vuoi comporre trasformazioni in streaming (
take,filter,map) senza costruire array intermedi.
Scegli un array normale quando i dati sono già in memoria e sono pochi. I generatori non sono gratis: il meccanismo che sospende e riprende una funzione ha un costo, e gli stack trace attraverso il codice dei generatori possono essere più difficili da leggere.
Prossimo passo: i Symbol
Symbol.iterator è il primo simbolo che la maggior parte delle persone incontra, ma è tutt'altro che l'unico. I Symbol sono un tipo primitivo pensato proprio per questo genere di punti di estensione: chiavi uniche che permettono al linguaggio e al tuo codice di agganciarsi agli oggetti senza entrare in conflitto con i normali nomi delle proprietà. Sono l'argomento della prossima pagina.
Domande frequenti
Qual è la differenza tra iterabile e iteratore in JavaScript?
Un iterabile è qualsiasi oggetto con un metodo Symbol.iterator che restituisce un iteratore. Un iteratore è l'oggetto che produce davvero i valori: ha un metodo next() che restituisce { value, done }. Array, stringhe, Map e Set sono iterabili; chiamando il loro metodo Symbol.iterator ottieni un iteratore da far avanzare passo dopo passo.
Cos'è una funzione generatore in JavaScript?
Una funzione dichiarata con function* che produce valori in modo lazy usando yield. Chiamarla non esegue il corpo: restituisce un oggetto generatore che è sia un iteratore sia un iterabile. Ogni chiamata a next() esegue il codice fino al yield successivo, si mette in pausa e restituisce il valore prodotto.
Qual è la differenza tra yield e return in un generatore?
yield mette in pausa il generatore e restituisce un valore, ma la funzione può riprendere da dove si era fermata alla chiamata successiva di next(). return termina il generatore per sempre: imposta done: true e non escono altri valori. Puoi usare yield molte volte; return ha senso una volta sola.
Quando usare un generatore invece di un array?
Quando la sequenza è infinita, costosa da calcolare, oppure ti serve solo una parte dei valori. Un generatore produce gli elementi uno alla volta su richiesta, quindi puoi rappresentare un flusso infinito di ID o i risultati paginati di un'API senza materializzare tutto in anticipo. Se hai già un piccolo array fisso, usa semplicemente l'array.