Генераторы в python

Итераторы под маской функций, yield и немного внутренностей python.

Генараторы в python

Intro

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

Функции-генераторы (далее просто генераторы) это очень интересный функционал как с точки зрения дизайна, так и с точки зрения реализации в интерпретаторе python. Не могу сказать, что считаю его на 100% удачным решением, но это 100% работающий и довольно удобный инструмент, который у себя внедрили ещё и другие платформы (ruby, C#, ECMA Script и ещё маленькая тележка языков). Так что умные дядьки-разработчики языков что-то понимают (в отличие от меня).

Что же это такое и откуда у генераторов растут ноги? Старый-старый [pep-255 (simple generators)]https://peps.python.org/pep-0255/) вводит понятие генераторов и ключевое слово yield. Как это ключевое слово используется на уровне языка, какие возможности есть у генераторов и где их можно применять, я попробую рассказать ниже.

Что за yield?

Если добавить yield в функцию, то теперь при её вызове получим не какое-то значение, а объект-генератор. Этот объект-генератор поддерживает протокол итераторов. При получении очередного значения, этот объект будет возвращать значение после yield, указанное в нашей функции. Сам генератор при этом будет выполнять код между yield, запоминая состояние функции. Как только наша функция вернёт значение при помощи return или достигнув конца , то генератор остановит своё выполнение, выбросив исключение StopIteration.

Подробнее работу генераторов можно рассмотреть на примере:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
def my_generator(): # создаём функцию-генератор
    i = 0
    yield i
    i += 1
    yield i

gen_a = my_generator() # инициализируем генератор

for item in gen_a: # пробегаемся по элементам генератора: вывод будет 0 и 1
    print(item)

gen_b = my_generator()

# можно использовать и протокол итераторов
print(next(gen_b)) # вывод 0
print(next(gen_b)) # вывод 1
print(next(gen_b)) # будет выбрашено исключение StopIteration

Как это работает?

Чтобы понять, как работают генераторы в python, придётся погрузиться в детали реализации языка (CPython).

Как выполняются функции в CPython

В CPython код функции не выполняется сам по себе, он выполняется внутри специального объекта, фрейма. И прежде, чем запустить функцию, этот объект нужно создать. Он будет содержать в себе информацию о коде, который нужно выполнить, указатель на точку выполнение, локальные переменные и много всего ещё. Глянуть подробнее можно на гитхабе и в доках (пункт про frame). После того, как объект-фрейм создан, его можно использовать для выполнения кода, содержащегося в нём. Что и сделает интерпретатор. Таким образом, выполнение функции включает 2 основных шага: инициализацию и вычисление. После выполнения объект-фрейм будет уничтожен.

Выполнение генераторов.

Генераторы в python выполняются похожим образом (это же функции, камон). Основное отличие состоит в том в процесс выполнения вводится новый участник: объект-генератор, который сохраняет в себе фрейм после его (фрейма) завершения выполнения. Таким образом, когда мы получаем следующее значение генератора, cpython выполняет хранящийся в генераторе фрейм, используя сохранённую информацию о прошлых выполнениях (где выполнение остановилось, какие значения внутренних переменных были и т.д.). Когда же выполнение генератора подойдёт к концу (это происходит, когда будет вызван return явный или неявный), будет выброшено исключение StopIteration, а фрейм будет уничтожен.

Что умеют генераторы

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
def my_generator():
    yield 1
    return "StopIteration result"

gen = my_generator()

print(next(gen)) # вывод 1

try:
    next(gen)
except StopIteration as exc:
    print(exc.args[0]) # вывод StopIteration result

Генераторы также поддерживают и передачу параметров прямо во время выполнения генератора. Для этого используются методы генератора send и throw. Обратите внимание, что это не передача параметров при вызове функции-генератора, а отдельный механизм, ведь в случае вызова функции-генератора мы создаём новый генератор, а не продолжаем выполнение имеющегося. send используется, чтобы передать в выполняемый генератор значение, а throw - чтобы выбросить исключение. Значение, переданное при помощи send, можно получить присваиванием результата yield переменной. При вызове send или throw генератор перейдёт к следующему yield, вернув в текущий переданный параметр (если был вызов send) или выбросив исключение (если был вызов throw):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
def my_generator():
  first_value = yield
  second_value = yield

  print("First value + second value: ", first_value + second_value)
  third_value = yield

gen = my_generator()

>>> next(gen)
>>> gen.send(3)
>>> gen.send(4)
First value + second value: 7
>>> gen.throw(ValueError())
Traceback (most recent call last):
...
ValueError

И последняя возможность генераторов, которую хотелось бы обсудить -- это работа с вложенными генераторами. В python для этого есть отдельная конструкция yield from, которая позволяет запускать вложенный генератор и прозрачно для клиентского кода передавать параметры. Во время вызова yield from "внешний" генератор как бы "подменяет" себя "внутренним" генератором, и далее, пока "внутренний" генератор не вернёт значение при помощи return, клиентский код будет взаимодействовать с "внутренним" генератором.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
def inner():
  print("We're inside")
  value = yield 2
  print("Received", value)
  return 4

def outer():
  yield 1
  retval = yield from inner()
  print("Returned", retval)
  yield 5

>>> g = outer()
>>> next(g)
1
>>> next(g)  # Automatically enter the inner() generator
We're inside
2
>>> g.send(3)
Received 3
Returned 4
5

Заключение

Вот, вроде как и всё, что я знаю о генераторах. Как по мне, фича довольно удобная, но есть нюансы в использовании, которые мне лично кажутся некоторым излишним усложнением дизайна языка (передача параметров и работа с yield from). Но я и не дизайнер языков программирования, слава Богу, так что не без удовольствия пользуюсь плодами деятельности других людей.