Polski

Kompleksowy przewodnik po zrozumieniu i implementacji protokołu iteratora w JavaScript, który pozwoli Ci tworzyć własne iteratory do zaawansowanej obsługi danych.

Demistyfikacja protokołu iteratora JavaScript i niestandardowych iteratorów

Protokół iteratora w JavaScript zapewnia standardowy sposób przechodzenia przez struktury danych. Zrozumienie tego protokołu pozwala programistom na efektywną pracę z wbudowanymi obiektami iterowalnymi, takimi jak tablice i ciągi znaków, oraz na tworzenie własnych, niestandardowych obiektów iterowalnych, dostosowanych do konkretnych struktur danych i wymagań aplikacji. Ten przewodnik zawiera kompleksowe omówienie protokołu iteratora i sposobu implementacji niestandardowych iteratorów.

Czym jest protokół iteratora?

Protokół iteratora definiuje, w jaki sposób obiekt może być iterowany, czyli jak jego elementy mogą być dostępne sekwencyjnie. Składa się on z dwóch części: protokołu Iterable (iterowalnego) i protokołu Iterator.

Protokół Iterable (Iterowalny)

Obiekt jest uważany za iterowalny (Iterable), jeśli posiada metodę z kluczem Symbol.iterator. Metoda ta musi zwracać obiekt zgodny z protokołem Iterator.

W istocie, obiekt iterowalny wie, jak stworzyć dla siebie iterator.

Protokół Iterator

Protokół Iterator definiuje, jak pobierać wartości z sekwencji. Obiekt jest uważany za iterator, jeśli posiada metodę next(), która zwraca obiekt z dwiema właściwościami:

Metoda next() jest siłą napędową protokołu iteratora. Każde wywołanie next() przesuwa iterator i zwraca następną wartość w sekwencji. Gdy wszystkie wartości zostaną zwrócone, next() zwraca obiekt z właściwością done ustawioną na true.

Wbudowane obiekty iterowalne

JavaScript dostarcza kilka wbudowanych struktur danych, które są z natury iterowalne. Należą do nich:

Te obiekty iterowalne mogą być bezpośrednio używane z pętlą for...of, składnią spread (...) i innymi konstrukcjami, które opierają się na protokole iteratora.

Przykład z tablicami:


const myArray = ["apple", "banana", "cherry"];

for (const item of myArray) {
  console.log(item); // Wynik: apple, banana, cherry
}

Przykład z ciągami znaków:


const myString = "Hello";

for (const char of myString) {
  console.log(char); // Wynik: H, e, l, l, o
}

Pętla for...of

Pętla for...of to potężna konstrukcja do iterowania po obiektach iterowalnych. Automatycznie obsługuje złożoność protokołu iteratora, ułatwiając dostęp do wartości w sekwencji.

Składnia pętli for...of wygląda następująco:


for (const element of iterable) {
  // Kod do wykonania dla każdego elementu
}

Pętla for...of pobiera iterator z obiektu iterowalnego (używając Symbol.iterator) i wielokrotnie wywołuje metodę next() iteratora, aż done stanie się true. W każdej iteracji zmienna element otrzymuje wartość właściwości value zwróconej przez next().

Tworzenie niestandardowych iteratorów

Chociaż JavaScript dostarcza wbudowane obiekty iterowalne, prawdziwa siła protokołu iteratora leży w możliwości definiowania niestandardowych iteratorów dla własnych struktur danych. Pozwala to kontrolować sposób, w jaki dane są przeglądane i dostępne.

Oto jak stworzyć niestandardowy iterator:

  1. Zdefiniuj klasę lub obiekt, który reprezentuje Twoją niestandardową strukturę danych.
  2. Zaimplementuj metodę Symbol.iterator w swojej klasie lub obiekcie. Metoda ta powinna zwracać obiekt iteratora.
  3. Obiekt iteratora musi mieć metodę next(), która zwraca obiekt z właściwościami value i done.

Przykład: Tworzenie iteratora dla prostego zakresu

Stwórzmy klasę o nazwie Range, która reprezentuje zakres liczb. Zaimplementujemy protokół iteratora, aby umożliwić iterowanie po liczbach w tym zakresie.


class Range {
  constructor(start, end) {
    this.start = start;
    this.end = end;
  }

  [Symbol.iterator]() {
    let currentValue = this.start;
    const that = this; // Przechwycenie 'this' do użycia wewnątrz obiektu iteratora

    return {
      next() {
        if (currentValue <= that.end) {
          return {
            value: currentValue++,
            done: false,
          };
        } else {
          return {
            value: undefined,
            done: true,
          };
        }
      },
    };
  }
}

const myRange = new Range(1, 5);

for (const number of myRange) {
  console.log(number); // Wynik: 1, 2, 3, 4, 5
}

Wyjaśnienie:

Przykład: Tworzenie iteratora dla listy połączonej

Rozważmy inny przykład: tworzenie iteratora dla struktury danych listy połączonej. Lista połączona to sekwencja węzłów, gdzie każdy węzeł zawiera wartość i odwołanie (wskaźnik) do następnego węzła na liście. Ostatni węzeł na liście ma odwołanie do null (lub undefined).


class LinkedListNode {
    constructor(value, next = null) {
        this.value = value;
        this.next = next;
    }
}

class LinkedList {
    constructor() {
        this.head = null;
    }

    append(value) {
        const newNode = new LinkedListNode(value);
        if (!this.head) {
            this.head = newNode;
            return;
        }

        let current = this.head;
        while (current.next) {
            current = current.next;
        }
        current.next = newNode;
    }

    [Symbol.iterator]() {
        let current = this.head;

        return {
            next() {
                if (current) {
                    const value = current.value;
                    current = current.next;
                    return {
                        value: value,
                        done: false
                    };
                } else {
                    return {
                        value: undefined,
                        done: true
                    };
                }
            }
        };
    }
}

// Przykład użycia:
const myList = new LinkedList();
myList.append("Londyn");
myList.append("Paryż");
myList.append("Tokio");

for (const city of myList) {
    console.log(city); // Wynik: Londyn, Paryż, Tokio
}

Wyjaśnienie:

Funkcje generatorów

Funkcje generatorów zapewniają bardziej zwięzły i elegancki sposób tworzenia iteratorów. Używają słowa kluczowego yield do produkowania wartości na żądanie.

Funkcję generatora definiuje się za pomocą składni function*.

Przykład: Tworzenie iteratora za pomocą funkcji generatora

Napiszmy ponownie iterator Range, używając funkcji generatora:


class Range {
  constructor(start, end) {
    this.start = start;
    this.end = end;
  }

  *[Symbol.iterator]() {
    for (let i = this.start; i <= this.end; i++) {
      yield i;
    }
  }
}

const myRange = new Range(1, 5);

for (const number of myRange) {
  console.log(number); // Wynik: 1, 2, 3, 4, 5
}

Wyjaśnienie:

Funkcje generatorów upraszczają tworzenie iteratorów, automatycznie obsługując metodę next() i flagę done.

Przykład: Generator ciągu Fibonacciego

Innym świetnym przykładem użycia funkcji generatorów jest generowanie ciągu Fibonacciego:


function* fibonacciSequence() {
  let a = 0;
  let b = 1;

  while (true) {
    yield a;
    [a, b] = [b, a + b]; // Przypisanie destrukturyzujące do jednoczesnej aktualizacji
  }
}

const fibonacci = fibonacciSequence();

for (let i = 0; i < 10; i++) {
  console.log(fibonacci.next().value); // Wynik: 0, 1, 1, 2, 3, 5, 8, 13, 21, 34
}

Wyjaśnienie:

Korzyści z używania protokołu iteratora

Zaawansowane techniki iteratorów

Łączenie iteratorów

Możesz połączyć wiele iteratorów w jeden. Jest to przydatne, gdy musisz przetwarzać dane z wielu źródeł w ujednolicony sposób.


function* combineIterators(...iterables) {
  for (const iterable of iterables) {
    for (const item of iterable) {
      yield item;
    }
  }
}

const array1 = [1, 2, 3];
const array2 = ["a", "b", "c"];
const string1 = "XYZ";

const combined = combineIterators(array1, array2, string1);

for (const value of combined) {
  console.log(value); // Wynik: 1, 2, 3, a, b, c, X, Y, Z
}

W tym przykładzie funkcja `combineIterators` przyjmuje dowolną liczbę obiektów iterowalnych jako argumenty. Iteruje po każdym z nich i zwraca (yield) każdy element. Rezultatem jest pojedynczy iterator, który produkuje wszystkie wartości ze wszystkich wejściowych obiektów iterowalnych.

Filtrowanie i transformowanie iteratorów

Możesz również tworzyć iteratory, które filtrują lub transformują wartości produkowane przez inny iterator. Pozwala to na przetwarzanie danych w potoku, stosując różne operacje do każdej wartości w miarę jej generowania.


function* filterIterator(iterable, predicate) {
  for (const item of iterable) {
    if (predicate(item)) {
      yield item;
    }
  }
}

function* mapIterator(iterable, transform) {
  for (const item of iterable) {
    yield transform(item);
    }
}

const numbers = [1, 2, 3, 4, 5, 6];

const evenNumbers = filterIterator(numbers, (x) => x % 2 === 0);
const squaredEvenNumbers = mapIterator(evenNumbers, (x) => x * x);

for (const value of squaredEvenNumbers) {
    console.log(value); // Wynik: 4, 16, 36
}

W tym przypadku `filterIterator` przyjmuje obiekt iterowalny i funkcję predykatu. Zwraca (yield) tylko te elementy, dla których predykat zwraca `true`. `mapIterator` przyjmuje obiekt iterowalny i funkcję transformującą. Zwraca (yield) wynik zastosowania funkcji transformującej do każdego elementu.

Zastosowania w świecie rzeczywistym

Protokół iteratora jest szeroko stosowany w bibliotekach i frameworkach JavaScript i jest cenny w różnych zastosowaniach w świecie rzeczywistym, zwłaszcza przy pracy z dużymi zbiorami danych lub operacjami asynchronicznymi.

Dobre praktyki

Podsumowanie

Protokół iteratora w JavaScript zapewnia potężny i elastyczny sposób przechodzenia przez struktury danych. Rozumiejąc protokoły Iterable i Iterator oraz wykorzystując funkcje generatorów, możesz tworzyć niestandardowe iteratory dostosowane do swoich specyficznych potrzeb. Pozwala to na efektywną pracę z danymi, poprawę czytelności kodu i zwiększenie wydajności aplikacji. Opanowanie iteratorów otwiera drogę do głębszego zrozumienia możliwości JavaScript i pozwala pisać bardziej elegancki i wydajny kod.