Назад в Центр знаний
Отпечатки

Ленивые и одноразовые помощники итератора: take и drop

Один зонд по помощникам итератора: когда вытягивается первое значение, почему результат нельзя пройти дважды и как take останавливает то, что drop перебирает.

BotBrowser Team

Документация

Нужна структурированная документация по теме Отпечатки?

Эта статья относится к редакционной библиотеке. Для пошаговой настройки, справки и постоянных обновлений переходите сразу в соответствующий раздел docs.

BotBrowser позволяет воспроизвести авторизованный фикстур того же источника под объявленным профилем и сохранить ограниченную запись того, что страница реально вытянула из потока, но он не может заставить уже израсходованный итератор выдать значения второй раз, не может превратить take в вычерпывание и не может помешать drop пройти значения, которые он пропускает. Этот разбор запускает один зонд по четырнадцати фиксированным случаям и фиксирует, когда каждый помощник вытягивает первое значение, почему результат помощника нельзя пройти дважды, тогда как исходный массив можно, насколько take и drop продвигают источник, что оставляет помощник, израсходованный наполовину, и как выброс внутри источника проходит через цепочку.

Кратко

  • Помощник ленив: после source().map(...) зонд печатает lazy-before -> pulled=0, и только первый next() вытягивает одно значение (lazy-after-one -> first=10 pulled=1).
  • Помощник возвращает итератор, а не массив: зонд печатает type -> isArray=false isIterator=true.
  • Помощник одноразовый: разворот дважды печатает consume-twice -> first=2,4,6 second=, тогда как исходный массив печатает array-reiterable -> a=1,2,3 b=1,2,3.
  • take(2) рано останавливает источник, поэтому зонд печатает take -> values=1,2 pulled=1,2 и никогда не вытягивает значения с третьего по пятое.
  • drop(3) не вычерпывает источник заранее, но его первый next() проходит три пропущенных значения: drop-after-one -> value=4 pulled=1,2,3,4.
  • flatMap требует итерируемый результат; возврат числа выбрасывает TypeError.
  • find и some перестают вытягивать, как только ответ известен, а reduce вычерпывает весь источник.
  • Выброс внутри источника доходит до потребителя после уже выданных значений, и цепочка там заканчивается.

Содержание

Первая панель показывает отображённый итератор, который ничего не вытянул до первого next и одно значение после. Вторая показывает помощник, сообщающий isArray=false и isIterator=true, второй spread, вернувший пусто, и исходный массив, который всё ещё разворачивается. Третья показывает take(2), останавливающийся на двух вытягиваниях, тогда как drop(3) вытягивает четыре значения, чтобы вернуть четвёртое. Четвёртая показывает Iterator.from, оборачивающий собственный итерируемый объект, flatMap, требующий итерируемого результата, и выброс, доходящий до потребителя после уже увиденных значений.

Когда помощник вытягивает первое значение

Помощник итератора это обёртка, которая производит значения по требованию, и весь смысл в том, что ничего не выполняется, пока потребитель не попросит. Зонд строит генератор, который записывает каждое выданное значение, оборачивает его в map и проверяет счётчик до того, как что-либо попросит значение:

node=v26.9.0
lazy-before -> pulled=0
lazy-after-one -> first=10 pulled=1
type -> isArray=false isIterator=true
consume-twice -> first=2,4,6 second=
array-reiterable -> a=1,2,3 b=1,2,3
helper-reiterable -> a=2,3,4 b=
take -> values=1,2 pulled=1,2
take-stopped -> pulledCount=2
drop -> values=4,5 pulled=1,2,3,4,5
drop-before-next -> pulled=0
drop-after-one -> value=4 pulled=1,2,3,4
partial -> head=1 rest=4,9,16
flatMap -> 1,10,2,20
flatMap-non-iterable -> TypeError
find -> value=2 pulled=1,2
some -> result=true pulled=1,2,3
reduce -> total=6 pulled=1,2,3
exhausted-source -> length=0
thrown -> seen=2 error=Error:source failed
from-before -> pulled=0 hasMap=true
from-take -> values=1,2,3 pulled=1,2,3
toArray -> 3,6,9 thenNext=true
break -> seen=1,2 nextDone=true

Первые две строки это определение устройства. lazy-before -> pulled=0 значит, что вызов map не сделал работы: он привязал обратный вызов к источнику и вернул управление. lazy-after-one -> first=10 pulled=1 показывает, что первый next() вытянул ровно одно значение из генератора, выполнил на нём обратный вызов и вернул отображённый результат. Цепочка помощников остаётся ленивой так же, потому что каждое звено вытягивает из предыдущего только когда потребитель вытягивает из цепочки.

Второй вход стоит упомянуть, потому что он меняет то, что помощник может обернуть. Iterator.from принимает любой итерируемый объект или итератор и возвращает итератор, несущий помощники, то есть объект, который реализует только Symbol.iterator, может получить те же помощники, что нативный итератор. Все случаи зонда начинаются с генератора или массива, и оба уже несут помощники, поэтому ценность Iterator.from видна на собственном итерируемом объекте, например курсоре по результату базы данных. Страница, которая оборачивает свой курсор в Iterator.from, может затем ограничить его через take без ручного цикла, и та же лень применяется: из курсора ничего не читается до первого вытягивания. Зонд печатает from-before -> pulled=0 hasMap=true, затем оборачивает собственный итерируемый объект из пяти значений в take(3) и печатает from-take -> values=1,2,3 pulled=1,2,3, то есть курсор продвинулся ровно три раза.

Это важно для страницы, которая читает из большого или неограниченного источника. map по генератору, который занял бы минуты, ничего не стоит построить, а take(10) дальше в цепочке делает так, что производится только десять значений. Альтернатива, промежуточный массив, заставляет весь источник выполниться до того, как доступно первое значение, что и есть разница между потоком, который начинает отдавать за долю секунды, и потоком, который блокирует до конца.

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

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

Почему результат одноразовый

Вторая половина устройства в том, что помощник возвращает итератор, а не коллекцию, и зонд показывает точное следствие. Он печатает type -> isArray=false isIterator=true, затем разворачивает один и тот же помощник дважды и получает consume-twice -> first=2,4,6 second=.

Первый разворот прошёл итератор до конца, а второй нашёл его исчерпанным и не произвёл ничего, без ошибки. Это поведение каждого итератора в языке, и помощник его наследует, потому что построен на протоколе итерации, а не на копии. Сравните с исходным массивом в следующей строке: array-reiterable -> a=1,2,3 b=1,2,3, потому что array.values() возвращает новый итератор при каждом вызове.

Следующая строка это острый конец того же правила. helper-reiterable -> a=2,3,4 b= показывает, что сам помощник не повторяем: он хранит единственный итератор, с которым построен, первый разворот его израсходовал, а второй не увидел ничего. Это ошибка, которая превращает ленивую цепочку в тихий пустой результат. Функция, которая возвращает помощник и вызывается дважды, получает мёртвый итератор на втором вызове, и отказ это пустая последовательность, а не исключение.

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

Недозаполненная пачка вытянутых значений имеет ту же проблему формы, что разреженный массив, где отсутствующий индекс и сохранённый undefined читаются одинаково, но итерируются по-разному. Частичное потребление помощника это мягкая версия того же правила. Зонд вытягивает одно значение, затем разворачивает остаток и печатает partial -> head=1 rest=4,9,16 для map, который возводит вход в квадрат. Единственный next() вернул 1 (квадрат 1), а следующий разворот продолжил с места, где остановилось вытягивание, вернув 4, 9, 16 для входов 2, 3, 4. Ничего не потерялось и не повторилось, потому что итератор помнит свою позицию; второй разворот вернул бы пусто. Терминальный помощник завершает итератор так же, как разворот: зонд печатает toArray -> 3,6,9 thenNext=true, то есть после того как toArray собрал все значения, следующий next() сообщил о завершении.

Источник, уже исчерпанный до построения помощника, это тихий случай. Зонд вычерпывает итератор массива, затем строит map по тому же объекту и разворачивает его, печатая exhausted-source -> length=0. Нет ни ошибки, ни предупреждения: помощник построен по итератору, который уже сообщил о завершении, поэтому каждое последующее вытягивание немедленно возвращает завершение. Страница, которая кеширует итератор и строит по нему помощник позже, получает пустой результат всякий раз, когда что-то другое израсходовало итератор между делом, и исправление то же, что для двойного разворота: строить помощник из источника, который может дать новый итератор каждый раз.

Цикл, который останавливается рано, ведёт себя наоборот тому, что подсказывает модель позиции. Зонд выполняет for...of по помощнику, прерывается на значении 2 и затем просит ещё одно значение: он печатает break -> seen=1,2 nextDone=true. То есть break не паркует итератор для позднего чтения; выход из цикла его закрывает, и значения после break пропадают. Потребитель, который хочет прочитать заголовок, остановиться и продолжить с тела, поэтому не может использовать break внутри for...of по одному итератору; он должен вытягивать через next(), чтобы итератор не закрылся, или построить новый итератор по источнику.

Это важно прежде всего по той причине, ради которой существует всё устройство: ограничить стоимость. Break это единственный ранний выход, который отбрасывает остаток, поэтому потребитель, который читает до условия и уходит, платит только за прочитанное, но больше ничего из этого итератора не получает. Форма через вытягивание держит возможность открытой, а форма с take ограничивает чтение, вообще не нуждаясь в break, поэтому цепочка, которая составляет take, это версия, которая останавливается чисто и не оставляет работу недоделанной.

Насколько take и drop продвигают источник

take и drop решают, сколько источника видит потребитель, и зонд показывает, что продвигают они его противоположно. take рано останавливается, а drop проходит пропускаемые значения.

Случай take печатает take -> values=1,2 pulled=1,2, затем take-stopped -> pulledCount=2. Источник производил пять значений всего, и после взятия двух записанные вытягивания это ровно первые два. take(2) не читал остальные три, чтобы их отбросить; он попросил два и остановился, что и позволяет ограничить бесконечный источник. Страница, которая берёт фиксированное число элементов из неограниченного потока, поэтому платит ровно за них, и остаток потока не трогается.

Случай drop это зеркальное отражение. Жадный разворот печатает drop -> values=4,5 pulled=1,2,3,4,5, потому что разворот до конца вытянул всё, и отброшенные значения пришлось произвести, чтобы их пропустить. Более показательная строка ленивая: drop-before-next -> pulled=0 показывает, что только что созданный drop(3) ничего не вытянул при построении, а drop-after-one -> value=4 pulled=1,2,3,4 показывает, что его первый next() прошёл четыре значения, чтобы вернуть четвёртое. Значит drop ленив при построении как любой помощник, но работа, которую он откладывает, это работа пропуска, и потребитель, который просит одно значение после drop(3), уже вызвал четыре вытягивания.

Эта асимметрия решает, как ограничить поток. take это безопасный предел, потому что он никогда не читает лишнего. drop это фильтр по позиции, и drop с большим счётом по медленному источнику делает медленную работу до того, как первое значение доходит до потребителя, поэтому страница, которая составляет большой drop с take, всё равно платит за drop. Потребитель, который хочет пропустить заголовок и затем прочитать тело, поэтому платит за заголовок, как только просит первое значение тела, не до начала, но и не даром.

Взаимодействие с flatMap стоит одной строки той же записи. Зонд печатает flatMap -> 1,10,2,20 для flatMap, который возвращает два значения на вход, то есть каждое вытягивание из источника стало двумя вытягиваниями для потребителя, а выравнивание на один уровень, а не рекурсивное. flatMap, который вернул бы вложенный массив, отдал бы потребителю массив как значение, а не его элементы, что та же правило одного уровня, что у метода массива с тем же именем.

flatMap также отказывается выравнивать значение, которое не итерируемо. Попытка зонда вернуть число печатает flatMap-non-iterable -> TypeError, поэтому обратный вызов, который иногда возвращает скаляр, а иногда список, отказывает на скаляре вместо того, чтобы его пропустить. Страница, которая строит flatMap из обратного вызова, способного вернуть undefined, поэтому должна нормализовать результат к пустому массиву или массиву из одного элемента до того, как помощник его увидит, потому что помощник не воспримет неитерируемое как «нечего выравнивать».

Что делает выброс внутри источника

Итератор, который отказывает на полпути, должен что-то сделать со значениями, которые уже произвёл, и зонд фиксирует поведение. Его источник выдаёт 1, затем выбрасывает Error('source failed'), а map по нему потребляется циклом for...of:

thrown -> seen=2 error=Error:source failed

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

Ошибка не оборачивается. Зонд печатает Error:source failed, то есть тот же конструктор и сообщение, что выбросил источник, поэтому цепочка помощников передаёт выброшенное значение без изменений. Страница, которая хочет добавить контекст, должна обернуть ошибку в точке, где обрабатывает элементы, потому что помощник не добавит слой, называющий шаг цепочки, который отказал.

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

reduce по тому же источнику жаден, и это меняет, когда выброс происходит относительно вызывающего. Зонд печатает reduce -> total=6 pulled=1,2,3 для источника из трёх значений, без частичного результата на полпути, поэтому reduce, который встречает выброс, теряет накопленный аккумулятор. Помощники, которые не сворачивают, дают потребителю видеть значения по одному и действовать на каждом; reduce сворачивает их в одно значение в конце, и отказ значит, что это значение никогда не придёт. Выбор между ними поэтому это выбор о том, сколько частичного прогресса потребитель должен сохранить.

Запись одного показания помощников итератора

Показание помощников итератора подтверждает узкое утверждение: эта среда, этот источник, эти помощники дали эти вытягивания. Четыре поля держат его проверяемым:

  1. Счёт вытягиваний до первого next(), потому что лень и есть весь смысл, а построенный помощник, который уже что-то вытянул, не ленив.
  2. Держал ли потребитель массив или итератор, потому что вторая итерация по итератору возвращает пусто, а по массиву возвращает значения снова.
  3. Счёт вытягиваний на каждой остановке, потому что take и drop продвигают источник на разные величины, и только счёт показывает, на какую.
  4. Пришёл ли отказ после уже выданных значений, потому что потребитель, который хранит частичные результаты, зависит от того, что более ранние значения переживут выброс.

Публичные источники

Практический вывод

Стройте цепочку помощников и платите только за значения, которые вытягиваете: постройте цепочку, ограничьте её через take, когда источник неограничен, и помните, что drop проходит пропускаемое на первом вытягивании, а не заранее. Возвращайте функцию, которая строит цепочку, а не сохранённый итератор, когда вызывающий может итерировать дважды, потому что помощник одноразовый, а второй разворот тихо пуст. Нормализуйте результат обратного вызова flatMap к массиву, чтобы скаляр не мог выбросить, и решите, нужно ли потребителю сохранять уже обработанные значения, потому что выброс завершает итератор, а reduce теряет аккумулятор. О порядке, в котором отдаёт поток на микрозадачах, читайте микрозадачи и таймеры; о дыре, которую оставляет недозаполненная пачка, читайте дыры разреженных массивов.

#Iterator#Ленивые Вычисления#Генераторы#ECMAScript#Потоки

Эта статья входит в руководство: Что такое отпечаток браузера?

Переведите BotBrowser из исследований в продакшн

Используйте эти руководства, чтобы понять модель, а затем перейти к кроссплатформенной валидации, изолированным контекстам и масштабируемому браузерному развертыванию.