Генераторы и итераторы в Python
Ленивая обработка - фирменная сила Python: гигабайтный файл строка за строкой в O(1) памяти. Вопрос-детектор практики: «почему второй проход по генератору ничего не вернул» - на нём горят регулярно.
// Понимание «итератор одноразов» экономит часы отладки «куда делись данные».
Протокол итерации: что крутит for
Итерируемое отдаёт итератор через iter(); итератор выдаёт элементы через next() и бросает StopIteration в конце. Цикл for - просто сахар вокруг этого протокола.
Ключевое различие: список можно обходить сколько угодно раз (каждый for берёт свежий итератор), а сам итератор одноразов - пройден и пуст.
// Файл - сам итератор по строкам: for line in f - идиома; f.read() ради построчной обработки - загрузка всего файла в память зря.
- StopIteration
- сигнал конца итератора; for ловит его сам
Генераторы: код, замороженный между yield
Генератор - функция с yield: вызов не исполняет тело, а возвращает объект-итератор. Код бежит до первого yield только при первом next(); между шагами всё состояние замораживается.
Ленивость это O(1) память: конвейер генераторов прокачивает данные поштучно, sum(x*x for x in xs) не строит промежуточный список.
// Выбор скобок: () - генераторное выражение, лениво и одноразово; [] - list comprehension, сразу и в памяти. Критерий - нужен ли повторный доступ.
- yield from
- делегирование вложенному итерируемому изнутри генератора
itertools: конвейерная сборка
itertools - детали конвейера: chain склеивает потоки, islice режет без материализации, product заменяет вложенные циклы, tee размножает итератор (с буфером).
groupby группирует только смежные элементы - без предварительной сортировки по тому же ключу группы окажутся порваны на куски. Это ловушка №1 модуля.
// return внутри генератора завершает его: значение уедет в StopIteration, а не наружу. Для «вернуть и продолжить» есть только yield.
- groupby
- группирует смежное; требует сортировки по ключу группировки
Как отвечать: «Почему второй проход по генератору ничего не вернул?»
Потому что генератор - одноразовый итератор: элементы выданы, внутри StopIteration, и повторный обход молча даёт пустоту - без ошибки. Классика: посчитали sum по генераторному выражению, потом max по нему же, и max падает на пустом. Проверить «пуст ли» через if data тоже нельзя: истинность генератора всегда True. Нужен повторный доступ - материализую в список или пересоздаю генератор; данные большие - пересоздание дешевле памяти.
Механизм, классический сценарий бага и обе стратегии лечения с критерием выбора.
На чём валят
- −Второй проход по генератору - пусто и без ошибки; классика «куда делись данные».
- −if data: для генератора не скажет, пуст ли он - генератор всегда truthy.
- −itertools.groupby без сортировки - группы порваны на куски.
- −f.read() ради построчной обработки - файл сам итератор по строкам.
- −Генератор, замыкающий переменную цикла, все инстансы видят последнее значение.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 15, остальные разбираются в тренажёре.
- Чем yield отличается от return?A)Оператор yield немедленно и окончательно завершает функцию точно так же, как и обычный returnB)return завершает функцию; yield ставит на паузу до следующего next()C)Yield можно использовать в функции лишь один разD)Return внутри генератора отдаёт значения по одному в цикле вызова
показать ответ и разбор
+B)return завершает функцию; yield ставит на паузу до следующего next()// разбор: return завершает функцию и возвращает значение. yield приостанавливает выполнение, отдаёт значение и сохраняет состояние (локальные переменные, точку остановки) до следующего запроса next(). Поэтому функция с yield становится генератором и может продолжить с того места, где остановилась, — хоть в бесконечном цикле.
- Почему повторный цикл for по одному и тому же генератору не выдаёт ничего?A)Один и тот же генератор можно совершенно свободно проходить циклом for сколько угодно раз подрядB)Второй проход по генератору выдаёт данные в обратном порядкеC)Генератор автоматически перезапускается с самого начала при новом циклеD)Генератор одноразовый: после исчерпания второй проход по нему пуст
показать ответ и разбор
+D)Генератор одноразовый: после исчерпания второй проход по нему пуст// разбор: Генератор — одноразовый итератор: пройдя его один раз, вы исчерпали поток, и повторный for не даёт ничего (частый источник багов — «данные вдруг пропали»). Если нужен повторный проход, материализуйте в список (память!), пересоздайте генератор заново или используйте itertools.tee для ограниченного дублирования.
- Чем генераторное выражение (x for x in ...) отличается от списочного [x for x in ...]?A)Генераторное выражение возвращает готовый список, а списочное — кортежB)Оба сразу материализуют все элементы в памяти совершенно одинаковоC)Списочное строит весь список в памяти; генераторное — лениво по одномуD)Между ними нет разницы, кроме вида используемых скобок
показать ответ и разбор
+C)Списочное строит весь список в памяти; генераторное — лениво по одному// разбор: Списочное включение [ ] сразу строит весь список в памяти. Генераторное выражение ( ) возвращает ленивый итератор, отдающий элементы по одному по требованию, — память константна независимо от размера потока. Для больших или бесконечных последовательностей это критично; список берут, когда нужен повторный проход или индексация.
- Поток из миллиардов чисел, нужны первые 5, удовлетворяющих условию. Почему генератор здесь принципиально лучше списка?A)Генератор работает быстрее списка во столько раз, сколько в потоке элементов, за счёт внутреннего кэширования значенийB)Разницы по памяти между ними нет никакой: генератор всё равно материализует все миллиарды значений в оперативной памяти точно так же, как и списокC)Генератор автоматически распараллеливает обработку всего потока сразу на все доступные ядра процессора вообще без какой-либо ручной настройкиD)Генератор ленив: значения считаются по одному по требованию, миллиард в память не грузится, а после 5 находок обход можно прекратить
показать ответ и разбор
+D)Генератор ленив: значения считаются по одному по требованию, миллиард в память не грузится, а после 5 находок обход можно прекратить// разбор: Генератор вычисляет значения ЛЕНИВО — по одному, только когда их запрашивают, и не хранит всю последовательность в памяти. Для «первых 5 из миллиарда» это критично: список пришлось бы целиком построить в памяти (или это вовсе невозможно — поток бесконечен), а с генератором (+ itertools.islice / break) обрабатывается ровно столько элементов, сколько нужно, с постоянной памятью. Генератор не быстрее «в N раз», не материализует всё и не распараллеливает сам.
- Что вернёт list(range(2, 10, 3))?A)[2, 5, 8]B)[2, 5, 8, 10]C)[3, 6, 9]D)[2, 4, 6, 8]
показать ответ и разбор
+A)[2, 5, 8]// разбор: range(start, stop, step) начинает со start и идёт с шагом step до stop, не включая его: 2, 5, 8 — следующее 11 уже за границей. Невключённая правая граница — общая конвенция Python (срезы такие же): длина равна stop − start, а соседние диапазоны стыкуются без дыр и перекрытий. Off-by-one на границах — частейший бытовой баг.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.