Ця тема повертає три борги одразу. У
темі 12 ми зʼясували, що for нічого не
рахує, а просить у обʼєкта ітератор, і мимохідь пообіцяли пояснити, чому цей ітератор
одноразовий. У темі 13 заміна квадратних
дужок на круглі раптом дала якийсь generator object, і ми сказали
«деталі — у темі 30». У темі 27 ми навчили
власний клас працювати в for через __iter__ — і зупинились рівно
там, де починається найцікавіше. Тепер усі три нитки сходяться в одну.
Попередня тема, анотації типів, була про те, як описати дані. Ця — про те, звідки вони беруться: не всі відразу, а по одному, рівно тоді, коли знадобились.
Наскрізний приклад тут один — журнал продажів. Той самий, що в темах 25-28, тільки тепер його не три рядки, а мільйон, і в памʼять він цілком не влазить. Задача проста: знайти перші три дорогі продажі. Ми розвʼяжемо її двічі й порівняємо не стиль, а байти.
01 / РізницяІтерований і ітератор — це різне
Почнемо з досліду, який виглядає як помилка інтерпретатора. Ось список, і його можна обходити скільки завгодно разів:
А ось майже те саме, тільки джерело — zip:
Другий цикл не падає з помилкою. Він просто не робить жодного кроку — так, ніби
пари порожні. Причина в тому, що товари й пари —
обʼєкти двох різних сортів, хоч обидва працюють у for.
Ітерований обʼєкт (iterable) — це той, у кого можна
попросити обхідник. Список, кортеж, рядок, словник, множина,
range, файл. Сам він нічого не памʼятає: він просто вміє на вимогу видати
новий обхідник, який почне з початку.
Ітератор (iterator) — це і є обхідник. Крихітний обʼєкт з єдиним умінням: віддати наступний елемент і запамʼятати, де він зупинився. Позиція всередині нього тільки росте; повернутись на початок він не вміє принципово, бо в нього немає для цього ані методу, ані потреби.
Різницю видно з двох рядків. Функція iter() просить обхідник, і
відповідь залежить від того, кого просиш:
Ось і вся розгадка. Коли for бере список, він щоразу отримує новий
обхідник із позицією нуль — тому обходів може бути скільки завгодно. Коли
for бере zip, він отримує той самий обхідник, у якого
позиція вже в кінці, — тому другий цикл завершується, не почавшись.
for», а «це джерело чи вже обхід». Список, кортеж, множина,
словник, range — джерела, їх можна обходити повторно. zip,
enumerate, map, filter, відкритий файл і все, що
ми напишемо в цій темі, — обходи, вони одноразові.02 / ПротоколПротокол: iter, next, кінець
Усередині цієї домовленості лише дві функції й один виняток. Розберемо їх по черзі на нашому списку товарів.
iter(x) викликає в обʼєкта метод __iter__ і отримує
ітератор. Для списку це list_iterator — усередині нього лише посилання
на список і число позиції.
next(обхідник) викликає __next__: віддає наступний
елемент і зсуває позицію на одиницю. Це буквально те, що робить for на
кожному повторенні.
Коли елементи скінчились, ітератор кидає StopIteration. Це не
поломка, а умовний сигнал: цикл for ловить його й тихо
завершується. Побачити його «голим» можна, лише покликавши next()
руками.
Виняток замість повернутого значення — рішення не випадкове. Якби кінець позначався
якимось особливим значенням (скажімо, None), то список
[1, None, 3] обірвався б посередині. Виняток не можна сплутати з даними,
бо він приходить не по тому каналу.
Схема 1 · Протокол перебору цілком
Два виклики й один виняток. Праворуч — те, у що for розгортається насправді.
iter() щоразу створює новий обхідник, а ітератор віддає самого себе —
саме тому його можна підставити просто в for і саме тому повторний
обхід не починається спочатку.Оскільки next() — звичайна функція, у неї є звичайний другий аргумент:
значення, яке повернути замість винятку. Це часто зручніше за try:
03 / РукамиСвій ітератор руками
У темі 27 ми зробили клас ітерованим
одним рядком: __iter__ віддавав iter(self.позиції), тобто
перекладав роботу на внутрішній список. Тепер напишемо ітератор самі, без списка
всередині — щоб побачити механіку зсередини й зрозуміти, від чого нас далі
позбавить yield.
Завдання навмисне просте: віддавати квадрати чисел від нуля до межі.
Працює саме так, як обіцяно, і в for, і з next():
Тут варто зупинитись на трьох речах, бо вони пояснюють усе подальше.
Стан довелося зберігати руками. Поле self.номер існує
лише тому, що між двома викликами __next__ обʼєкт має щось памʼятати.
Метод виконується від початку до кінця й помирає; єдине, що переживає виклик, — поля.
__iter__ повертає self. Це те саме
iter(обхідник) is обхідник зі схеми вище — і саме воно робить наш клас
одноразовим: другий for отримає той самий обʼєкт із
номер == межа й одразу почує StopIteration.
Тринадцять рядків заради чотирьох чисел. І це найпростіший можливий ітератор. Спробуй уявити те саме для «читати файл, розбирати рядок, пропускати порожні» — стан розповзеться на пʼять полів, а логіка перемішається з бухгалтерією позицій. Саме через це в мові й зʼявився окремий інструмент.
04 / yieldФункція, яка вміє спати
Той самий ітератор, записаний по-іншому:
Чотири рядки замість тринадцяти, жодного self, жодного
raise StopIteration. Функція, у тілі якої є слово yield,
називається генераторною функцією (generator function), і поводиться
вона зовсім не так, як звичайна.
Виклик не виконує тіло. Це найдивніше й найважливіше. Ось доказ:
Виклик квадрати(3) лише створює обʼєкт-генератор:
запамʼятовує функцію, запамʼятовує аргументи й зупиняється. Тіло не виконало жодної
інструкції — рядок print мовчить. Перший next() запускає тіло
й доводить його до першого yield.
yield віддає значення й заморожує функцію. На відміну
від return, який завершує роботу назавжди, yield ставить її
на паузу: кадр функції (frame) — тобто всі локальні змінні й точка, де ми зупинились —
лишається жити всередині обʼєкта-генератора. Наступний next() не починає
спочатку, а продовжує з наступного рядка після yield.
Коли тіло дійде до кінця (або натрапить на return), генератор сам кине
StopIteration. Писати це руками не треба — і забути теж не вийде.
Інтерактив 1 · Що робить кожен виклик next()
Тисни «наступний крок» і дивись, звідки функція продовжує роботу й що памʼятає між викликами.
yield. Підсвічений
рядок починається не з початку функції, а з номер += 1 — тобто рівно
з місця, де вона заснула. Змінна номер при цьому не обнулилась: вона
пережила паузу разом з усім кадром. Перемкнись на return — і побачиш
протилежне: одне натискання виконує все тіло, повертає готовий список і функція
зникає без сліду.gi_frame, і в ньому живуть локальні змінні:
г.gi_frame.f_locals покаже {'межа': 3, 'номер': 1} між
викликами. Поруч лежить gi_frame.f_lasti — зміщення інструкції, з якої
відновиться виконання. Після вичерпання gi_frame стає None:
кадр звільнено, і саме тому генератор не можна «перемотати» — перемотувати вже нема
чого.05 / ЦінаСкільки це коштує в памʼяті
Синтаксична краса — не аргумент. Справжня вигода генератора вимірюється, і зараз ми її зміряємо на мільйоні квадратів. Два способи порахувати ту саму суму:
Результат обчислення в обох випадках однаковий —
333332833333500000. Витрати — ні.
Схема 2 · Мільйон квадратів: список проти генератора
Заміряно на CPython 3.12. Обидві смужки в одному масштабі.
sys.getsizeof дає для
списку 8.06 МБ, бо рахує лише масив посилань; справжні 38.6 МБ показує
tracemalloc, який бачить ще й мільйон обʼєктів-чисел за цими
посиланнями.Два висновки, і другий важливіший за перший.
Памʼять. Генератор тримає рівно один елемент за раз плюс свій кадр — звідси 464 байти незалежно від того, мільйон там значень чи мільярд. Список тримає всі відразу, і його вартість росте лінійно. Різниця тут — у 87 173 рази.
Затримка до першого результату. Список зобовʼязаний побудуватись цілком, перш ніж ти побачиш нульовий елемент. Генератор віддає нульовий елемент одразу. Для мільйона квадратів це частки секунди, для мільйона рядків з мережі чи з бази — хвилини очікування проти миттєвої відповіді.
06 / ВиразГенераторний вираз
У темі 13 ми навчились писати включення й наприкінці замінили квадратні дужки на круглі, отримавши щось незрозуміле. Тепер це вже не загадка:
Генераторний вираз (generator expression) — це та сама генераторна
функція, тільки записана одним рядком. Правила ті самі, що у включення: вираз, потім
for імʼя, потім in джерело, потім необовʼязковий
if умова. Різниця лише в тому, коли виконується робота.
Найчастіше такий вираз пишуть просто всередині виклику функції — і тоді власні дужки йому не потрібні, бо дужок виклику досить:
Останній рядок вартий окремої уваги. any зупиняється на першому
істинному значенні — і оскільки джерело ліниве, решта мільйона продажів навіть не
обчислюється. Замініть круглі дужки на квадратні — і any отримає готовий
список із мільйона логічних значень, з яких прочитає одне. Те саме стосується
all, min, max, sum і оператора
in.
", ".join(str(ц) for ц in ціни) працює, бо аргумент
один, а функція((x for x in ціни), 10) — уже потребує дужок.07 / ЛанцюжокЛанцюжок лінивих
Тепер повернімось до журналу продажів. Кожен його рядок має вигляд
"чай;2;591" — товар, кількість, сума. Рядків мільйон. Треба знайти перші
три продажі, дорожчі за 700.
Розвʼязок — три генератори, кожен з яких читає попередній:
Зібрати їх у конвеєр — три рядки, і жоден із них нічого не робить:
Робота починається аж тоді, коли хтось попросить значення. І тут головна ідея всієї
теми: дані не течуть уперед, їх тягнуть назад. Коли ти просиш елемент
у дорогі, той просить один словник у розібрати, той просить
один рядок у читати_журнал. Якщо словник не пройшов поріг —
лише_дорогі просить наступний, і так доти, доки не знайде потрібний.
Жодного проміжного списку не існує ніде.
Інтерактив 2 · Скільки роботи справді виконалось
Повзунок — скільки дорогих продажів ти просиш. Сітка внизу — перші сто рядків журналу з мільйона.
08 / Делегуванняyield from
Журналів зазвичай не один, а по одному на день. Хочеться склеїти їх у суцільний потік рядків. Перша спроба виглядає так:
Внутрішній цикл тут — чиста бухгалтерія: взяти елемент і одразу віддати його далі. Для цього є скорочення:
yield from джерело означає «віддавай назовні все, що дасть це джерело,
поки воно не скінчиться». Джерелом може бути будь-що ітероване — інший генератор,
список, рядок. Читається коротше, працює трохи швидше (проміжна ланка не витрачає крок
на кожен елемент) і, головне, чесно пропускає крізь себе те, чого простий цикл не
пропускає: значення з return, надіслані значення й винятки. Це стане
важливим у темі 32, коли генератори перетворяться
на асинхронні функції.
Класичне застосування — обхід укладеної структури, де глибина наперед невідома:
09 / ШухлядаШухляда itertools
Стандартний модуль itertools — це набір готових генераторів для того,
що доводиться робити щодня. Вони написані на C, нічого не матеріалізують і зчіплюються
один з одним. Ось шість, які трапляються найчастіше.
Схема 3 · Шпаргалка itertools
Шість функцій, які покривають більшість щоденних потреб. Кожна — з мінімальним прикладом і його результатом.
groupby. Вона групує лише
сусідні однакові ключі, тому дані спершу треба відсортувати за тим самим
ключем — інакше та сама назва товару дасть кілька окремих груп. І сама група теж
ітератор: якщо перейти до наступної, не прочитавши поточну, вона зникне назавжди.Найкорисніша з них у щоденній роботі — islice. Вона робить із
генератором те, чого той не вміє сам: бере зріз. Наш конвеєр із розділу 07 разом із
нею стає одним рядком:
А count варто спробувати хоча б раз, щоб відчути, що нескінченне
джерело — не метафора. count(10, 5) ніколи не закінчиться; спробуй
зробити з нього список — і програма їстиме памʼять, доки не впаде. Але
islice(count(10, 5), 4) віддасть чотири числа й зупиниться, бо просить
рівно стільки, скільки потрібно.
10 / ПасткиТри пастки
Плата за лінь — набір обмежень, і всі три відкриваються однаково неприємно.
Перша: генератор вичерпується після одного обходу. Це та сама одноразовість із розділу 01, тільки тепер вона твоя власна. Найчастіше вона кусає там, де людина не бачить обходу очима:
Друга: len() не працює. І не може: генератор не знає,
скільки в нього елементів, доки не дійде до кінця, а дійшовши — вичерпається.
Третя: зрізи не працюють. З тієї ж причини — щоб узяти
[5:8], треба вміти стрибати до пʼятого елемента, а генератор уміє тільки
«наступний».
Схема 4 · Що вміє список, а що генератор
Одні й ті самі вісім дій над мільйоном квадратів.
False, і перевірка if дані: роками працює правильно. Але
порожній генератор — True, бо він обʼєкт, а не колекція, і питати в
нього «чи ти порожній» ніхто не має права: відповідь коштувала б одного елемента.Ліки залежать від того, чого саме бракує.
- Потрібні всі дані двічі — зафіксуй списком:
дані = list(генератор). Це чесна відмова від лінощів, і часто вона правильна. - Потрібні два незалежні проходи одразу —
itertools.tee(генератор, 2). Але памʼятай:teeбуферизує все, що один прохід уже прочитав, а другий ще ні; якщо проходи розбігаються далеко, памʼять зʼїдається так само, як від списку. - Потрібне джерело, яке лишається лінивим і його можна обходити
багато разів — зроби клас із
__iter__, який щоразу створює новий генератор.
Останній прийом вартий коду, бо він зшиває цю тему з темою 27:
Різниця з класом Квадрати з розділу 03 — в одному рядку.
Там __iter__ повертав self і клас був ітератором,
одноразовим. Тут __iter__ повертає новий генератор — і клас стає
ітерованим джерелом, як список, але без списка в памʼяті.
11 / ГлибшеДля тих, кому мало
Генератор уміє не лише віддавати, а й приймати. Якщо написати
отримане = yield значення, то yield перетворюється на вираз:
він віддає значення назовні й повертає всередину те, що йому надішлють методом
г.send(щось). Виходить двостороння труба: функція, яка живе між викликами
й реагує на те, що їй кажуть. Такий генератор називають
корутиною (coroutine; трапляється й переклад «співпрограма»), і саме на цій механіці — заморожений кадр
плюс send — виросла вся асинхронність Python. Спершу
yield from і send написали руками, потім дали їм окремі
ключові слова async і await. Про це —
тема 32; поки що досить знати, що
await усередині — це наш yield із розділу 04.
Чому sum(генератор) дешевший за sum(список).
Різниця не лише в піковій памʼяті, хоча починається з неї. Мільйон посилань — це
8 МБ суцільного масиву, який доводиться виділити, кілька разів перевиділити
(список росте не одразу на весь розмір), пройти й потім віддати збирачу сміття.
Кожен із мільйона обʼєктів-чисел живе, поки живе список, тож збирач має їх усіх
обходити. Генератор натомість тримає одне число: воно народжується, додається до суми
й помирає, а лічильник посилань звільняє памʼять одразу. Дані весь час лишаються в
кеші процесора, а не тягнуться з оперативної памʼяті.
Чесна поправка: на малих обсягах усе навпаки. Кожне відновлення генератора коштує кілька інструкцій, тому на сотні елементів список часто швидший, а на десятці — завжди. Генератор виграє тоді, коли даних більше, ніж зручно тримати в памʼяті, або коли потрібна не вся послідовність, а її початок. Це не «швидший спосіб писати цикли», а інструмент з чіткою межею застосовності.
12 / ПідсумокЩо забрати з теми
Уся тема тримається на одному переозначенні: послідовність — це не обовʼязково дані, які лежать; це може бути правило, за яким їх видають.
- Ітерований — джерело, ітератор — обхід. Перший на прохання
iter()видає новий обхідник, другий видає самого себе. Звідси й уся різниця в поведінці списку йzip. - Протокол — це
iter(),next()іStopIteration. Циклfor— синтаксичний цукор над цими трьома речами, і нічого більше в ньому немає. - Свій ітератор класом вимагає полів для стану — і саме тому в мові
зʼявився
yield: він зберігає стан за тебе, у замороженому кадрі функції. - Виклик генераторної функції не виконує тіло. Тіло рушає з
першим
next(), доходить доyield, віддає значення й засинає. Наступнийnext()продовжує з наступного рядка. - Вигода вимірюється. Мільйон квадратів: 38.6 МБ проти 464 байтів, тобто в 87 173 рази менше, плюс миттєвий перший результат замість очікування на побудову.
- Генераторний вираз — те саме круглими дужками. Усередині виклику
функції власні дужки не потрібні:
sum(x * x for x in ...). - Генератори зчіплюються в конвеєр, у якому дані тягнуть назад, а
не штовхають уперед. Три елементи з мільйона коштують десяток прочитаних рядків, а
не мільйон.
yield fromробить ланку конвеєра прозорою. - Ціна лінощів — три «не можна»: один обхід, немає
len(), немає зрізів (замість нихislice). І порожній генератор — цеTrue. - Клас, чий
__iter__повертає новий генератор, дає лінивий і водночас багаторазовий обхід — найкраще з обох світів.
А тепер згадай тему 28. Там рядок
@dataclass над класом дописував у нього методи, і ми чесно сказали:
декоратор — це просто функція, яка бере обʼєкт і повертає змінений. Пояснити повністю
ми тоді не могли, бо бракувало саме того, що ти щойно вивчив: функції, яка вміє
обгорнути іншу функцію й зберегти стан між викликами. У темі 31
з декораторів нарешті зникне магія — і разом із нею стане зрозуміло, як влаштовані
@property, @cache і сам @dataclass.
practice.ipynb ти напишеш той
самий ітератор двічі — класом і генератором — і доведеш assert-ом, що вони
дають однакове; заміряєш памʼять через sys.getsizeof і
tracemalloc і отримаєш числа з цієї лекції; збудуєш лінивий конвеєр із
трьох генераторів і порахуєш, скільки рядків він прочитав; переконаєшся,
що другий обхід генератора дає рівно нуль елементів; спіймаєш TypeError на
len() і на зрізі; і зробиш клас, який лишається лінивим, але обходиться
багато разів.Далі в темі
Теорію прочитано. Тепер закріпи її на практиці.