36. Генераторы и ленивая итерация
В главе 35 мы изучили, как в Python работает итерация через итерируемые объекты и итераторы. Мы увидели, что итераторы возвращают значения по одному по запросу, что позволяет Python обрабатывать последовательности, не загружая всё в память сразу. Теперь мы рассмотрим генераторы(generator) — самый элегантный и практичный способ создавать итераторы(iterator) в Python.
Генераторы — это функции(function), которые могут приостанавливать и возобновлять своё выполнение, выдавая значения по одному по мере запроса, вместо того чтобы вычислять все значения заранее и хранить их в памяти. Такой подход — ленивые вычисления(lazy evaluation) — означает, что значения генерируются только при необходимости, что делает его одной из самых мощных возможностей Python для написания кода с экономией памяти.
36.1) Что такое генераторы и почему они полезны
36.1.1) Проблема создания больших списков
Начнём с понимания проблемы, которую решают генераторы. Представьте, что вам нужно обработать последовательность из одного миллиона чисел. Вот традиционный подход с использованием списка(list):
# Создание списка из одного миллиона квадратов
def get_squares_list(n):
"""Return a list of squares from 0 to n-1."""
squares = []
for i in range(n):
squares.append(i * i)
return squares
# Это создаёт список с 1 000 000 чисел в памяти
numbers = get_squares_list(1_000_000)
print(f"First five squares: {numbers[:5]}") # Output: First five squares: [0, 1, 4, 9, 16]У этого подхода есть серьёзная проблема: он создаёт и хранит все один миллион чисел в памяти сразу, даже если вам нужно обрабатывать их по одному. Для более крупных наборов данных или более сложных вычислений это может потреблять огромные объёмы памяти или даже привести к аварийному завершению программы.
36.1.2) Введение в генераторы: вычисление значений по запросу
Генератор(generator) — это особый тип функции(function), который выдаёт значения по одному, только когда их запрашивают. Вместо построения и возврата полного списка(list) генератор вычисляет каждое значение по мере необходимости и «помнит», где он остановился, между вызовами.
Вот та же функциональность, реализованная как генератор:
# Создание генератора квадратов
def get_squares_generator(n):
"""Generate squares from 0 to n-1, one at a time."""
for i in range(n):
yield i * i # yield приостанавливает функцию и возвращает значение
# Это создаёт объект генератора, а не список
squares_gen = get_squares_generator(1_000_000)
print(squares_gen) # Output: <generator object get_squares_generator at 0x...>
# Получаем значения по одному
print(next(squares_gen)) # Output: 0
print(next(squares_gen)) # Output: 1
print(next(squares_gen)) # Output: 4Генератор не вычисляет все один миллион квадратов заранее. Вместо этого он вычисляет каждый квадрат только когда вы вызываете next() для него. Между вызовами генератор «приостанавливается» и помнит своё состояние (текущее значение i).
36.1.3) Эффективность по памяти: ключевое преимущество
Разница по памяти между списками(list) и генераторами становится драматичной при больших наборах данных. Давайте сравним:
import sys
# Подход со списком: хранит все значения
def squares_list(n):
return [i * i for i in range(n)]
# Подход с генератором: вычисляет значения по запросу
def squares_generator(n):
for i in range(n):
yield i * i
# Сравним использование памяти для 100 000 чисел
list_result = squares_list(100_000)
gen_result = squares_generator(100_000)
print(f"List size in memory: {sys.getsizeof(list_result):,} bytes")
# Output: List size in memory: 800,984 bytes (actual size may vary)
print(f"Generator size in memory: {sys.getsizeof(gen_result)} bytes")
# Output: Generator size in memory: 200 bytes (actual size may vary)Список потребляет более 800 КБ памяти, тогда как генератор использует всего 200 байт — независимо от того, сколько значений он в итоге выдаст. Генератор хранит только состояние функции (текущее значение i и место, откуда продолжать), а не реальную последовательность значений.
36.1.4) Когда генераторы полезны
Генераторы отлично подходят для нескольких распространённых сценариев:
Обработка больших файлов:
def read_large_file(filename):
"""Генерировать строки файла по одной."""
with open(filename, 'r') as file:
for line in file:
yield line.strip()
# Обрабатываем огромный лог-файл, не загружая его целиком в память
for line in read_large_file('huge_log.txt'):
if 'ERROR' in line:
print(line)Бесконечные последовательности:
def fibonacci():
"""Генерировать числа Фибоначчи бесконечно."""
a, b = 0, 1
while True:
yield a
a, b = b, a + b
# Генерируем числа Фибоначчи бесконечно (или пока вы не перестанете запрашивать)
fib = fibonacci()
print([next(fib) for _ in range(10)])
# Output: [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]36.1.5) Генераторы — это итераторы
Как мы узнали в главе 35, генераторы на самом деле являются особым видом итератора(iterator). Они автоматически реализуют протокол итератора (__iter__() и __next__()), поэтому они без проблем работают с циклами(loop) for:
def countdown(n):
"""Генерировать обратный отсчёт от n до 1."""
while n > 0:
yield n
n -= 1
# Генераторы напрямую работают в циклах for
for num in countdown(5):
print(num)
# Output:
# 5
# 4
# 3
# 2
# 1Когда вы используете генератор в цикле(loop) for, Python автоматически многократно вызывает next() для него, пока генератор не будет исчерпан (не вызовет StopIteration).
36.2) Создание функций-генераторов с yield
36.2.1) Оператор yield: приостановка и возобновление
Оператор yield делает функцию генератором. Когда Python встречает yield, он делает нечто особенное: вместо того чтобы вернуть значение и завершить функцию, он приостанавливает функцию и возвращает значение. В следующий раз, когда вы вызовете next() для генератора, выполнение продолжится сразу после оператора yield.
Вот простой пример, демонстрирующий это поведение приостановки и возобновления:
def simple_generator():
"""Продемонстрировать, как yield приостанавливает выполнение."""
print("Starting generator")
yield 1
print("Resuming after first yield")
yield 2
print("Resuming after second yield")
yield 3
print("Generator finished")
gen = simple_generator()
print("Created generator")
# Output:
# Created generator
print(f"First value: {next(gen)}")
# Output:
# Starting generator
# First value: 1
print(f"Second value: {next(gen)}")
# Output:
# Resuming after first yield
# Second value: 2
print(f"Third value: {next(gen)}")
# Output:
# Resuming after second yield
# Third value: 3
try:
next(gen)
except StopIteration:
print("Generator exhausted - no more values")
# Output:
# Generator finished
# Generator exhausted - no more valuesОбратите внимание, как выполнение функции чередуется с вызовами next(). Каждый yield приостанавливает функцию, а каждый next() возобновляет её с того места, где она остановилась.
36.2.2) Состояние генератора: запоминание локальных переменных
Генераторы запоминают все свои локальные переменные между yield. Это делает их полезными для сохранения состояния между несколькими вызовами:
def counter(start=0):
"""Генерировать последовательные числа, начиная со start."""
current = start
while True:
yield current
current += 1
# Генератор запоминает 'current' между yield
count = counter(10)
print(next(count)) # Output: 10
print(next(count)) # Output: 11
print(next(count)) # Output: 12
# У каждого генератора есть своё независимое состояние
count1 = counter(0)
count2 = counter(100)
print(next(count1)) # Output: 0
print(next(count2)) # Output: 100
print(next(count1)) # Output: 1
print(next(count2)) # Output: 101Переменная current сохраняется каждый раз, когда генератор приостанавливается на yield, и восстанавливается при следующем вызове next(). Это позволяет генератору продолжать счёт с последнего значения. Каждый экземпляр генератора поддерживает собственное независимое состояние.
36.2.3) yield в циклах: самый распространённый шаблон
Самое распространённое использование генераторов — выдача значений внутри цикла(loop). Этот шаблон генерирует последовательность значений:
def even_numbers(start, end):
"""Генерировать чётные числа в заданном диапазоне."""
current = start if start % 2 == 0 else start + 1
while current <= end:
yield current
current += 2
# Используем генератор
evens = even_numbers(1, 20)
print(list(evens))
# Output: [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]Каждая итерация цикла выдаёт одно значение, а затем переходит к следующей итерации, когда next() вызывается снова.
36.2.4) Несколько операторов yield
У генератора может быть несколько операторов yield в разных местах кода. Выполнение проходит через них по порядку:
def process_data(data):
"""Генерировать обработанные данные вместе с сообщениями о статусе."""
yield "Starting processing..."
cleaned = [item.strip().lower() for item in data]
yield f"Cleaned {len(cleaned)} items"
unique = list(set(cleaned))
yield f"Found {len(unique)} unique items"
for item in sorted(unique):
yield item
# Обрабатываем данные
data = [" Apple ", "Banana", "apple", "Cherry", "BANANA"]
processor = process_data(data)
for result in processor:
print(result)
# Output:
# Starting processing...
# Cleaned 5 items
# Found 3 unique items
# apple
# banana
# cherryЭтот шаблон полезен для генераторов, которым нужно выполнить подготовительную работу, выдать информацию о статусе, а затем выдавать реальные данные.
36.3) Генераторные выражения vs списковые включения
36.3.1) Введение в генераторные выражения
В главе 34 мы изучили списковые включения — краткий способ создавать списки(list). Генераторные выражения(generator expressions) используют почти идентичный синтаксис, но создают генераторы(generator), а не списки.
Генераторное выражение по сути является компактным способом написать простую функцию-генератор. Сравните два эквивалентных подхода:
# Функция-генератор
def squares_function(n):
for x in range(n):
yield x * x
# Генераторное выражение — делает то же самое
squares_expression = (x * x for x in range(10))
# Оба создают объекты генератора
gen1 = squares_function(10)
gen2 = squares_expression
print(type(gen1)) # Output: <class 'generator'>
print(type(gen2)) # Output: <class 'generator'>
# Оба выдают одинаковые значения
print(list(squares_function(10))) # Output: [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
print(list(squares_expression)) # Output: [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]Синтаксис почти идентичен списковым включениям. Различия такие: используются круглые скобки () вместо квадратных [], и если списковые включения создают списки, то генераторные выражения создают генераторы:
# Списковое включение — создаёт весь список в памяти
squares_list = [x * x for x in range(10)]
print(squares_list)
# Output: [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
# Генераторное выражение — создаёт объект генератора
squares_gen = (x * x for x in range(10))
print(squares_gen)
# Output: <generator object <genexpr> at 0x...>
# Преобразуем в список, чтобы увидеть значения
print(list(squares_gen))
# Output: [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]Генераторные выражения дают тот же краткий синтаксис, что и списковые включения, но с эффективностью по памяти, как у генераторов.
36.3.2) Сравнение памяти: когда это важно
Для небольших последовательностей разница по памяти между списковыми включениями и генераторными выражениями пренебрежимо мала. Но для больших последовательностей она становится существенной:
import sys
# Небольшая последовательность — минимальная разница
small_list = [x for x in range(100)]
small_gen = (x for x in range(100))
print(f"Small list: {sys.getsizeof(small_list)} bytes")
# Output: Small list: 920 bytes (actual size may vary)
print(f"Small generator: {sys.getsizeof(small_gen)} bytes")
# Output: Small generator: 192 bytes (actual size may vary)
# Большая последовательность — огромная разница
large_list = [x for x in range(1_000_000)]
large_gen = (x for x in range(1_000_000))
print(f"Large list: {sys.getsizeof(large_list):,} bytes")
# Output: Large list: 8,448,728 bytes (actual size may vary)
print(f"Large generator: {sys.getsizeof(large_gen)} bytes")
# Output: Large generator: 192 bytes (actual size may vary)Размер генератора остаётся постоянным независимо от того, сколько значений он выдаст — он хранит только выражение и текущее состояние. Список же должен хранить все значения в памяти, поэтому его размер растёт пропорционально числу элементов.
36.3.3) Генераторные выражения в вызовах функций
Генераторные выражения особенно элегантны, когда их передают напрямую в функции, которые потребляют итерируемые объекты. Можно опустить дополнительные скобки, когда генераторное выражение — единственный аргумент:
# Вычисляем сумму квадратов без создания списка
total = sum(x * x for x in range(100)) # Note: no extra parentheses needed
print(total)
# Output: 328350
# Находим максимум среди преобразованных значений
numbers = [1, 2, 3, 4, 5]
max_square = max(x * x for x in numbers)
print(max_square)
# Output: 25
# Проверяем, удовлетворяет ли какое-либо значение условию
data = [10, 15, 20, 25, 30]
has_large = any(x > 100 for x in data)
print(has_large)
# Output: FalseЭтот шаблон одновременно экономит память и остаётся читабельным. Такие функции, как sum(), max(), min(), any() и all(), обрабатывают генератор по одному значению за раз, никогда не создавая промежуточный список.
36.3.4) Фильтрация с помощью генераторных выражений
Генераторные выражения поддерживают ту же условную логику, что и списковые включения:
# Фильтруем чётные числа
numbers = range(20)
evens = (x for x in numbers if x % 2 == 0)
print(list(evens))
# Output: [0, 2, 4, 6, 8, 10, 12, 14, 16, 18]
# Преобразуем и фильтруем
words = ["hello", "world", "python", "programming"]
long_upper = (word.upper() for word in words if len(word) > 5)
print(list(long_upper))
# Output: ['PYTHON', 'PROGRAMMING']36.3.5) Когда генераторных выражений недостаточно
Генераторные выражения лаконичны и элегантны, но у них есть ограничения. Используйте функции-генераторы, когда вам нужно:
Сложная логика:
# Слишком сложно для генераторного выражения
def process_log_lines(filename):
"""Обрабатывать лог-файл со сложной логикой."""
with open(filename, 'r') as file:
for line in file:
line = line.strip()
if not line or line.startswith('#'):
continue # Пропустить пустые строки и комментарии
parts = line.split('|')
if len(parts) >= 3:
timestamp, level, message = parts[0], parts[1], parts[2]
if level in ('ERROR', 'CRITICAL'):
yield {
'timestamp': timestamp,
'level': level,
'message': message
}Несколько yield или состояние:
# Генераторное выражение не может поддерживать состояние между итерациями
def running_total(numbers):
"""Генерировать накопительную сумму чисел."""
total = 0
for num in numbers:
total += num
yield total
numbers = [1, 2, 3, 4, 5]
print(list(running_total(numbers)))
# Output: [1, 3, 6, 10, 15]Обработка ошибок:
# Генераторное выражение не может обрабатывать исключения
def safe_divide(numbers, divisor):
"""Генерировать результаты деления, обрабатывая ошибки."""
for num in numbers:
try:
yield num / divisor
except ZeroDivisionError:
yield float('inf')36.4) Когда использовать генераторы вместо списков
36.4.1) Большие наборы данных: основной сценарий использования
Самая убедительная причина использовать генераторы — работа с большим объёмом данных. Если вы обрабатываете миллионы записей, генераторы могут стать разницей между программой, которая работает стабильно, и программой, которая падает.
Плохой подход — загрузка всего файла в память:
# НЕ ДЕЛАЙТЕ ТАК с большими файлами
def count_errors_bad(filename):
"""Загрузить весь файл в память — упадёт на больших файлах."""
with open(filename, 'r') as file:
lines = file.readlines() # Загружает ВЕСЬ файл в память
error_count = 0
for line in lines:
if 'ERROR' in line:
error_count += 1
return error_count
# If the file is 10 GB, this tries to load 10 GB into memory!Хороший подход — использование генератора:
def read_log_lines(filename):
"""Генерировать строки лог-файла по одной."""
with open(filename, 'r') as file:
for line in file:
yield line.strip()
def count_errors_good(filename):
"""Подсчитать ошибки, не загружая весь файл в память."""
error_count = 0
for line in read_log_lines(filename):
if 'ERROR' in line:
error_count += 1
return error_count
# Это эффективно работает даже с лог-файлами размером в гигабайты
# потому что в памяти одновременно держится только одна строка
count = count_errors_good('huge_application.log')
print(f"Found {count} errors")Подход с генератором обрабатывает по одной строке за раз, поэтому использование памяти остаётся постоянным независимо от размера файла. Файл на 10 ГБ использует столько же памяти, сколько файл на 10 КБ.
36.4.2) Бесконечные или неизвестной длины последовательности
Генераторы идеально подходят для последовательностей, длину которых вы не знаете заранее, или которые концептуально бесконечны:
def user_input_stream():
"""Генерировать пользовательский ввод, пока они не введут 'quit'."""
while True:
user_input = input("Enter a number (or 'quit'): ")
if user_input.lower() == 'quit':
break
try:
yield int(user_input)
except ValueError:
print("Invalid number, try again")
# Обрабатываем пользовательский ввод по мере поступления
total = 0
count = 0
for number in user_input_stream():
total += number
count += 1
print(f"Running average: {total / count:.2f}")Нельзя создать список неизвестной длины, но генератор справляется с этим естественно.
36.4.3) Цепочки преобразований: построение конвейеров обработки данных
Когда нужно применить к данным несколько преобразований, генераторы позволяют выстраивать цепочки операций без создания промежуточных списков:
# Преобразуем числа через несколько этапов
def generate_numbers(n):
"""Генерировать числа от 1 до n."""
for i in range(1, n + 1):
yield i
def square_numbers(numbers):
"""Генерировать квадраты входных чисел."""
for num in numbers:
yield num * num
def keep_even(numbers):
"""Генерировать только чётные числа."""
for num in numbers:
if num % 2 == 0:
yield num
# Связываем генераторы в цепочку — промежуточные списки не создаются
numbers = generate_numbers(10)
squared = square_numbers(numbers)
even_squares = keep_even(squared)
# Обрабатываем результаты
print(list(even_squares))
# Output: [4, 16, 36, 64, 100]Каждый этап обрабатывает по одному значению за раз, передавая его на следующий этап. Это экономит память и позволяет обрабатывать наборы данных, превышающие доступный объём RAM.
Без генераторов понадобились бы промежуточные списки:
# Подход без генераторов — создаёт промежуточные списки
numbers = list(range(1, 11)) # [1, 2, 3, ..., 10]
squared = [n * n for n in numbers] # [1, 4, 9, ..., 100]
even_squares = [n for n in squared if n % 2 == 0] # [4, 16, 36, 64, 100]
# С генераторами — промежуточные списки не создаются
numbers = (i for i in range(1, 11))
squared = (n * n for n in numbers)
even_squares = (n for n in squared if n % 2 == 0)
print(list(even_squares))
# Output: [4, 16, 36, 64, 100]Для конвейера из трёх этапов, обрабатывающего один миллион элементов, подход со списками создал бы три списка по одному миллиону элементов каждый. Подход с генераторами держит в памяти только одно значение за раз.
36.4.4) Когда списки лучше генераторов
Несмотря на преимущества, генераторы подходят не всегда. Используйте списки(list), когда вам нужно:
Несколько проходов:
# Список — можно проходить несколько раз
numbers = [1, 2, 3, 4, 5]
print(sum(numbers)) # Output: 15
print(max(numbers)) # Output: 5 (works fine)
# Генератор — пройти можно только один раз
numbers_gen = (x for x in range(1, 6))
print(sum(numbers_gen)) # Output: 15
print(max(numbers_gen)) # Output: ValueError: max() iterable argument is emptyЕсли вам нужно обрабатывать одни и те же данные несколько раз, используйте список.
Произвольный доступ:
# Нужно обращаться к элементам по индексу — используйте список
students = ['Alice', 'Bob', 'Charlie', 'Diana']
print(students[2]) # Output: Charlie
# Генераторы не поддерживают индексацию
students_gen = (name for name in students)
# students_gen[2] # ERROR: 'generator' object is not subscriptableИнформация о длине:
# Нужно знать длину — используйте список
data = [1, 2, 3, 4, 5]
print(f"Processing {len(data)} items")
# У генераторов нет длины
data_gen = (x for x in data)
# len(data_gen) # ERROR: object of type 'generator' has no len()Небольшие наборы данных:
# Для небольших наборов данных списки подходят и удобнее
small_data = [x * 2 for x in range(10)]
# Здесь экономия памяти от генератора несущественна
# а список более гибкий36.4.5) Практическое руководство по выбору
Вот практическое руководство по выбору между генераторами и списками:
Используйте генераторы, когда:
- Обрабатываете большие файлы или наборы данных
- Работаете с потоками данных или пользовательским вводом
- Строите конвейеры обработки данных
- Важна экономия памяти
- Нужно пройти последовательность только один раз
- Последовательность бесконечная или очень длинная
Используйте списки, когда:
- Набор данных маленький (обычно)
- Нужно пройти данные несколько раз
- Нужен произвольный доступ по индексу
- Нужно знать длину
- Нужно передать данные в код, который ожидает список
36.4.6) Преобразование между генераторами и списками
При необходимости вы легко можете преобразовывать генераторы и списки:
# Генератор в список
numbers_gen = (x * 2 for x in range(5))
numbers_list = list(numbers_gen)
print(numbers_list)
# Output: [0, 2, 4, 6, 8]
# Список в генератор (с помощью генераторного выражения)
numbers_list = [1, 2, 3, 4, 5]
numbers_gen = (x for x in numbers_list)Эта гибкость означает, что вы можете начать с генератора ради эффективности и преобразовать в список только тогда, когда вам понадобятся возможности, специфичные для списков:
# Начинаем с генератора для экономии памяти
numbers = (x for x in range(1, 1001))
filtered = (x for x in numbers if x % 7 == 0)
# Преобразуем в список, когда нужно несколько проходов
multiples_of_seven = list(filtered)
# Теперь можно использовать возможности списка
print(f"Count: {len(multiples_of_seven)}")
# Output: Count: 142
print(f"First: {multiples_of_seven[0]}")
# Output: First: 7
print(f"Last: {multiples_of_seven[-1]}")
# Output: Last: 994
# Можно проходить несколько раз
total = sum(multiples_of_seven)
average = total / len(multiples_of_seven)
print(f"Average: {average:.1f}")
# Output: Average: 500.5Генераторы — одна из самых элегантных возможностей Python для написания кода с экономией памяти. Они позволяют обрабатывать большие наборы данных, строить конвейеры обработки данных и работать с бесконечными последовательностями — и при этом сохранять код чистым и читабельным. По мере накопления опыта у вас появится интуиция, когда генераторы являются правильным инструментом для задачи.