Эффектное программирование. Часть 1: итераторы и генераторы

от автора

Javascript на данный момент является самым популярным языком программирования по версиям многих площадок (например Github). Является ли при этом он самым продвинутым или самым любимым языком? В нём отсутствуют конструкции, которые для других языков являются неотъемлемыми частями: обширная стандартная библиотека, иммутабильность, макросы. Но в нём есть одна деталь, которая не получает, на мой взгляд, достаточно внимания — генераторы.

Далее читателю предложена статья, которая, в случае положительного отклика, может перерасти в цикл. В случае успешного написания мной этого цикла, а Читателем его успешного освоения, про следующий код будет понятно не только то, что он делает, но и как устроен под капотом:

while (true) {     const data = yield getNextChunk(); // вызов асинхронной логики     const processed = processData(data);     try {         yield sendProcessedData(processed);         showOkResult();     } catch (err) {         showError();     } } 

Это первая, пилотная часть: Итераторы и Генераторы.

Итераторы

Итак, итератор — это интерфейс, предоставляющий последовательный доступ к данным.

Как видно, в определении ничего не сказано о структурах данных или памяти. Действительно, последовательность undefined-ов может быть представлена в виде итератора, при этом не занимать места в памяти.

Предлагаю читателю ответить на вопрос: является ли массив итератором?

Ответ

Является. Методы shift и pop отлично позволяют работать с массивом как с итератором.

Зачем же тогда нужны итераторы, если массив, одна из базовых структур языка, позволяет работать с данными и последовательно, и в произвольном порядке?

Представим, что нам нужен итератор, который реализует последовательность натуральных чисел. Или чисел Фибоначчи. Или любую другую бесконечную последовательность. В массиве сложно разместить бесконечную последовательность, понадобится механизм постепенного наполнения массива данными, а также изъятия старых данных, чтобы не заполнить всю память процесса. Это излишнее усложнение, которое несёт с собой дополнительную сложность реализации и поддержки, при том, что решение без массива может уместиться в несколько строчек:

const getNaturalRow = () => {     let current = 0;     return () => ++current; }; 

Также итератором можно представить получение данных из внешнего канала, например websocket.

В javascript итератором является любой объект, у которого есть метод next(), который возвращает структуру с полями value — текущее значение итератора и done — флагом, указывающим на завершение последовательности (эта договорённость описана в стандарте языка ECMAScript). Такой объект реализует интерфейс Iterator. Перепишем прошлый пример в этом формате:

const getNaturalRow = () => ({     _current: 0,     next() { return {         value: ++this._current,         done: false,     }}, }); 

В javascript также есть интерфейс Iterable — это объект, который имеет метод @@iterator (данная константа доступна как Symbol.iterator), который возвращает итератор. Для объектов, реализующих такой интерфейс, доступен обход с помощью оператора for..of. Перепишем наш пример ещё раз, только в этот раз как реализацию Iterable:

const naturalRowIterator = {     [Symbol.iterator]: () => ({         _current: 0,         next() { return {             value: ++this._current,             done: this._current > 3,        }},    }), }  for (num of naturalRowIterator) {     console.log(num); } // Вывод: 1, 2, 3 

Как можно видеть, нам пришлось сделать так, чтобы флаг done в какой-то момент стал положительным, иначе бы цикл был бесконечным.

Генераторы

Следующим этапом эволюции итераторов стали генераторы. Они предоставляют синтаксический сахар, позволяющий возвращать значения итератора будто значение функции. Генератор — это функция (объявляется со звёздочкой: function*), возвращающая итератор. При этом итератор не возвращается явно, в функции лишь возвращаются значения итератора с помощью оператора yield. Когда функция заканчивает своё выполнение, итератор считается завершённым (результаты последующих вызовов метода next будут иметь флаг done равным true)

function* naturalRowGenerator() {     let current = 1;     while (current <= 3) {         yield current;         current++;     } }  for (num of naturalRowGenerator()) {     console.log(num); } // Вывод: 1, 2, 3 

Уже в этом простом примере невооружённым глазом виден главный нюанс генераторов: код внутри функции генератора не выполняется синхронно. Выполнение кода генератора происходит поэтапно, в результате вызовов next() у соответствующего итератора. Рассмотрим, как выполняется код генератора на прошлом примере. Специальным курсором будем отмечать, где остановилось выполнение генератора.

В момент вызова naturalRowGenerator создаётся итератор.

function* naturalRowGenerator() {     ▷let current = 1;     while (current <= 3) {         yield current;         current++;     } } 

Далее, когда мы первые три раза вызываем метод next или, в нашем случае, проходим итерации цикла, курсор встаёт после оператора yield.

function* naturalRowGenerator() {     let current = 1;     while (current <= 3) {         yield current; ▷         current++;     } } 

И на все последующие вызовы next и после выхода из цикла генератор завершает своё выполнение и, результатами вызова next будет { value: undefined, done: true }

Передача параметров в итератор

Представим, что в наш итератор натуральных чисел нужно добавить возможность сбрасывать текущий счётчик и начинать отчёт с начала.

naturalRowIterator.next() // 1 naturalRowIterator.next() // 2 naturalRowIterator.next(true) // 1 naturalRowIterator.next() // 2 

Понятно как обработать такой параметр в самописном итераторе, но как быть с генераторами?
Оказывается, генераторы поддерживают передачу параметров!

function* naturalRowGenerator() {     let current = 1;     while (true) {         const reset = yield current;         if (reset) {           current = 1;         } else {           current++;         }     } } 

Переданный параметр становится доступен как результат оператора yield. Попробуем добавить ясности с помощью подхода с курсором. В момент создания итератора ничего не поменялось. Далее следует первый вызов метода next():

function* naturalRowGenerator() {     let current = 1;     while (true) {         const reset = ▷yield current;         if (reset) {           current = 1;         } else {           current++;         }     } } 

Курсор замер на моменте возврата из оператора yield. При следующем вызове next, переданное в функцию значение установит значение переменной reset. Куда же попадёт значение, переданное в самый первый вызов next, ведь там же ещё не было вызова yield? Никуда! Растворится в просторах garbage collector-а. Если нужно передать какое-то начальное значение в генератор, то это можно сделать с помощью аргументов самого генератора. Пример:

function* naturalRowGenerator(start = 1) {     let current = start;     while (true) {         const reset = yield current;         if (reset) {           current = start;         } else {           current++;         }     } }  const iterator = naturalRowGenerator(10); iterator.next() // 10 iterator.next() // 11 iterator.next(true) // 10 

Заключение

Мы рассмотрели концепцию итераторов и её реализацию в языке javascript. Также мы изучили генераторы — синтаксическую конструкцию для удобной реализации итераторов.

Хотя в данной статье я приводил примеры с числовыми последовательностями, итераторы в javascript позволяют решить намного больше задач. С помощью них можно представить любую последовательность данных и даже многие конечные автоматы. В следующей статье я хотел бы рассказать о том, как можно использовать генераторы для построения асинхронных процессов (coroutines, goroutines, csp и т. д.).

ссылка на оригинал статьи https://habr.com/ru/post/522864/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *