Shanraq.org Shanraq.org
Итераторы и генераторы: 800 984 байта против 208
IT

Python: от данных до своей сводки Урок 16 из 56

Итераторы и генераторы: 800 984 байта против 208

Шестнадцатый урок курса по Python. Сто тысяч квадратов списком занимают почти четыре мегабайта, генератором — 376 байт: измерено, вместе со всеми числами. Разница не в том, как считать, а в том, когда появляются значения. Плюс главная ошибка: генератор одноразовый, и второй проход по нему тихо даёт пустоту.

Зачем это нужно

Пятый урок кончился обещанием: «списковые выражения — в шестнадцатом». Одиннадцатый добавил долг про гигабайтный файл, двенадцатый — про выгрузку в сотни тысяч строк. Всё это один и тот же вопрос, и сегодня он закрывается.

Вопрос такой: когда появляются значения. Список делает их все сразу и держит в памяти. Генератор делает по одному, когда попросят, и не держит ничего.

Пока данных мало, разница незаметна. Когда их много, она решает, работает программа или падает с нехваткой памяти.

Сразу целиком

Файл agyn.py. Запуск: python agyn.py из окружения.

Обязательное — второй и третий блоки: генератор с yield и разница между списком и генератором. Первый показывает, что for делал всё это время, четвёртый — ошибку, ради которой урок стоит дочитать.

"""Урок 16: генератор — данные, которых ещё нет в памяти.

Файл на сто тысяч строк в память поместится, а на гигабайт — нет. Разница не в
том, как читать, а в том, когда появляются значения: сразу все или по одному.
"""

import sys
import tracemalloc
from pathlib import Path

HERE = Path(__file__).parent
data = HERE / "vygruzka.csv"

# Учебная выгрузка: сто тысяч строк «год;значение».
with data.open("w", encoding="utf-8") as target:
    for year in range(2000, 2100):
        for number in range(1000):
            target.write(f"{year};{number % 20 + 1}.5\n")

print("== что делает for на самом деле")
numbers = [1, 2, 3]
step = iter(numbers)
print("по одному:", next(step), next(step), next(step))
try:
    next(step)
except StopIteration:
    print("StopIteration — на этом месте for просто заканчивается")

print()
print("== генератор отдаёт значения по одному")


def values(path):
    """Отдаёт (год, значение) по одному, не читая файл целиком."""
    with path.open(encoding="utf-8") as source:
        for line in source:
            year, _, text = line.strip().partition(";")
            yield int(year), float(text)


stream = values(data)
print("тип:", type(stream).__name__)
print("первое значение:", next(stream))
print("второе значение:", next(stream))

print()
print("== скобки решают: список или генератор")
# getsizeof меряет сам объект, а список — это только ссылки на числа. Сколько
# занято на самом деле, считает tracemalloc. Печатаем мегабайты: точный байт
# у tracemalloc свой на каждой машине, а «четыре мегабайта» — везде одни.
tracemalloc.start()
base = tracemalloc.get_traced_memory()[0]
squares_list = [number * number for number in range(100_000)]
list_all = tracemalloc.get_traced_memory()[0] - base
squares_gen = (number * number for number in range(100_000))
gen_all = tracemalloc.get_traced_memory()[0] - base - list_all
tracemalloc.stop()
print(f"список:    сам объект {sys.getsizeof(squares_list)} байт, со всеми числами {list_all / 1_000_000:.1f} МБ")
print(f"генератор: сам объект {sys.getsizeof(squares_gen)} байт, со всем, что держит {gen_all} байт")
print("сумма одна и та же:", sum(squares_list) == sum(squares_gen))

print()
print("== генератор одноразовый")
stream = values(data)
total = 0.0
count = 0
for _, value in stream:
    total += value
    count += 1
print(f"строк: {count}, сумма: {total:.1f}")
try:
    print(max(value for _, value in stream))
except ValueError as error:
    print("второй проход по тому же генератору:", error)

data.unlink()

Выводит:

== что делает for на самом деле
по одному: 1 2 3
StopIteration — на этом месте for просто заканчивается

== генератор отдаёт значения по одному
тип: generator
первое значение: (2000, 1.5)
второе значение: (2000, 2.5)

== скобки решают: список или генератор
список:    сам объект 800984 байт, со всеми числами 4.0 МБ
генератор: сам объект 208 байт, со всем, что держит 376 байт
сумма одна и та же: True

== генератор одноразовый
строк: 100000, сумма: 1100000.0
второй проход по тому же генератору: max() iterable argument is empty

Разбор

for всё это время звал next

по одному: 1 2 3
StopIteration — на этом месте for просто заканчивается

for — не магия. Он берёт у объекта итератор (iter), потом дёргает next до тех пор, пока не прилетит StopIteration, и на этом заканчивается.

Отсюда важное следствие: for работает не со списками, а со всем, что умеет отдавать по одному. Файл, словарь, range, строка, генератор — всё это перебирается одинаково, и range(1_000_000) не создаёт миллион чисел, а считает их по дороге.

Образ. Очередь в кассу. Кассир не спрашивает, сколько всего людей; он обслуживает следующего, пока следующий есть.

yield — функция, которая отдаёт по одному и запоминает место

def values(path):
    with path.open(encoding="utf-8") as source:
        for line in source:
            ...
            yield int(year), float(text)

Функция с yield — генераторная. Вызов её ничего не считает: values(data) возвращает объект-генератор, и файл в этот момент даже не открыт. Значения появляются при первом next — и по одному дальше.

return заканчивает функцию, yieldприостанавливает: при следующем next она продолжит с того же места, с теми же переменными и с тем же открытым файлом. Поэтому генератор читает гигабайтный файл на одной строке памяти: в ней всегда только текущая строка.

Такой генератор — ещё и место для отсева. Строку, которую не удалось разобрать, он может просто не отдавать наружу, и тому, кто его читает, не нужно про неё знать.

Скобки решают: список или генератор

squares_list = [number * number for number in range(100_000)]
squares_gen = (number * number for number in range(100_000))

Квадратные скобки — списковое выражение: короткая запись цикла, который собирает список. [x * 2 for x in prices] — это for с append, написанный одной строкой. Есть и с условием: [x for x in prices if x > 500].

Круглые скобки — генераторное выражение: то же самое, но значения не собираются, а выдаются по одному. Разница видна в измерении:

список:    сам объект 800984 байт, со всеми числами 4.0 МБ
генератор: сам объект 208 байт, со всем, что держит 376 байт

Здесь измерено дважды, и обе цифры нужны. sys.getsizeof показывает сам объект: у списка это 800 килобайт — сто тысяч ссылок и ничего больше, потому что сами числа лежат отдельно. tracemalloc считает всё, что было выделено, и вот там уже видно правду: около четырёх мегабайт против трёхсот семидесяти шести байт.

Разница почти в десять тысяч раз, и она не растёт вместе с данными только у одной стороны. Генератор занимает столько же на любом количестве: он хранит не значения, а место, на котором остановился, — свои локальные переменные и текущий шаг. «Не хранит ничего» было бы неправдой; правда в том, что он не хранит готовый результат.

Правило выбора простое: если результат нужен целиком и не раз — список; если он нужен один раз и по дороге — генератор. sum(x * x for x in range(100_000)) не создаёт список вовсе.

Генератор одноразовый

строк: 100000, сумма: 1100000.0
второй проход по тому же генератору: max() iterable argument is empty

Вот ошибка, ради которой стоит дочитать урок. Генератор кончается. Пройдя по нему один раз, вы его исчерпали, и второй проход даёт пустоту — не ошибку, а ноль элементов.

Здесь max пожаловался вслух, потому что у пустого нет максимума. Но sum на пустом даст 0, list[], а цикл просто не выполнится ни разу. Отчёт выйдет с нулями, и никто не скажет почему.

Лечится одним из двух решений: либо всё, что нужно, считают за один проход (как в примере: и сумма, и количество), либо генератор создают заново — values(data) — и платят вторым чтением файла. Что дешевле, зависит от размера файла; что честнее — всегда видно по коду.

Чего мы не берём сегодня

itertools — библиотека для потоков: islice берёт первые N, chain склеивает, groupby группирует подряд идущее. Всё это работает с генераторами и всё это по одному значению за раз. Знать, что модуль есть, сегодня достаточно.

Карта урока

Карта урока: список, генератор и один проход

Скажите своими словами

Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.

  1. Что делает for под капотом и почему по файлу можно идти так же, как по списку?
  2. Чем [x for x in ...] отличается от (x for x in ...) — и когда что берут?
  3. Почему второй проход по генератору даёт пустоту, и что с этим делают?

Разминка

Три коротких шага перед заданием: предсказать, дописать, починить. Ответы — в конце урока, но сначала ответьте сами.

1. Предскажите. Что напечатает эта программа? Обе суммы считает одно и то же выражение.

squares = (n * n for n in range(3))
print(sum(squares), sum(squares))

2. Заполните пропуск. Вместо ... поставьте выражение, которое удвоит каждую цену.

prices = [260, 620, 1890]
doubled = [... for price in prices]
print(doubled)

3. Почините. Программа падает: у генератора нет длины. Посчитайте количество, не собирая список.

values = (n for n in range(5))
print(len(values))

Задание

Обязательное. Дано:

rows = ["2024;8.7", "2025;n/a", "2025;11.4", "2026;15.0", "мусор", "2026;12.0"]

Запишите эти строки в файл рядом с программой. Напишите генератор values(path), который отдаёт (год, значение) по одному и молча пропускает строки, которые не разбираются. Возьмите из него первые два значения через next и напечатайте их. Затем за один проход посчитайте, сколько строк взято и какое среднее с двумя знаками. В конце напечатайте списковым выражением годы после 2024-го. Файл уберите за собой.

Ожидаемый вывод:

первое: (2024, 8.7)
второе: (2025, 11.4)
строк взято: 4, среднее: 11.78
годы после 2024: [2025, 2026, 2026]

Готово, когда: вывод совпадает построчно; в программе есть yield, а файл нигде не читается целиком; сумма и количество посчитаны в одном проходе; негодные строки отсеиваются внутри генератора, а не после него.

На своих данных. Возьмите свой файл — выгрузку, лог, что угодно построчное. Напишите генератор, который отдаёт из него только то, что вам нужно, и посчитайте по нему одну цифру. Скажите вслух, сколько строк файла при этом одновременно было в памяти.

По желанию.

  • Сравните sys.getsizeof для списка и генератора на миллионе элементов.
  • Соберите [x for x in range(20) if x % 3 == 0] и то же самое генератором — сравните типы.
  • Возьмите itertools.islice(values(path), 3) и посмотрите, сколько строк файла было прочитано.

Куда это встанет в проекте

Сводка перестаёт зависеть от размера выгрузки. Раньше она читала файл в список и считала по нему; теперь строки текут через генератор, и в памяти всегда одна. Тот же код будет работать и на сотне строк, и на гигабайте.

Долги. Один проход — это дисциплина: всё, что нужно от данных, приходится решать заранее. И itertools мы назвали, но не разобрали; вернёмся, когда сводке понадобится склеивать несколько источников.

Ответы

Показать ответы

На вопросы

  1. Берёт итератор через iter и дёргает next, пока не прилетит StopIteration. Поэтому for одинаково работает со всем, что умеет отдавать значения по одному, — со списком, файлом, словарём, генератором.
  2. Квадратные скобки собирают список сразу и держат его в памяти; круглые дают генератор, который выдаёт значения по одному и не хранит ничего. Список берут, когда результат нужен целиком и не раз; генератор — когда он нужен один раз и по дороге.
  3. Потому что генератор помнит место, на котором остановился, а остановился он в конце. Второй проход не ошибка, а пустота — и это опаснее ошибки. Либо считают всё за один проход, либо создают генератор заново.

К разминке

  1. 5 0. Первая сумма прошла генератор до конца — 0 + 1 + 4. Ко второй в нём уже ничего не осталось, и sum от пустого даёт ноль, не сказав ни слова.
5 0
  1. price * 2. Слева от for в списковом выражении стоит то, что кладут в список, справа — откуда берут.
prices = [260, 620, 1890]
doubled = [price * 2 for price in prices]
print(doubled)
[520, 1240, 3780]
  1. TypeError: object of type 'generator' has no len(). Длины у генератора нет: он и сам не знает, сколько значений отдаст. Считают по дороге:
values = (n for n in range(5))
print(sum(1 for _ in values))
5

Источники

Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом

Проверить задание

Сначала решите и запустите в VS Code — редактор покажет ошибку на месте. Готовое решение вставьте сюда. Проверяет модель: она укажет на ошибку, но не даст готовый ответ.

Чтобы проверить, нужно войти. Войти

Комментарии (0)

Пока нет комментариев. Будьте первым.