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

Unexpected behaviour with a conditional generator expression

我正在 运行 编写一段代码,但意外地在程序的一部分给出了逻辑错误。在调查该部分时,我创建了一个测试文件来测试 运行 的语句集,并发现了一个看起来很奇怪的异常错误。

我测试了这个简单的代码:

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

生成器是惰性的,在您遍历它们之前不会对其求值。在这种情况下,就是在 print.

处以生成器作为输入创建 list

生成器求值是 "lazy" -- 在您使用适当的引用实现它之前,它不会被执行。用你的线:

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

在您开始从中获取值之前,您的生成器不会被评估。那时,它使用可用值在那一点而不是它被定义的那一点。


编码为"make it work"

这取决于您所说的 "make it work" 是什么意思。如果您希望 f 成为过滤列表,请使用列表,而不是生成器:

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

如果这是此代码的主要用途,那么您没有正确使用生成器。使用列表理解而不是生成器理解。只需将括号替换为方括号即可。如果您不知道,它会评估为一个列表。

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]

由于生成器的性质,您收到此回复。当生成器的内容将评估为 []

时,您正在调用生成器

正如其他人所提到的,Python generators 很懒惰。当这一行是运行:

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

实际上还没有发生任何事情。您刚刚声明了生成器函数 f 的工作方式。数组还没有看。然后,您创建一个新数组来替换第一个数组,最后当您调用

print(list(f)) # Outputs filtered

生成器现在需要实际值并开始从生成器 f 中提取它们。但是此时,array 已经引用了第二个,所以你得到一个空列表。

如果您需要重新分配列表,并且不能使用不同的变量来保存它,请考虑在第二行中创建列表而不是生成器:

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

发电机是 惰性 并且您新定义的 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,如果不需要维护原始数组中值的顺序,它会很有用。

问题的根本原因是生成器懒惰;每次评估变量:

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

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

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

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

最pythonic的解决方案绝对是列表理解:

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

但是,如果出于某种原因不想创建列表,您也可以force a scope close 超过 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]

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

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

Early Binding versus Late Binding

After much discussion, it was decided that the first (outermost) for-expression [of the generator expression] should be evaluated immediately and that the remaining expressions be evaluated when the generator is executed.

[...] Python takes a late binding approach to lambda expressions and has no precedent for automatic, early binding. It was felt that introducing a new paradigm would unnecessarily introduce complexity.

After exploring many possibilities, a consensus emerged that binding issues were hard to understand and that users should be strongly encouraged to use generator expressions inside functions that consume their arguments immediately. For more complex applications, full generator definitions are always superior in terms of being obvious about scope, lifetime, and binding.

这意味着它在创建生成器表达式时计算最外层的for。所以它实际上 绑定 “子表达式” in array 中名称为 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(请参阅上面的 link)还指出,对于任何更复杂的事物,最好使用完整的生成器定义。

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

更好的解决方案(避免二次运行时行为,因为您为数组中的每个元素遍历整个数组)是计算 (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,因为我对生成器表达式迭代哪个列表以及它在哪个列表中计数感兴趣。方法主体实际上只是委托给超类并打印一些东西(因为它使用 super 没有参数和 f-strings 它需要 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
[]