Неожиданное поведение с условным выражением генератора

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

Я проверил этот простой код:

array = [1, 2, 2, 4, 5] # Original array
f = (x for x in array if array.count(x) == 2) # Filters original
array = [5, 6, 1, 2, 9] # Updates original to something else

print(list(f)) # Outputs filtered

И вывод был:

>>> []

Да ничего. Я ожидал, что понимание фильтра получит элементы в массиве с числом 2 и выведет это, но я не получил этого:

# Expected output
>>> [2, 2]

Когда я закомментировал третью строку, чтобы проверить ее еще раз:

array = [1, 2, 2, 4, 5] # Original array
f = (x for x in array if array.count(x) == 2) # Filters original
### array = [5, 6, 1, 2, 9] # Ignore line

print(list(f)) # Outputs filtered

Вывод был верным (вы можете проверить это сами):

>>> [2, 2]

В какой-то момент я вывел тип переменной f:

array = [1, 2, 2, 4, 5] # Original array
f = (x for x in array if array.count(x) == 2) # Filters original
array = [5, 6, 1, 2, 9] # Updates original

print(type(f))
print(list(f)) # Outputs filtered

И я получил:

>>> <class 'generator'>
>>> []

Почему обновление списка в Python изменяет вывод другой переменной генератора? Это кажется мне очень странным.

Ответ 1

Выражения генератора Python имеют позднюю привязку (см. PEP 289 - Выражения генератора) (что другие ответы называют "ленивым"):

Раннее связывание против позднего связывания

После долгих обсуждений было решено, что первое (самое внешнее) выражение for [выражения генератора] должно быть оценено немедленно и что остальные выражения должны быть оценены при выполнении генератора.

[...] Python использует подход позднего связывания с лямбда-выражениями и не имеет прецедента для автоматического раннего связывания. Было высказано мнение, что введение новой парадигмы приведет к излишней сложности.

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

Это означает, что он оценивает только самый внешний for при создании выражения генератора. Таким образом, он фактически связывает значение с array имен в "подвыражении" in array (на самом деле он связывает эквивалент с iter(array) в этой точке). Но когда вы перебираете генератор, вызов if array.count фактически ссылается на то, что в настоящее время называется array.


Поскольку это на самом деле list не array я изменил имена переменных в остальной части ответа, чтобы быть более точным.

В первом случае list вы перебираете, и list вы рассчитываете, будут другими. Это как если бы вы использовали:

list1 = [1, 2, 2, 4, 5]
list2 = [5, 6, 1, 2, 9]
f = (x for x in list1 if list2.count(x) == 2)

Таким образом, вы проверяете для каждого элемента в list1, если его счет в list2 это два.

Вы можете легко проверить это, изменив второй список:

>>> lst = [1, 2, 2]
>>> f = (x for x in lst if lst.count(x) == 2)
>>> lst = [1, 1, 2]
>>> list(f)
[1]

Если бы он перебрал первый список и сосчитал в первом списке, он бы возвратил [2, 2] (потому что первый список содержит два 2). Если он повторяется и учитывается во втором списке, вывод должен быть [1, 1]. Но поскольку он выполняет итерацию по первому списку (содержащему один 1), но проверяет второй список (который содержит два 1 с), на выходе получается только один 1.

Решение с использованием функции генератора

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

def keep_only_duplicated_items(lst):
    for item in lst:
        if lst.count(item) == 2:
            yield item

И затем используйте это так:

lst = [1, 2, 2, 4, 5]
f = keep_only_duplicated_items(lst)
lst = [5, 6, 1, 2, 9]

>>> list(f)
[2, 2]

Обратите внимание, что в PEP (см. Ссылку выше) также указано, что для чего-либо более сложного предпочтительнее полное определение генератора.

Лучшее решение с использованием функции генератора со счетчиком

Лучшее решение (избегая квадратичного поведения во время выполнения, потому что вы перебираете весь массив для каждого элемента в массиве), заключалось бы в том, чтобы подсчитать ( collections.Counter) элементы один раз, а затем выполнить поиск в постоянное время (что приводит к линейному времени):

from collections import Counter

def keep_only_duplicated_items(lst):
    cnts = Counter(lst)
    for item in lst:
        if cnts[item] == 2:
            yield item

Приложение: Использование подкласса для "визуализации" того, что происходит и когда это происходит

Создать подкласс list который печатается при вызове определенных методов, довольно просто, поэтому можно убедиться, что он действительно работает так.

В этом случае я просто переопределяю методы __iter__ и count потому что меня интересует, какой список __iter__ выражение генератора и в каком списке он учитывается. Тела метода на самом деле просто делегируют суперклассу и печатают что-то (поскольку он использует super без аргументов и f-строк, ему требуется Python 3.6, но его легко адаптировать для других версий Python):

class MyList(list):
    def __iter__(self):
        print(f'__iter__() called on {self!r}')
        return super().__iter__()

    def count(self, item):
        cnt = super().count(item)
        print(f'count({item!r}) called on {self!r}, result: {cnt}')
        return cnt

Это простой подкласс, который просто печатает при __iter__ методов __iter__ и count:

>>> lst = MyList([1, 2, 2, 4, 5])

>>> f = (x for x in lst if lst.count(x) == 2)
__iter__() called on [1, 2, 2, 4, 5]

>>> lst = MyList([5, 6, 1, 2, 9])

>>> print(list(f))
count(1) called on [5, 6, 1, 2, 9], result: 1
count(2) called on [5, 6, 1, 2, 9], result: 1
count(2) called on [5, 6, 1, 2, 9], result: 1
count(4) called on [5, 6, 1, 2, 9], result: 0
count(5) called on [5, 6, 1, 2, 9], result: 1
[]

Ответ 2

Как уже упоминали другие, генераторы Python ленивы. Когда эта строка запускается:

f = (x for x in array if array.count(x) == 2) # Filters original

на самом деле пока ничего не происходит. Вы только что объявили, как будет работать генераторная функция f. Массив еще не смотрел. Затем вы создаете новый массив, который заменяет первый, и, наконец, когда вы вызываете

print(list(f)) # Outputs filtered

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

Если вам нужно переназначить список и не можете использовать другую переменную для его хранения, рассмотрите возможность создания списка вместо генератора во второй строке:

f = [x for x in array if array.count(x) == 2] # Filters original
...
print(f)

Ответ 3

Другие уже объяснили причину проблемы - генератор привязывает имя локальной переменной array, а не ее значение.

Одно из самых питонных решений - это, безусловно, понимание списка:

f = [x for x in array if array.count(x) == 2]

Однако, если есть какая-то причина, по которой вы не хотите создавать список, вы также можете принудительно закрыть область по array:

f = (lambda array=array: (x for x in array if array.count(x) == 2))()

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

Обратите внимание, что это все еще привязывается к переменной (ссылка), а не к значению, поэтому, например, следующее напечатает [2, 2, 4, 4]:

array = [1, 2, 2, 4, 5] # Original array

f = (lambda array=array: (x for x in array if array.count(x) == 2))() # Close over array
array.append(4)  # This *will* be captured

array = [5, 6, 1, 2, 9] # Updates original to something else

print(list(f)) # Outputs [2, 2, 4, 4]

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

Ответ 4

Вы не используете генератор правильно, если это основное использование этого кода. Используйте понимание списка вместо понимания генератора. Просто замените скобки на скобки. Он оценивает список, если вы не знаете.

array = [1, 2, 2, 4, 5]
f = [x for x in array if array.count(x) == 2]
array = [5, 6, 1, 2, 9]

print(f)
#[2, 2]

Вы получаете этот ответ из-за природы генератора. Вы вызываете генератор, когда его содержимое оценивается как []

Ответ 5

Генераторы ленивы, они не будут оцениваться, пока вы не выполните их. В этом случае, когда вы создаете list с генератором в качестве входных данных, при print.

Ответ 6

Основная причина проблемы заключается в том, что генераторы ленивы; переменные оцениваются каждый раз:

>>> l = [1, 2, 2, 4, 5, 5, 5]
>>> filtered = (x for x in l if l.count(x) == 2)
>>> l = [1, 2, 4, 4, 5, 6, 6]
>>> list(filtered)
[4]

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

Полная функция самоанализа для любопытных (строка с комментарием является важной строкой):

>>> l = [1, 2, 2, 4, 5]
>>> filtered = (x for x in l if l.count(x) == 2)
>>> l = [1, 2, 4, 4, 5, 6, 6]
>>> list(filtered)
[4]
>>> def f(original, new, count):
    current = original
    filtered = (x for x in current if current.count(x) == count)
    current = new
    return list(filtered)

>>> from dis import dis
>>> dis(f)
  2           0 LOAD_FAST                0 (original)
              3 STORE_DEREF              1 (current)

  3           6 LOAD_CLOSURE             0 (count)
              9 LOAD_CLOSURE             1 (current)
             12 BUILD_TUPLE              2
             15 LOAD_CONST               1 (<code object <genexpr> at 0x02DD36B0, file "<pyshell#17>", line 3>)
             18 LOAD_CONST               2 ('f.<locals>.<genexpr>')
             21 MAKE_CLOSURE             0
             24 LOAD_DEREF               1 (current)
             27 GET_ITER
             28 CALL_FUNCTION            1 (1 positional, 0 keyword pair)
             31 STORE_FAST               3 (filtered)

  4          34 LOAD_FAST                1 (new)
             37 STORE_DEREF              1 (current)

  5          40 LOAD_GLOBAL              0 (list)
             43 LOAD_FAST                3 (filtered)
             46 CALL_FUNCTION            1 (1 positional, 0 keyword pair)
             49 RETURN_VALUE
>>> f.__code__.co_varnames
('original', 'new', 'count', 'filtered')
>>> f.__code__.co_cellvars
('count', 'current')
>>> f.__code__.co_consts
(None, <code object <genexpr> at 0x02DD36B0, file "<pyshell#17>", line 3>, 'f.<locals>.<genexpr>')
>>> f.__code__.co_consts[1]
<code object <genexpr> at 0x02DD36B0, file "<pyshell#17>", line 3>
>>> dis(f.__code__.co_consts[1])
  3           0 LOAD_FAST                0 (.0)
        >>    3 FOR_ITER                32 (to 38)
              6 STORE_FAST               1 (x)
              9 LOAD_DEREF               1 (current)  # This loads the current list every time, as opposed to loading a constant.
             12 LOAD_ATTR                0 (count)
             15 LOAD_FAST                1 (x)
             18 CALL_FUNCTION            1 (1 positional, 0 keyword pair)
             21 LOAD_DEREF               0 (count)
             24 COMPARE_OP               2 (==)
             27 POP_JUMP_IF_FALSE        3
             30 LOAD_FAST                1 (x)
             33 YIELD_VALUE
             34 POP_TOP
             35 JUMP_ABSOLUTE            3
        >>   38 LOAD_CONST               0 (None)
             41 RETURN_VALUE
>>> f.__code__.co_consts[1].co_consts
(None,)

Повторим: список для повторения загружается только один раз. Однако любые замыкания в условии или выражении загружаются из охватывающей области каждую итерацию. Они не хранятся в константе.

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

Ответ 7

Оценка генератора "ленива" - она не будет выполнена, пока вы не актуализируете ее с правильной ссылкой. С вашей линией:

Посмотрите еще раз на вывод с типом f: этот объект является генератором, а не последовательностью. Это ожидание, чтобы быть использованным, итератор сортов.

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


Код для "заставить это работать"

Это зависит от того, что вы подразумеваете под "заставить это работать". Если вы хотите, чтобы f был фильтрованным списком, используйте список, а не генератор:

f = [x for x in array if array.count(x) == 2] # Filters original

Ответ 8

Генераторы ленивы, и ваш вновь определенный array используется, когда вы исчерпали свой генератор после переопределения. Поэтому вывод правильный. Быстрое решение состоит в том, чтобы использовать понимание списка, заменив скобки () на скобки [].

Переходя к тому, как лучше написать свою логику, подсчет значения в цикле имеет квадратичную сложность. Для алгоритма, который работает в линейное время, вы можете использовать collections.Counter для подсчета значений и сохранения копии вашего исходного списка:

from collections import Counter

array = [1, 2, 2, 4, 5]   # original array
counts = Counter(array)   # count each value in array
old_array = array.copy()  # make copy
array = [5, 6, 1, 2, 9]   # updates array

# order relevant
res = [x for x in old_array if counts[x] >= 2]
print(res)
# [2, 2]

# order irrelevant
from itertools import chain
res = list(chain.from_iterable([x]*count for x, count in counts.items() if count >= 2))
print(res)
# [2, 2]

Обратите внимание, что вторая версия даже не требует old_array и полезна, если нет необходимости поддерживать порядок значений в исходном массиве.