Pair programming in Javascript: gli Iteratori

P

ITERATORI SINCRONI

Questo articolo fa parte di una serie di articoli:

  1. Gli Iteratori sincroni
  2. I Generatori sincroni
  3. Gli Iteratori asincroni
  4. I Generatori asincroni

 

PAIR PROGRAMMING CON FABIO BIONDI

Ebbene sì, se avete tempo e voglia di sentire i miei errori grammaticali a go go dettati dall’ansia, pronunce di termini inglesi da strapparsi i capelli (sempre mie) e di vedere l’innaturalezza pura, esiste una versione di questo articolo sottoforma di chiaccherata tra devs con il mitico Fabio Biondi! È un format assolutamente nuovo per noi che potrebbe avere un futuro, siamo sicuri che ci perdonerete queste mancanze e vi farete qualche risata.
Alcuni aspetti che sono saltati fuori durante la nostra conversazione non sono presenti in questo articolo e viceversa, perciò visionateli tutti e due 😀
Per eventuali errori e/o imprecisioni tartassatemi pure nei commenti.

 

INTRODUZIONE

La prima cosa da puntualizzare è che quella dell’iteratore non è l’ennesima idea strampalata di Brendan, bensì è un’interfaccia. Per chi fosse poco avvezzo a questo concetto, potete immaginare un’interfaccia come una specie di contratto tra due entità del codice. Queste due parti sarebbero altrimenti troppo collegate tra loro e una modifica ad una delle due ci forzerebbe a modificare anche l’altra.
Nel caso degli iteratori, essi servono a scorrere una qualsiasi collezione di dati. Ad esempio un banale array, ma anche liste, alberi, ecc. Se questo contratto tra la collezione e il consumer della collezione stessa viene messo in pratica e rispettato, le due parti saranno completamente indipendenti l’una dall’altra.
Potremmo tranquillamente modificare la logica del consumer, come esso usa i dati, senza dover mettere mano alla collezione. Oppure potremmo modificare il modo in cui la collezione stessa fornisce i dati, purché rispetti comunque il contratto, e non sarà necessario modificare il codice nel consumer.
Anzi, sarà possibile sostituire interamente la collezione con un’altra completamente diversa senza alcun problema. Questo significa quindi che una collezione che rispetti il contratto potrà essere consumata da un qualsiasi tipo di consumer, indipendentemente dalla logica di utilizzo dei dati stessi.
L’importante, se non si era capito, è rispettare questo contratto, questa interfaccia.

L’interfaccia dell’iteratore la si può trovare nelle specifiche del linguaggio. Il Javascript non possiede il concetto di interfaccia nativamente, perciò dobbiamo vedere cosa richiedono le specifiche ed implementarla a mano.
A dirla tutta le interfacce sono 3: Iterator, IteratorResult e Iterable che rispettivamente definiscono come deve essere implementato un iteratore (interfaccia Iterator), il risultato di una iterazione (interfaccia IteratorResult) e un’entità iterabile (interfaccia Iterable), tipo una collezione di dati.
Iniziamo dall’Iterator: il metodo principale, che tra l’altro è l’unico obbligatorio, è next() che deve restituire il primo, o il successivo, IteratorResult.

const Iterator = {
    next() { 
       return IteratorResult;
    }
}

Un IteratorResult è un oggetto con due proprietà: value che è il vero valore corrente dell’iterazione e done, un semplice booleano che indica con true quando l’iterazione è completata. Un paio di precisazioni al volo: l’ultimo value valido restituito deve essere collegato comunque ad un done false. Dopodiché done sarà settato a true e value diventerà undefined per tutte le successive eventuali iterazioni.

const IteratorResult = { 
    value: aValue || undefined, 
    done: false || true, 
}

Per ultimo l’Iterable: una qualsiasi entità che per essere definita iterabile deve implementare un metodo particolare, il [Symbol.iterator], il quale quando invocato deve restituire un iteratore nuovo di zecca.
Attenzione! Di solito vogliamo che ogni invocazione sia un iteratore nuovo, un iteratore non è reversibile quindi se vogliamo consumare nuovamente l’entità semplicemente ce ne serve uno nuovo. Però non sarà sempre così, dopo vedremo il perché.

const Iterable = { 
    // data 
    [Symbol.iterator]() {
        return Iterator 
    } 
}

 

Default iterators AND BEHAVIOURS

Prima di crearne uno noi vediamo in breve come usarne uno tra quelli già provveduti dal linguaggio. Questo è possibile perché array e stringhe, ma non gli oggetti, hanno già implementato il metodo [Symbol.iterator]().
Ho scelto l’array, ma con le stringhe non ci sarebbe stata alcuna differenza.

const array = [1, 2, 3];

// richiediamo un iteratore alla collezione 
const iterator = array[Symbol.iterator]();

// utilizziamo l’iteratore ricordando che per
// ricevere un IteratorResult dobbiamo invocare il metodo next()

console.log(iterator.next()); // { value: 1, done: false } 
console.log(iterator.next()); // { value: 2, done: false } 
console.log(iterator.next()); // { value: 3, done: false } 
console.log(iterator.next()); // { value: undefined, done: true } 
console.log(iterator.next()); // { value: undefined, done: true }

Sono uscito intenzionalmente fuori dai limiti e si può vedere come l’interfaccia è rispettata: done settato a true e value a undefined. E si può vedere anche che per l’ultimo valore, il 3, il flag done rimane impostato su false.

Proviamo a creare un ciclo che sfrutta questa interfaccia riconoscendo automaticamente quando fermarsi:

const array = [1, 2, 3];
const iterator = array[Symbol.iterator]();

// sfruttiamo la condizione di stop per aggiornare l’iteratorResult
for (let iteratorResult;
    (iteratorResult = iterator.next(), !iteratorResult.done);) {
    // destrutturiamo il value per pou utilizzarlo

    const {
        value
    } = iteratorResult;

    console.log(value);
}

Per fortuna il TC39 ha ben pensato di non farci scrivere tali oscenità, fornendoci un costrutto ancora migliore: il for..of. Il for..of aggiunge al nostro ciclo la richiesta di un iteratore alla collezione che gli diamo in pasto.
In effetti noi non scriviamo così:

const array = [1, 2, 3];
const iterator = array[Symbol.iterator]();

for (const value of iterator) {
    console.log(value);
}

Ma così:

const array = [1, 2, 3];

// forniamo al for..of l’iterabile, non l’iteratore
for (const value of array) {
    console.log(value);
}

Proprio perché la prima cosa che il for..of fa è quella di invocare il metodo [Symbol.iterator]() sull’entità che gli “diamo in pasto”, per poi utilizzare l’iteratore risultante. La cosa strana è che entrambi gli script, per qualche oscuro motivo, sono corretti  e portano al medesimo risultato. Sapete com’è, il Javascript…
Più avanti capiremo il perché.

Ci sono altri modi per utilizzare un iteratore oltre al for..of: la destrutturazione di array, che solitamente lo consuma parzialmente, e l’array spread operator, che lo consuma in toto.
Sotto sotto ad un’istruzione come:const [a, b] = array; oppure: const anotherArray = […array]; o anche foo(...array); si nasconde lo stesso meccanismo. E dato che è sufficiente che l’oggetto sia un Iterable, quindi che rispetti quel contratto, possiamo utilizzare destrutturazione di array e l’array spread operator praticamente con qualsiasi cosa vogliamo.
Stringhe, Map e Set ad esempio sono Iterables di default come gli Array.

 

CUSTOM ITERATORS

Vediamo, finalmente, come implementare le interfacce che abbiamo visto in un oggetto custom. Vedremo i due principali casi: una collezione e un producer di valori. Poi vedremo perché non sempre il metodo [Symbol.iterator]() restituisce un iteratore nuovo di zecca e perché possiamo passare al for..of sia iterabili (come di consueto) che iteratori. O almeno sembra che possiamo farlo con gli iteratori provveduti di default dal Javascript.
Questo semplice oggetto contiene, banalmente, i nomi dei partecipanti di un ipotetico gruppo telegram con un flag che indica se l’utente è o non è un amministratore

const users = {
    giacomo: false,
    andrea: true,
    fabio: true,
    luca: false,
    marco: false,
    clara: true,
}

L’esempio è volutamente semplice. Vogliamo fare in modo che l’iteratore per questa collezione restituisca, iterazione dopo iterazione, solo gli utenti amministratori. Provvediamo quindi all’oggetto users il metodo [Symbol.iterator]():

[Symbol.iterator]() {
    // sappiamo che un iteratore è un oggetto formato da un solo metodo: next 
    return {
        next() {}
    }
}

Così abbiamo creato lo scheletro dell’iterator. Adesso pensiamo all’iteratorResult:

[Symbol.iterator]() {
    return {
        next() {
            // un iteratorResult non è altro che un oggetto
            // con due proprietà: done e value
            return {
                value: undefined,
                done: true
            }
        }
    }
}

Per quanto un iteratore del genere non è ancora in linea con il nostro obiettivo, è in linea teorica completo. Potremmo già utilizzare l’oggetto users con un for..or ad esempio.

Vediamo come possiamo aggiungere la logica necessaria ad iterare solo gli utenti amministratori.
Per prima cosa abbiamo bisogno di trasformare le proprietà dell’oggetto in un array che scorreremo grazie ad un indice. Entrambe le entità devono rimanere univoche per l’intera iterazione, perciò le imposteremo ad ogni invocazione del metodo [Symbol.iterator]() che produce un nuovo iteratore.

[Symbol.iterator]() {
    const properties = Object.keys(this);
    let index = 0;
    return {
        next() {
            return {
                value: undefined,
                done: true
            }
        }
    }
}

Ad ogni iterazione dobbiamo scorrere l’array con le properties dell’oggetto e saltare quelle che contengono il valore false, perché si riferiscono ad utenti che non sono amministratori. Dato che questa parte di logica deve essere ripetuta ad ogni iterazione, la inseriremo nel metodo next() dell’iteratore. Da notare che ho leggermente cambiato la signature per utilizzare una arrow function e non impazzire col this, che così punterà sempre all’oggetto users. Altrimenti, riuscite ad indovinare a cosa avrebbe puntato? All’iteratore 😀

[Symbol.iterator]() {
    const properties = Object.keys(this);
    let index = 0;
    return {
        next: () => {
            // questo ciclo salta tutte le properties che valgono false ma si ferma 
            // quando ha raggiunto l’ultima proprietà
            while (!this[properties[index]] && index < properties.length) index++;
            return {
                value: undefined,
                done: true
            }
        }
    }
}

Adesso rimane da definire il risultato dell’iteratore. Per il done possiamo semplicemente eseguire un check con l’index sulla length dell’array con le properietà. Il valore lo ricaviamo sempre grazie a questo array.
Dobbiamo ricordarci anche di far avanzare l’index sull’utente successivo:

[Symbol.iterator]() {
    const properties = Object.keys(this);
    let index = 0;
    return {
        next: () => {
            while (!this[properties[index]] && index < properties.length) index++;
            return {
                done: index >= properties.length,
                value: properties[index++]
            }
        }
    }
}

Per chi si chiedesse se così facendo non vado effettivamente fuori dall’indice massimo ha ragione, ma a meno che l’iteratore non venga spostato manualmente non ci sono problemi. Il for..or, l’array spread ed eventualmente anche la destrutturazione porteranno l’indice una posizione avanti alla massima e, come da specifiche, il risultato sarà undefined. Ma al contempo anche done cambierà da false a true e l’iterazione si interromperà.
Ecco il risultato finale:

const users = {
    giacomo: false,
    andrea: true,
    fabio: true,
    luca: false,
    marco: false,
    clara: true,
    [Symbol.iterator]() {
        const properties = Object.keys(this);
        let index = 0;
        return {
            next: () => {
                while (!this[properties[index]] && index < properties.length) index++;
                return {
                    done: index >= properties.length,
                    value: properties[index++]
                }
            }
        }
    }
}

Che testiamo dentro ad un for..of:

for (const user of users) {
    console.log(user); // andrea fabio clara 
}

Per fare un dispetto al javascript proviamo a dare in pasto al for..of non l’oggetto users ma un suo iteratore. Dato che per qualche oscuro motivo funziona con gli iteratori provveduti dal js stesso, può darsi che funzionerà anche con uno creato da noi:

const usersIterator = users[Symbol.iterator]();
for (const user of usersIterator) {
    console.log(user);
}

Brutta sorpresa: “TypeError: usersIterator is not iterable
E certo che usersIterator non è un iterabile, perché è un iteratore! Ma aspettate, come mai con gli iteratori di default non fa storie? Fermi tutti! Non ditemi che un iteratore può anche essere un iterabile. No no…ma che significa iterare un iteratore?!?!?!

Inception.

Ora vediamo il caso in cui un iteratore è utilizzato per sfruttare un producer. Vi prometto che poi torneremo su quel “problema” per chiarire definitivamente la situazione. Dato che con la serie di Fibonacci non si sbaglia mai, creiamo un producer che ad ogni invocazione restituisce il successivo numero della serie e consumiamolo con un iteratore.
Non giudicate le mie doti algoritmiche per favore.

const fibonacciIterator = function() {
    // producer 
    const nextFibonacciNumber = function() {
        let previous = 0,
            current = 1;
        return function() {
            const tmp = current;
            current += previous;
            previous = tmp;
            return tmp;
        }
    }();
    return function iteratorProducer() {
        return {
            next: () => {
                const current = nextFibonacciNumber();
                const outOfRange = current > Number.MAX_SAFE_INTEGER;
                return {
                    done: outOfRange,
                    value: outOfRange ? undefined : current
                }
            }
        }
    }();
}();

fibonacciIterator.next(); // { done: false, value: 1 } 
fibonacciIterator.next(); // { done: false, value: 1 } 
fibonacciIterator.next(); // { done: false, value: 2 } 
fibonacciIterator.next(); // { done: false, value: 3 } 
fibonacciIterator.next(); // { done: false, value: 5 }
fibonacciIterator.next(); // { done: false, value: 8 }

Ci sono ovviamente delle differenze rispetto al caso precedente. IIFE e quella che dovrebbe essere una sottospecie di memoization a parte, notiamo che adesso non disponiamo più di una collezione ma solo ed esclusivamente di un iteratore.
Inoltre un producer potrebbe virtualmente non essere mai fermato; questo dipende dalle specifiche del nostro progetto. Un ciclo for..of potrebbe tranquillamente provvedere ad un’istruzione di break per sospendere l’iterazione ad un certo punto.
Ad ogni modo questo continua ad essere errato:

for (const value of fibonacciIterator) {
    console.log(value);
}

Perché fibonacciIterator è un iteratore, ma non un iterabile.
La costrizione a poter interagire solo con l’iteratore è voluta. Può accadere infatti di doverci relazionare con un iteratore scollegato da una qualsiasi collezione, come in questo caso, oppure collegato ad una collezione che però non possiamo raggiungere direttamente.
Esempio banale: abbiamo creato un iteratore e vogliamo passarlo ad una funzione perché non vogliamo passare la collezione stessa, ma la funzione utilizza un ciclo for..of.
Altro esempio banale: la collezione è membro privato di una classe, quindi non accessibile direttamente. La classe però provvede un metodo che restituisce un iteratore per quella collezione.

Dobbiamo quindi trasformare l’iteratore in un iterabile. Ma torna la domanda: quale senso ha iterare un iteratore? Nessuno! Che ci crediate o no, la cosa più intelligente da fare per avere la botte piena e la moglie ubriaca è quella di fare in modo che l’iteratore, se usato come iterabile, ritorni se stesso!
Non è così illogico a pensarci bene. Ricordiamoci che un for..of richiede all’entità che deve scorrere un iteratore, richiamandone il metodo [Symbol.iterator](). Se quell’entità fosse essa stessa un iteratore dovrebbe semplicemente ritornare se stessa.
Riprendiamo quindi la nostra definizione iniziale di Iterator e modifichiamola in base a quanto imparato:

const Iterator = {
    next() {
        return IteratorResult;
    },
    [Symbol.iterator]() {
        return this;
    }
}

Non dovete pensare che questo è solo un caso limite. Tutti gli iterabili presenti di default nel linguaggio forniscono iteratori così definiti. Tutti gli iteratori ottenibili con qualsiasi caratteristica del linguaggio seguono questa ulteriore specifica.
È bene quindi implementarla sempre, dato che difficilmente abbiamo modo di sapere a priori come le collezioni e gli iteraotri che creiamo verranno utilizzati.

A proposito di me

Andrea Simone Costa

Classe 1997, Toscano DOCP, Asimov oriented.
Si è diplomato come perito elettronico, ma ha ben presto tradito le origini per immergersi nell'universo JavaScript. Ama condividere ciò che ha imparato in modo semplice e pragmatico, senza mai ricadere nel banale, coltivando segretamente il sogno di insegnare.

Gli articoli più letti

Articoli recenti

Commenti recenti