$ sudo teach IT
МОДУЛЬ 8 · УРОК 4

Генераторные выражения

Вы уже знаете генераторы через yield. Теперь познакомьтесь с их «младшим братом» — генераторным выражением. Это однострочная запись генератора, похожая на list comprehension, но работающая лениво: не загружает всё в память сразу, а вычисляет значения по одному — именно тогда, когда они нужны.

⏱ ~45 минут 🎓 Для новичков 🐍 Python 3

📐 Синтаксис: почти как list comprehension

Вы уже умеете писать list comprehension — краткую запись для создания списка:

# List comprehension — создаёт список (квадратные скобки)
squares_list = [x * 2 for x in range(10)]
print(squares_list)   # [0, 2, 4, 6, 8, 10, 12, 14, 16, 18]
print(type(squares_list))  # <class 'list'>

# Generator expression — создаёт генератор (круглые скобки)
squares_gen = (x * 2 for x in range(10))
print(squares_gen)    # <generator object <genexpr> at 0x...>
print(type(squares_gen))   # <class 'generator'>

Единственное отличие в записи: квадратные скобки [...] — это список, круглые скобки (...) — это генераторное выражение. Синтаксис внутри одинаковый.

Можно также добавить условие фильтрации — так же, как в list comprehension:

# Только чётные числа
even_gen = (x for x in range(20) if x % 2 == 0)

# Итерируем по генератору как обычно
for val in even_gen:
    print(val, end=" ")
# 0 2 4 6 8 10 12 14 16 18

🧠 Главное отличие: память

List comprehension сразу создаёт весь список и держит его в памяти. Генераторное выражение — ленивое: оно ничего не вычисляет заранее, а отдаёт значения по одному только когда их запросят.

Посмотрим разницу в реальных цифрах с помощью модуля sys:

import sys

# Создаём list comprehension и generator expression на миллион элементов
big_list = [x * 2 for x in range(1_000_000)]
big_gen  = (x * 2 for x in range(1_000_000))

print(sys.getsizeof(big_list))  # ~8 697 456 байт (около 8.3 МБ!)
print(sys.getsizeof(big_gen))   # 104 байта (всегда!)

# Список занял 8 мегабайт — он хранит ВСЕ миллион чисел.
# Генератор занял 104 байта — он хранит только инструкцию,
# как получить следующее значение.

📦

List comprehension

Похоже на ящик, в котором сразу лежат все предметы. Ящик занимает место — даже если вы возьмёте только первый предмет.

🏭

Generator expression

Похоже на фабрику-автомат: предметов нет, но как только вы нажмёте кнопку — выдаст один. Фабрика занимает одинаковое место при любом количестве предметов.

⚠️ Важно помнить: генераторное выражение — одноразовое. После полного обхода оно «исчерпано» и не сбрасывается назад. Если нужно пройти по данным несколько раз — используйте список.

🎯 Когда применять генераторное выражение?

📂

Большие данные

Файлы на гигабайты, логи с миллионами строк — читать построчно, не загружая всё.

🔁

Одноразовый проход

Нужен только один проход по данным — нет смысла создавать список.

⚙️

Передача в функцию

sum(), max(), any() — принимают любой итерируемый объект, зачем создавать лишний список?

➕ Генераторное выражение в sum(), min(), max(), any(), all()

Встроенные функции Python, принимающие итерируемые объекты, отлично работают с генераторами. Причём если генераторное выражение — единственный аргумент функции, можно написать его без дополнительных скобок:

numbers = range(1, 101)  # числа от 1 до 100

# sum — сумма квадратов без создания списка
total = sum(x ** 2 for x in numbers)
print(total)  # 338350

# max и min — максимум/минимум отфильтрованных значений
evens = range(1, 21)
print(max(x for x in evens if x % 3 == 0))  # 18
print(min(x for x in evens if x % 3 == 0))  # 3

# any — есть ли хотя бы один элемент, удовлетворяющий условию?
# Останавливается как только найдёт первый подходящий — очень быстро!
has_negative = any(x < 0 for x in [1, 5, -3, 8])
print(has_negative)  # True

# all — все ли элементы удовлетворяют условию?
all_positive = all(x > 0 for x in [1, 5, 3, 8])
print(all_positive)  # True

💡 Про скобки: sum((x**2 for x in numbers)) и sum(x**2 for x in numbers) — одно и то же. Когда генератор — единственный аргумент, двойные скобки избыточны. Python это понимает.

Но если функция принимает несколько аргументов — скобки нужны: sorted((x for x in data), reverse=True).

# Сравнение: список vs генератор в sum()
import sys, time

n = 10_000_000

# С list comprehension
t1 = time.perf_counter()
result1 = sum([x * 2 for x in range(n)])
t2 = time.perf_counter()
print(f"List: {t2 - t1:.3f}s")   # ~0.6s, занял ~80MB RAM

# С generator expression
t3 = time.perf_counter()
result2 = sum(x * 2 for x in range(n))
t4 = time.perf_counter()
print(f"Gen:  {t4 - t3:.3f}s")   # ~0.5s, занял ~0MB (почти!)

print(result1 == result2)  # True — результат одинаков!

🔗 Пайплайны генераторов — ленивые цепочки

Пайплайн (pipeline) — это цепочка обработки данных, где каждый шаг получает данные от предыдущего. Ключевая идея: данные проходят через всю цепочку по одному элементу за раз, без промежуточных больших списков.

Представьте конвейер на заводе: деталь не ждёт, пока все детали пройдут первый станок. Она сразу движется дальше — к следующему станку.

# Эмулируем поток данных: числа от 1 до 1 000 000
raw_data = range(1, 1_000_001)

# Шаг 1: берём только чётные
step1 = (x for x in raw_data if x % 2 == 0)

# Шаг 2: возводим в квадрат
step2 = (x ** 2 for x in step1)

# Шаг 3: берём только те, что меньше 1000
step3 = (x for x in step2 if x < 1000)

# Только здесь начинается РЕАЛЬНОЕ вычисление!
result = list(step3)
print(result)
# [4, 16, 36, 64, 100, 144, 196, 256, 324, 400, 484, 576, 676, 784, 900]

🎯 Магия ленивости: когда мы присваиваем step1, step2, step3 — Python ничего не вычисляет. Это просто инструкции. Реальная работа начинается только при вызове list(step3). Элемент проходит через все три шага сразу, прежде чем берётся следующий.

Пайплайн можно написать более компактно — вложив генераторы:

# Тот же пайплайн, но в одну строку
result = list(
    x ** 2
    for x in range(1, 1_000_001)
    if x % 2 == 0 and x ** 2 < 1000
)
print(result)
# [4, 16, 36, 64, 100, 144, 196, 256, 324, 400, 484, 576, 676, 784, 900]

📄 Практика: обработка «большого файла»

Самый классический пример применения генераторных выражений — чтение и обработка больших файлов. Файл читается построчно: в памяти в любой момент времени находится только одна строка, а не весь файл.

Эмулируем большой лог-файл прямо в коде:

def fake_log_file(n):
    """Генерирует n строк лога — эмуляция чтения файла."""
    statuses = ["200 OK", "404 Not Found", "500 Internal Server Error", "200 OK", "200 OK"]
    for i in range(n):
        status = statuses[i % len(statuses)]
        yield f"2024-01-{(i % 28) + 1:02d} GET /page/{i} {status}"

# Эмулируем файл из 1 000 000 строк
log = fake_log_file(1_000_000)

# --- ЗАДАЧА: найти все строки с ошибками 500 ---

# ПЛОХОЙ СПОСОБ (загружает всё в память):
# errors = [line for line in log if "500" in line]

# ХОРОШИЙ СПОСОБ — генераторный пайплайн:
error_lines = (line for line in log if "500" in line)
error_urls  = (line.split()[2] for line in error_lines)   # берём только URL

# Считаем количество ошибок 500, не держа список в памяти
count = sum(1 for _ in error_urls)
print(f"Ошибок 500: {count}")   # Ошибок 500: 200000
# Ещё один реальный пример: работа с настоящим файлом
# (предположим, что файл существует)

def count_long_lines(filename, min_length=80):
    """Считает строки длиннее min_length символов, не загружая файл целиком."""
    with open(filename, encoding="utf-8") as f:
        # f — итерируемый объект, читает построчно автоматически
        long = (line for line in f if len(line.strip()) > min_length)
        return sum(1 for _ in long)

# При огромном файле (несколько ГБ) — этот код всё равно работает корректно!
# count = count_long_lines("huge_log.txt")

✅ Правило профессионала: если вы обрабатываете файл (или любой поток данных) и результат нужен только для одного прохода — всегда используйте генераторы. Это хороший тон и экономия памяти.

📋 Шпаргалка: что когда использовать

📝 List comprehension

  • Нужен список (индексы, len, срезы)
  • Нужен многократный проход
  • Данные небольшие
  • Нужно отладить — печатать промежуточный результат

⚡ Generator expression

  • Данные большие / потоковые
  • Нужен только один проход
  • Передача в sum/any/all/max/min
  • Строите пайплайн обработки

🏁 Итоги урока

  • Генераторное выражение — это генератор в одну строку: (expr for x in iterable if cond)
  • Отличается от list comprehension только круглыми скобками и ленивостью
  • Не хранит данные в памяти — размер всегда ~104 байта, независимо от объёма данных
  • Идеально передавать в sum(), max(), min(), any(), all()
  • Пайплайн — цепочка из нескольких генераторов: данные обрабатываются по одному элементу без промежуточных списков
  • Классический use-case — построчное чтение больших файлов без загрузки в память