[Из песочницы] Эффектное программирование. Часть 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 и т. д.).