Итераторы повзрослели, давайте относиться к ним серьёзнее
• js
Введение
Итераторы в JS с нами уже больше 10 лет, со времён ES6. Тогда же появились и функции-генераторы.
Думаю, до сих пор эта концепция не очень популярна. Однако со времён первого появления итераторы прошли большой путь. Сейчас в языке все новые возвращаемые “коллекции” значений возвращают не массив, а итератор. Итераторы обросли статичными методами, а итераторы-объекты – ворохом методов по типу .map() и .chunks(). Появились как асинхронные генераторы, так и асинхронные итераторы.
Попробуем пробежаться и просуммировать текущее состояние итераторов. Это не является доскональной базой знаний, а, скорее, “короткой шпаргалкой”. Если хочется углубиться, это можно сделать на MDN, например.
Iterator protocol и iterable protocol
Протокол итератора – не синтаксис, а соглашение об использовании. Пример несложного бесконечного итератора:
const iterator = {
next() {
return {
value: 'Hello world',
done: false,
};
},
};Объект имеет метод .next(), который при вызове возвращает объект IteratorResult с двумя полями – value и done. Первое – значение (итерации), второе – признак, что итератор закончился. На самом деле, оба поля опциональны, но обычно указывают и то и то.
Итератор – это объект, необходимый для процесса итерации, и обычно он создаётся в процессе старта итерирования. А чтобы стартовать, объект должен поддерживать протокол перебора (iterable protocol). Для этого он должен иметь специальный метод – [Symbol.iterator]():
const iterable = {
[Symbol.iterator]() {
return iterator;
},
};В случае, если что-то захочет обойти iterable, то будет проверено наличие [Symbol.iterator](). Если метод присутствует, то он будет вызван, а полученный итератор будет использован для обхода:
// for of вызывает [Symbol.iterator]()
for (const item of iterable) {
// item получаем в результате вызова .next()
console.log(item);
// Наш итератор бесконечный – вызовем вручную break
break;
}Генераторы
Вр времена появления итераторов появились и генераторы. Причём в то время ещё не было асинхронных функций с async - await, поэтому генераторы являлись единственным способом “приостановить” выполнение функции.
function* generate() {
yield 1;
yield 2;
}
for (const item of generate()) {
console.log(item);
}
// -> 1
// -> 2Генераторы отлично встраиваются в сказанное ранее и реализуют одновременно оба протокола. Функция-генератор при вызове на самом деле возвращает объект генератора, реализующий [Symbol.iterator]() и возвращающий сам себя. Дальше вступает в дело протокола итератора – и у этого объекта есть метод .next(), выполняющий следующий шаг функции-генератора.
Дополнительные методы итератора
В своё время это прошло мимо меня, но у итераторов есть не только метод .next(), но и методы .return() и .throw(). Это может быть полезно, если итерация окончена досрочно. Например, использование break в for-of под капотом вызовет метод .return():
// Одновременно и итерируемый, и итератор
const iterator = {
next() {
return {
value: 'Hello world',
done: false,
};
},
return() {
console.log('return!');
return {
value: undefined,
done: true
};
},
[Symbol.iterator]() {
return iterator;
},
};
for (const item of iterator) {
console.log(item);
break;
}
// -> Hello world
// -> return!Все методы итератора .next(), .return() и .throw() должны возвращать объект IteratorResult, который мы уже обсуждали выше.
.return() и .throw() могут быть полезны, если итератору нужно провести некие действия для “очистки” состояния. Например, если требуется закрыть соединение по веб-сокетам.
При этом .throw() предусмотрен в протоколе, но стандартными функциями языка не вызывается – его можно вызывать (на текущий момент) только вручную.
Класс Iterator
Уже какое-то время назад появился целый класс, от которого можно наследовать свои итераторы (но никто не обязывает):
class MyIterator extends Iterator {
next() {
return {
value: 'Hello world',
done: false
};
}
}
for (const item of new MyIterator()) {
console.log(item);
break;
}
// -> Hello worldФункциональность класса примерно такая же, как и у примеров выше. Сам Iterator не реализует .next(), поэтому создавать его напрямую обычно бессмысленно.
Что же даёт нам наследование от Iterator? Две вещи: протокол обхода (реализацию .[Symbol.iterator](), которая возвращает инстанс класса) и методы, которые есть в прототипе класса. Например:
[...new MyIterator().take(2)]
// -> ['Hello world', 'Hello world']И да, у нас появилось много новых методов итераторов! Посмотрим их чуть ближе.
take и drop
Метод .take(n) создаёт новый итератор, который берёт из исходного итератора не больше n элементов (меньше – если исходный итератор закончится раньше), а затем завершает цепочку.
.drop(n) возвращает новый итератор, который “пропускает” n элементов в исходном итераторе, а дальше возвращает элементы, как есть.
Вместе всё это очень похоже на .slice() у массива – .drop() позволяет отрезать начало, а .take() – взять сколько-то элементов.
toArray
Метод .toArray() собирает все элементы в массив. В моём понимании, разница между [...iterator] и iterator.toArray() не очень большая, но метод есть, и он может обладать большей читаемостью, например.
filter, map, find и другие
Вы всё правильно поняли – в итераторы добавили привычные методы из массивов:
everyfilterfindflatMapforEachmapreducesome
Например, можно использовать .find() для поиска элемента точно также, как это делается в массиве:
new Set([1, 2, 3]).values().find(it => it % 2 === 0)
// -> 2values возвращает итератор, а find перебирает элементы, пока не найдёт подходящий.
includes и join
Ещё два метода массивов только-только добавляют, и с поддержкой у них хуже.
Использование у них вполне ожидаемое:
new Set([1, 2, 3]).values().join(', ')
// -> "1, 2, 3"chunks и windows
Два следующих метода не имеют аналогов в массивах. .chunks(chunkSize) позволяет разбить исходный итератор на “подмассивы” длинной chunkSize (не на итераторы!). Это может пригодиться в потоковой обработке или выводе.
[...new Set([1, 2, 3, 4, 5]).values().chunks(2)]
// -> [[1, 2], [3, 4], [5]].windows(windowSize) позволяет сделать сдвигающееся “окно” по исходным элементам. На первой итерации получили массив из windowSize элементов, затем сдвинули на один и так далее:
[...new Set([1, 2, 3, 4, 5]).values().windows(2)]
[[1, 2], [2, 3], [3, 4], [4, 5]]В значительной части случаев может оказаться, что выполненная “вручную” аналогичная операция будет быстрее, поэтому будьте внимательны.
Статические методы
Как мы уже выяснили, есть протокол итератора с его методом .next(), а есть методы-хелперы. И объект может выполнять протокол итератора, но не иметь соответствующих хелперов. Чтобы это исправить, можно использовать статический метод Iterator.from(...): если объект уже обладает нужной цепочкой прототипов, то он же и вернётся, а если нет, то вернётся обёртка, которая “улучшит” исходный итератор.
Ещё один статичный метод – Iterator.concat(a, b, ...). Он позволяет “склеить” несколько итераторов. Когда закончатся элементы в первом, то пойдут элементы из второго, когда закончатся во втором, пойдут из третьего и так далее.
Iterator.zip([a, b, ...]) позволяет “соединить” элементы нескольких итераторов между собой. Собственно, название происходит от “молния” (zipper), как на одежде. Молния соединяет две половны так, что каждый элемент становится поочерёдно. Тут тоже самое, только может быть больше 2х соединяемых частей.
new Map(Iterator.zip([Iterator.from('abc'), Iterator.from('xyz')]))
// -> Map {"a" => "x", "b" => "y", "c" => "z"}Iterator.zipKeyed(object) делает примерно тоже самое, только принимает на вход объект со значениями, а не сами значения:
[...Iterator.zipKeyed({from: Iterator.from('abc'), to: Iterator.from('xyz')})]
// -> [{from: "a", to: "x"}, {from: "b", to: "y"}, {from: "c", to: "z"}]Привычки
Думаю, многие, встречая итераторы сейчас, просто превращают их в массив и всё:
[...new URLSearchParams(location.search).keys()]
// -> ["a", "b", c"]Возможно, со всеми хелперами настало время перестать так делать и нам нужны новые привычки?
Зачем нужны итераторы вместо массивов? Примерно затем же, зачем нужны и потоки (stream): мы можем начать обрабатывать данные раньше, не выделять на них всю память разом, и можем оборвать обработку в произвольный момент.
Предлагаю в следующий раз, когда потянется рука превратить итератор в массив, попробовать не делать этого. Если поддержка окружения позволяет, конечно же (в случае Node.js вполне актуально).
Только нужно быть осторожным с модификацией коллекций во время итераций. В каких-то случаях всё может быть хорошо (например, в случае модификации Map), а в каких-то может породить сложнонаходимые баги.
Итого
Я опустил некоторые дополнительные параметры методов, и вообще не раскрыл тему с асинхронными итераторами. Простите.
Но, надеюсь, кого-нибудь завлёк на более полное изучение. А может быть, кому-то просто пригодится подобный функционал, и он вспомнит, что такое в принципе есть.
В любом случае, итераторы активно развиваются, новые методы подтягиваются и совсем скоро будут во всех браузерах. Останется подтянуться нам и начать всё это использовать)
Обсудить в Telegram



