Практика · Ітератори й генератори¶

Лекція: lecture.html · Тест: quiz.html · ДЗ: homework.md

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

  1. побачимо різницю між ітерованим і ітератором на списку й на zip;
  2. розберемо протокол по кроках: iter(), next(), StopIteration;
  3. напишемо той самий обхід двічі — класом і генератором — і доведемо assert-ом, що вони дають однакове;
  4. заміряємо памʼять двома мірками — sys.getsizeof і tracemalloc — і отримаємо числа з лекції;
  5. збудуємо лінивий конвеєр із трьох генераторів і порахуємо, скільки рядків він насправді прочитав;
  6. спіймаємо всі три пастки: вичерпаність, len() і зріз;
  7. полагодимо їх через list, itertools.tee і клас із __iter__;
  8. пройдемось по itertools і по yield from.

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

1 · Список можна обходити багато разів, а zip — один¶

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

In [1]:
товари = ["кава", "чай", "сік"]

# рахуємо елементи окремо для кожного обходу, щоб побачити, чи джерело «зношується»
перший_обхід = [товар for товар in товари]
другий_обхід = [товар for товар in товари]

print("перший обхід списку :", перший_обхід)
print("другий обхід списку :", другий_обхід)
assert len(другий_обхід) == 3, "список має обходитись повторно"
print("✅ список обходиться скільки завгодно разів")
перший обхід списку : ['кава', 'чай', 'сік']
другий обхід списку : ['кава', 'чай', 'сік']
✅ список обходиться скільки завгодно разів

А тепер те саме з zip. Код відрізняється одним словом, а поведінка — принципово.

In [2]:
пари = zip(товари, [85, 40, 25])

перший_обхід = [пара for пара in пари]
другий_обхід = [пара for пара in пари]

print("перший обхід zip :", перший_обхід)
print("другий обхід zip :", другий_обхід)
assert другий_обхід == [], "другий обхід ітератора має дати нуль елементів"
print("✅ другий обхід дав рівно 0 елементів — і жодної помилки")
перший обхід zip : [('кава', 85), ('чай', 40), ('сік', 25)]
другий обхід zip : []
✅ другий обхід дав рівно 0 елементів — і жодної помилки

2 · Чому так: iter() від списку й iter() від ітератора¶

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

In [3]:
обхідник_один = 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. Це рівно те, що робить цикл усередині.

In [4]:
обхідник = iter(товари)

print("next() →", next(обхідник))
print("next() →", next(обхідник))
print("next() →", next(обхідник))

# другий аргумент next() — значення замість винятку, коли елементи скінчились
print("next() з підстраховкою →", next(обхідник, "нічого немає"))
next() → кава
next() → чай
next() → сік
next() з підстраховкою → нічого немає

А ось як виглядає кінець без підстраховки. Ця клітинка навмисно падає — StopIteration тут не поломка, а домовлений сигнал «усе».

In [5]:
next(обхідник)
---------------------------------------------------------------------------
StopIteration                             Traceback (most recent call last)
Cell In[5], line 1
----> 1 next(обхідник)

StopIteration: 

4 · Свій ітератор класом: стан доводиться зберігати руками¶

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

In [6]:
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 писати не треба.

In [7]:
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 усередині.

In [8]:
def квадрати_з_гуком(межа):
    print("   ← тіло почалося")
    номер = 0
    while номер < межа:
        yield номер * номер
        номер += 1


print("викликаємо функцію:")
г = квадрати_з_гуком(3)
print("отримали обʼєкт:", г)
print("а тепер перший next():")
print("   next(г) →", next(г))
викликаємо функцію:
отримали обʼєкт: <generator object квадрати_з_гуком at 0x7f6e5948edc0>
а тепер перший next():
   ← тіло почалося
   next(г) → 0

Тепер покроково. Дивись на змінну номер між викликами: вона не обнуляється, бо переживає паузу разом з усім кадром функції.

In [9]:
г = квадрати(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 бачить усе, що виділилось насправді, — разом із мільйоном обʼєктів-чисел за цими посиланнями. Числа мають збігтися з лекцією.

In [10]:
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 разів

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

In [11]:
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¶

Круглі дужки замість квадратних — і замість списку виходить генератор. Усередині виклику функції власні дужки не потрібні зовсім.

In [12]:
ціни = [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 · Лінивий конвеєр із трьох генераторів¶

Тепер головне. Три генератори, кожен читає попередній. Щоб побачити, скільки роботи справді виконалось, поставимо всередину лічильники.

In [13]:
ТОВАРИ = ["кава", "чай", "какао", "сік", "вода"]
лічильник = {"прочитано": 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

Складаємо конвеєр. Зверни увагу: після трьох присвоєнь лічильники ще на нулях — жоден генератор не зрушив з місця.

In [14]:
лічильник = {"прочитано": 0, "розібрано": 0, "віддано": 0}

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

print("конвеєр зібрано, лічильники:", лічильник)
assert лічильник["прочитано"] == 0, "до першого запиту не має виконатись нічого"
print("✅ поки ніхто не просить — не виконано жодного рядка")
конвеєр зібрано, лічильники: {'прочитано': 0, 'розібрано': 0, 'віддано': 0}
✅ поки ніхто не просить — не виконано жодного рядка
In [15]:
# просимо рівно три дорогі продажі й зупиняємось
перші_три = []
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 прочитаних рядків

Перевіримо, що лінивий конвеєр дає точно те саме, що чесний варіант зі списками. Мільйон рядків для порівняння брати не будемо — досить перших двохсот.

In [16]:
лічильник = {"прочитано": 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, тільки тепер вона твоя власна. Найгірше в ній те, що другий обхід не падає — він просто нічого не робить.

In [17]:
квадрати_ = квадрати(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() не працює¶

Генератор не знає, скільки в нього елементів, доки не дійде до кінця, — а дійшовши, вичерпається. Клітинка навмисно падає.

In [18]:
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], треба вміти стрибати до потрібної позиції, а генератор уміє тільки «наступний». Клітинка навмисно падає.

In [19]:
квадрати(4)[0]
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[19], line 1
----> 1 квадрати(4)[0]

TypeError: 'generator' object is not subscriptable

Зріз повертає itertools.islice — вона просто пропускає потрібну кількість елементів, беручи їх по одному.

In [20]:
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__ щоразу створює новий генератор.

In [21]:
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]
✅ два незалежні проходи по одному генератору
In [22]:
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, і клас був ітератором, одноразовим. Тут повертає новий генератор — і клас став ітерованим джерелом, як список.

In [23]:
одноразовий = Квадрати(4)
print("Квадрати: перший обхід :", list(одноразовий))
print("Квадрати: другий обхід :", list(одноразовий))

assert list(одноразовий) == [], "клас, чий __iter__ віддає self, — одноразовий"
print("✅ той самий клас, але з __iter__ → self, обходиться лише раз")
Квадрати: перший обхід : [0, 1, 4, 9]
Квадрати: другий обхід : []
✅ той самий клас, але з __iter__ → self, обходиться лише раз

14 · Шухляда itertools¶

Шість функцій, які трапляються найчастіше. count нескінченний — зупиняє його islice, а не він сам.

In [24]:
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 — і її головна пастка. Вона групує лише сусідні однакові ключі, тому спершу дані треба відсортувати за тим самим ключем.

In [25]:
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 каже те саме одним рядком.

In [26]:
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
✅ п’ять рядків із двох джерел, жодного проміжного списку
In [27]:
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 дані: роками працює правильно. Але генератор — обʼєкт, а не колекція, і питати в нього «чи ти порожній» ніхто не може: відповідь коштувала б одного елемента.

In [28]:
порожній_список = []
порожній_генератор = квадрати(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 г:
In [29]:
# правильний спосіб спитати «чи є хоч щось»
перший = 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.