Выражения генератора против функций генератора и удивительно нетерпеливые оценки - PullRequest
2 голосов
/ 18 октября 2019

По причинам, которые не имеют значения, я объединяю некоторые структуры данных определенным образом, в то же время заменяя стандартное значение Python 2.7 dict на OrderedDict. Структуры данных используют кортежи в качестве ключей в словарях. Пожалуйста, игнорируйте эти детали (замена типа dict ниже не нужна, но в реальном коде).

import __builtin__
import collections
import contextlib
import itertools


def combine(config_a, config_b):
    return (dict(first, **second) for first, second in itertools.product(config_a, config_b))


@contextlib.contextmanager
def dict_as_ordereddict():
    dict_orig = __builtin__.dict
    try:
        __builtin__.dict = collections.OrderedDict
        yield
    finally:
        __builtin__.dict = dict_orig

Первоначально это работает как ожидалось (dict может принимать не строковые значения)ключевые аргументы как особый случай):

print 'one level nesting'
with dict_as_ordereddict():
    result = combine(
        [{(0, 1): 'a', (2, 3): 'b'}],
        [{(4, 5): 'c', (6, 7): 'd'}]
    )
print list(result)
print

Вывод:

one level nesting
[{(0, 1): 'a', (4, 5): 'c', (2, 3): 'b', (6, 7): 'd'}]

Однако при вложении вызовов в выражение генератора combine видно, что dictссылка обрабатывается как OrderedDict, при этом отсутствует специальное поведение dict для использования кортежей в качестве аргументов ключевых слов:

print 'two level nesting'
with dict_as_ordereddict():
    result = combine(combine(
        [{(0, 1): 'a', (2, 3): 'b'}],
        [{(4, 5): 'c', (6, 7): 'd'}]
    ),
        [{(8, 9): 'e', (10, 11): 'f'}]
    )
print list(result)
print

Вывод:

two level nesting
Traceback (most recent call last):
  File "test.py", line 36, in <module>
    [{(8, 9): 'e', (10, 11): 'f'}]
  File "test.py", line 8, in combine
    return (dict(first, **second) for first, second in itertools.product(config_a, config_b))
  File "test.py", line 8, in <genexpr>
    return (dict(first, **second) for first, second in itertools.product(config_a, config_b))
TypeError: __init__() keywords must be strings

Более того, реализация осуществляется через yieldвместо выражения генератора исправляет проблему:

def combine_yield(config_a, config_b):
    for first, second in itertools.product(config_a, config_b):
        yield dict(first, **second)


print 'two level nesting, yield'
with dict_as_ordereddict():
    result = combine_yield(combine_yield(
        [{(0, 1): 'a', (2, 3): 'b'}],
        [{(4, 5): 'c', (6, 7): 'd'}]
    ),
        [{(8, 9): 'e', (10, 11): 'f'}]
    )
print list(result)
print

Вывод:

two level nesting, yield
[{(0, 1): 'a', (8, 9): 'e', (2, 3): 'b', (4, 5): 'c', (6, 7): 'd', (10, 11): 'f'}]

Вопросы:

  1. Почему какой-то элемент (только первый?)из выражения генератора вычисляется раньше, чем требуется во втором примере, или для чего оно требуется?
  2. Почему оно не оценивается в первом примере? Я действительно ожидал такого поведения в обоих случаях.
  3. Почему работает версия на yield?

1 Ответ

1 голос
/ 18 октября 2019

Прежде чем углубляться в детали, обратите внимание на следующее: itertools.product оценивает аргументы итератора для вычисления продукта. Это видно из эквивалентной реализации Python в документации (первая строка актуальна):

def product(*args, **kwds):
    pools = map(tuple, args) * kwds.get('repeat', 1)
    ...

Вы также можете попробовать это с помощью пользовательского класса и короткого тестового сценария:

import itertools


class Test:
    def __init__(self):
        self.x = 0

    def __iter__(self):
        return self

    def next(self):
        print('next item requested')
        if self.x < 5:
            self.x += 1
            return self.x
        raise StopIteration()


t = Test()
itertools.product(t, t)

Создание объекта itertools.product покажет в выводе, что все элементы итераторов немедленно запрашиваются.

Это означает, что, как только вы вызовете itertools.product, аргументы итератора будут оценены. Это важно, потому что в первом случае аргументы - это просто два списка, и поэтому проблем нет. Затем вы оцениваете окончательный result через list(result после , когда менеджер контекста dict_as_ordereddict вернулся, и поэтому все вызовы dict будут разрешены как обычные встроенные dict.

Теперь для второго примера внутренний вызов combine работает по-прежнему нормально, теперь возвращает выражение генератора, которое затем используется в качестве одного из аргументов для вызова второго combine в itertools.product. Как мы видели выше, эти аргументы немедленно оцениваются, и поэтому объект генератора должен генерировать его значения. Для этого необходимо разрешить dict. Однако теперь мы все еще внутри контекстного менеджера dict_as_ordereddict, и по этой причине dict будет преобразован в OrderedDict, который не принимает не строковые ключи для аргументов ключевых слов.

Важнообратите внимание, что первая версия, использующая return, должна создать объект генератора, чтобы вернуть его. Это включает в себя создание объекта itertools.product. Это означает, что эта версия так же ленива, как и itertools.product.

Теперь вопрос о том, почему работает версия yield. Используя yield, вызов функции вернет генератор. Теперь это действительно ленивая версия в том смысле, что выполнение тела функции не начинается до тех пор, пока не будут запрошены элементы. Это означает, что ни внутренний, ни внешний вызов convert не начнут выполнять тело функции и, следовательно, будут вызывать itertools.product, пока элементы не будут запрошены через list(result). Вы можете проверить это, поместив дополнительный оператор печати внутри этой функции и прямо за менеджером контекста:

def combine(config_a, config_b):
    print 'start'
    # return (dict(first, **second) for first, second in itertools.product(config_a, config_b))
    for first, second in itertools.product(config_a, config_b):
        yield dict(first, **second)

with dict_as_ordereddict():
    result = combine(combine(
        [{(0, 1): 'a', (2, 3): 'b'}],
        [{(4, 5): 'c', (6, 7): 'd'}]
    ),
        [{(8, 9): 'e', (10, 11): 'f'}]
    )
print 'end of context manager'
print list(result)
print

С версией yield мы заметим, что она печатает следующее:

end of context manager
start
start

Т.е. генераторы запускаются только тогда, когда результаты запрашиваются через list(result). Это отличается от версии return (раскомментируйте в приведенном выше коде). Теперь вы увидите

start
start

и до того, как будет достигнут конец диспетчера контекста, ошибка уже возникла.

В примечании, чтобы ваш код работал,замена dict должна быть неэффективной (и это касается первой версии), поэтому я не понимаю, зачем вообще использовать этот диспетчер контекста. Во-вторых, литералы dict не упорядочены в Python 2, а также не являются аргументами ключевых слов, что также отрицает цель использования OrderedDict. Также обратите внимание, что в Python 3 поведение нестандартных аргументов ключевых слов dict было удалено, и чистый способ обновления словарей любых ключей - использовать dict.update.

...