Que capturent les fermetures de fonctions (lambda)?

249

Récemment, j'ai commencé à jouer avec Python et j'ai trouvé quelque chose de particulier dans le fonctionnement des fermetures. Considérez le code suivant:

adders=[0,1,2,3]

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

print adders[1](3)

Il crée un tableau simple de fonctions qui prennent une seule entrée et retournent cette entrée ajoutée par un nombre. Les fonctions sont construites en forboucle où l'itérateur iva de 0à 3. Pour chacun de ces nombres, une lambdafonction est créée qui la capture iet l'ajoute à l'entrée de la fonction. La dernière ligne appelle la deuxième lambdafonction avec 3comme paramètre. À ma grande surprise, la sortie était 6.

Je m'attendais à un 4. Mon raisonnement était: en Python, tout est un objet et donc chaque variable est un pointeur essentiel pour lui. Lors de la création des lambdafermetures pour i, je m'attendais à ce qu'il stocke un pointeur sur l'objet entier actuellement pointé par i. Cela signifie que lorsqu'il est iaffecté à un nouvel objet entier, il ne doit pas affecter les fermetures précédemment créées. Malheureusement, l'inspection du adderstableau dans un débogueur montre que c'est le cas. Toutes les lambdafonctions se rapportent à la dernière valeur i, 3dont les résultats en adders[1](3)retour 6.

Ce qui me fait me demander ce qui suit:

  • Que capturent exactement les fermetures?
  • Quelle est la façon la plus élégante de convaincre les lambdafonctions de capturer la valeur actuelle d' iune manière qui ne sera pas affectée lors d'un ichangement de valeur?
Boaz
la source
35
J'ai eu ce problème dans le code de l'interface utilisateur. M'a rendu fou. L'astuce consiste à se rappeler que les boucles ne créent pas de nouvelle portée.
detly
3
@TimMB Comment iquitte l'espace de noms?
detly
3
@detly Eh bien, j'allais dire que print icela ne fonctionnerait pas après la boucle. Mais je l'ai testé par moi-même et maintenant je vois ce que vous voulez dire - cela fonctionne. Je n'avais aucune idée que les variables de boucle persistaient après le corps de la boucle en python.
Tim MB
1
@TimMB - Oui, c'est ce que je voulais dire. Idem pour if, with, tryetc.
detly
13
C'est dans la FAQ Python officielle, sous Pourquoi les lambdas définis dans une boucle avec des valeurs différentes renvoient-ils tous le même résultat? , avec une explication et la solution de contournement habituelle.
abarnert

Réponses:

161

On a répondu à votre deuxième question, mais comme pour la première:

qu'est-ce que la fermeture capture exactement?

La portée en Python est dynamique et lexicale. Une fermeture se souviendra toujours du nom et de la portée de la variable, pas de l'objet vers lequel elle pointe. Étant donné que toutes les fonctions de votre exemple sont créées dans la même étendue et utilisent le même nom de variable, elles se réfèrent toujours à la même variable.

EDIT: Concernant votre autre question sur la façon de surmonter cela, il y a deux façons qui me viennent à l'esprit:

  1. La manière la plus concise, mais pas strictement équivalente, est celle recommandée par Adrien Plisson . Créez un lambda avec un argument supplémentaire et définissez la valeur par défaut de l'argument supplémentaire sur l'objet que vous souhaitez conserver.

  2. Un peu plus verbeux mais moins hacky serait de créer une nouvelle portée à chaque fois que vous créez le 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

    La portée ici est créée à l'aide d'une nouvelle fonction (un lambda, par souci de concision), qui lie son argument et transmet la valeur que vous souhaitez lier comme argument. Dans le code réel, cependant, vous aurez très probablement une fonction ordinaire au lieu du lambda pour créer la nouvelle étendue:

    def createAdder(x):
        return lambda y: y + x
    adders = [createAdder(i) for i in range(4)]
Max Shawabkeh
la source
1
Max, si vous ajoutez une réponse pour mon autre (question plus simple), je peux marquer cela comme une réponse acceptée. THX!
Boaz
3
Python a une portée statique, pas une portée dynamique ... c'est juste que toutes les variables sont des références, donc lorsque vous définissez une variable sur un nouvel objet, la variable elle-même (la référence) a le même emplacement, mais elle pointe vers autre chose. la même chose se produit dans Scheme si vous set!. voir ici quelle est réellement l'étendue dynamique: voidspace.org.uk/python/articles/code_blocks.shtml .
Claudiu
6
L'option 2 ressemble à ce que les langages fonctionnels appellent une «fonction curry».
Crashworks
205

vous pouvez forcer la capture d'une variable en utilisant un argument avec une valeur par défaut:

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

l'idée est de déclarer un paramètre (intelligemment nommé i) et de lui donner une valeur par défaut de la variable que vous souhaitez capturer (la valeur de i)

Adrien Plisson
la source
7
+1 pour utiliser les valeurs par défaut. Être évalué lorsque le lambda est défini les rend parfaits pour cette utilisation.
quornian
21
+1 également parce que c'est la solution approuvée par la FAQ officielle .
abarnert
23
Ceci est incroyable. Cependant, le comportement par défaut de Python ne l'est pas.
Cecil Curry
1
Cependant, cela ne semble pas être une bonne solution ... vous changez en fait la signature de la fonction juste pour capturer une copie de la variable. Et aussi ceux qui invoquent la fonction peuvent jouer avec la variable i, non?
David Callanan
@DavidCallanan nous parlons d'un lambda: un type de fonction ad-hoc que vous définissez généralement dans votre propre code pour boucher un trou, pas quelque chose que vous partagez à travers un sdk entier. si vous avez besoin d'une signature plus forte, vous devez utiliser une fonction réelle.
Adrien Plisson
33

Pour être complet, une autre réponse à votre deuxième question: vous pouvez utiliser partial dans le module functools .

Avec l'importation d'ajout depuis l'opérateur comme Chris Lutz l'a proposé, l'exemple devient:

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
la source
24

Considérez le code suivant:

x = "foo"

def print_x():
    print x

x = "bar"

print_x() # Outputs "bar"

Je pense que la plupart des gens ne trouveront pas cela déroutant du tout. C'est le comportement attendu.

Alors, pourquoi les gens pensent-ils que ce serait différent quand cela se fait en boucle? Je sais que j'ai fait cette erreur moi-même, mais je ne sais pas pourquoi. C'est la boucle? Ou peut-être la lambda?

Après tout, la boucle n'est qu'une version plus courte 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
la source
11
C'est la boucle, car dans de nombreuses autres langues, une boucle peut créer une nouvelle portée.
detly
1
Cette réponse est bonne car elle explique pourquoi la même ivariable est accessible pour chaque fonction lambda.
David Callanan
3

En réponse à votre deuxième question, la façon la plus élégante de le faire serait d'utiliser une fonction qui prend deux paramètres au lieu d'un tableau:

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

Cependant, utiliser lambda ici est un peu idiot. Python nous donne le operatormodule, qui fournit une interface fonctionnelle aux opérateurs de base. Le lambda ci-dessus a une surcharge inutile juste pour appeler l'opérateur d'addition:

from operator import add
add(1, 3)

Je comprends que vous jouez, essayez d'explorer le langage, mais je ne peux pas imaginer une situation où j'utiliserais un éventail de fonctions où l'étrangeté de la portée de Python entraverait.

Si vous le souhaitez, vous pouvez écrire une petite classe qui utilise votre syntaxe d'indexation de tableau:

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

adders = Adders()
adders[1](3)
Chris Lutz
la source
2
Chris, bien sûr, le code ci-dessus n'a rien à voir avec mon problème d'origine. Il est construit pour illustrer mon propos de manière simple. C'est bien sûr inutile et idiot.
Boaz
3

Voici un nouvel exemple qui met en évidence la structure des données et le contenu d'une fermeture, pour aider à clarifier quand le contexte englobant est «enregistré».

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'y a-t-il dans une fermeture?

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

Notamment, my_str n'est pas dans la fermeture de f1.

Qu'y a-t-il dans la fermeture de f2?

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

Notez (à partir des adresses mémoire) que les deux fermetures contiennent les mêmes objets. Vous pouvez donc commencer à penser à la fonction lambda comme ayant une référence à la portée. Cependant, my_str n'est pas dans la fermeture pour f_1 ou f_2, et i n'est pas dans la fermeture pour f_3 (non illustré), ce qui suggère que les objets de fermeture eux-mêmes sont des objets distincts.

Les objets de fermeture sont-ils le même objet?

>>> print f_1.func_closure is f_2.func_closure
False
Jeff
la source
NB La sortie int object at [address X]>m'a fait penser que la fermeture stocke [adresse X] AKA une référence. Cependant, [adresse X] changera si la variable est réaffectée après l'instruction lambda.
Jeff