Python: использовать `yield from` или вернуть генератор?
Я написал следующий простой фрагмент кода:
def mymap(func, *seq):
return (func(*args) for args in zip(*seq))
Должен ли я использовать оператор return
, как показано выше, чтобы вернуть генератор, или следует использовать инструкцию yield from
, как в этом примере:
def mymap(func, *seq):
yield from (func(*args) for args in zip(*seq))
Кроме технической разницы между return
и yield from
, какой из подходов является более предпочтительным в общем случае?
4 ответ(ов)
Разница заключается в том, что ваша первая версия mymap
представляет собой обычную функцию, в данном случае фабрику, которая возвращает генератор. Все, что находится внутри тела функции, выполняется сразу, как только вы вызываете функцию.
def gen_factory(func, seq):
"""Фабрика генераторов, возвращающая генератор."""
# выполняем действия ... сразу же, когда вызывается фабрика
print("Создание генератора и возврат")
return (func(*args) for args in seq)
Вторая версия mymap
также является фабрикой, но она сама по себе является генератором и использует yield
из внутреннего генератора. Поскольку это генератор, выполнение тела не начинается до первого вызова next(generator)
.
def gen_generator(func, seq):
"""Генератор, использующий yield из внутреннего генератора."""
# выполняем действия ... в первый раз, когда вызывается 'next'
print("Создание генератора и yield")
yield from (func(*args) for args in seq)
Я думаю, что следующий пример сделает это более понятным. Мы определим наборы данных, которые будут обработаны с помощью функций, упакованных в задачи, которые мы передаем генераторам.
def add(a, b):
return a + b
def sqrt(a):
return a ** 0.5
data1 = [*zip(range(1, 5))] # [(1,), (2,), (3,), (4,)]
data2 = [(2, 1), (3, 1), (4, 1), (5, 1)]
job1 = (sqrt, data1)
job2 = (add, data2)
Теперь мы запустим следующий код в интерактивной оболочке, такой как IPython, чтобы увидеть различия в поведении. Функция gen_factory
немедленно выводит результат, в то время как gen_generator
делает это только после вызова next()
.
gen_fac = gen_factory(*job1)
# Создание генератора и возврат <-- выводится немедленно
next(gen_fac) # запуск
# Вывод: 1.0
[*gen_fac] # опустошение оставшейся части генератора
# Вывод: [1.4142135623730951, 1.7320508075688772, 2.0]
gen_gen = gen_generator(*job1)
next(gen_gen) # запуск
# Создание генератора и yield <-- выводится при первом вызове next()
# Вывод: 1.0
[*gen_gen] # опустошение оставшейся части генератора
# Вывод: [1.4142135623730951, 1.7320508075688772, 2.0]
Чтобы дать вам более разумный пример использования конструкции, подобной gen_generator
, мы немного расширим её и сделаем корутину, назначив yield
переменным, чтобы мы могли вставлять задачи в работающий генератор с помощью send()
.
Кроме того, мы создадим вспомогательную функцию, которая будет выполнять все задачи в задаче и запрашивать новую после завершения.
def gen_coroutine():
"""Корутинный генератор, использующий yield из внутреннего генератора."""
# выполняем действия ... в первый раз, когда вызывается 'next'
print("Прием задачи, создание генератора и yield, цикл")
while True:
try:
func, seq = yield "Отправь работу ... или я выйду с помощью next()"
except TypeError:
return "Задач больше нет"
else:
yield from (func(*args) for args in seq)
def do_job(gen, job):
"""Выполнить все задачи в задаче."""
print(gen.send(job))
while True:
result = next(gen)
print(result)
if result == "Отправь работу ... или я выйду с помощью next()":
break
Теперь мы запускаем gen_coroutine
вместе с нашей вспомогательной функцией do_job
и двумя задачами.
gen_co = gen_coroutine()
next(gen_co) # запуск
# Прием задачи, создание генератора и yield, цикл <-- выводится при первом вызове next()
# Вывод: 'Отправь работу ... или я выйду с помощью next()'
do_job(gen_co, job1) # выводит все результаты из задачи
# 1
# 1.4142135623730951
# 1.7320508075688772
# 2.0
# Отправь работу ... или я выйду с помощью next()
do_job(gen_co, job2) # отправляем еще одну задачу в генератор
# 3
# 4
# 5
# 6
# Отправь работу ... или я выйду с помощью next()
next(gen_co)
# Traceback ...
# StopIteration: Задач больше нет
Возвращаясь к вашему вопросу о том, какой вариант является лучшим подходом в общем, на мой взгляд, что-то вроде gen_factory
имеет смысл только в тех случаях, когда вам нужно сделать одно и то же для нескольких генераторов, которые вы собираетесь создать, или в случаях, когда процесс создания генераторов достаточно сложен, чтобы оправдать использование фабрики вместо создания отдельных генераторов на месте с помощью генераторного выражения.
Примечание:
Описание выше для функции gen_generator
(второй mymap
) утверждает, что "это генератор". Это немного неопределенно и технически не совсем верно, но это помогает осознать различия между функциями в этой запутанной конструкции, где gen_factory
тоже возвращает генератор, а именно, созданный внутренним генераторным выражением.
На самом деле любая функция (не только те, что в этом вопросе с включенными генераторными выражениями!), в которой есть yield
, при вызове просто возвращает объект генератора, который создается из тела функции.
type(gen_coroutine) # функция
gen_co = gen_coroutine(); type(gen_co) # генератор
Таким образом, все действия, которые мы наблюдали выше для gen_generator
и gen_coroutine
, происходят внутри этих объектов генераторов, созданных функциями с yield
.
Я предпочитаю вариант с yield from
, потому что он упрощает обработку исключений и контекстных менеджеров.
Рассмотрим пример генератора для строк файла:
def with_return(some_file):
with open(some_file, 'rt') as f:
return (line.strip() for line in f)
for line in with_return('/tmp/some_file.txt'):
print(line)
В этом случае версия с return
вызывает ValueError: I/O operation on closed file.
, поскольку файл закрывается после выполнения операции return
, и последующий доступ к его содержимому оказывается невозможным.
С другой стороны, версия с yield from
работает так, как ожидается:
def with_yield_from(some_file):
with open(some_file, 'rt') as f:
yield from (line.strip() for line in f)
for line in with_yield_from('/tmp/some_file.txt'):
print(line)
Здесь файл остается открытым до тех пор, пока генератор не исчерпает все значения, что позволяет избежать ошибки и правильно обработать каждую строку.
Самое важное различие (не знаю, оптимизирован ли yield from generator
) заключается в том, что контекст для return
и yield from
различен.
В приведенном примере, когда мы используем return
в функции use_generator()
, после первого вызова next(g)
мы получаем 1
, а при следующем вызове происходит выброс исключения Exception
. Это связано с тем, что use_generator()
просто возвращает объект-генератор и не обрабатывает никаких исключений, возникающих при его итерации.
С другой стороны, в функции yield_generator()
, где мы используем yield from
, исключение, возникающее в generator()
, продолжает быть переданным в вызывающий контекст. Это приводит к тому, что когда мы вызываем next(g)
второй раз, исключение также выбрасывается из yield_generator()
. Таким образом, yield from
позволяет корректно обрабатывать итерации и исключения из дочернего генератора.
Таким образом, ключевое отличие заключается в том, как исключения обрабатываются в зависимости от использования return
или yield from
.
Генераторы используют yield
, функции - return
.
Генераторы обычно применяются в циклах for
для повторного перебора значений, которые автоматически предоставляет генератор, но также могут использоваться в других контекстах, например, в функции list() для создания списка - снова из значений, которые автоматически предоставляет генератор.
Функции вызываются для предоставления возвратного значения, и вызываются лишь для получения единственного значения при каждом обращении.
Как получить исходный код функции Python?
Почему у некоторых функций есть двойные подчеркивания "__" перед и после имени функции?
Разница между return и exit в функциях Bash
Следует ли добавлять запятую после последнего аргумента в вызове функции? [закрыто]
Как работает это выражение с лямбдой/yield/генератором?