0

Python: использовать `yield from` или вернуть генератор?

11

Я написал следующий простой фрагмент кода:

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 ответ(ов)

0

Разница заключается в том, что ваша первая версия 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.

0

Я предпочитаю вариант с 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)

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

0

Самое важное различие (не знаю, оптимизирован ли 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.

0

Генераторы используют yield, функции - return.

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

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

Чтобы ответить на вопрос, пожалуйста, войдите или зарегистрируйтесь