Генераторы Python: повышение производительности и упрощение кода

Представьте, что вы работаете над проектом по науке о данных, и ваша задача – обработать набор данных такого размера, что его загрузка в память приводит к сбою вашего компьютера. Или вы имеете дело с бесконечной последовательностью, например, с потоком живых данных, где вы не можете хранить все одновременно. Вот эти вызовы заставляют ученых-данных прибегать к кофейнику – и иногда к кнопке сброса.  

В этой статье мы узнаем о Python генераторах и о том, как их можно использовать для упрощения вашего кода. Для понимания этой идеи требуется практика, поэтому, если вы новичок в Python и немного запутались в этой статье, попробуйте наш Курс по введению в Python , чтобы создать крепкое основание. 

Что такое Python генераторы?

По своей сути, генераторы Python – это особый вид функции или даже компактное выражение, которое лениво создает последовательность значений. Представьте генераторы как конвейер на фабрике: вместо того чтобы складывать все продукты в одно место и исчерпывать пространство, вы обрабатываете каждый элемент по мере его поступления. Это делает генераторы эффективными по памяти и естественным расширением протокола iterator Python, который лежит в основе многих встроенных инструментов Python, таких как циклы for и генераторы.

Магия генераторов заключается в ключевом слове yield. В отличие от return, который выводит одно значение и завершает выполнение функции, yield производит значение, приостанавливает выполнение функции и сохраняет ее состояние. Когда генератор вызывается снова, он продолжает работу с того места, где остановился.

Например, представьте, что вы читаете огромный файл журнала построчно. Генератор может обрабатывать каждую строку по мере чтения без загрузки всего файла в память. Это “ленивое вычисление” отличает генераторы от традиционных функций и делает их ключевым инструментом для задач, требующих высокой производительности.

Пример базового генератора Python

Давайте немного попрактикуемся, чтобы понять идею. Вот функция-генератор, которая создает первые n целых чисел.

def generate_integers(n): for i in range(n): yield i # Здесь делается пауза и возвращается i # Использование генератора for num in generate_integers(5): print(num)
0 1 2 3 4

Я создал визуализацию, чтобы вы могли увидеть, что происходит под капотом:

Синтаксис и шаблоны генераторов в Python

Генераторы могут быть реализованы несколькими способами. Сказано, что существуют два основных способа: функции-генераторы и выражения-генераторы.

Функции-генераторы

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

def count_up_to(n): count = 1 while count <= n: yield count count += 1 # Использование генератора counter = count_up_to(5) for num in counter: print(num)
1 2 3 4 5

Из приведенного выше примера мы видим, что при вызове функции count_up_to возвращается объект-генератор. Каждый раз, когда цикл for запрашивает значение, функция выполняется до тех пор, пока не достигнет yield, возвращая текущее значение count и сохраняя свое состояние между итерациями, чтобы продолжить работу с того места, где она остановилась.

Выражения-генераторы

Выражения-генераторы – это компактный способ создания генераторов. Они похожи на генераторы списков, но используют круглые скобки вместо квадратных скобок.

# Генератор списков (жадное вычисление) squares_list = [x**2 for x in range(5)] # [0, 1, 4, 9, 16] # Выражение-генератор (ленивое вычисление) squares_gen = (x**2 for x in range(5)) # Использование генератора for square in squares_gen: print(square)
0 1 4 9 16

Итак, в чем разница между списковым включением и выражением-генератором? Списковое включение создает весь список в памяти, в то время как выражение-генератор производит значения по одному, экономя память. Если вы не знакомы со списковыми включениями, вы можете прочитать об этом в нашем Учебнике по списковым включениям Python.

Python генератор против итератора

Традиционные итераторы в Python требовали создания классов с явными методами __iter__() и __next__(), что включало много шаблонного кода и ручного управления состоянием, в то время как генераторные функции упрощают процесс, автоматически сохраняя состояние и устраняя необходимость в этих методах, как это демонстрируется простой функцией, возвращающей квадрат каждого числа до n.

Почему мы используем генераторы Python

При объяснении, что такое генераторы в Python, я также передал некоторые идеи о том, зачем они используются. В этом разделе я хочу подробнее остановиться. Потому что генераторы – это не просто изысканная особенность Python, а они действительно решают реальные проблемы.

Эффективность использования памяти  

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

Например, рассмотрим разницу между range() в Python 2 и xrange():  

  •  range() создавал список в памяти, что могло быть проблематично для больших диапазонов.

  • Функция xrange() действовала как генератор, лениво производя значения.

Поскольку поведение xrange() было более полезным, сейчас, в Python 3, range() также ведет себя как генератор, избегая избыточного расхода памяти на хранение всех значений одновременно.  

Чтобы продемонстрировать идею, давайте сравним использование памяти при генерации последовательности из 10 миллионов чисел:  

import sys # Используя список numbers_list = [x for x in range(10_000_000)] print(f"Memory used by list: {sys.getsizeof(numbers_list) / 1_000_000:.2f} MB") # Используя генератор numbers_gen = (x for x in range(10_000_000)) print(f"Memory used by generator: {sys.getsizeof(numbers_gen)} bytes")
Memory used by list: 89.48 MB Memory used by the generator: 112 bytes

Как видно, генератор использует практически никакой памяти по сравнению со списком, и эта разница значительна.  

Улучшение производительности  

Благодаря ленивой оценке, значения вычисляются только при необходимости. Это означает, что вы можете начать обрабатывать данные немедленно, не дожидаясь генерации всей последовательности.  

Например, представьте, что нужно сложить квадраты первых 1 миллиона чисел:

# Использование списка (жадная оценка) sum_of_squares_list = sum([x**2 for x in range(1_000_000)]) # Использование генератора (ленивая оценка) sum_of_squares_gen = sum(x**2 for x in range(1_000_000))

Хотя оба подхода дают одинаковый результат, версия с генератором избегает создания массивного списка, поэтому мы получаем результат быстрее. 

Простота и читаемость  

Генераторы упрощают реализацию итераторов, устраняя шаблонный код. Сравните итератор на основе класса с функцией-генератором:

Вот итератор на основе класса:

class SquaresIterator: def __init__(self, n): self.n = n self.current = 0 def __iter__(self): return self def __next__(self): if self.current >= self.n: raise StopIteration result = self.current ** 2 self.current += 1 return result # Использование squares = SquaresIterator(5) for square in squares: print(square)

Вот функция-генератор:

def squares_generator(n): for i in range(n): yield i ** 2 # Использование squares = squares_generator(5) for square in squares: print(square)

Версия с генератором короче, легче читать и не требует шаблонного кода. Это отличный пример философии Python: простое – значит лучшее.

Обработка бесконечных последовательностей

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

def fibonacci(): a, b = 0, 1 while True: yield a a, b = b, a + b # Использование fib = fibonacci() for _ in range(10): print(next(fib))
0 1 1 2 3 5 8 13 21 34

Этот генератор может бесконечно производить числа Фибоначчи без исчерпания памяти. Другие примеры включают обработку потоков данных в реальном времени или работу с данными временных рядов.

Продвинутые концепции генераторов в Python  

Теперь давайте рассмотрим немного более сложные идеи. В этом разделе мы исследуем, как компоновать генераторы и использовать уникальные методы генератора, такие как .send(), .throw() и .close().

Сочетание генераторов

Генераторы можно объединять. Вы можете модульно преобразовывать, фильтровать и обрабатывать данные, объединяя генераторы.

Предположим, у вас есть бесконечная последовательность чисел, и вы хотите возвести в квадрат каждое число и отфильтровать нечетные результаты:

def infinite_sequence(): num = 0 while True: yield num num += 1 def square_numbers(sequence): for num in sequence: yield num ** 2 def filter_evens(sequence): for num in sequence: if num % 2 == 0: yield num # Составление генераторов numbers = infinite_sequence() squared = square_numbers(numbers) evens = filter_evens(squared) # Вывод первых 10 четных квадратов for _ in range(10): print(next(evens))
0 4 16 36 64 100 144 196 256 324

Процесс включает функцию infinite_sequence, генерирующую числа бесконечно, в то время как square_numbers выдает квадрат каждого числа, а затем filter_evens фильтрует нечетные числа, чтобы произвести только четные квадраты. Наш карьерный трек Associate Python Developer затрагивает подобные вещи, так что вы можете увидеть, как создавать и отлаживать сложные конвейеры с использованием генераторов, а также итераторов и списковых включений. 

Специальные методы генератора  

Генераторы обладают продвинутыми методами, позволяющими двустороннее взаимодействие и управление завершением.

send()

Метод .send() позволяет вам передавать значения обратно в генератор, превращая его в корутину. Это полезно для создания интерактивных или состояний генераторов.  

def accumulator(): total = 0 while True: value = yield total if value is not None: total += value # Использование генератора acc = accumulator() next(acc) # Запуск генератора print(acc.send(10)) # Вывод: 10 print(acc.send(5)) # Вывод: 15 print(acc.send(20)) # Вывод: 35

Вот как это работает: 

  • Генератор начинается с next(acc) для инициализации.  

  • Каждый вызов .send(value) передает значение в генератор, которое присваивается value в операторе yield.  

  • Генератор обновляет свое состояние (total) и возвращает новый результат.

throw()

Метод .throw() позволяет вызвать исключение внутри генератора, что может быть полезно для обработки ошибок или сигнализации определенных условий.

def resilient_generator(): try: for i in range(5): yield i except ValueError: yield "Error occurred!" # Использование генератора gen = resilient_generator() print(next(gen)) # Вывод: 0 print(next(gen)) # Вывод: 1 print(gen.throw(ValueError)) # Вывод: "Произошла ошибка!"

Вот как это работает: 

  • Генератор обычно работает до вызова .throw().

  • Исключение генерируется внутри генератора, который может обработать его с помощью блока try-except.

close()

Метод .close() останавливает генератор, вызывая исключение GeneratorExit. Это полезно для очистки ресурсов или остановки бесконечных генераторов.

def infinite_counter(): count = 0 try: while True: yield count count += 1 except GeneratorExit: print("Generator closed!") # Использование генератора counter = infinite_counter() print(next(counter)) # Вывод: 0 print(next(counter)) # Вывод: 1 counter.close() # Вывод: "Генератор закрыт!"

И вот как это работает:

  • Генератор работает до вызова .close().

  • Исключение GeneratorExit возбуждается, позволяя генератору завершить работу или зарегистрировать сообщение перед завершением.

Прикладные программы в области науки о данных  

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

Обработка больших наборов данных  

Одной из наиболее распространенных проблем в области науки о данных является работа с наборами данных, слишком большими для помещения их в память. Генераторы предоставляют способ обработки таких данных построчно.  

Представьте, что у вас есть файл CSV размером 10 ГБ, содержащий данные о продажах, и вам нужно отфильтровать записи для определенного региона. Вот как вы можете использовать конвейер генераторов для достижения этой цели:

import csv def read_large_csv(file_path): """ Generator to read a large CSV file line by line.""" with open(file_path, mode="r") as file: reader = csv.DictReader(file) for row in reader: yield row def filter_by_region(data, region): """ Generator to filter rows by a specific region.""" for row in data: if row["Region"] == region: yield row # Конвейер генераторов file_path = "sales_data.csv" region = "North America" data = read_large_csv(file_path) filtered_data = filter_by_region(data, region) # Обработка отфильтрованных данных for record in filtered_data: print(record)

Вот что происходит:

  1. read_large_csv читает файл построчно, возвращая каждую строку в виде словаря.

  2. filter_by_region фильтрует строки на основе указанного региона.

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

Этот подход полезен для рабочих процессов извлечения, трансформации и загрузки, где данные должны быть очищены и преобразованы перед анализом. Вы увидите подобное в нашем курсе ETL и ELT на Python.

Стриминг и пайплайны

Иногда данные поступают как непрерывный поток. Датчики, онлайн-потоки или социальные медиа – вот образцы.

Предположим, что вы работаете с устройствами Интернета вещей, которые генерируют показания температуры каждую секунду. Вы хотите рассчитать среднюю температуру за скользящее окно из 10 показаний:

def sensor_data_stream(): """Simulate an infinite stream of sensor data.""" import random while True: yield random.uniform(0, 100) # Симуляция данных сенсора def sliding_window_average(stream, window_size): """ Calculate the average over a sliding window of readings.""" window = [] for value in stream: window.append(value) if len(window) > window_size: window.pop(0) if len(window) == window_size: yield sum(window) / window_size # Конвейер генераторов sensor_stream = sensor_data_stream() averages = sliding_window_average(sensor_stream, window_size=10) # Печать среднего значения каждую секунду for avg in averages: print(f"Average temperature: {avg:.2f}")

Вот объяснение:

  1. sensor_data_stream симулирует бесконечный поток показаний сенсора.

  2. sliding_window_average поддерживает скользящее окно последних 10 показаний и выдает их среднее значение.

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

Дополнительные случаи использования

Генераторы также используются в ситуациях, когда размер данных непредсказуем или когда они постоянно поступают/бесконечны.

Веб-скрапинг

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

def scrape_website(url): """ Generator to scrape a website page by page.""" while url: # Симуляция загрузки и разбора страницы print(f"Scraping {url}") data = f"Data from {url}" yield data url = get_next_page(url) # Гипотетическая функция для получения следующей страницы # Использование scraper = scrape_website("https://example.com/page1") for data in scraper: print(data)

Задачи симуляции

В симуляциях, таких как метод Монте-Карло или разработка игр, генераторы могут представлять бесконечные или динамические последовательности:

def monte_carlo_simulation(): """ Generator to simulate random events for Monte Carlo analysis.""" import random while True: yield random.random() # Использование simulation = monte_carlo_simulation() for _ in range(10): print(next(simulation))

Тесты на Производительность и Память

Благодаря своему принципу работы генераторы отлично справляются в сценариях, где критична эффективность использования памяти, но (вы можете удивиться) они не всегда являются самым быстрым вариантом. Давайте сравним генераторы с списками, чтобы понять их компромиссы.  

Ранее мы показали, что генераторы лучше списков с точки зрения использования памяти. Это была часть, где мы сравнили использование памяти при создании последовательности из 10 миллионов чисел. Теперь давайте сделаем другое, сравнение скорости:

import time # Списковое включение start_time = time.time() sum([x**2 for x in range(1_000_000)]) print(f"List comprehension time: {time.time() - start_time:.4f} seconds") # Генераторное выражение start_time = time.time() sum(x**2 for x in range(1_000_000)) print(f"Generator expression time: {time.time() - start_time:.4f} seconds")
List comprehension time: 0.1234 seconds Generator expression time: 0.1456 seconds

Хотя генератор экономит память, в этом случае он на самом деле медленнее списка. Это потому, что для этого более маленького набора данных есть накладные расходы на приостановку и возобновление выполнения.  

Разница в производительности незначительна для небольших наборов данных, но для больших наборов данных экономия памяти генераторов часто перевешивает небольшой штраф за скорость.  

Проблемы, с которыми сталкиваются

Наконец, давайте рассмотрим некоторые распространенные ошибки или проблемы:

Генераторы исчерпываемы.

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

gen = (x for x in range(5)) print(list(gen)) # Вывод: [0, 1, 2, 3, 4] print(list(gen)) # Вывод: [] (генератор исчерпан)

Ленивая оценка может быть сложной

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

Можно злоупотреблять генераторами

Для небольших наборов данных или простых задач издержки использования генератора могут не оправдать экономию памяти. Рассмотрите этот пример, где я материализую данные для нескольких итераций.

# Генераторное выражение gen = (x**2 for x in range(10)) # Преобразование в список squares = list(gen) # Повторное использование списка print(sum(squares)) # Вывод: 285 print(max(squares)) # Вывод: 81

Выбор момента использования генераторов

Подводя итог, я предоставлю некоторые очень общие правила, когда использовать генераторы. Используйте для:

  • Больших наборов данных: Используйте генераторы при работе с наборами данных, слишком большими для помещения в память.  
  • Бесконечных последовательностей: Используйте генераторы для представления бесконечных последовательностей, таких как потоки живых данных или симуляции.
  • Каналов обработки данных: Используйте генераторы для построения модульных конвейеров обработки данных, которые трансформируют и фильтруют данные постепенно.  

Когда материализовывать данные (преобразовывать в список)  

  • Небольшие наборы данных: Не используйте генераторы, если память не проблема и вам нужен быстрый доступ ко всем элементам; вместо этого используйте список.  
  • Несколько итераций: Не используйте генераторы, если вам нужно многократно итерироваться по тем же данным; вместо этого материализуйте их в список, чтобы избежать повторного создания генератора.  

Вывод и ключевые моменты

На протяжении этой статьи мы исследовали, как генераторы могут помочь вам справиться с реальными задачами в области науки о данных, начиная от обработки больших наборов данных до создания потоков данных в реальном времени. Продолжайте практиковаться. Лучший способ освоить генераторы – использовать их в своей работе. Например, попробуйте заменить списковое включение выражения на генераторное выражение или преобразовать цикл в функцию-генератор.

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

  • Корутины: Используйте .send() и .throw(), чтобы создавать генераторы, которые могут получать и обрабатывать данные, обеспечивая двустороннюю связь.

  • Асинхронное программирование: Комбинируйте генераторы с библиотекой asyncio Python для создания эффективных, неблокирующих приложений.

  • Конкурентность: Узнайте, как генераторы могут реализовать кооперативное мультизадачное и легковесное параллельное выполнение.

Продолжайте учиться и становитесь экспертом. Пройдите нашу карьерную программу Python Developer или навык Python Programming уже сегодня. Нажмите на ссылку ниже, чтобы начать.

Source:
https://www.datacamp.com/tutorial/python-generators