Iteratori JavaScript

Gli iteratori in JavaScript sono oggetti che implementano un protocollo specifico per attraversare sequenze di dati. Rappresentano il meccanismo fondamentale che permette ai costrutti come for...of, destructuring e operatori spread di funzionare con diversi tipi di collezioni. Comprendere gli iteratori significa capire come JavaScript gestisce l’iterazione a livello profondo.

Il Protocollo Iteratore

Un iteratore è semplicemente un oggetto che implementa il metodo next(). Questo metodo restituisce un oggetto con due proprietà: value (il valore corrente) e done (booleano che indica se l’iterazione è completata). Questa semplicità concettuale nasconde una potenza notevole nel gestire qualsiasi tipo di sequenza dati.

// Esempio base di un iteratore manuale
function creaIteratoreNumeri(max) {
  let corrente = 0;

  return {
    next: function () {
      if (corrente < max) {
        return { value: corrente++, done: false };
      } else {
        return { value: undefined, done: true };
      }
    },
  };
}

const iteratore = creaIteratoreNumeri(3);
console.log(iteratore.next()); // { value: 0, done: false }
console.log(iteratore.next()); // { value: 1, done: false }
console.log(iteratore.next()); // { value: 2, done: false }
console.log(iteratore.next()); // { value: undefined, done: true }

Quando done diventa true, l’iterazione è considerata completata e le chiamate successive a next() dovrebbero continuare a restituire { value: undefined, done: true }.

Oggetti Iterabili

Un oggetto è iterabile se implementa il metodo Symbol.iterator, che deve restituire un iteratore. Tutti gli oggetti iterabili possono essere utilizzati con for...of, destructuring e operatori spread. Gli array, le stringhe, le Map, le Set e molti altri oggetti built-in sono già iterabili.

// Esempio di oggetto iterabile personalizzato
class RangeNumeri {
  constructor(inizio, fine) {
    this.inizio = inizio;
    this.fine = fine;
  }

  // Implementa Symbol.iterator per rendere l'oggetto iterabile
  [Symbol.iterator]() {
    let corrente = this.inizio;
    const fine = this.fine;

    return {
      next() {
        if (corrente <= fine) {
          return { value: corrente++, done: false };
        } else {
          return { value: undefined, done: true };
        }
      },
    };
  }
}

const range = new RangeNumeri(1, 5);

// Ora possiamo usare for...of
for (const numero of range) {
  console.log(numero); // 1, 2, 3, 4, 5
}

// E anche destructuring
const [primo, secondo, ...resto] = range;
console.log(primo, secondo, resto); // 1, 2, [3, 4, 5]

// E spread operator
const array = [...range];
console.log(array); // [1, 2, 3, 4, 5]

Iteratori degli Oggetti Built-in

Array e i Suoi Iteratori

Gli array forniscono diversi iteratori per different modalità di accesso:

const array = ["a", "b", "c"];

// Iteratore dei valori (predefinito)
for (const valore of array) {
  console.log(valore); // 'a', 'b', 'c'
}

// Iteratore degli indici
for (const indice of array.keys()) {
  console.log(indice); // 0, 1, 2
}

// Iteratore delle coppie [indice, valore]
for (const [indice, valore] of array.entries()) {
  console.log(indice, valore); // 0 'a', 1 'b', 2 'c'
}

// Accesso diretto agli iteratori
const iteratoreValori = array.values();
const iteratoreChiavi = array.keys();
const iteratoreEntries = array.entries();

console.log(iteratoreValori.next()); // { value: 'a', done: false }
console.log(iteratoreChiavi.next()); // { value: 0, done: false }
console.log(iteratoreEntries.next()); // { value: [0, 'a'], done: false }

Map e Set

Le collezioni Map e Set hanno iteratori nativi che permettono di attraversare i loro elementi:

const mappa = new Map([
  ["nome", "Mario"],
  ["età", 30],
  ["città", "Roma"],
]);

// Iterazione delle chiavi
for (const chiave of mappa.keys()) {
  console.log(chiave); // 'nome', 'età', 'città'
}

// Iterazione dei valori
for (const valore of mappa.values()) {
  console.log(valore); // 'Mario', 30, 'Roma'
}

// Iterazione delle coppie (predefinito per Map)
for (const [chiave, valore] of mappa) {
  console.log(`${chiave}: ${valore}`);
}

const insieme = new Set(["rosso", "verde", "blu"]);

// Set itera sui valori
for (const colore of insieme) {
  console.log(colore); // 'rosso', 'verde', 'blu'
}

Stringhe come Iterabili

Le stringhe sono iterabili carattere per carattere, gestendo correttamente i caratteri Unicode:

const emoji = "👋🌍✨";

for (const carattere of emoji) {
  console.log(carattere); // 👋, 🌍, ✨
}

// Conversione in array rispettando Unicode
const caratteri = [...emoji];
console.log(caratteri); // ['👋', '🌍', '✨']
console.log(caratteri.length); // 3 (non 6 come con .length)

Creazione di Iteratori Avanzati

Iteratore per Strutture Dati Personalizzate

class AlberoPersonalizzato {
  constructor() {
    this.radice = null;
  }

  aggiungi(valore) {
    if (!this.radice) {
      this.radice = { valore, figli: [] };
    } else {
      this.radice.figli.push({ valore, figli: [] });
    }
  }

  // Iteratore depth-first
  [Symbol.iterator]() {
    const stack = this.radice ? [this.radice] : [];

    return {
      next() {
        if (stack.length === 0) {
          return { done: true };
        }

        const nodo = stack.pop();

        // Aggiungi i figli allo stack (in ordine inverso per mantenere l'ordine)
        for (let i = nodo.figli.length - 1; i >= 0; i--) {
          stack.push(nodo.figli[i]);
        }

        return { value: nodo.valore, done: false };
      },
    };
  }
}

const albero = new AlberoPersonalizzato();
albero.aggiungi("radice");
albero.radice.figli.push({ valore: "figlio1", figli: [] });
albero.radice.figli.push({ valore: "figlio2", figli: [] });

for (const valore of albero) {
  console.log(valore); // radice, figlio1, figlio2
}

Iteratore Infinito con Controllo

class SequenzaFibonacci {
  constructor(limite = null) {
    this.limite = limite;
  }

  [Symbol.iterator]() {
    let a = 0,
      b = 1,
      contatore = 0;
    const limite = this.limite;

    return {
      next() {
        if (limite !== null && contatore >= limite) {
          return { done: true };
        }

        const valore = a;
        [a, b] = [b, a + b];
        contatore++;

        return { value: valore, done: false };
      },
    };
  }
}

// Fibonacci limitato
const fibLimitato = new SequenzaFibonacci(10);
console.log([...fibLimitato]); // [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

// Fibonacci infinito (attenzione nei loop!)
const fibInfinito = new SequenzaFibonacci();
const iteratoreFib = fibInfinito[Symbol.iterator]();

// Prendi solo i primi 5
for (let i = 0; i < 5; i++) {
  console.log(iteratoreFib.next().value); // 0, 1, 1, 2, 3
}

Iteratori e Metodi di Array

Molti metodi di array funzionano con qualsiasi oggetto iterabile:

// Array.from converte iterabili in array
const range = new RangeNumeri(1, 5);
const arrayDaRange = Array.from(range);
console.log(arrayDaRange); // [1, 2, 3, 4, 5]

// Con funzione di trasformazione
const arrayRaddoppiato = Array.from(range, (x) => x * 2);
console.log(arrayRaddoppiato); // [2, 4, 6, 8, 10]

// Creazione array da stringa (rispetta Unicode)
const caratteriEmoji = Array.from("👋🌍");
console.log(caratteriEmoji); // ['👋', '🌍']

Composizione e Chaining di Iteratori

class IteratoreTransformatore {
  constructor(iterabile, trasformatore) {
    this.iterabile = iterabile;
    this.trasformatore = trasformatore;
  }

  [Symbol.iterator]() {
    const iteratore = this.iterabile[Symbol.iterator]();
    const trasformatore = this.trasformatore;

    return {
      next() {
        const risultato = iteratore.next();
        if (risultato.done) {
          return risultato;
        }

        return {
          value: trasformatore(risultato.value),
          done: false,
        };
      },
    };
  }
}

class IteratoreFiltro {
  constructor(iterabile, predicato) {
    this.iterabile = iterabile;
    this.predicato = predicato;
  }

  [Symbol.iterator]() {
    const iteratore = this.iterabile[Symbol.iterator]();
    const predicato = this.predicato;

    return {
      next() {
        while (true) {
          const risultato = iteratore.next();
          if (risultato.done || predicato(risultato.value)) {
            return risultato;
          }
        }
      },
    };
  }
}

// Composizione di iteratori
const numeri = new RangeNumeri(1, 10);
const pari = new IteratoreFiltro(numeri, (x) => x % 2 === 0);
const pariRaddoppiati = new IteratoreTransformatore(pari, (x) => x * 2);

console.log([...pariRaddoppiati]); // [4, 8, 12, 16, 20]

Vantaggi e Casi d’Uso

Lazy Evaluation: Gli iteratori calcolano valori solo quando richiesti, permettendo di lavorare con sequenze infinite o molto grandi senza problemi di memoria.

Interfaccia Uniforme: Tutti gli oggetti iterabili possono essere utilizzati con gli stessi costrutti linguistici (for...of, destructuring, spread).

Composizione: Gli iteratori possono essere facilmente composti per creare pipeline di trasformazione dati efficiente.

Controllo Preciso: Permettono controllo fine grained sull’iterazione, includendo pausa, ripresa e terminazione anticipata.

Gli iteratori rappresentano un esempio perfetto di come JavaScript combini semplicità concettuale con potenza espressiva. Forniscono un’astrazione elegante per traversare qualsiasi tipo di collezione o sequenza, mantenendo il codice pulito e le performance ottimali attraverso la lazy evaluation. Comprendere gli iteratori è essenziale per scrivere JavaScript moderno ed efficiente.