Menu

Iteratori e generatori in JavaScript: function*, yield, Symbol.iterator

Come funziona il protocollo degli iteratori in JavaScript, come rendere iterabili i tuoi oggetti e come le funzioni generatore rendono tutto semplice.

Questa pagina include editor eseguibili: modifica, esegui e vedi subito l'output.

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 proprio current. È questo che ti permette di scorrere range due 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.iterator su 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.

Illustrazione dei linguaggi di programmazione di Coddy

Impara a programmare con Coddy

INIZIA