ES6 - Iterator, Iterabil si tipul de date symbol in JavaScript

Pas cu pas vom trece prin exemple cu Iteratori si vom intelege tipul de date symbol

Daniel Turcu

13 minute read

Ce este symbol si ce sunt iteratorii?

In ES6 avem un nou tip de date: symbol. Functia Symbol() returneaza o valoare primitiva de tipul symbol, pe care compilatorul JavaScript o garanteaza ca fiind unica.

Un iterator in JavaScript este un obiect care defineste o secventa. Pe un iterator este prezenta metoda next() care returneaza un obiect de forma { value: 100, done: false }. Se spune ca obiectul implementeaza protocolul Iterator .

Iterabil este o structura de date ce permite iteratii asupra datelor. Array-urile sunt iterabile, putem sa utilizam for..of asupra lor. Orice obiect poate deveni iterabil prin implementarea protocolului Iterable . Cu alte cuvinte, trebuie sa implementam metoda [Symbol.iterator] (care este un well-known symbol - vezi mai jos).

Inainte sa trecem la exemple practice sa raspunem pe scurt la intrebarea:


Care sunt beneficiile tipului de date symbol?

  1. Ne garanteaza o valoare unica la fiecare invocare a functiei Symbol(). Daca, de exemplu, unui obiect ii adaugam o proprietate ca symbol, suntem siguri ca nu vom suprascrie o proprietate existenta.

  2. Putem adauga functionalitati prin proprietati de tip symbol fara sa fie luate in seama de instructiuni de enumerare precum for..in. Deci, daca rulam o iteratie asupra proprietatilor, acestea sunt excluse din rezultat.

const obj = { a: 100, b: 200, c: true };
const newSymbolProperty = Symbol();
obj[newSymbolProperty] = 1001;

for (let p in obj) 
{
   console.log(p);  // a b c dar nu afiseaza 1001
}

// doar asa putem afla valoarea
console.log( obj[newSymbolProperty] ); // 1001

console.log( Object.keys(obj) );  // Object.keys() ignora proprietatea symbol
// ["a", "b", "c"]

ES6 a facut asta cu Array-urile. Le-a facut iterabile prin implementarea functiei Symbol.iterator() si aceasta proprietate nu apare in lista de chei a Array-ului.

  1. Exista deja definite valori symbol standard in ES6 (well-known symbols). Acestea pot fi utilizate pentru a modifica comportamentul default al limbajului. Daca le implementam, JavaScript le va invoca in locul celor standard.

Acestea sunt denumite Symbol.<denumire> dar pot fi apelate, prin conventie, si cu @@denumire.

Putem, de exemplu, sa furnizam un alt algoritm de cautare pentru search(regular-expression):

  • "JavaScript".search('Script') returneaza 4, indexul unde incepe substringul.
  • "JavaScript".search('Skript') returneaza -1 (nu gaseste textul)
  • Daca vrem sa returneze true sau false in locul comportamentului implicit putem folosi simbolul standard Symbol.search (sau @@search):
// structura de date cu comportament modificat (inlocuieste RegEx)
class SearchThis {
    constructor(pattern) {
        this.pattern = pattern;
    }
    [Symbol.search](text) {
        return text.indexOf(this.pattern) === -1 ? false : true;
    }
}

let text1 = new SearchThis('Script');
let text2 = new SearchThis('Skript');

"JavaScript".search(text1); // true
"JavaScript".search(text2); // false


  • deci JavaScript ne-a oferit un hook prin care putem modifica comportamentul limbajului

Putem sa facem obiecte iterabile. Daca JavaScript gaseste Symbol.iterable implementat, stie ca acel obiect poate fi parcurs ca o colectie.


Mai multe exemple cu symbol si iteratori

Sa rulam urmatorul cod:


let a = [1,20,5,10];

let it = a[Symbol.iterator]();

it.next();      // {value:  1, done: false}
it.next();      // {value: 20, done: false}  
it.next();      // {value:  5, done: false}
it.next();      // {value: 10, done: false}
it.next();      // {value: undefined, done: true}

Deci un array este o structura de date iterabila.

Daca vrem ca o structura de date sa fie iterabila implementam o metoda care are cheia Symbol.iterator. - adica numele proprietatii (numele acestei metode) este un symbol (pentru obiecte iterabile, numele este Symbol.iterator) - aceasta metoda este un factory de iterators - va crea iteratori

Chiar si stringurile sunt iterabile, adica implementeaza aceste fabrici de iteratori:


let text = 'Un text';

let it = text[Symbol.iterator]();

it.next();  // {value: "U", done: false}
it.next();  // {value: "n", done: false}
it.next();  // {value: " ", done: false}
it.next();  // {value: "t", done: false}
it.next();  // {value: "e", done: false}
it.next();  // {value: "x", done: false}
it.next();  // {value: "t", done: false}
it.next();  // {value: undefined, done: true}

Sa le (re)luam acum pe rand:


Iterabil (Iterable)

O structura este iterabila daca permite utilizarea datelor ei printr-o anumita conventie. Conventia presupune implementarea unei metode denumita Symbol.iterator.

Invocarea metodei returneaza un obiect special Iterator.

Daca un obiect are nevoie sa fie iterat, de exemplu atunci cand folosim instructiunea for..of, este invocata metoda [Symbol.iterator] iar rezultatul este un Iterator (adica un obiect care are functia next())


Iterator

Utilizarea obiectelor iterator este noua pentru JavaScript, si aceste obiecte sunt un nou mod de a accesa elementele unei colectii.

In ES5 eram obisnuiti cu for, while, do while.

In ES6 putem folosi for .. of in locul unui for. Este mult mai simplu si mai clar; si nu mai trebuie sa cream si sa incrementam o variabila de tip contor (var i=0, i++).

for .. of utilizeaza intern un iterabil.

Surse de date Interfata/Protocol Consumatori
Array for .. of
String ... (spread)
Map >> Iterable >> Promise.all()/race()
Set Map/Set contructor
Array Array.from()
arguments

Un iterator este un obiect care implementeaza metoda next() (adica implementeaza protocolul Iterator).

Apelarea metodei next() returneaza un obiect care urmeaza si el o conventie (iteratorResult). Are doua proprietati: value si done(boolean).


{ value: 100, done: false }
{ value: undefined, done: true }

value este valoarea iteratiei curente. De fiecare data cand apelam next() se returneaza o noua valoare din colectie.


Symbols

O noua primitiva in ES6. Nu are forma literala. Deci o cream doar prin intermediul functiei Symbol().

Nu este o functie constructor. Nu putem avea new Symbol().

Inainte sa vedem la ce sunt bune aceste variabile de tipul symbol, sa vedem cateva caracteristici interesante:


let a = Symbol();
let b = Symbol();

console.log( a === b );     // false

console.log( Symbol() === Symbol() );   // false

Symbol() accepta un parametru de tipul string:


let a = Symbol('my desc');
let b = Symbol('my desc');

console.log( a === b );     // false

Parametrul lui Symbol este util doar pentru debugging. Modul in care functioneaza nu este influentat de acest parametru.

Am vazut mai sus ca o variabila de tipul Symbol() este intotdeauna diferita de alta variabila de tipul Symbol(). De fapt, crearea unei variabile de tipul Symbol ne garanteaza ca va fi diferita de orice alta variabila sau denumire de proprietate.

Practic, crearea unei variabile de acest tip este echivalenta cu crearea de valori unice. Asta este si principala ei motivatie, sa avitam coliziunile.

Sa vedem urmatorul exemplu:


console.log( Symbol('desc') === Symbol('desc') );                       // false

console.log( Symbol('desc').toString() === Symbol('desc').toString() ); // true

In al doilea caz se compara string-urile "Symbol(desc)" === "Symbol(desc)".

Nu exista conversie automata la string cum exista la alte tipuri de date:


let a = "Text " + undefined;    // "Text undefined"
let b = "Text " + null;         // "Text null"
let c = "Text " + 100;          // "Text 100"
let d = "Text " + true;         // "Text true"

let e = "Text " + Symbol('desc');
// TypeError: Cannot convert a Symbol value to a string

// trebuie sa fim expliciti cu conversia la string
let f = "Text " + Symbol('desc').toString('desc');      // "Text Symbol(desc)"

Verificarea tipului de date pentru un simbol:


let a = Symbol('desc a');

console.log(typeof a);  // symbol


Registrul global al simbolurilor

Exista un registry unde se stocheaza aceste simboluri.

In principal este utilizat intern, insa avem la dispozitie doua metode prin care putem comunica cu acest registru.

Daca vrem sa punem la dispozitie global un symbol, trebuie sa utilizam functia Symbol.for() pentru crearea unui simbol, in loc de apelul functiei Symbol():


let globalSymbol = Symbol.for('gSymbol');

Symbol.for('gSymbol') va cauta prima data un symbol cu cheia gSymbol. Daca nu o gaseste o va crea, iar daca exista o utilizeaza.

Sa verificam cu urmatorul cod:


let globalSymbol = Symbol.for('gSymbol');

let myCustomSymbol = Symbol.for('gSymbol');

console.log( globalSymbol === myCustomSymbol );     // true - e acelasi simbol

Daca vrem sa aflam cheia din registru a unei variabile symbol:


console.log( Symbol.keyFor(myCustomSymbol) );   // gSymbol
console.log( Symbol.keyFor(globalSymbol) );     // gSymbol

let symbolNedefinitInRegistrulGlobal = Symbol('symbol personal');
console.log( Symbol.keyFor(symbolNedefinitInRegistrulGlobal) );     // undefined


Evitarea coliziunilor intr-un obiect folosind valori symbol

Sa presupunem ca avem deja definit urmatorul obiect in aplicatia noastra:


let object = {
    description: 'Evitam coliziunile',
    active: true
};

Acum trebuie sa-i adaugam o noua proprietate. Cu codul de mai jos garantam ca nu vom suprascrie o proprietate existenta.

let active = Symbol('active');  
object[active] = 1,

console.log( object );          // {description: "Evitam coliziunile", active: true, Symbol(active): 1}
console.log( object[active] );  // 1

// daca am pierdut variabila active nu mai putem accesa dinamic aceasta proprietate
let active2 = Symbol('active');
console.log( object[active2] ); // undefined - nu putem re-crea o valoare symbol (va fi tot timpul alta)

In exemplul de mai sus, daca suprascriem variabila active nu mai putem sa accesam proprietatea respectiva de pe obiect.

Spre deosebire de Symbol.for('active'), pentru care parametrul conteaza (‘active’ reprezinta cheia simbolului din registrul global), pentru Symbol('active') nu conteaza parametrul, este util doar in debugging sau ca descriere pentru developer (un alt fel de comentariu).


Evitarea coliziunilor intr-un obiect (global registry)

De data asta vom folosi chei pentru a crea valori symbol. In felul asta le vom putea accesa ulterior, din registrul global.


// pornim cu crearea urmatorului obiect
let object = {
    description: 'Evitam coliziunile',
    active: true
};


// trebuie sa-i adaugam o proprietate
// cu codul de mai jos garantam ca nu vom suprascrie o proprietate existenta
let active = Symbol.for('active');  // in loc de Symbol()
object[active] = 1,

console.log( object );  // {description: "Evitam coliziunile", active: true, Symbol(active): 1}
console.log( object[active] );  // 1

// putem accesa proprietatea preluand symbol-ul din registrul global
let active2 = Symbol.for('active');
console.log( object[active2] ); // 1


Built-in Symbols

Exista anumite variabile symbol built-in (well known). De exemplu, unul dintre cele mai utilizate symbol-uri este Symbol.iterator despre care am mai vorbit mai sus.

Symbol.iterator pus la dispozitie de catre JavaScript, ne ofera un id unic general valabil pe care il putem utiliza pentru a implementa structuri de date iterable.

Este ca o conventie de denumire, doar ca folosind symbol suntem siguri ca nu suprascriem niciodata o proprietate existenta. Daca am fi avut conventia de denumire, de exemplu, getIterator(), poate intr-o aplicatie ar fi existat un obiect pe care developerul ar fi pus deja metoda getIterator().

Asa ca in loc de o conventie clasica precum getIterator() s-a utilizat un symbol built-in, pus deja la dispozitie de catre limbajul JavaScript.

Daca vrem sa aflam lista acestor well known symbols putem sa rulam codul de mai jos in consola:


Object.getOwnPropertyNames(Symbol);

Rezultatul este:

["length", "name", "prototype", "for", "keyFor", "asyncIterator", "hasInstance", "isConcatSpreadable", "iterator", "match", "matchAll", "replace", "search", "species", "split", "toPrimitive", "toStringTag", "unscopables"]

Nu uitam ca fiecare dintre aceste simboluri standard pot fi accesate prin Symbol.<nume> sau mai scurt @@nume, adica Symbol.hasInstance sau @@hasInstance.

Alte exemple cu variabile built-in de tipul symbol sunt:


Symbol.hasInstance

  • putem modifica comportamentul default al lui instanceof

class MyDataStructure {

}

var arr = [];
console.log( arr instanceof MyDataStructure );   // false

Acum putem sa implementam Symbol.hasInstance. JavaScript va sti sa-l ia in seama:


class MyDataStructure {
    static [Symbol.hasInstance](a) {
        // consideram orice Array ca fiind compatibil cu structura noastra de date
        return Array.isArray(a);    
    }
}

var arr = [];
console.log( arr instanceof MyDataStructure );   // true

Deci in aplicatia noastra am putea avea o functie care sa accepte structuri de tipul MyDataStructure iar array-urile vor fi considerate a fi compatibile.


Symbol.toPrimitive

Stabileste ce se va intampla cu obiectul atunci cand trebuie sa fie convertit intr-o valoare de tip primitiva:


function Task(subject, desc, status) {
    this.subject = subject;
    this.description = desc;
    this.status = status;
}

// JavaScript furnizeaza un hint al contextului expresiei: number, string, default
// Vom folosi acest hint ca sa stim ce solutie furnizam ca rezultat al conversiei
Task.prototype[Symbol.toPrimitive] = function(hint) {
    var result;
    switch (hint) { 
        case 'string':
            result = this.subject;
            break;
        case 'number':
            result = NaN;
            break;
        case 'default':
            result = this.subject;
            break;
    }
    return result;
}

var task = new Task( 'Task One', 'more about task one', 'New' );

console.log('Subject: ' + task); // Subject: Task One
console.log(+task + 1);         // NaN

Acum sa revenim la Iteratori.


Destructurarea array-urilor si teratorii

Destructurarea array-urilor este un syntactic sugar care intern foloseste iteratori:

De exemplu, codul care destructureaza urmatorul array:


let arr = [100, 200, 300, 400];

let [a, , c, d] = arr;

// a = 100

// c = 300
// d = 400

este echivalent cu urmatorul cod, care foloseste iterator:


let arr = [100, 200, 300, 400];

let it = arr[Symbol.iterator]();

let a = it.next().value;
// a = 100

it.next().value;
// am sarit peste b - nu exista asignare

let c = it.next().value;
// c = 300

let d = it.next().value;
// d = 400



Cum cream un obiect custom iterable?

Vrem sa obtinem o structura de date complexa dar ca sa suporte iteratii precum array-urile.

Sa zicem ca avem urmatoarea structura de date:


let productsData = {

    products: {
        "categoryA" : [
            { id: 1,  name: 'One',  price: 7 }, 
            { id: 2,  name: 'Two',  price: 3 }, 
            { id: 4,  name: 'Four', price: 9 }, 
        ],
        "categoryB" : [
            { id: 3,  name: 'Three', price: 12 },
            { id: 5,  name: 'Five',  price: 1 }, 
            { id: 6,  name: 'Six',   price: 9 },
            { id: 8,  name: 'Eight', price: 16 }, 
        ],
        "categoryC" : [
            { id: 7,  name: 'Seven', price: 21 }, 
        ],
        "categoryD" : [
            { id: 9,  name: 'Nine',  price: 6 }, 
            { id: 10, name: 'Ten',   price: 5 }
        ]
    }

}

Daca vrem sa obtinem toate categoriile acestei structuri este simplu:


Object.keys( productsData.products ); 
// ["categoryA", "categoryB", "categoryC", "categoryD"]

Acum sa obtinem toate produsele din aceeasi structura de date. Vom folosi metoda .reduce() ( pentru a reduce liniile de cod ;-) ):

Pentru mai multe exemple cu reduce accesati link-ul


Object.values( productsData.products ).reduce( (acc,categoryProducts) => {
  return [...acc, ...categoryProducts]
}, [] );

Acum avem logica prin care putem gasi lista tuturor produselor. Sa implementam asta ca iterator.

Mai jos vom modifica obiectul productsData sa urmeze prototcolul (interfata) iterable. Adica sa implementam o metoda, al carei nume sa fie Symbol.iterator.

Apelul acestei functii va returna un obiect care ne va permite sa iteram asupra produselor cu iterator.next().

Un alt beneficiu al acestei implementari este ca vom putea sa rulam codul urmator:


for (let product of productsData) {
    console.log(product.name);
}

Deocamdata, daca rulam codul de mai sus avem eroarea TypeError: productsData is not iterable


Structura de date productsData o modificam astfel:


let productsData = {

    products: {
        "categoryA" : [
            ... 
        ],
        "categoryB" : [
            ... 
        ],
        "categoryC" : [
            ... 
        ],
        "categoryD" : [
            ...
        ]
    },
    [Symbol.iterator]() {       // aderam la protoculul Iterable

        // urmeaza sa dezvoltam algoritmul prin care cream secventa de produse
        // lista de produse va fi ordonata dupa id
        let allProducts = Object.values( this.products )  // un array de array-uri
            .reduce( (acc,categoryProducts) => {
                return [...acc, ...categoryProducts]
            }, [] )     // pana aici avem lista de produse nesortate
            .sort( (p1, p2) => {
                return p1.id - p2.id; // ordonare crescatoare; 0 - nu schimba ordinea, <0 - p1 e primul, >0 p2 e primul 
            } );        

        // tinem minte indexul curent
        let currentProductIndex = 0;

        // returnam un obiect de tipul `iterator` - are metoda next() 
        // next() returneaza un obiect care are proprietatile `value` si `done`
        return {
            next() {

                // cazul cand nu mai avem produse
                let nuMaiSuntProduse = !(currentProductIndex < allProducts.length);

                if (nuMaiSuntProduse) {
                    return {
                        value: undefined,
                        done: true
                    }
                }

                return {
                    value: allProducts[currentProductIndex++],
                    done: false
                }

            }
        }

    }

}

Sa facem urmatorul test:


let iterator = productsData[Symbol.iterator]();

iterator.next();    // { value: {id: 1, name: "One", price: 7}, done: false}
iterator.next();    // { value: {id: 2, name: "Two", price: 3}, done: false}
iterator.next();    // { value: {id: 3, name: "Three", price: 12}, done: false}

Un alt beneficiu este ca putem utiliza for .. of peste obiectul nostru:


for (let item of productsData) {
    console.log(item.name);
}

// One
// Two
// Three
// Four
// Five
// Six
// Seven
// Eight
// Nine
// Ten

Daca tot suntem aici putem implementa si metoda getCategories:


let productsData: {
    products: {
        ...
    },
    [System.iterator]() {
        ...
    },
    getCategories() {
        return Object.keys( this.products ); 
    }
}


Concluzie

In acest tutorial am a analizat noul tip de date primitiv symbol.

Am vazut ce inseamna un Iterator si am vazut ce presupune crearea unei structuri de date iterabile.

Am creat tipuri de date symbol in doua moduri, unul dintre ele (Symbol.for()) permite accesarea ulterioara a acestor valori dintr-un global registry.

Am vazut cateva beneficii ale valorilor de tip symbol si am utilizat well known symbols pentru a schimba comportamentul default al lui JavaScript pentru anumite structuri de date sau pentru a crea obiecte custom compatibile cu instructiunile JavaScript standard (am putut sa utilizam for..of cu obiectul nostru).

Exemplele pot continua, insa intr-un alt tutorial.

comments powered by Disqus