Генераторные выражения
Вы уже знаете генераторы через yield. Теперь познакомьтесь с их «младшим братом» — генераторным выражением. Это однострочная запись генератора, похожая на list comprehension, но работающая лениво: не загружает всё в память сразу, а вычисляет значения по одному — именно тогда, когда они нужны.
📐 Синтаксис: почти как 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 — построчное чтение больших файлов без загрузки в память