Итераторы и генераторы
Цикл for в Python не «обходит коллекцию» и ничего не знает ни про длину, ни про индексы. Он выполняет ровно один сценарий — один раз вызывает iter(obj), получает итератор, а дальше дёргает next() до тех пор, пока не прилетит StopIteration, которое молча гасит. Всё, что перебирается в Python — list, dict, файл, zip, range, генератор, — подключено к этому единственному протоколу, и своя структура данных становится пригодной для for ровно тогда, когда реализует два дандер-метода.
Из протокола следуют две вещи, вокруг которых и строятся вопросы на собеседовании. Первая — различие между итерируемым объектом и итератором. Итерируемый объект выдаёт по запросу свежий курсор, поэтому list перебирается сколько угодно раз; итератор — сам курсор — хранит позицию и исчерпывается необратимо. Вторая — генераторы, самый короткий способ написать итератор: функция с yield в теле возвращает объект-генератор, не выполнив ни строчки, а yield замораживает кадр вместе со всеми локальными переменными. Отсюда растут три классические ловушки — генератор читается один раз и на втором проходе отдаёт пустоту, (x for x in y) создаёт вовсе не кортеж, а StopIteration, поднятое внутри тела генератора, превращается в RuntimeError. Разберите механику по слоям.
Карта темы
- Итерируемый объект — что делает объект пригодным для
forи почемуlistперебирается многократно, а курсор — один раз. - Протокол итератора —
__next__,__iter__, возвращающийself,StopIterationи разворачиваниеforвiterплюс повторныеnext. - Свой итератор классом — курсор, написанный руками, разделение контейнера и курсора и цена ошибки «
__iter__возвращаетself». - Генераторные функции —
yieldв теле превращает функцию в фабрику генераторов; вызов не выполняет тело, а исчерпание необратимо. - yield и заморозка кадра — как приостанавливается кадр, что происходит с локальными переменными и что делегирует
yield from. - send, throw и close — двусторонний канал генератора,
GeneratorExitи честная финализация ресурсов. - Включения и генераторные выражения —
[...]против(...), отсутствующее «кортежное включение» и область видимости переменной цикла. - Ленивость и память — O(1) против O(n), потоки больше RAM, бесконечные последовательности и случаи, когда список лучше.
Частые ошибки и ловушки
| Ошибка | Последствие |
|---|---|
| Путать итерируемый объект с итератором | Ожидание повторного обхода там, где живёт одноразовый курсор — второй проход молча даёт пустоту |
Реализовать __next__ без __iter__ | iter() падает с TypeError «object is not iterable», хотя next() на объекте работает |
Не поднимать StopIteration в __next__ | Цикл for никогда не завершится — обход превращается в бесконечный |
| Ждать, что генератор перезапустится на втором проходе | Второй list(gen) вернёт [] — для повторного прохода нужен новый генератор или список |
Считать (x for x in y) кортежем | Это генераторное выражение — нет ни len, ни индексации; кортеж получается только через tuple(...) |
Вызвать send(x) до прайминга генератора | TypeError о попытке отправить не-None в только что созданный генератор |
Поднять StopIteration внутри тела генератора | По PEP 479 оно подменяется на RuntimeError — тихого завершения не будет, будет падение |
Вернуть self из __iter__ контейнера | Контейнер становится одноразовым — вложенный цикл по нему выдаёт обрезанный результат вместо декартова произведения |
Значение для собеседований
Тема входит в обязательный минимум для middle-разработчика, и начинают её почти всегда с определений. Просят объяснить, чем итерируемый объект отличается от итератора, — правильный ответ звучит как «итерируемый отдаёт свежий итератор из __iter__, итератор реализует __next__ и хранит позицию», и он же закрывает половину продолжений. Дальше проверяют следствие — почему __iter__ итератора возвращает self и что произойдёт с next() после первого StopIteration. Затем идёт код — свой reversed генератором и классом или разворот вложенного списка через yield from. Здесь смотрят не на алгоритм, а на протокол — есть ли __iter__, поднимается ли StopIteration, не материализуется ли втихую копия.
Вторая половина разговора — про генераторы и ленивость. Стандартный набор — что вернёт вызов генераторной функции, что напечатает list(gen) дважды, чем (...) отличается от [...] и почему генератор экономит память; ответ «O(n) против O(1)» ожидается дословно. На senior-уровне добавляют send/throw/close с обязательным упоминанием прайминга и GeneratorExit, семантику yield from вместе с возвратом значения подгенератора и откат iter() на __getitem__. Типичная ошибка на всех уровнях одна — кандидат описывает поведение («перебирает элементы»), но не может назвать вызовы, из которых оно складывается.