¿Qué capturan los cierres de función (lambda)?

249

Recientemente comencé a jugar con Python y encontré algo peculiar en la forma en que funcionan los cierres. Considere el siguiente código:

adders=[0,1,2,3]

for i in [0,1,2,3]:
   adders[i]=lambda a: i+a

print adders[1](3)

Construye una matriz simple de funciones que toman una sola entrada y devuelven esa entrada agregada por un número. Las funciones se construyen en forbucle donde el iterador ise extiende desde 0a 3. Para cada uno de estos números lambda, se crea una función que captura iy agrega a la entrada de la función. La última línea llama a la segunda lambdafunción con 3como parámetro. Para mi sorpresa, la salida fue 6.

Esperaba un 4. Mi razonamiento fue: en Python todo es un objeto y, por lo tanto, cada variable es un puntero esencial. Al crear los lambdacierres para i, esperaba que almacenara un puntero al objeto entero al que apunta actualmente i. Eso significa que cuando se le iasigna un nuevo objeto entero no debería afectar los cierres creados anteriormente. Lamentablemente, la inspección de la addersmatriz dentro de un depurador muestra que sí. Todas las lambdafunciones se refieren al último valor de i, 3, que se traduce en adders[1](3)regresar 6.

Lo que me hace preguntarme sobre lo siguiente:

  • ¿Qué capturan exactamente los cierres?
  • ¿Cuál es la forma más elegante de convencer a las lambdafunciones de capturar el valor actual de iuna manera que no se verá afectada cuando icambie su valor?
Booz
fuente
35
He tenido este problema en el código de la interfaz de usuario. Me volvió loco. El truco es recordar que los bucles no crean un nuevo alcance.
detly
3
@TimMB ¿Cómo se ideja el espacio de nombres?
desvío
3
@detly Bueno, iba a decir que print ino funcionaría después del ciclo. Pero lo probé por mí mismo y ahora veo lo que quieres decir: funciona. No tenía idea de que las variables de bucle persistían después del cuerpo del bucle en Python.
Tim MB
1
@TimMB - Sí, a eso me refería. Lo mismo para if, with, tryetc.
detly
13
Esto se encuentra en las preguntas frecuentes oficiales de Python, en ¿Por qué las lambdas definidas en un bucle con diferentes valores devuelven el mismo resultado? , con una explicación y la solución habitual.
abarnert

Respuestas:

161

Su segunda pregunta ha sido respondida, pero en cuanto a la primera:

¿Qué captura exactamente el cierre?

El alcance en Python es dinámico y léxico. Un cierre siempre recordará el nombre y el alcance de la variable, no el objeto al que apunta. Como todas las funciones en su ejemplo se crean en el mismo ámbito y usan el mismo nombre de variable, siempre se refieren a la misma variable.

EDITAR: Con respecto a su otra pregunta sobre cómo superar esto, hay dos formas que vienen a la mente:

  1. La forma más concisa, pero no estrictamente equivalente, es la recomendada por Adrien Plisson . Cree una lambda con un argumento adicional y establezca el valor predeterminado del argumento adicional para el objeto que desea conservar.

  2. Un poco más detallado pero menos hacky sería crear un nuevo alcance cada vez que cree el lambda:

    >>> adders = [0,1,2,3]
    >>> for i in [0,1,2,3]:
    ...     adders[i] = (lambda b: lambda a: b + a)(i)
    ...     
    >>> adders[1](3)
    4
    >>> adders[2](3)
    5

    El alcance aquí se crea usando una nueva función (una lambda, para abreviar), que vincula su argumento y pasa el valor que desea vincular como argumento. Sin embargo, en el código real, lo más probable es que tenga una función ordinaria en lugar de la lambda para crear el nuevo ámbito:

    def createAdder(x):
        return lambda y: y + x
    adders = [createAdder(i) for i in range(4)]
Max Shawabkeh
fuente
1
Max, si agregas una respuesta para mi otra (pregunta más simple), puedo marcarla como una respuesta aceptada. ¡Gracias!
Booz
3
Python tiene un alcance estático, no un alcance dinámico ... es solo que todas las variables son referencias, por lo que cuando establece una variable en un nuevo objeto, la variable en sí (la referencia) tiene la misma ubicación, pero apunta a otra cosa. lo mismo sucede en Scheme si tú set!. Vea aquí qué es realmente el alcance dinámico: voidspace.org.uk/python/articles/code_blocks.shtml .
Claudiu
66
La opción 2 se asemeja a lo que los lenguajes funcionales llamarían una "función currificada".
Crashworks
205

Puede forzar la captura de una variable utilizando un argumento con un valor predeterminado:

>>> for i in [0,1,2,3]:
...    adders[i]=lambda a,i=i: i+a  # note the dummy parameter with a default value
...
>>> print( adders[1](3) )
4

la idea es declarar un parámetro (ingeniosamente nombrado i) y darle un valor predeterminado de la variable que desea capturar (el valor de i)

Adrien Plisson
fuente
77
+1 por usar valores predeterminados. Ser evaluado cuando se define la lambda los hace perfectos para este uso.
Quornian
21
+1 también porque esta es la solución respaldada por las preguntas frecuentes oficiales .
abarnert
23
Esto es increíble. Sin embargo, el comportamiento predeterminado de Python no lo es.
Cecil Curry
1
Sin embargo, esto no parece ser una buena solución ... en realidad está cambiando la firma de la función solo para capturar una copia de la variable. Y también aquellos que invocan la función pueden meterse con la variable i, ¿verdad?
David Callanan
@DavidCallanan estamos hablando de una lambda: un tipo de función ad-hoc que normalmente define en su propio código para tapar un agujero, no algo que comparte a través de un SDK completo. Si necesita una firma más fuerte, debe usar una función real.
Adrien Plisson
33

Para completar, otra respuesta a su segunda pregunta: podría usar parcial en el módulo functools .

Con la importación de agregar desde el operador como Chris Lutz propuso, el ejemplo se convierte en:

from functools import partial
from operator import add   # add(a, b) -- Same as a + b.

adders = [0,1,2,3]
for i in [0,1,2,3]:
   # store callable object with first argument given as (current) i
   adders[i] = partial(add, i) 

print adders[1](3)
Joma
fuente
24

Considere el siguiente código:

x = "foo"

def print_x():
    print x

x = "bar"

print_x() # Outputs "bar"

Creo que la mayoría de la gente no encontrará esto confuso en absoluto. Es el comportamiento esperado.

Entonces, ¿por qué la gente piensa que sería diferente cuando se realiza en un bucle? Sé que cometí ese error, pero no sé por qué. Es el bucle? O tal vez la lambda?

Después de todo, el bucle es solo una versión más corta de:

adders= [0,1,2,3]
i = 0
adders[i] = lambda a: i+a
i = 1
adders[i] = lambda a: i+a
i = 2
adders[i] = lambda a: i+a
i = 3
adders[i] = lambda a: i+a
truppo
fuente
11
Es el bucle, porque en muchos otros idiomas un bucle puede crear un nuevo alcance.
detly
1
Esta respuesta es buena porque explica por qué ise accede a la misma variable para cada función lambda.
David Callanan
3

En respuesta a su segunda pregunta, la forma más elegante de hacerlo sería utilizar una función que tome dos parámetros en lugar de una matriz:

add = lambda a, b: a + b
add(1, 3)

Sin embargo, usar lambda aquí es un poco tonto. Python nos proporciona el operatormódulo, que proporciona una interfaz funcional para los operadores básicos. El lambda anterior tiene una sobrecarga innecesaria solo para llamar al operador de adición:

from operator import add
add(1, 3)

Entiendo que estás jugando, tratando de explorar el lenguaje, pero no puedo imaginar una situación en la que usaría una variedad de funciones en las que la rareza de alcance de Python se interpondría en el camino.

Si lo desea, puede escribir una pequeña clase que use su sintaxis de indexación de matriz:

class Adders(object):
    def __getitem__(self, item):
        return lambda a: a + item

adders = Adders()
adders[1](3)
Chris Lutz
fuente
2
Chris, por supuesto, el código anterior no tiene nada que ver con mi problema original. Está construido para ilustrar mi punto de una manera simple. Por supuesto, es inútil y tonto.
Booz
3

Aquí hay un nuevo ejemplo que resalta la estructura de datos y el contenido de un cierre, para ayudar a aclarar cuándo se "guarda" el contexto adjunto.

def make_funcs():
    i = 42
    my_str = "hi"

    f_one = lambda: i

    i += 1
    f_two = lambda: i+1

    f_three = lambda: my_str
    return f_one, f_two, f_three

f_1, f_2, f_3 = make_funcs()

¿Qué hay en un cierre?

>>> print f_1.func_closure, f_1.func_closure[0].cell_contents
(<cell at 0x106a99a28: int object at 0x7fbb20c11170>,) 43 

En particular, my_str no está en el cierre de f1.

¿Qué hay en el cierre de f2?

>>> print f_2.func_closure, f_2.func_closure[0].cell_contents
(<cell at 0x106a99a28: int object at 0x7fbb20c11170>,) 43

Observe (de las direcciones de memoria) que ambos cierres contienen los mismos objetos. Por lo tanto, puede comenzar a pensar en la función lambda como una referencia al alcance. Sin embargo, my_str no está en el cierre para f_1 o f_2, y yo no está en el cierre para f_3 (no se muestra), lo que sugiere que los objetos de cierre en sí mismos son objetos distintos.

¿Los objetos de cierre son el mismo objeto?

>>> print f_1.func_closure is f_2.func_closure
False
Jeff
fuente
NB La salida int object at [address X]>me hizo pensar que el cierre está almacenando [dirección X] AKA una referencia. Sin embargo, [dirección X] cambiará si la variable se reasigna después de la declaración lambda.
Jeff