带有条件生成器表达式的意外行为

这个问题在这里已有答案:

  • 生成器表达式使用生成器创建后分配的列表                                     5个答案

我正在运行一段代码,该代码在程序的某个部分意外出了逻辑错误。 在研究本节时,我创建了一个测试文件来测试正在运行的语句集,并发现了一个看起来很奇怪的异常错误。

我测试了以下简单代码:

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中更新列表会更改另一个生成器变量的输出? 我觉得这很奇怪。

8个解决方案
59 votes

Python生成器表达式是后期绑定(请参阅PEP 289-生成器表达式)(其他答案称为“惰性”):

早期绑定与后期绑定

经过大量讨论,我们决定应立即评估[生成器表达式]的第一个(最外)表达式,并在执行生成器时评估其余表达式。

[...] Python对lambda表达式采用了后期绑定方法,并且没有自动早期绑定的先例。 人们认为,引入新的范例将不必要地引入复杂性。

在探索了许多可能性之后,出现了一个共识,即绑定问题难以理解,应大力鼓励用户在函数中使用生成器表达式,这些表达式立即消耗其参数。 对于更复杂的应用程序,完整的生成器定义在范围,生存期和绑定方面显而易见,因此始终是上乘的。

这意味着在创建生成器表达式时,它仅评估最外面的for。 因此,它实际上将“ subexpression” count中的名称绑定为值__iter__的值(实际上,此时将其等效于super绑定)。 但是,当您遍历生成器时,1调用实际上是指当前名为1的调用。


由于它实际上是__iter__,而不是count,因此我将其余答案中的变量名称更改为更准确。

在第一种情况下,您进行迭代的__iter__和您所计数的count将有所不同。 就好像您使用了:

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

因此,如果count中的元素数为2,则检查__iter__中的每个元素。

您可以通过修改第二个列表轻松地验证这一点:

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

如果遍历第一个列表并计入第一个列表,它将返回__iter__(因为第一个列表包含两个count)。 如果对其进行迭代并计入第二个列表,则输出应为super。但是,由于对第一列表(包含一个1)进行迭代,但检查第二个列表(包含两个1s),因此输出仅为单个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(请参阅上面的链接)还指出,对于更复杂的事情,最好使用完整的生成器定义。

使用带有计数器的生成器功能的更好解决方案

更好的解决方案(避免遍历二次运行时的行为,因为您遍历了整个数组中的每个元素)将计数一次(__iter__)元素,然后在恒定时间内进行查找(导致线性时间):

from collections import Counter

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

附录:使用子类“可视化”发生的情况以及发生的时间

创建一个__iter__子类很容易,该子类在调用特定方法时将进行打印,因此可以验证它是否确实可以那样工作。

在这种情况下,我只是重写方法__iter__count,因为我对生成器表达式迭代哪个列表以及在哪个列表中计数感兴趣。 方法主体实际上只是委托给超类并打印一些内容(因为它使用没有参数和f字符串的super,因此它需要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__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
[]
MSeifert answered 2019-11-08T13:32:49Z
18 votes

正如其他人提到的那样,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)
Steven answered 2019-11-08T13:33:42Z
9 votes

其他人已经解释了此问题的根本原因-生成器绑定到2605205316719608608832局部变量的名称,而不是其值。

最pythonic的解决方案肯定是列表理解:

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))()

这里发生的是,array在运行该行时捕获了对array的引用,即使生成器后来被重新定义,也确保生成器看到了您期望的变量。

请注意,它仍然绑定到变量(引用),而不是值,因此,例如,以下内容将打印array

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]

这在某些语言中是常见的模式,但是它不是pythonic,因此只有在有很好的理由不使用列表推导(例如,如果array很长,或者正在嵌套生成器推导中使用)时才有意义 ,而您担心内存)。

sapi answered 2019-11-08T13:34:48Z
7 votes

如果这是此代码的主要用途,则说明您未正确使用生成器。 使用列表理解而不是生成器理解。 只需将括号替换为括号即可。 如果您不知道,它将评估为列表。

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]

由于生成器的性质,您会收到此响应。 您正在调用生成器,但其内容将评估为[]

Jab answered 2019-11-08T13:35:25Z
5 votes

生成器是惰性的,只有在您迭代生成器之前,它们才会被评估。 在这种情况下,您需要在print以发电机作为输入来创建list

Mark Ransom answered 2019-11-08T13:35:54Z
4 votes

问题的根本原因在于生成器是惰性的。 每次都评估变量:

>>> 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,)

重申一下:要迭代的列表仅加载一次。 但是,条件或表达式中的所有闭包都是在每次迭代时从封闭范围中加载的。 它们没有存储在常量中。

解决您问题的最佳解决方案是创建一个引用原始列表的新变量,并将其用于生成器表达式中。

Solomon Ucko answered 2019-11-08T13:36:54Z
2 votes

生成器评估是“懒惰的”-直到使用正确的引用将其实现之前,它不会执行。 与您的行:

再次查看f类型的输出:该对象是生成器,而不是序列。 它正在等待使用,它是一种迭代器。

在开始要求生成器提供值之前,不会对其进行评估。 那时,它使用该点的可用值,而不是定义它的点。


使代码“起作用”的代码

那取决于您所说的“使其工作”。 如果希望f是过滤列表,请使用列表,而不是生成器:

f = [x for x in array if array.count(x) == 2] # Filters original
Prune answered 2019-11-08T13:37:52Z
2 votes

生成器是惰性的,并且在重新定义后耗尽生成器时将使用新定义的old_array。 因此,输出正确。 一种快速的解决方法是使用括号,将括号()替换为括号[],以使用列表推导。

继续讨论如何更好地编写逻辑,对循环中的值进行计数具有二次复杂度。 对于线性时间有效的算法,可以使用old_array对值进行计数,并保留原始列表的副本:

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,如果不需要在原始数组中保持值的顺序,则第二个版本非常有用。

jpp answered 2019-11-08T13:38:45Z
translate from https://stackoverflow.com:/questions/54245618/unexpected-behaviour-with-a-conditional-generator-expression