python - Comportamiento inesperado con una expresión generadora condicional
generator variable-assignment (8)
Como otros han mencionado, los generadores de Python son perezosos. Cuando esta línea se ejecuta:
f = (x for x in array if array.count(x) == 2) # Filters original
En realidad, nada sucede todavía. Acaba de declarar cómo funcionará la función del generador f. Array no se ha visto todavía. Luego, crea una nueva matriz que reemplaza a la primera, y finalmente cuando llama
print(list(f)) # Outputs filtered
el generador ahora necesita los valores reales y comienza a extraerlos del generador f. Pero en este punto, la matriz ya se refiere a la segunda, por lo que obtiene una lista vacía.
Si necesita reasignar la lista y no puede usar una variable diferente para mantenerla, considere crear la lista en lugar de un generador en la segunda línea:
f = [x for x in array if array.count(x) == 2] # Filters original
...
print(f)
Esta pregunta ya tiene una respuesta aquí:
Estaba ejecutando un fragmento de código que inesperadamente dio un error lógico en una parte del programa. Al investigar la sección, creé un archivo de prueba para probar el conjunto de declaraciones que se estaban ejecutando y descubrí un error inusual que parece muy extraño.
He probado este código simple:
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
Y la salida fue:
>>> []
Si nada. Esperaba que la comprensión del filtro obtuviera los elementos de la matriz con un conteo de 2 y obtuviera esto, pero no obtuve eso:
# Expected output
>>> [2, 2]
Cuando comenté la tercera línea para probarlo una vez más:
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
La salida fue correcta (puedes probarlo por ti mismo):
>>> [2, 2]
En un momento dado, escribí el tipo de la variable
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
Y tengo:
>>> <class ''generator''>
>>> []
¿Por qué la actualización de una lista en Python está cambiando la salida de otra variable del generador? Esto me parece muy extraño.
La causa raíz del problema es que los generadores son perezosos; Las variables son evaluadas cada vez:
>>> 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]
Se itera sobre la lista original y evalúa la condición con la lista actual. En este caso, 4 aparecieron dos veces en la nueva lista, haciendo que aparezca en el resultado. Solo aparece una vez en el resultado porque solo apareció una vez en la lista original. Los 6 aparecen dos veces en la nueva lista, pero nunca aparecen en la lista anterior y, por lo tanto, nunca se muestran.
Función completa de introspección para los curiosos (la línea con el comentario es la línea importante):
>>> 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,)
Para reiterar: la lista que se va a iterar solo se carga una vez. Sin embargo, todos los cierres en la condición o expresión se cargan desde el ámbito de cierre en cada iteración. No se almacenan en una constante.
La mejor solución para su problema sería crear una nueva variable que haga referencia a la lista original y usarla en su expresión del generador.
La evaluación del generador es "perezosa": no se ejecuta hasta que se actualice con una referencia adecuada. Con tu línea:
Mire nuevamente su salida con el tipo de
f
: ese objeto es un
generador
, no una secuencia.
Está a la espera de ser utilizado, una especie de iterador.
Su generador no se evalúa hasta que empiece a exigirle valores. En ese punto, utiliza los valores disponibles en ese punto , no el punto en el que se definió.
Código para "hacerlo funcionar"
Eso depende de lo que quieras decir con "haz que funcione".
Si desea que
f
sea una lista filtrada, use una lista, no un generador:
f = [x for x in array if array.count(x) == 2] # Filters original
Las expresiones del generador de Python son vinculantes tardías (ver PEP 289 - Expresiones del generador ) (lo que las otras respuestas llaman "perezoso"):
Unión temprana versus unión tardía
Después de mucha discusión, se decidió que la primera expresión (más externa) para [la expresión del generador] debería evaluarse de inmediato y que las expresiones restantes deben evaluarse cuando se ejecuta el generador.
[...] Python adopta un enfoque de enlace tardío para las expresiones lambda y no tiene precedentes para el enlace temprano y automático. Se consideró que introducir un nuevo paradigma introduciría innecesariamente la complejidad.
Después de explorar muchas posibilidades, surgió el consenso de que los problemas vinculantes eran difíciles de entender y que se debería alentar a los usuarios a usar expresiones generadoras dentro de las funciones que consumen sus argumentos de inmediato. Para aplicaciones más complejas, las definiciones de generadores completos siempre son superiores en términos de ser obvias sobre el alcance, la vida útil y el enlace.
Eso significa que
solo
evalúa lo más externo al crear la expresión del generador.
Entonces, en realidad,
enlaza
el valor con la
array
nombres en la "subexpresión"
in array
(de hecho, en este punto está vinculando el equivalente a
iter(array)
).
Pero cuando se itera sobre el generador, la llamada
if array.count
realidad se refiere a lo que actualmente se denomina
array
.
Como en realidad es una
list
no una
array
, cambié los nombres de las variables en el resto de la respuesta para ser más precisos.
En su primer caso, la
list
que repite y la
list
que cuenta será diferente.
Es como si lo usaras:
list1 = [1, 2, 2, 4, 5]
list2 = [5, 6, 1, 2, 9]
f = (x for x in list1 if list2.count(x) == 2)
Así que verifica cada elemento en la
list1
si su conteo en la
list2
es dos.
Puedes verificar esto fácilmente modificando la segunda lista:
>>> lst = [1, 2, 2]
>>> f = (x for x in lst if lst.count(x) == 2)
>>> lst = [1, 1, 2]
>>> list(f)
[1]
Si iteraba sobre la primera lista y contaba en la primera lista, habría devuelto
[2, 2]
(porque la primera lista contiene dos
2
).
Si se repite y se cuenta en la segunda lista, la salida debería ser
[1, 1]
.
Pero como se repite en la primera lista (que contiene un
1
) pero se comprueba la segunda lista (que contiene dos
1
s), la salida es solo un
1
.
Solución utilizando una función de generador.
Hay varias soluciones posibles, generalmente prefiero no usar "expresiones generadoras" si no se repiten de inmediato. Una simple función de generador será suficiente para que funcione correctamente:
def keep_only_duplicated_items(lst):
for item in lst:
if lst.count(item) == 2:
yield item
Y luego úsalo así:
lst = [1, 2, 2, 4, 5]
f = keep_only_duplicated_items(lst)
lst = [5, 6, 1, 2, 9]
>>> list(f)
[2, 2]
Tenga en cuenta que el PEP (ver el enlace anterior) también establece que para algo más complicado es preferible una definición de generador completo.
Una mejor solución utilizando una función de generador con un contador.
Una mejor solución (evitando el comportamiento de tiempo de ejecución cuadrático porque se repite en toda la matriz para cada elemento de la matriz) sería contar (
collections.Counter
) los elementos una vez y luego hacer la búsqueda en tiempo constante (que resulta en tiempo lineal):
from collections import Counter
def keep_only_duplicated_items(lst):
cnts = Counter(lst)
for item in lst:
if cnts[item] == 2:
yield item
Apéndice: Uso de una subclase para "visualizar" lo que sucede y cuándo sucede
Es bastante fácil crear una subclase de
list
que se imprime cuando se llaman métodos específicos, por lo que uno puede verificar que realmente funciona así.
En este caso, simplemente
__iter__
los métodos
__iter__
y
count
porque me interesa sobre qué lista itera la expresión del generador y en qué lista cuenta.
En realidad, los cuerpos de los métodos simplemente delegan en la superclase e imprimen algo (ya que usa
super
sin argumentos y cadenas de caracteres, requiere Python 3.6, pero debería ser fácil de adaptar para otras versiones de 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
Esta es una subclase simple que se imprime cuando se
__iter__
los
__iter__
y
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
[]
Los generadores son
perezosos
y su nueva
array
definida se usa cuando agota su generador después de redefinirlo.
Por lo tanto, la salida es correcta.
Una solución rápida es utilizar una comprensión de lista reemplazando los paréntesis
()
entre corchetes
[]
.
Pasando a cómo escribir mejor su lógica, contar un valor en un bucle tiene una complejidad cuadrática.
Para un algoritmo que funciona en tiempo lineal, puede usar
collections.Counter
Contador para contar valores y
mantener una copia de su lista original
:
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]
Note que la segunda versión ni siquiera requiere
old_array
y es útil si no hay necesidad de mantener el orden de los valores en su matriz original.
Los generadores son perezosos, no serán evaluados hasta que los repitas.
En este caso, en el punto en el que se crea la
list
con el generador como entrada, en la
print
.
No está utilizando un generador correctamente si este es el uso principal de este código. Use una lista de comprensión en lugar de un generador de comprensión. Solo reemplaza los paréntesis con corchetes. Se evalúa a una lista si no lo sabes.
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]
Está recibiendo esta respuesta debido a la naturaleza de un generador.
Estás llamando al generador cuando el contenido evaluará a
[]
Otros ya han explicado la causa raíz del problema: el generador está vinculado al nombre de la variable local de la
array
, en lugar de a su valor.
La solución más pythonic es definitivamente la lista de comprensión:
f = [x for x in array if array.count(x) == 2]
Sin embargo
, si hay alguna razón por la que no desea crear una lista, también
puede
forzar un alcance cerca de la
array
:
f = (lambda array=array: (x for x in array if array.count(x) == 2))()
Lo que sucede aquí es que la
lambda
captura la referencia a la
array
en el momento en que se ejecuta la línea, asegurándose de que el generador vea la variable que espera, incluso si la variable se redefine más adelante.
Tenga en cuenta que esto aún se une a la
variable
(referencia), no al
valor
, por lo que, por ejemplo, se imprimirá
[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]
Este es un patrón común en algunos idiomas, pero no es muy pitónico, por lo que solo tiene sentido si hay una buena razón para no usar la comprensión de la lista (por ejemplo, si la
array
es muy larga o si se usa en una comprensión de generador anidado , y te preocupa la memoria).