【问题标题】:Unexpected behaviour with a conditional generator expression [duplicate]条件生成器表达式的意外行为[重复]
【发布时间】:2019-06-12 05:24:20
【问题描述】:

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

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

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

【问题讨论】:

  • 你重新定义 array 并且你的 new arraylazy 生成器理解所引用的。
  • 很高兴看到一个提到范围的答案。
  • 这是python 闭包的“后期绑定”问题的变体。生成器在这里基本上就像一个闭包。 (我不确定为什么答案如此关注懒惰……我认为,对于任何使用生成器的人来说,这都是显而易见的。)

标签: python generator variable-assignment generator-expression


【解决方案1】:

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

早期绑定与晚期绑定

经过多次讨论,决定[生成器表达式]的第一个(最外层)for-表达式应立即求值,而其余表达式在生成器执行时求值。

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

在探索了许多可能性之后,达成了一个共识,即绑定问题很难理解,应强烈鼓励用户在立即使用其参数的函数中使用生成器表达式。对于更复杂的应用程序,完整的生成器定义在范围、生命周期和绑定方面的明显性方面总是优越的。

这意味着它在创建生成器表达式时评估最外层的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 中的计数为 2。

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

>>> 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)但检查第二个列表(包含两个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(请参阅上面的链接)还指出,对于更复杂的事情,最好使用完整的生成器定义。

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

更好的解决方案(避免二次运行时行为,因为您为数组中的每个元素迭代整个数组)将计数 (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 字符串,它需要 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
[]

【讨论】:

  • 这是解释所质疑行为的所有微妙之处的唯一答案。
  • 您给出的示例(带有结果 [1])可能只查看第二个列表。如果您使用 [1, 1, 2, 2, 3, 4, 5] 和 [1, 2, 2, 3, 3, 4, 6] 之类的东西会更好,结果为 [2, 2, 3 ].
  • 参见例如tio.run/…
  • @hkBst 感谢您提供额外的示例。但是我不确定我的例子模棱两可是什么意思。我认为如果它只查看第一个列表,结果将是[2,2],如果它只查看第二个列表,结果将是[1, 1]。结果为[1] 表明它遍历第一个列表,但基于第二个列表进行过滤。我的想法有错吗?
  • 哇,这简直是违反直觉的。通常 Python 比这更容易解释。
【解决方案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)

【讨论】:

【解决方案3】:

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

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

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

不过,如果出于某种原因不想创建列表,您可以也可以通过force a scope closearray

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 很长,或者正在使用在嵌套生成器理解中,并且您关心内存)。

【讨论】:

  • 展示如何覆盖默认行为的有用答案!
【解决方案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]

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

【讨论】:

  • 谢谢。我似乎使用了错误的括号。但总的来说,使用生成器理解似乎很奇怪。
  • 随着您的更改,list(f) 变得多余。
  • 大声笑@Mark Ransom,复制粘贴了我,我编辑了。
  • @SurajKothari 这并不奇怪,它是一个很棒的工具!只需要一些时间来包裹旧大脑。做一些研究,你会发现生成器很棒!
  • 这不能解释观察到的行为,因此不能回答问题。
【解决方案5】:

生成器是惰性的,在您遍历它们之前不会对它们进行评估。在这种情况下,您将在print 处创建list,并将生成器作为输入。

【讨论】:

  • 我什么时候遍历它们。我是故意的吗?
  • @SurajKothari 当您创建 list 时,它会为您迭代,而无需您明确执行。
  • 还有哪个列表?当我声明第一个,或重新分配第二个?
  • 第一和第二是什么?您只在代码的最后一行定义了一个列表。
  • 这可能是我自己的答案,但它不正确(请参阅 MSeifert 的答案)或尝试解释 tio.run/…
【解决方案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 在新列表中出现了两次,导致它出现在结果中。它只在结果中出现一次,因为它只在原始列表中出现过一次。 6s 在新列表中出现两次,但从未出现在旧列表中,因此从未显示。

好奇者的全功能自省(带注释的行是重要的行):

>>> 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,如果不需要维护原始数组中值的顺序,它就很有用。

    【讨论】:

      猜你喜欢
      • 2014-05-06
      • 2021-08-28
      • 1970-01-01
      • 1970-01-01
      • 2020-02-16
      • 1970-01-01
      • 2013-07-16
      • 2021-08-26
      • 2016-08-13
      相关资源
      最近更新 更多