Практика · Ітератори й генератори¶
Лекція: lecture.html · Тест: quiz.html · ДЗ: homework.md
Наскрізний приклад той самий, що в лекції, — журнал продажів на мільйон рядків. Тут ми руками зробимо все, про що йшлося:
- побачимо різницю між ітерованим і ітератором на списку й на
zip; - розберемо протокол по кроках:
iter(),next(),StopIteration; - напишемо той самий обхід двічі — класом і генератором — і доведемо
assert-ом, що вони дають однакове; - заміряємо памʼять двома мірками —
sys.getsizeofіtracemalloc— і отримаємо числа з лекції; - збудуємо лінивий конвеєр із трьох генераторів і порахуємо, скільки рядків він насправді прочитав;
- спіймаємо всі три пастки: вичерпаність,
len()і зріз; - полагодимо їх через
list,itertools.teeі клас із__iter__; - пройдемось по
itertoolsі поyield from.
Усе виконується згори вниз без правок. Клітинки, які навмисно падають, позначені в тексті — їхній traceback теж навчальний матеріал.
1 · Список можна обходити багато разів, а zip — один¶
Почнемо з досліду, який виглядає як помилка інтерпретатора. Обійдемо список двічі й порахуємо, скільки елементів дав кожен обхід.
товари = ["кава", "чай", "сік"]
# рахуємо елементи окремо для кожного обходу, щоб побачити, чи джерело «зношується»
перший_обхід = [товар for товар in товари]
другий_обхід = [товар for товар in товари]
print("перший обхід списку :", перший_обхід)
print("другий обхід списку :", другий_обхід)
assert len(другий_обхід) == 3, "список має обходитись повторно"
print("✅ список обходиться скільки завгодно разів")
перший обхід списку : ['кава', 'чай', 'сік'] другий обхід списку : ['кава', 'чай', 'сік'] ✅ список обходиться скільки завгодно разів
А тепер те саме з zip. Код відрізняється одним словом, а поведінка — принципово.
пари = zip(товари, [85, 40, 25])
перший_обхід = [пара for пара in пари]
другий_обхід = [пара for пара in пари]
print("перший обхід zip :", перший_обхід)
print("другий обхід zip :", другий_обхід)
assert другий_обхід == [], "другий обхід ітератора має дати нуль елементів"
print("✅ другий обхід дав рівно 0 елементів — і жодної помилки")
перший обхід zip : [('кава', 85), ('чай', 40), ('сік', 25)]
другий обхід zip : []
✅ другий обхід дав рівно 0 елементів — і жодної помилки
2 · Чому так: iter() від списку й iter() від ітератора¶
Розгадка в одному досліді. Список на кожне прохання видає новий обхідник, а ітератор віддає самого себе — тому в нього немає способу почати спочатку.
обхідник_один = iter(товари)
обхідник_два = iter(товари)
print("два iter() від списку — це той самий обʼєкт?", обхідник_один is обхідник_два)
пари = zip(товари, [85, 40, 25])
print("iter() від ітератора — це той самий обʼєкт?", iter(пари) is пари)
assert обхідник_один is not обхідник_два, "список має видавати новий обхідник"
assert iter(пари) is пари, "ітератор має віддавати самого себе"
print("✅ ось звідки різниця в поведінці")
два iter() від списку — це той самий обʼєкт? False iter() від ітератора — це той самий обʼєкт? True ✅ ось звідки різниця в поведінці
3 · Протокол по кроках: iter, next, StopIteration¶
Пройдемо список руками, без for. Це рівно те, що робить цикл усередині.
обхідник = iter(товари)
print("next() →", next(обхідник))
print("next() →", next(обхідник))
print("next() →", next(обхідник))
# другий аргумент next() — значення замість винятку, коли елементи скінчились
print("next() з підстраховкою →", next(обхідник, "нічого немає"))
next() → кава next() → чай next() → сік next() з підстраховкою → нічого немає
А ось як виглядає кінець без підстраховки. Ця клітинка навмисно падає —
StopIteration тут не поломка, а домовлений сигнал «усе».
next(обхідник)
--------------------------------------------------------------------------- StopIteration Traceback (most recent call last) Cell In[5], line 1 ----> 1 next(обхідник) StopIteration:
4 · Свій ітератор класом: стан доводиться зберігати руками¶
Той самий протокол, але з нашого боку. Клас має памʼятати позицію між викликами — і єдине місце, де вона переживає виклик методу, це поле обʼєкта.
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 значення
класом = list(Квадрати(5))
print("Квадрати(5) →", класом)
# зазирнемо в стан посеред обходу: позиція справді лежить у полі обʼєкта
недочитаний = Квадрати(5)
next(недочитаний)
next(недочитаний)
print("стан після двох next():", недочитаний.__dict__)
Квадрати(5) → [0, 1, 4, 9, 16]
стан після двох next(): {'межа': 5, 'номер': 2}
5 · Те саме генератором: чотири рядки замість тринадцяти¶
yield зберігає стан за нас — у замороженому кадрі функції. Ані поля, ані
raise StopIteration писати не треба.
def квадрати(межа):
"""Генераторна функція: віддає квадрати по одному й засинає між ними."""
номер = 0
while номер < межа:
yield номер * номер
номер += 1
генератором = list(квадрати(5))
print("квадрати(5) →", генератором)
assert генератором == класом, "генератор має давати те саме, що й наш клас"
assert генератором == [x * x for x in range(5)], "і те саме, що включення"
print("✅ клас, генератор і включення дають однаковий результат")
квадрати(5) → [0, 1, 4, 9, 16] ✅ клас, генератор і включення дають однаковий результат
6 · Доказ того, що виклик не виконує тіло¶
Найдивніша властивість генераторної функції: квадрати(3) не запускає жодного
рядка тіла. Переконаємось у цьому найпростіше — рядком print усередині.
def квадрати_з_гуком(межа):
print(" ← тіло почалося")
номер = 0
while номер < межа:
yield номер * номер
номер += 1
print("викликаємо функцію:")
г = квадрати_з_гуком(3)
print("отримали обʼєкт:", г)
print("а тепер перший next():")
print(" next(г) →", next(г))
викликаємо функцію: отримали обʼєкт: <generator object квадрати_з_гуком at 0x7f6e5948edc0> а тепер перший next(): ← тіло почалося next(г) → 0
Тепер покроково. Дивись на змінну номер між викликами: вона не обнуляється,
бо переживає паузу разом з усім кадром функції.
г = квадрати(3)
print("next(г) →", next(г))
print(" локальні змінні між викликами:", г.gi_frame.f_locals)
print("next(г) →", next(г))
print(" локальні змінні між викликами:", г.gi_frame.f_locals)
print("next(г) →", next(г))
print(" локальні змінні між викликами:", г.gi_frame.f_locals)
print("next(г) →", next(г, "StopIteration"))
print(" кадр після вичерпання:", г.gi_frame)
next(г) → 0
локальні змінні між викликами: {'межа': 3, 'номер': 0}
next(г) → 1
локальні змінні між викликами: {'межа': 3, 'номер': 1}
next(г) → 4
локальні змінні між викликами: {'межа': 3, 'номер': 2}
next(г) → StopIteration
кадр після вичерпання: None
7 · Скільки це коштує: дві мірки памʼяті¶
sys.getsizeof показує розмір самого обʼєкта-списку, тобто масив посилань.
tracemalloc бачить усе, що виділилось насправді, — разом із мільйоном
обʼєктів-чисел за цими посиланнями. Числа мають збігтися з лекцією.
import sys
import tracemalloc
МІЛЬЙОН = 1_000_000
# 1) розмір самих обʼєктів
розмір_списку = sys.getsizeof([x * x for x in range(МІЛЬЙОН)])
розмір_генератора = sys.getsizeof(x * x for x in range(МІЛЬЙОН))
print(f"sys.getsizeof(список) = {розмір_списку:>10} Б")
print(f"sys.getsizeof(генератор) = {розмір_генератора:>10} Б")
print(f"різниця у {розмір_списку // розмір_генератора} разів")
sys.getsizeof(список) = 8448728 Б sys.getsizeof(генератор) = 200 Б різниця у 42243 разів
Друга мірка чесніша, бо бачить усе, що виділилось насправді. Щоб не приписати жодному зі способів чужих байтів, заміряємо ще й фон — пік на порожній дії — і далі дивимось, скільки кожен спосіб додає понад нього. Обидві дії ховаємо у функції: так у замір не потрапляють змінні, які лишились би жити після нього.
def нічого():
"""Порожня дія: показує, скільки памʼяті ворушиться саме собою."""
return 0
def сума_через_список():
"""Спершу будуємо весь список, і аж потім додаємо."""
квадрати_списком = [x * x for x in range(МІЛЬЙОН)]
return sum(квадрати_списком)
def сума_через_генератор():
"""Додаємо на льоту, не тримаючи жодного проміжного списку."""
return sum(x * x for x in range(МІЛЬЙОН))
def пік_памʼяті(дія):
"""Скільки байтів найбільше було зайнято одночасно, поки виконувалась дія."""
tracemalloc.start()
результат = дія()
_, пік = tracemalloc.get_traced_memory()
tracemalloc.stop()
return пік, результат
фон, _ = пік_памʼяті(нічого)
пік_списку, сума_списком = пік_памʼяті(сума_через_список)
пік_генератора, сума_генератором = пік_памʼяті(сума_через_генератор)
print(f"фон, коли не робимо нічого : {фон:>10} Б")
print(f"пік зі списком : {пік_списку:>10} Б ({пік_списку / 1024 / 1024:.1f} МБ)")
print(f"пік із генератором : {пік_генератора:>10} Б")
print()
print(f"список додав до фону : {пік_списку - фон:>10} Б")
print(f"генератор додав до фону : {пік_генератора - фон:>10} Б")
print(f"сума в обох випадках: {сума_списком} і {сума_генератором}")
assert сума_списком == сума_генератором, "результат не має залежати від способу"
assert пік_списку - фон > 30_000_000, "список мав би зайняти десятки мегабайтів"
assert пік_генератора - фон < 1_000_000, "генератор не має додавати помітної памʼяті"
print("✅ той самий результат: список коштує десятки мегабайтів, генератор — сотні байтів")
фон, коли не робимо нічого : 72 Б пік зі списком : 40448459 Б (38.6 МБ) пік із генератором : 608 Б список додав до фону : 40448387 Б генератор додав до фону : 536 Б сума в обох випадках: 333332833333500000 і 333332833333500000 ✅ той самий результат: список коштує десятки мегабайтів, генератор — сотні байтів
Головне в цьому замірі — не точна цифра, а те, від чого вона не залежить. Пік генератора — кількасот байтів: його власний кадр плюс одне число, яке зараз обчислюється. Постав замість мільйона мільярд — і це число не зміниться, а пік списку виросте в тисячу разів. У лекції стоїть 464 байти, заміряні в чистому скрипті; тут кілька зайвих десятків байтів додала наша ж функція-обгортка.
8 · Генераторний вираз: обіцянка з теми 13¶
Круглі дужки замість квадратних — і замість списку виходить генератор. Усередині виклику функції власні дужки не потрібні зовсім.
ціни = [85, 40, 25, 120, 60]
# усередині виклику дужок виразу не пишемо — досить дужок самого виклику
сума_ліниво = sum(ціна * 2 for ціна in ціни)
сума_списком = sum([ціна * 2 for ціна in ціни])
print("sum(генератор) =", сума_ліниво)
print("sum(список) =", сума_списком)
# any зупиняється на першому істинному — і решту навіть не обчислює
є_дорогі = any(ціна > 100 for ціна in ціни)
print("чи є ціна понад 100:", є_дорогі)
assert сума_ліниво == сума_списком, "результат має бути однаковий"
print("✅ той самий результат, але без проміжного списку")
sum(генератор) = 660 sum(список) = 660 чи є ціна понад 100: True ✅ той самий результат, але без проміжного списку
9 · Лінивий конвеєр із трьох генераторів¶
Тепер головне. Три генератори, кожен читає попередній. Щоб побачити, скільки роботи справді виконалось, поставимо всередину лічильники.
ТОВАРИ = ["кава", "чай", "какао", "сік", "вода"]
лічильник = {"прочитано": 0, "розібрано": 0, "віддано": 0}
def читати_журнал(скільки):
"""Удає файл журналу: віддає рядки по одному, не будуючи список."""
for номер in range(скільки):
лічильник["прочитано"] += 1
код = (номер * 1103515245 + 12345) % 2**31
yield f"{ТОВАРИ[номер % 5]};{номер % 3 + 1};{код % 1000 + 1}"
def розібрати(рядки):
"""Перетворює рядок на словник — теж по одному."""
for рядок in рядки:
лічильник["розібрано"] += 1
товар, кількість, сума = рядок.split(";")
yield {"товар": товар, "кількість": int(кількість), "сума": int(сума)}
def лише_дорогі(продажі, поріг=700):
"""Пропускає далі тільки те, що більше за поріг."""
for продаж in продажі:
if продаж["сума"] > поріг:
лічильник["віддано"] += 1
yield продаж
print("перші пʼять рядків журналу:")
for рядок in читати_журнал(5):
print(" ", рядок)
перші пʼять рядків журналу: кава;1;346 чай;2;591 какао;3;188 сік;1;433 вода;2;30
Складаємо конвеєр. Зверни увагу: після трьох присвоєнь лічильники ще на нулях — жоден генератор не зрушив з місця.
лічильник = {"прочитано": 0, "розібрано": 0, "віддано": 0}
рядки = читати_журнал(1_000_000)
продажі = розібрати(рядки)
дорогі = лише_дорогі(продажі)
print("конвеєр зібрано, лічильники:", лічильник)
assert лічильник["прочитано"] == 0, "до першого запиту не має виконатись нічого"
print("✅ поки ніхто не просить — не виконано жодного рядка")
конвеєр зібрано, лічильники: {'прочитано': 0, 'розібрано': 0, 'віддано': 0}
✅ поки ніхто не просить — не виконано жодного рядка
# просимо рівно три дорогі продажі й зупиняємось
перші_три = []
for продаж in дорогі:
перші_три.append(продаж)
if len(перші_три) == 3:
break
for продаж in перші_три:
print(" ", продаж)
print()
print("прочитано рядків :", лічильник["прочитано"])
print("розібрано рядків :", лічильник["розібрано"])
print("віддано продажів :", лічильник["віддано"])
print("частка журналу :", f'{лічильник["прочитано"] / 1_000_000 * 100:.4f} %')
assert лічильник["прочитано"] == 10, "трьох дорогих вистачає десяти рядків журналу"
print("✅ три результати з мільйона коштували 10 прочитаних рядків")
{'товар': 'чай', 'кількість': 1, 'сума': 872}
{'товар': 'сік', 'кількість': 3, 'сума': 714}
{'товар': 'вода', 'кількість': 1, 'сума': 959}
прочитано рядків : 10
розібрано рядків : 10
віддано продажів : 3
частка журналу : 0.0010 %
✅ три результати з мільйона коштували 10 прочитаних рядків
Перевіримо, що лінивий конвеєр дає точно те саме, що чесний варіант зі списками. Мільйон рядків для порівняння брати не будемо — досить перших двохсот.
лічильник = {"прочитано": 0, "розібрано": 0, "віддано": 0}
# спосіб 1: усе через списки, як робили б до цієї теми
усі_рядки = list(читати_журнал(200))
усі_продажі = [розібраний for розібраний in розібрати(усі_рядки)]
дорогі_списком = [п for п in усі_продажі if п["сума"] > 700]
прочитано_списком = лічильник["прочитано"]
# спосіб 2: той самий результат лінивим конвеєром
лічильник = {"прочитано": 0, "розібрано": 0, "віддано": 0}
дорогі_ліниво = list(лише_дорогі(розібрати(читати_журнал(200))))
print("дорогих продажів списками :", len(дорогі_списком))
print("дорогих продажів ліниво :", len(дорогі_ліниво))
assert дорогі_ліниво == дорогі_списком, "лінивий конвеєр має дати той самий список"
print("✅ результати збігаються до останнього словника")
дорогих продажів списками : 59 дорогих продажів ліниво : 59 ✅ результати збігаються до останнього словника
10 · Пастка перша: генератор вичерпується після одного обходу¶
Це та сама одноразовість із розділу 1, тільки тепер вона твоя власна. Найгірше в ній те, що другий обхід не падає — він просто нічого не робить.
квадрати_ = квадрати(4)
перший = list(квадрати_)
другий = list(квадрати_)
сума_після = sum(квадрати_)
print("перший обхід :", перший)
print("другий обхід :", другий)
print("sum() після :", сума_після)
assert перший == [0, 1, 4, 9], "перший обхід має віддати всі елементи"
assert len(другий) == 0, "другий обхід генератора має дати НУЛЬ елементів"
assert сума_після == 0, "у вичерпаному генераторі не лишилось нічого"
print("✅ другий обхід дав 0 елементів — мовчки, без жодної помилки")
перший обхід : [0, 1, 4, 9] другий обхід : [] sum() після : 0 ✅ другий обхід дав 0 елементів — мовчки, без жодної помилки
11 · Пастка друга: len() не працює¶
Генератор не знає, скільки в нього елементів, доки не дійде до кінця, — а дійшовши, вичерпається. Клітинка навмисно падає.
len(квадрати(4))
--------------------------------------------------------------------------- TypeError Traceback (most recent call last) Cell In[18], line 1 ----> 1 len(квадрати(4)) TypeError: object of type 'generator' has no len()
12 · Пастка третя: зрізів теж немає¶
Щоб узяти [0] чи [2:5], треба вміти стрибати до потрібної позиції, а генератор
уміє тільки «наступний». Клітинка навмисно падає.
квадрати(4)[0]
--------------------------------------------------------------------------- TypeError Traceback (most recent call last) Cell In[19], line 1 ----> 1 квадрати(4)[0] TypeError: 'generator' object is not subscriptable
Зріз повертає itertools.islice — вона просто пропускає потрібну кількість елементів,
беручи їх по одному.
from itertools import islice
шматок = list(islice(квадрати(1000), 2, 5))
print("islice(квадрати(1000), 2, 5) →", шматок)
assert шматок == [4, 9, 16], "islice має віддати елементи з позицій 2, 3, 4"
print("✅ зріз без матеріалізації тисячі значень")
islice(квадрати(1000), 2, 5) → [4, 9, 16] ✅ зріз без матеріалізації тисячі значень
13 · Ліки: tee для двох проходів і клас для багаторазового¶
Якщо потрібні два незалежні проходи одразу — itertools.tee. Якщо потрібне джерело,
яке лишається лінивим і його можна обходити скільки завгодно, — клас, чий __iter__
щоразу створює новий генератор.
from itertools import tee
а, б = tee(квадрати(4))
print("прохід а :", list(а))
print("прохід б :", list(б))
assert list(tee(квадрати(4))[0]) == [0, 1, 4, 9], "tee має дублювати обхід"
print("✅ два незалежні проходи по одному генератору")
прохід а : [0, 1, 4, 9] прохід б : [0, 1, 4, 9] ✅ два незалежні проходи по одному генератору
class Журнал:
"""Ітерований (не ітератор!): щоразу видає свіжий обхід."""
def __init__(self, скільки):
self.скільки = скільки
def __iter__(self):
return читати_журнал(self.скільки) # НОВИЙ генератор на кожен for
журнал = Журнал(5)
перший_обхід = list(журнал)
другий_обхід = list(журнал)
print("перший обхід :", перший_обхід)
print("другий обхід :", другий_обхід)
assert перший_обхід == другий_обхід, "клас має обходитись повторно"
assert len(другий_обхід) == 5, "другий обхід не має бути порожнім"
print("✅ ліниво — і при цьому скільки завгодно разів")
перший обхід : ['кава;1;346', 'чай;2;591', 'какао;3;188', 'сік;1;433', 'вода;2;30'] другий обхід : ['кава;1;346', 'чай;2;591', 'какао;3;188', 'сік;1;433', 'вода;2;30'] ✅ ліниво — і при цьому скільки завгодно разів
Різниця з класом Квадрати з розділу 4 — рівно в одному рядку. Там __iter__
повертав self, і клас був ітератором, одноразовим. Тут повертає новий
генератор — і клас став ітерованим джерелом, як список.
одноразовий = Квадрати(4)
print("Квадрати: перший обхід :", list(одноразовий))
print("Квадрати: другий обхід :", list(одноразовий))
assert list(одноразовий) == [], "клас, чий __iter__ віддає self, — одноразовий"
print("✅ той самий клас, але з __iter__ → self, обходиться лише раз")
Квадрати: перший обхід : [0, 1, 4, 9] Квадрати: другий обхід : [] ✅ той самий клас, але з __iter__ → self, обходиться лише раз
14 · Шухляда itertools¶
Шість функцій, які трапляються найчастіше. count нескінченний — зупиняє його
islice, а не він сам.
from itertools import count, chain, pairwise
лічилка = list(islice(count(10, 5), 4))
склеєне = list(chain([1, 2], [3], [4, 5]))
сусіди = list(pairwise([1, 2, 3, 4]))
print("islice(count(10, 5), 4) →", лічилка)
print("chain([1,2], [3], [4,5]) →", склеєне)
print("pairwise([1,2,3,4]) →", сусіди)
assert лічилка == [10, 15, 20, 25]
assert склеєне == [1, 2, 3, 4, 5]
assert сусіди == [(1, 2), (2, 3), (3, 4)]
print("✅ усі три працюють без проміжних списків")
islice(count(10, 5), 4) → [10, 15, 20, 25] chain([1,2], [3], [4,5]) → [1, 2, 3, 4, 5] pairwise([1,2,3,4]) → [(1, 2), (2, 3), (3, 4)] ✅ усі три працюють без проміжних списків
А тепер groupby — і її головна пастка. Вона групує лише сусідні однакові ключі,
тому спершу дані треба відсортувати за тим самим ключем.
from itertools import groupby
from operator import itemgetter
продажі = list(розібрати(читати_журнал(15)))
# спершу НЕ сортуємо — і бачимо, що груп із однією назвою вийшло кілька
без_сортування = [(ключ, len(list(група)))
for ключ, група in groupby(продажі, key=itemgetter("товар"))]
print("без сортування:", без_сортування)
# тепер сортуємо тим самим ключем — і групи стають правильними
за_товаром = sorted(продажі, key=itemgetter("товар"))
з_сортуванням = [(ключ, len(list(група)))
for ключ, група in groupby(за_товаром, key=itemgetter("товар"))]
print("з сортуванням :", з_сортуванням)
assert len(без_сортування) > len(з_сортуванням), "без сортування груп більше, ніж товарів"
assert len(з_сортуванням) == 5, "товарів у журналі рівно пʼять"
print("✅ groupby без сортування розсипає одну назву на кілька груп")
без сортування: [('кава', 1), ('чай', 1), ('какао', 1), ('сік', 1), ('вода', 1), ('кава', 1), ('чай', 1), ('какао', 1), ('сік', 1), ('вода', 1), ('кава', 1), ('чай', 1), ('какао', 1), ('сік', 1), ('вода', 1)]
з сортуванням : [('вода', 3), ('кава', 3), ('какао', 3), ('сік', 3), ('чай', 3)]
✅ groupby без сортування розсипає одну назву на кілька груп
15 · yield from: віддати назовні все, що дасть інше джерело¶
Коли генератор просто перекладає елементи з іншого джерела, внутрішній цикл —
зайва бухгалтерія. yield from каже те саме одним рядком.
def усі_журнали(розміри):
"""Склеює кілька журналів в один суцільний потік рядків."""
for розмір in розміри:
yield from читати_журнал(розмір)
склеєні = list(усі_журнали([2, 3]))
for рядок in склеєні:
print(" ", рядок)
assert len(склеєні) == 5, "два журнали по 2 і 3 рядки дають 5 рядків"
print("✅ п’ять рядків із двох джерел, жодного проміжного списку")
кава;1;346 чай;2;591 кава;1;346 чай;2;591 какао;3;188 ✅ п’ять рядків із двох джерел, жодного проміжного списку
def розгорнути(вкладене):
"""Витягує всі числа з довільно вкладених списків."""
for елемент in вкладене:
if isinstance(елемент, list):
yield from розгорнути(елемент) # той самий генератор глибше
else:
yield елемент
плоске = list(розгорнути([1, [2, [3, 4]], 5]))
print("розгорнути([1, [2, [3, 4]], 5]) →", плоске)
assert плоске == [1, 2, 3, 4, 5], "усі числа мають зібратись в один рівний список"
print("✅ рекурсія через yield from читається як звичайний цикл")
розгорнути([1, [2, [3, 4]], 5]) →
[1, 2, 3, 4, 5] ✅ рекурсія через yield from читається як звичайний цикл
16 · Найтихіша пастка наостанок: порожній генератор — це True¶
Порожній список хибний, і перевірка if дані: роками працює правильно. Але
генератор — обʼєкт, а не колекція, і питати в нього «чи ти порожній» ніхто не може:
відповідь коштувала б одного елемента.
порожній_список = []
порожній_генератор = квадрати(0)
print("bool(порожній список) =", bool(порожній_список))
print("bool(порожній генератор) =", bool(порожній_генератор))
print("а елементів у ньому =", len(list(квадрати(0))))
assert bool(порожній_список) is False
assert bool(порожній_генератор) is True, "генератор істинний завжди, навіть порожній"
print("✅ якщо треба перевірити наявність — бери next(г, None), а не if г:")
bool(порожній список) = False bool(порожній генератор) = True а елементів у ньому = 0 ✅ якщо треба перевірити наявність — бери next(г, None), а не if г:
# правильний спосіб спитати «чи є хоч щось»
перший = next(квадрати(0), None)
перший_із_чотирьох = next(квадрати(4), None)
print("перший елемент порожнього :", перший)
print("перший елемент із чотирьох:", перший_із_чотирьох)
assert перший is None, "у порожньому генераторі першого елемента немає"
assert перший_із_чотирьох == 0, "а тут він є і дорівнює нулю"
print("✅ next(г, None) відповідає чесно й коштує рівно один крок")
перший елемент порожнього : None перший елемент із чотирьох: 0 ✅ next(г, None) відповідає чесно й коштує рівно один крок
Що далі — три завдання¶
🟢 Рівень 1¶
Напиши генераторну функцію парні(межа), яка віддає парні числа від 0 до межі.
Доведи трьома assert-ами: (1) list(парні(10)) == [0, 2, 4, 6, 8],
(2) другий обхід того самого генератора дає нуль елементів,
(3) sys.getsizeof генератора на мільйоні менший за 1000 байтів.
🟡 Рівень 2¶
Додай до конвеєра з розділу 9 четверту ланку — генератор з_націнкою(продажі, відсоток),
який додає в кожен словник поле ціна_з_націнкою. Постав у ньому лічильник і покажи,
що на запит трьох дорогих продажів він виконався рівно тричі, а не мільйон разів.
🔴 Рівень 3¶
Зроби клас Журнал з розділу 13 повноцінним: додай йому __len__, який рахує
рядки новим обходом, і метод дорогі(поріг), що повертає генератор. Доведи
assert-ами, що len(журнал) можна викликати двічі поспіль і що після цього
list(журнал) усе одно не порожній.
Розгорнуті умови з критеріями «зроблено» — у homework.md.