Python з нуля · Блок 7 · Тема 30

Значення на вимогу

Мільйон рядків журналу можна обробити, тримаючи в памʼяті один. Для цього треба навчити функцію зупинятися посеред роботи — і памʼятати, де саме вона зупинилась.

Ця тема повертає три борги одразу. У темі 12 ми зʼясували, що for нічого не рахує, а просить у обʼєкта ітератор, і мимохідь пообіцяли пояснити, чому цей ітератор одноразовий. У темі 13 заміна квадратних дужок на круглі раптом дала якийсь generator object, і ми сказали «деталі — у темі 30». У темі 27 ми навчили власний клас працювати в for через __iter__ — і зупинились рівно там, де починається найцікавіше. Тепер усі три нитки сходяться в одну.

Попередня тема, анотації типів, була про те, як описати дані. Ця — про те, звідки вони беруться: не всі відразу, а по одному, рівно тоді, коли знадобились.

Наскрізний приклад тут один — журнал продажів. Той самий, що в темах 25-28, тільки тепер його не три рядки, а мільйон, і в памʼять він цілком не влазить. Задача проста: знайти перші три дорогі продажі. Ми розвʼяжемо її двічі й порівняємо не стиль, а байти.

01 / РізницяІтерований і ітератор — це різне

Почнемо з досліду, який виглядає як помилка інтерпретатора. Ось список, і його можна обходити скільки завгодно разів:

товари = ["кава", "чай", "сік"] for товар in товари: print(товар) # кава, чай, сік for товар in товари: print(товар) # кава, чай, сік — знову всі три

А ось майже те саме, тільки джерело — zip:

пари = zip(товари, [85, 40, 25]) for назва, ціна in пари: print(назва, ціна) # три рядки for назва, ціна in пари: print(назва, ціна) # ЖОДНОГО рядка

Другий цикл не падає з помилкою. Він просто не робить жодного кроку — так, ніби пари порожні. Причина в тому, що товари й пари — обʼєкти двох різних сортів, хоч обидва працюють у for.

Ітерований обʼєкт (iterable) — це той, у кого можна попросити обхідник. Список, кортеж, рядок, словник, множина, range, файл. Сам він нічого не памʼятає: він просто вміє на вимогу видати новий обхідник, який почне з початку.

Ітератор (iterator) — це і є обхідник. Крихітний обʼєкт з єдиним умінням: віддати наступний елемент і запамʼятати, де він зупинився. Позиція всередині нього тільки росте; повернутись на початок він не вміє принципово, бо в нього немає для цього ані методу, ані потреби.

Різницю видно з двох рядків. Функція iter() просить обхідник, і відповідь залежить від того, кого просиш:

>>> iter(товари) is iter(товари) False # список щоразу видає НОВИЙ обхідник >>> обхідник = iter(пари) >>> iter(обхідник) is обхідник True # ітератор на прохання видає самого себе

Ось і вся розгадка. Коли for бере список, він щоразу отримує новий обхідник із позицією нуль — тому обходів може бути скільки завгодно. Коли for бере zip, він отримує той самий обхідник, у якого позиція вже в кінці, — тому другий цикл завершується, не почавшись.

Правило, яке рятує від половини несподіванок. Питай себе не «чи це працює в for», а «це джерело чи вже обхід». Список, кортеж, множина, словник, range — джерела, їх можна обходити повторно. zip, enumerate, map, filter, відкритий файл і все, що ми напишемо в цій темі, — обходи, вони одноразові.

02 / ПротоколПротокол: iter, next, кінець

Усередині цієї домовленості лише дві функції й один виняток. Розберемо їх по черзі на нашому списку товарів.

крок 1 · попросити обхідник

iter(x) викликає в обʼєкта метод __iter__ і отримує ітератор. Для списку це list_iterator — усередині нього лише посилання на список і число позиції.

>>> обхідник = iter(товари) >>> обхідник <list_iterator object at 0x7f3c...>
крок 2 · брати, поки дають

next(обхідник) викликає __next__: віддає наступний елемент і зсуває позицію на одиницю. Це буквально те, що робить for на кожному повторенні.

>>> next(обхідник) 'кава' >>> next(обхідник) 'чай'
крок 3 · упізнати кінець

Коли елементи скінчились, ітератор кидає StopIteration. Це не поломка, а умовний сигнал: цикл for ловить його й тихо завершується. Побачити його «голим» можна, лише покликавши next() руками.

>>> next(обхідник) 'сік' >>> next(обхідник) Traceback (most recent call last): StopIteration

Виняток замість повернутого значення — рішення не випадкове. Якби кінець позначався якимось особливим значенням (скажімо, None), то список [1, None, 3] обірвався б посередині. Виняток не можна сплутати з даними, бо він приходить не по тому каналу.

Схема 1 · Протокол перебору цілком

Два виклики й один виняток. Праворуч — те, у що for розгортається насправді.

Порівняй два підписи під рамками. Список на прохання iter() щоразу створює новий обхідник, а ітератор віддає самого себе — саме тому його можна підставити просто в for і саме тому повторний обхід не починається спочатку.

Оскільки next() — звичайна функція, у неї є звичайний другий аргумент: значення, яке повернути замість винятку. Це часто зручніше за try:

>>> next(обхідник, "нічого немає") 'нічого немає'

03 / РукамиСвій ітератор руками

У темі 27 ми зробили клас ітерованим одним рядком: __iter__ віддавав iter(self.позиції), тобто перекладав роботу на внутрішній список. Тепер напишемо ітератор самі, без списка всередині — щоб побачити механіку зсередини й зрозуміти, від чого нас далі позбавить yield.

Завдання навмисне просте: віддавати квадрати чисел від нуля до межі.

class Квадрати: """Ітератор: сам себе віддає і сам памʼятає позицію.""" def __init__(self, межа): self.межа = межа self.номер = 0 # стан живе в полі обʼєкта def __iter__(self): return self # я і є обхідник def __next__(self): if self.номер >= self.межа: raise StopIteration # сигнал «усе» значення = self.номер * self.номер self.номер += 1 # зсуваємо позицію вручну return значення

Працює саме так, як обіцяно, і в for, і з next():

>>> for к in Квадрати(4): ... print(к, end=" ") 0 1 4 9

Тут варто зупинитись на трьох речах, бо вони пояснюють усе подальше.

Стан довелося зберігати руками. Поле self.номер існує лише тому, що між двома викликами __next__ обʼєкт має щось памʼятати. Метод виконується від початку до кінця й помирає; єдине, що переживає виклик, — поля.

__iter__ повертає self. Це те саме iter(обхідник) is обхідник зі схеми вище — і саме воно робить наш клас одноразовим: другий for отримає той самий обʼєкт із номер == межа й одразу почує StopIteration.

Тринадцять рядків заради чотирьох чисел. І це найпростіший можливий ітератор. Спробуй уявити те саме для «читати файл, розбирати рядок, пропускати порожні» — стан розповзеться на пʼять полів, а логіка перемішається з бухгалтерією позицій. Саме через це в мові й зʼявився окремий інструмент.

04 / yieldФункція, яка вміє спати

Той самий ітератор, записаний по-іншому:

def квадрати(межа): номер = 0 while номер < межа: yield номер * номер номер += 1

Чотири рядки замість тринадцяти, жодного self, жодного raise StopIteration. Функція, у тілі якої є слово yield, називається генераторною функцією (generator function), і поводиться вона зовсім не так, як звичайна.

Виклик не виконує тіло. Це найдивніше й найважливіше. Ось доказ:

def квадрати(межа): print("тіло почалося") номер = 0 while номер < межа: yield номер * номер номер += 1 >>> г = квадрати(3) >>> г <generator object квадрати at 0x7f2a...> # жодного «тіло почалося» >>> next(г) тіло почалося 0

Виклик квадрати(3) лише створює обʼєкт-генератор: запамʼятовує функцію, запамʼятовує аргументи й зупиняється. Тіло не виконало жодної інструкції — рядок print мовчить. Перший next() запускає тіло й доводить його до першого yield.

yield віддає значення й заморожує функцію. На відміну від return, який завершує роботу назавжди, yield ставить її на паузу: кадр функції (frame) — тобто всі локальні змінні й точка, де ми зупинились — лишається жити всередині обʼєкта-генератора. Наступний next() не починає спочатку, а продовжує з наступного рядка після yield.

Коли тіло дійде до кінця (або натрапить на return), генератор сам кине StopIteration. Писати це руками не треба — і забути теж не вийде.

Інтерактив 1 · Що робить кожен виклик next()

Тисни «наступний крок» і дивись, звідки функція продовжує роботу й що памʼятає між викликами.

виклик0
віддано
стан функції
Головне — другий і третій кроки з yield. Підсвічений рядок починається не з початку функції, а з номер += 1 — тобто рівно з місця, де вона заснула. Змінна номер при цьому не обнулилась: вона пережила паузу разом з усім кадром. Перемкнись на return — і побачиш протилежне: одне натискання виконує все тіло, повертає готовий список і функція зникає без сліду.
Для профі: заморожений кадр — це справжній обʼєкт. У генератора є атрибут gi_frame, і в ньому живуть локальні змінні: г.gi_frame.f_locals покаже {'межа': 3, 'номер': 1} між викликами. Поруч лежить gi_frame.f_lasti — зміщення інструкції, з якої відновиться виконання. Після вичерпання gi_frame стає None: кадр звільнено, і саме тому генератор не можна «перемотати» — перемотувати вже нема чого.

05 / ЦінаСкільки це коштує в памʼяті

Синтаксична краса — не аргумент. Справжня вигода генератора вимірюється, і зараз ми її зміряємо на мільйоні квадратів. Два способи порахувати ту саму суму:

import sys, tracemalloc tracemalloc.start() списком = [x * x for x in range(1_000_000)] _, пік_списку = tracemalloc.get_traced_memory() tracemalloc.stop() tracemalloc.start() всього = sum(x * x for x in range(1_000_000)) _, пік_генератора = tracemalloc.get_traced_memory() tracemalloc.stop()

Результат обчислення в обох випадках однаковий — 333332833333500000. Витрати — ні.

Схема 2 · Мільйон квадратів: список проти генератора

Заміряно на CPython 3.12. Обидві смужки в одному масштабі.

Смужку генератора не видно не через помилку малювання. 464 байти від 40 448 200 — це 0.0011 %, тобто менше за товщину лінії на цій ширині. І зверни увагу на різницю між двома мірками: sys.getsizeof дає для списку 8.06 МБ, бо рахує лише масив посилань; справжні 38.6 МБ показує tracemalloc, який бачить ще й мільйон обʼєктів-чисел за цими посиланнями.

Два висновки, і другий важливіший за перший.

Памʼять. Генератор тримає рівно один елемент за раз плюс свій кадр — звідси 464 байти незалежно від того, мільйон там значень чи мільярд. Список тримає всі відразу, і його вартість росте лінійно. Різниця тут — у 87 173 рази.

Затримка до першого результату. Список зобовʼязаний побудуватись цілком, перш ніж ти побачиш нульовий елемент. Генератор віддає нульовий елемент одразу. Для мільйона квадратів це частки секунди, для мільйона рядків з мережі чи з бази — хвилини очікування проти миттєвої відповіді.

Коли список усе-таки правильний вибір. Дані потрібні більше одного разу; потрібен доступ за номером; потрібна довжина; потрібно відсортувати. У всіх цих випадках усе одно доведеться зібрати все в памʼять, і генератор лише додасть проміжний крок. Генератор — про один прохід, а не про «завжди краще».

06 / ВиразГенераторний вираз

У темі 13 ми навчились писати включення й наприкінці замінили квадратні дужки на круглі, отримавши щось незрозуміле. Тепер це вже не загадка:

квадрати_списком = [x * x for x in range(1_000_000)] # список, 8 МБ квадрати_ліниво = (x * x for x in range(1_000_000)) # генератор, 200 Б

Генераторний вираз (generator expression) — це та сама генераторна функція, тільки записана одним рядком. Правила ті самі, що у включення: вираз, потім for імʼя, потім in джерело, потім необовʼязковий if умова. Різниця лише в тому, коли виконується робота.

Найчастіше такий вираз пишуть просто всередині виклику функції — і тоді власні дужки йому не потрібні, бо дужок виклику досить:

>>> сума = sum(продаж["сума"] for продаж in продажі) >>> найдорожчий = max(продаж["сума"] for продаж in продажі) >>> є_дорогі = any(продаж["сума"] > 700 for продаж in продажі)

Останній рядок вартий окремої уваги. any зупиняється на першому істинному значенні — і оскільки джерело ліниве, решта мільйона продажів навіть не обчислюється. Замініть круглі дужки на квадратні — і any отримає готовий список із мільйона логічних значень, з яких прочитає одне. Те саме стосується all, min, max, sum і оператора in.

Де круглі дужки все-таки потрібні. Якщо аргумент не один, генераторний вираз доводиться взяти в дужки явно, інакше Python не зрозуміє, де закінчується вираз: ", ".join(str(ц) for ц in ціни) працює, бо аргумент один, а функція((x for x in ціни), 10) — уже потребує дужок.

07 / ЛанцюжокЛанцюжок лінивих

Тепер повернімось до журналу продажів. Кожен його рядок має вигляд "чай;2;591" — товар, кількість, сума. Рядків мільйон. Треба знайти перші три продажі, дорожчі за 700.

Розвʼязок — три генератори, кожен з яких читає попередній:

def читати_журнал(скільки): """Удає файл: віддає рядки по одному, не будуючи список.""" for номер in range(скільки): код = (номер * 1103515245 + 12345) % 2**31 yield f"{ТОВАРИ[номер % 5]};{номер % 3 + 1};{код % 1000 + 1}" def розібрати(рядки): """Перетворює рядок на словник — теж по одному.""" for рядок in рядки: товар, кількість, сума = рядок.split(";") yield {"товар": товар, "кількість": int(кількість), "сума": int(сума)} def лише_дорогі(продажі, поріг=700): """Пропускає далі тільки те, що більше за поріг.""" for продаж in продажі: if продаж["сума"] > поріг: yield продаж

Зібрати їх у конвеєр — три рядки, і жоден із них нічого не робить:

рядки = читати_журнал(1_000_000) продажі = розібрати(рядки) дорогі = лише_дорогі(продажі) # тут прочитано рівно нуль рядків

Робота починається аж тоді, коли хтось попросить значення. І тут головна ідея всієї теми: дані не течуть уперед, їх тягнуть назад. Коли ти просиш елемент у дорогі, той просить один словник у розібрати, той просить один рядок у читати_журнал. Якщо словник не пройшов поріг — лише_дорогі просить наступний, і так доти, доки не знайде потрібний. Жодного проміжного списку не існує ніде.

перші_три = [] for продаж in дорогі: перші_три.append(продаж) if len(перші_три) == 3: break >>> перші_три [{'товар': 'чай', 'кількість': 1, 'сума': 872}, {'товар': 'сік', 'кількість': 3, 'сума': 714}, {'товар': 'вода', 'кількість': 1, 'сума': 959}]

Інтерактив 2 · Скільки роботи справді виконалось

Повзунок — скільки дорогих продажів ти просиш. Сітка внизу — перші сто рядків журналу з мільйона.

рядків прочитано0
розібрано0
зібрано дорогих0
частка роботи
Порівняй два нижні числа на схемі. Ліниво прочитано десятки рядків; той самий конвеєр на списках прочитав би й розібрав усі 1 000 000, щоб віддати три. Зверни увагу й на те, як росте вартість: щоб знайти вдесятеро більше дорогих продажів, читати доводиться приблизно вдесятеро більше рядків — лінь не робить роботу дешевшою, вона лише не робить зайвої.

08 / Делегуванняyield from

Журналів зазвичай не один, а по одному на день. Хочеться склеїти їх у суцільний потік рядків. Перша спроба виглядає так:

def усі_журнали(розміри): for розмір in розміри: for рядок in читати_журнал(розмір): yield рядок

Внутрішній цикл тут — чиста бухгалтерія: взяти елемент і одразу віддати його далі. Для цього є скорочення:

def усі_журнали(розміри): for розмір in розміри: yield from читати_журнал(розмір)

yield from джерело означає «віддавай назовні все, що дасть це джерело, поки воно не скінчиться». Джерелом може бути будь-що ітероване — інший генератор, список, рядок. Читається коротше, працює трохи швидше (проміжна ланка не витрачає крок на кожен елемент) і, головне, чесно пропускає крізь себе те, чого простий цикл не пропускає: значення з return, надіслані значення й винятки. Це стане важливим у темі 32, коли генератори перетворяться на асинхронні функції.

Класичне застосування — обхід укладеної структури, де глибина наперед невідома:

def розгорнути(вкладене): """Витягує всі числа з довільно вкладених списків.""" for елемент in вкладене: if isinstance(елемент, list): yield from розгорнути(елемент) # той самий генератор глибше else: yield елемент >>> list(розгорнути([1, [2, [3, 4]], 5])) [1, 2, 3, 4, 5]

09 / ШухлядаШухляда itertools

Стандартний модуль itertools — це набір готових генераторів для того, що доводиться робити щодня. Вони написані на C, нічого не матеріалізують і зчіплюються один з одним. Ось шість, які трапляються найчастіше.

Схема 3 · Шпаргалка itertools

Шість функцій, які покривають більшість щоденних потреб. Кожна — з мінімальним прикладом і його результатом.

Найпідступніша тут — groupby. Вона групує лише сусідні однакові ключі, тому дані спершу треба відсортувати за тим самим ключем — інакше та сама назва товару дасть кілька окремих груп. І сама група теж ітератор: якщо перейти до наступної, не прочитавши поточну, вона зникне назавжди.

Найкорисніша з них у щоденній роботі — islice. Вона робить із генератором те, чого той не вміє сам: бере зріз. Наш конвеєр із розділу 07 разом із нею стає одним рядком:

from itertools import islice перші_три = list(islice(лише_дорогі(розібрати(читати_журнал(1_000_000))), 3))

А count варто спробувати хоча б раз, щоб відчути, що нескінченне джерело — не метафора. count(10, 5) ніколи не закінчиться; спробуй зробити з нього список — і програма їстиме памʼять, доки не впаде. Але islice(count(10, 5), 4) віддасть чотири числа й зупиниться, бо просить рівно стільки, скільки потрібно.

10 / ПасткиТри пастки

Плата за лінь — набір обмежень, і всі три відкриваються однаково неприємно.

Перша: генератор вичерпується після одного обходу. Це та сама одноразовість із розділу 01, тільки тепер вона твоя власна. Найчастіше вона кусає там, де людина не бачить обходу очима:

квадрати_ = квадрати(4) >>> sum(квадрати_) 14 >>> sum(квадрати_) 0 # не помилка, просто там уже нічого немає >>> list(квадрати_) []

Друга: len() не працює. І не може: генератор не знає, скільки в нього елементів, доки не дійде до кінця, а дійшовши — вичерпається.

>>> len(квадрати(4)) TypeError: object of type 'generator' has no len()

Третя: зрізи не працюють. З тієї ж причини — щоб узяти [5:8], треба вміти стрибати до пʼятого елемента, а генератор уміє тільки «наступний».

>>> квадрати(4)[0] TypeError: 'generator' object is not subscriptable

Схема 4 · Що вміє список, а що генератор

Одні й ті самі вісім дій над мільйоном квадратів.

Останній рядок — найтихіша пастка з усіх. Порожній список — це False, і перевірка if дані: роками працює правильно. Але порожній генератор — True, бо він обʼєкт, а не колекція, і питати в нього «чи ти порожній» ніхто не має права: відповідь коштувала б одного елемента.

Ліки залежать від того, чого саме бракує.

Останній прийом вартий коду, бо він зшиває цю тему з темою 27:

class Журнал: """Ітерований (не ітератор!): щоразу видає свіжий обхід.""" def __init__(self, скільки): self.скільки = скільки def __iter__(self): return читати_журнал(self.скільки) # НОВИЙ генератор на кожен for журнал = Журнал(1_000_000) for рядок in журнал: ... # працює for рядок in журнал: ... # і вдруге теж працює

Різниця з класом Квадрати з розділу 03 — в одному рядку. Там __iter__ повертав self і клас був ітератором, одноразовим. Тут __iter__ повертає новий генератор — і клас стає ітерованим джерелом, як список, але без списка в памʼяті.

11 / ГлибшеДля тих, кому мало

Генератор уміє не лише віддавати, а й приймати. Якщо написати отримане = yield значення, то yield перетворюється на вираз: він віддає значення назовні й повертає всередину те, що йому надішлють методом г.send(щось). Виходить двостороння труба: функція, яка живе між викликами й реагує на те, що їй кажуть. Такий генератор називають корутиною (coroutine; трапляється й переклад «співпрограма»), і саме на цій механіці — заморожений кадр плюс send — виросла вся асинхронність Python. Спершу yield from і send написали руками, потім дали їм окремі ключові слова async і await. Про це — тема 32; поки що досить знати, що await усередині — це наш yield із розділу 04.

Чому sum(генератор) дешевший за sum(список). Різниця не лише в піковій памʼяті, хоча починається з неї. Мільйон посилань — це 8 МБ суцільного масиву, який доводиться виділити, кілька разів перевиділити (список росте не одразу на весь розмір), пройти й потім віддати збирачу сміття. Кожен із мільйона обʼєктів-чисел живе, поки живе список, тож збирач має їх усіх обходити. Генератор натомість тримає одне число: воно народжується, додається до суми й помирає, а лічильник посилань звільняє памʼять одразу. Дані весь час лишаються в кеші процесора, а не тягнуться з оперативної памʼяті.

Чесна поправка: на малих обсягах усе навпаки. Кожне відновлення генератора коштує кілька інструкцій, тому на сотні елементів список часто швидший, а на десятці — завжди. Генератор виграє тоді, коли даних більше, ніж зручно тримати в памʼяті, або коли потрібна не вся послідовність, а її початок. Це не «швидший спосіб писати цикли», а інструмент з чіткою межею застосовності.

12 / ПідсумокЩо забрати з теми

Уся тема тримається на одному переозначенні: послідовність — це не обовʼязково дані, які лежать; це може бути правило, за яким їх видають.

А тепер згадай тему 28. Там рядок @dataclass над класом дописував у нього методи, і ми чесно сказали: декоратор — це просто функція, яка бере обʼєкт і повертає змінений. Пояснити повністю ми тоді не могли, бо бракувало саме того, що ти щойно вивчив: функції, яка вміє обгорнути іншу функцію й зберегти стан між викликами. У темі 31 з декораторів нарешті зникне магія — і разом із нею стане зрозуміло, як влаштовані @property, @cache і сам @dataclass.

Далі в практиці. У practice.ipynb ти напишеш той самий ітератор двічі — класом і генератором — і доведеш assert-ом, що вони дають однакове; заміряєш памʼять через sys.getsizeof і tracemalloc і отримаєш числа з цієї лекції; збудуєш лінивий конвеєр із трьох генераторів і порахуєш, скільки рядків він прочитав; переконаєшся, що другий обхід генератора дає рівно нуль елементів; спіймаєш TypeError на len() і на зрізі; і зробиш клас, який лишається лінивим, але обходиться багато разів.

Далі в темі

Теорію прочитано. Тепер закріпи її на практиці.