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