Что такое «сопрограмма» в Python?

Многим новичкам, изучающим Python, сложно осмыслить PEP 380. Меня обычно спрашивают:

  1. Что делает ключевое слово «yield»?
  2. В каких ситуациях полезно использовать «yield from»?
  3. Каков классический вариант использования?
  4. Почему его сравнивают с микропотоками?

Чтобы понять, что делает yield, вы должны понимать, что такое генераторы. И прежде чем вы сможете понять генераторы, вы должны понять итерации.

Итерируемые объекты

Итерируемый - это объект, у которого есть __iter__ метод, который возвращает итератор, или который определяет __getitem__ метод, который может принимать последовательные индексы, начиная с нуля (и поднимает IndexError, когда индексы больше не действительны). Итак, итератор - это объект, из которого вы можете получить итератор.

  • все, что можно зациклить (то есть вы можете зациклить строку или файл) или
  • все, что может появиться справа от цикла for: for x in iterable: ... или
  • все, что вы можете вызвать с помощью iter(), которое вернет ИТЕРАТОР: iter(obj)
>>> mylist = [1, 2, 3]
>>> # mylist = [x*x for x in range(3)]    # or a list comprehension
>>> for i in mylist:
...    print(i)
1
2
3

Генераторы

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

>>> mygenerator = (x*x for x in range(3))
>>> for i in mygenerator:
...    print(i)
0
1
4

Это то же самое, за исключением того, что вы использовали () вместо []. НО вы не можете выполнить for i in mygenerator второй раз, поскольку генераторы можно использовать только один раз: они вычисляют 0, затем забывают об этом и вычисляют 1, и заканчивают вычисление 4, один за другим.

Урожай

yield - ключевое слово, которое используется как return, за исключением того, что функция вернет генератор.

>>> def func():                # a function with yield
...     yield 'I am'           # is still a function
...     yield 'a generator!'
... 
>>> gen = func()
>>> type(gen)                  # but it returns a generator
<type 'generator'>
>>> hasattr(gen, '__iter__')   # that's an iterable
True
>>> hasattr(gen, 'next')       # and with .next (.__next__ in Python 3)
True                           # implements the iterator protocol.

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

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

В первый раз, когда for вызывает объект-генератор, созданный из вашей функции, он будет запускать код в вашей функции с самого начала до тех пор, пока не достигнет yield, а затем вернет первое значение цикла. Затем каждый последующий вызов будет запускать другую итерацию цикла, который вы написали в функции, и возвращать следующее значение. Это будет продолжаться до тех пор, пока генератор не будет считаться пустым, что происходит, когда функция выполняется без нажатия yield. Это может быть из-за того, что цикл подошел к концу, или из-за того, что вы больше не удовлетворяете "if/else".

>>> list(gen)
['I am', 'a generator!']
>>> list(gen)
[]

Вам придется сделать еще один, если вы хотите снова использовать его функции:

>>> list(func())
['I am', 'a generator!']

yield разрешен только внутри определения функции, а включение yield в определение функции приводит к возврату генератора.

yield обеспечивает простой способ реализации протокола итератора, определяемый следующими двумя методами: __iter__ и next (Python 2) или __next__ (Python 3). Оба этих метода делают объект итератором, который можно проверить с помощью абстрактного базового класса Iterator из модуля collections.

Ключевое слово yield сводится к двум простым фактам:

  1. Если компилятор обнаруживает ключевое слово yield где-нибудь внутри функции, эта функция больше не возвращается с помощью оператора return. Вместо он немедленно возвращает ленивый объект «ожидающий список», называемый генератором.
  2. Генератор повторяется. Что такое итерация? Это что-то вроде list, set, range или dict-view, со встроенным протоколом для посещения каждого элемента в определенном порядке.

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

Сопрограммы

yield формирует выражение, которое позволяет отправлять данные в генератор.

def bank_account(deposited, interest_rate):
    while True:
        calculated_interest = interest_rate * deposited 
        received = yield calculated_interest
        if received:
            deposited += received


my_account = bank_account(1000, .05)
first_year_interest = next(my_account)
# 50.0
next_year_interest = my_account.send(first_year_interest + 1000)
# 102.5

Совместное делегирование суб-сопрограммы

def money_manager(expected_rate):
    under_management = yield          # must receive deposited value
    while True:
        try:
            additional_investment = yield expected_rate * under_management 
            if additional_investment:
                under_management += additional_investment
        except GeneratorExit:
            # ignored
        finally:
            # ignored

def investment_account(deposited, manager):
    """
       very simple model of an investment account 
       that delegates to a manager
    """
    next(manager) # must queue up manager
    manager.send(deposited)
    while True:
        try:
            yield from manager
        except GeneratorExit:
            return manager.close()
my_manager = money_manager(.06)
my_account = investment_account(1000, my_manager)

first_year_return = next(my_account)
# 60.0
next_year_return = my_account.send(first_year_return + 1000)
# 123.6

Концепция, что yield from generator эквивалентно for value in generator: yield value , даже не начинает отдавать должное тому, о чем идет речь yield from. Потому что, давайте посмотрим правде в глаза, если все, что делает yield from, это расширяет цикл for, тогда это не гарантирует добавления yield from к языку и исключает реализацию целого ряда новых функций в Python 2.x.

yield from делает это устанавливает прозрачное двунаправленное соединение между вызывающим и вспомогательным генератором:

  • Соединение «прозрачно» в том смысле, что оно также будет правильно распространять все, а не только генерируемые элементы (например, распространяются исключения).
  • Соединение является «двунаправленным» в том смысле, что данные могут быть отправлены как от, так и к генератору.

Почему его сравнивают с микропотоками?

Я думаю, что этот раздел в PEP говорит о том, что у каждого генератора действительно есть свой собственный изолированный контекст выполнения. Вместе с тем фактом, что выполнение переключается между генератором-итератором и вызывающим с помощью yield и __next__() соответственно, это похоже на потоки, где операционная система время от времени переключает выполняющийся поток вместе с контекстом выполнения (стек, регистры, ...).

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

Однако эта аналогия не имеет ничего общего с yield from - это скорее общее свойство генераторов в Python.

Ошибка в Python «yield»:

Примечание. Это была ошибка в обработке CPython yield в интерпретациях и выражениях генератора, исправленная в Python 3.8, с предупреждением об устаревании в Python 3.7. См. Отчет об ошибке Python и записи Что нового для Python 3.7 и Python 3.8.

>>> [(yield i) for i in range(3)]
<generator object <listcomp> at 0x0245C148>
>>> list([(yield i) for i in range(3)])
[0, 1, 2]
>>> list((yield i) for i in range(3))
[0, None, 1, None, 2, None]

Это кажется странным:

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

Выражения генератора, а также выражения set и dict компилируются в объекты функции (генератора). В Python 3 понимание списков обрабатывается точно так же; По сути, все они представляют собой новую вложенную область видимости.

Вы можете увидеть это, если попытаетесь дизассемблировать выражение генератора:

>>> dis.dis(compile("(i for i in range(3))", '', 'exec'))
  1           0 LOAD_CONST               0 (<code object <genexpr> at 0x10f7530c0, file "", line 1>)
              3 LOAD_CONST               1 ('<genexpr>')
              6 MAKE_FUNCTION            0
              9 LOAD_NAME                0 (range)
             12 LOAD_CONST               2 (3)
             15 CALL_FUNCTION            1 (1 positional, 0 keyword pair)
             18 GET_ITER
             19 CALL_FUNCTION            1 (1 positional, 0 keyword pair)
             22 POP_TOP
             23 LOAD_CONST               3 (None)
             26 RETURN_VALUE
>>> dis.dis(compile("(i for i in range(3))", '', 'exec').co_consts[0])
  1           0 LOAD_FAST                0 (.0)
        >>    3 FOR_ITER                11 (to 17)
              6 STORE_FAST               1 (i)
              9 LOAD_FAST                1 (i)
             12 YIELD_VALUE
             13 POP_TOP
             14 JUMP_ABSOLUTE            3
        >>   17 LOAD_CONST               0 (None)
             20 RETURN_VALUE

Выше показано, что выражение генератора компилируется в объект кода, загруженный как функция (MAKE_FUNCTION создает объект функции из объекта кода). Ссылка .co_consts[0] позволяет нам увидеть объект кода, сгенерированный для выражения, и он использует YIELD_VALUE так же, как функция генератора.

Таким образом, выражение yield работает в этом контексте, поскольку компилятор рассматривает их как скрытые функции.

Это ошибка; yield нет места в этих выражениях. грамматика Python до того, как Python 3.7 допускает это (поэтому код компилируемый), но yield выражение_спецификация показывает, что использование yield здесь на самом деле не должно работать:

Выражение yield используется только при определении функции-генератора и, следовательно, может использоваться только в теле определения функции.

Это было подтверждено как ошибка в проблеме 10544. Решение ошибки состоит в том, что использование yield и yield from поднимет SyntaxError в Python 3.8; в Python 3.7 он выдает DeprecationWarning, чтобы гарантировать, что код перестанет использовать эту конструкцию. Вы увидите такое же предупреждение в Python 2.7.15 и выше, если вы используете -3 переключатель командной строки, включающий предупреждения совместимости Python 3.

Ленивый метод чтения большого файла в Python

Чтобы написать ленивую функцию, просто используйте yield:

def read_in_chunks(file_object, chunk_size=1024):
    """
       Lazy function (generator) to read a file piece by piece.
       Default chunk size: 1k.
    """
    while True:
        data = file_object.read(chunk_size)
        if not data:
            break
        yield data

with open('really_big_file.dat') as f:
    for piece in read_in_chunks(f):
        process_data(piece)

Для чего вы можете использовать функцию генератора?

Генераторы дают вам ленивую оценку. Вы используете их, перебирая их, либо явно с помощью «for», либо неявно, передавая его в любую функцию или конструкцию, которая выполняет итерацию.

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

Еще одно применение генераторов (что на самом деле то же самое) - замена обратных вызовов итерацией. В некоторых ситуациях вы хотите, чтобы функция выполняла много работы и иногда отчитывалась перед вызывающим. Традиционно для этого использовалась функция обратного вызова. Вы передаете этот обратный вызов рабочей функции, и она будет периодически вызывать этот обратный вызов. Подход с генератором состоит в том, что рабочая функция (теперь генератор) ничего не знает о обратном вызове и просто уступает, когда хочет что-то сообщить. Вызывающий, вместо того, чтобы писать отдельный обратный вызов и передавать его рабочей функции, выполняет всю работу по созданию отчетов в небольшом цикле «for» вокруг генератора.

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

Если вы хотите увидеть пример двух последних подходов, см. Os.path.walk () (старая функция обхода файловой системы с обратным вызовом) и os.walk () (новый генератор обхода файловой системы). Конечно, если вы действительно хотели собрать все результаты в список, подход генератора тривиально преобразовать в подход большого списка:

big_list = list(the_generator)

Когда не следует использовать функцию генератора

Используйте список вместо генератора, когда:

Вам необходимо получить доступ к данным несколько раз (т.е. кешировать результаты, а не пересчитывать их):

for i in outer:           # used once, okay to be a generator
    for j in inner:       # used multiple times, reuse a list
         ...

Вам потребуется произвольный доступ (или любой доступ, кроме прямого последовательного порядка):

for i in reversed(data): ...     # generators aren't reversible
s[i], s[j] = s[j], s[i]          # generators aren't indexable

Вам необходимо объединить строки (для чего требуется два прохода данных):

s = ''.join(data)                # lists are faster than generators in this use case

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

Надеюсь, вам понравился рассказ! Больше в моем профиле.
Прочтите мой предыдущий рассказ: Прогнозирование ветвей - все, что вам нужно знать.
Посетите мое портфолио: https://harshal.one.

Image Attribution.