Question "Least Astonishment" et l'argument par défaut mutable


N'importe qui bricolant avec Python assez longtemps a été mordu (ou déchiré en morceaux) par le problème suivant:

def foo(a=[]):
    a.append(5)
    return a

Les débutants en Python s'attendraient à ce que cette fonction renvoie toujours une liste avec un seul élément: [5]. Le résultat est plutôt très différent, et très étonnant (pour un novice):

>>> foo()
[5]
>>> foo()
[5, 5]
>>> foo()
[5, 5, 5]
>>> foo()
[5, 5, 5, 5]
>>> foo()

Un de mes managers a déjà eu sa première rencontre avec cette fonctionnalité et l'a qualifié de «faille dramatique de conception» de la langue. J'ai répondu que le comportement avait une explication sous-jacente, et il est en effet très énigmatique et inattendu si vous ne comprenez pas les internes. Cependant, je n'ai pas pu répondre (à moi-même) à la question suivante: quelle est la raison de lier l'argument par défaut à la définition de la fonction, et non à l'exécution de la fonction? Je doute que le comportement expérimenté ait un usage pratique (qui a vraiment utilisé des variables statiques en C, sans engendrer de bugs?)

modifier:

Baczek a fait un exemple intéressant. Avec la plupart de vos commentaires et ceux d'Utaal en particulier, j'ai précisé:

>>> def a():
...     print("a executed")
...     return []
... 
>>>            
>>> def b(x=a()):
...     x.append(5)
...     print(x)
... 
a executed
>>> b()
[5]
>>> b()
[5, 5]

Pour moi, il semble que la décision de conception était relative à l'endroit où mettre la portée des paramètres: à l'intérieur de la fonction ou "ensemble" avec elle?

Faire la liaison à l'intérieur de la fonction signifierait que x est effectivement lié à la valeur par défaut spécifiée lorsque la fonction est appelée, non définie, quelque chose qui présenterait un défaut def la ligne serait "hybride" en ce sens qu'une partie de la liaison (de l'objet fonction) arriverait à la définition, et une partie (affectation des paramètres par défaut) à l'instant de l'invocation de la fonction.

Le comportement réel est plus cohérent: tout de cette ligne est évalué lorsque cette ligne est exécutée, ce qui signifie à la définition de la fonction.


2049
2017-07-15 18:00


origine


Réponses:


En fait, ce n'est pas un défaut de conception, et ce n'est pas à cause de l'interne, ou de la performance.
Cela vient simplement du fait que les fonctions en Python sont des objets de première classe, et pas seulement un morceau de code.

Dès que vous arrivez à penser de cette façon, cela a tout son sens: une fonction est un objet évalué sur sa définition; les paramètres par défaut sont des sortes de "données de membre" et par conséquent leur état peut changer d'un appel à l'autre - exactement comme dans n'importe quel autre objet.

En tout cas, Effbot a une très bonne explication des raisons de ce comportement dans Valeurs de paramètre par défaut en Python.
Je l'ai trouvé très clair, et je suggère vraiment de le lire pour une meilleure connaissance du fonctionnement des objets fonctionnels.


1349
2017-07-17 21:29



Supposons que vous avez le code suivant

fruits = ("apples", "bananas", "loganberries")

def eat(food=fruits):
    ...

Quand je vois la déclaration de manger, la chose la moins étonnante est de penser que si le premier paramètre n'est pas donné, il sera égal au tuple ("apples", "bananas", "loganberries")

Cependant, supposé plus tard dans le code, je fais quelque chose comme

def some_random_function():
    global fruits
    fruits = ("blueberries", "mangos")

alors si les paramètres par défaut étaient liés à l'exécution de la fonction plutôt qu'à la déclaration de la fonction, je serais étonné (d'une très mauvaise façon) de découvrir que les fruits avaient été changés. Ce serait plus étonnant IMO que de découvrir que votre foofonction ci-dessus était en train de muter la liste.

Le vrai problème réside dans les variables mutables, et toutes les langues ont ce problème dans une certaine mesure. Voici une question: supposons en Java que j'ai le code suivant:

StringBuffer s = new StringBuffer("Hello World!");
Map<StringBuffer,Integer> counts = new HashMap<StringBuffer,Integer>();
counts.put(s, 5);
s.append("!!!!");
System.out.println( counts.get(s) );  // does this work?

Maintenant, ma carte utilise-t-elle la valeur de StringBuffer clé quand il a été placé sur la carte, ou stocke-t-il la clé par référence? De toute façon, quelqu'un est étonné; soit la personne qui a essayé de sortir l'objet de la Map en utilisant une valeur identique à celle avec laquelle ils l'ont mise, ou la personne qui n'arrive pas à récupérer leur objet même si la clé qu'ils utilisent est littéralement le même objet que celui utilisé pour le mettre dans la carte (ceci est en fait, pourquoi Python n'autorise pas l'utilisation de ses types de données intégrés mutables comme clés de dictionnaire).

Votre exemple est un bon exemple d'un cas où les nouveaux venus de Python seront surpris et mordus. Mais je dirais que si nous «réparions» cela, cela ne ferait que créer une situation différente où ils seraient mordus à la place, et ce serait encore moins intuitif. De plus, c'est toujours le cas lorsqu'on traite des variables mutables; vous rencontrez toujours des cas où quelqu'un pourrait intuitivement s'attendre à un comportement ou à un comportement contraire en fonction du code qu'il écrit.

Personnellement, j'aime l'approche actuelle de Python: les arguments de la fonction par défaut sont évalués lorsque la fonction est définie et que cet objet est toujours la valeur par défaut. Je suppose qu'ils pourraient utiliser une liste vide, mais ce type de boîtier spécial causerait encore plus d'étonnement, sans parler de l'incompatibilité inverse.


231
2017-07-15 18:11



AFAICS personne n'a encore posté la partie pertinente de la Documentation:

Les valeurs de paramètre par défaut sont évaluées lorsque la définition de la fonction est exécutée. Cela signifie que l'expression est évaluée une fois, lorsque la fonction est définie et que la même valeur "pré-calculée" est utilisée pour chaque appel. Ceci est particulièrement important pour comprendre quand un paramètre par défaut est un objet mutable, comme une liste ou un dictionnaire: si la fonction modifie l'objet (par exemple en ajoutant un élément à une liste), la valeur par défaut est en effet modifiée. Ce n'est généralement pas ce qui était prévu. Un moyen de contourner cela est d'utiliser None comme défaut, et de le tester explicitement dans le corps de la fonction [...]


195
2017-07-10 14:50



Je ne sais rien sur le fonctionnement interne de l'interpréteur Python (et je ne suis pas un expert en compilateurs et interprètes non plus) alors ne me blâmez pas si je propose quelque chose d'insensible ou impossible.

Fourni que les objets python sont mutables Je pense que cela devrait être pris en compte lors de la conception des arguments par défaut. Lorsque vous instanciez une liste:

a = []

vous vous attendez à obtenir un Nouveau liste référencée par une.

Pourquoi le a = [] dans

def x(a=[]):

instancier une nouvelle liste sur la définition de la fonction et non sur l'invocation? C'est comme si vous demandiez "si l'utilisateur ne fournit pas l'argument alors instancier une nouvelle liste et l'utiliser comme si elle avait été produite par l'appelant ". Je pense que c'est ambigu:

def x(a=datetime.datetime.now()):

utilisateur, voulez-vous une par défaut à la date-heure correspondant à quand vous définissez ou exécutez X? Dans ce cas, comme dans le précédent, je garderai le même comportement que si l'argument par défaut "affectation" était la première instruction de la fonction (datetime.now () appelée sur l'invocation de fonction). D'un autre côté, si l'utilisateur voulait le mappage de l'heure de définition, il pourrait écrire:

b = datetime.datetime.now()
def x(a=b):

Je sais, je sais: c'est une fermeture. Alternativement, Python pourrait fournir un mot-clé pour forcer la liaison de définition-heure:

def x(static a=b):

97
2017-07-15 23:21



Eh bien, la raison est tout simplement que les liaisons sont faites lorsque le code est exécuté, et la définition de la fonction est exécutée, eh bien ... lorsque les fonctions sont définies.

Comparez ceci:

class BananaBunch:
    bananas = []

    def addBanana(self, banana):
        self.bananas.append(banana)

Ce code souffre exactement du même hasard inattendu. bananas est un attribut de classe, et par conséquent, lorsque vous ajoutez des choses, il est ajouté à toutes les instances de cette classe. La raison est exactement la même.

C'est juste "Comment ça marche", et le faire fonctionner différemment dans le cas de la fonction serait probablement compliqué, et dans le cas de la classe probablement impossible, ou au moins ralentir beaucoup l'instanciation d'objet, car vous devriez garder le code de classe et l'exécuter lorsque les objets sont créés.

Oui, c'est inattendu. Mais une fois que le penny tombe, cela correspond parfaitement à la façon dont fonctionne Python en général. En fait, c'est un bon outil d'enseignement, et une fois que vous comprenez pourquoi cela se produit, vous allez beaucoup mieux python.

Cela dit, il devrait figurer en bonne place dans tout bon tutoriel Python. Parce que comme vous le dites, tout le monde se heurte à ce problème tôt ou tard.


72
2017-07-15 18:54



J'avais l'habitude de penser que créer les objets à l'exécution serait la meilleure approche. Je suis moins certain maintenant, puisque vous perdez quelques fonctionnalités utiles, même si cela peut en valoir la peine, tout simplement pour éviter la confusion des débutants. Les inconvénients de le faire sont:

1. Performance

def foo(arg=something_expensive_to_compute())):
    ...

Si l'évaluation du temps d'appel est utilisée, la fonction coûteuse est appelée chaque fois que votre fonction est utilisée sans argument. Vous payez un prix élevé à chaque appel, ou vous devez manuellement mettre la valeur en cache, polluer votre espace de noms et ajouter de la verbosité.

2. Forcer les paramètres liés

Une astuce utile consiste à lier les paramètres d'un lambda à actuel liaison d'une variable lorsque le lambda est créé. Par exemple:

funcs = [ lambda i=i: i for i in range(10)]

Cela renvoie une liste de fonctions qui renvoient respectivement 0,1,2,3 .... Si le comportement est modifié, ils se lieront à la place i au temps d'appel valeur de i, de sorte que vous obtiendrez une liste de fonctions qui ont tous retourné 9.

La seule façon de l'implémenter autrement serait de créer une fermeture supplémentaire avec le i lié, c'est-à-dire:

def make_func(i): return lambda: i
funcs = [make_func(i) for i in range(10)]

3. Introspection

Considérez le code:

def foo(a='test', b=100, c=[]):
   print a,b,c

Nous pouvons obtenir des informations sur les arguments et les valeurs par défaut en utilisant le inspect module, qui

>>> inspect.getargspec(foo)
(['a', 'b', 'c'], None, None, ('test', 100, []))

Cette information est très utile pour des choses comme la génération de documents, la métaprogrammation, les décorateurs, etc.

Supposons maintenant que le comportement des valeurs par défaut puisse être modifié de sorte que ceci soit l'équivalent de:

_undefined = object()  # sentinel value

def foo(a=_undefined, b=_undefined, c=_undefined)
    if a is _undefined: a='test'
    if b is _undefined: b=100
    if c is _undefined: c=[]

Cependant, nous avons perdu la capacité d'introspection et de voir quels sont les arguments par défaut sont. Parce que les objets n'ont pas été construits, nous ne pouvons jamais les saisir sans réellement appeler la fonction. Le mieux que nous puissions faire est de stocker le code source et de le renvoyer sous forme de chaîne.


50
2017-07-16 10:05



5 points en défense de Python

  1. Simplicité: Le comportement est simple dans le sens suivant: La plupart des gens tombent dans ce piège une seule fois, pas plusieurs fois.

  2. Cohérence: Python toujours passe des objets, pas des noms. Le paramètre par défaut fait évidemment partie de la fonction en-tête (pas le corps de la fonction). Il devrait donc être évalué au moment du chargement du module (et uniquement au moment du chargement du module, sauf s'il est imbriqué), au moment de l'appel de la fonction.

  3. Utilité: Comme Frederik Lundh souligne dans son explication de "Valeurs de paramètre par défaut en Python", la Le comportement actuel peut être très utile pour la programmation avancée. (Utiliser avec modération.)

  4. Documentation suffisante: Dans la documentation Python la plus basique, le tutoriel, le problème est annoncé à haute voix comme un "Avertissement important" dans le premier sous-section de la section "Plus sur les fonctions de définition". L'avertissement utilise même le gras, ce qui est rarement appliqué en dehors des rubriques. RTFM: Lisez le bon manuel.

  5. Méta-apprentissage: Tomber dans le piège est en fait un très moment utile (du moins si vous êtes un apprenant réflexif), parce que vous comprendrez mieux le point par la suite "Cohérence" ci-dessus et qui sera vous apprendre beaucoup sur Python.


47
2018-03-30 11:18



Pourquoi ne faites-vous pas introspection?

je suis vraiment surpris personne n'a effectué l'introspection perspicace offerte par Python (2 et 3 appliquer) sur callables.

Étant donné une simple petite fonction func défini comme:

>>> def func(a = []):
...    a.append(5)

Quand Python le rencontre, la première chose qu'il va faire est de le compiler pour créer un code objet pour cette fonction. Alors que cette étape de compilation est terminée, Python évalue* et alors magasins les arguments par défaut (une liste vide [] ici) dans l'objet fonction lui-même. Comme la première réponse mentionnée: la liste a peut maintenant être considéré comme un membre de la fonction func.

Alors, faisons une introspection, un avant et après pour examiner comment la liste se développe à l'intérieur l'objet fonction. j'utilise Python 3.x pour cela, pour Python 2, la même chose s'applique __defaults__ ou func_defaults en Python 2; oui, deux noms pour la même chose).

Fonction avant l'exécution:

>>> def func(a = []):
...     a.append(5)
...     

Après que Python ait exécuté cette définition, il prendra tous les paramètres par défaut spécifiés (a = [] ici et les entasser dans le __defaults__ attribut pour l'objet fonction (section pertinente: Callables):

>>> func.__defaults__
([],)

O.k, donc une liste vide comme l'entrée unique dans __defaults__, comme prévu.

Fonction après l'exécution:

Nous allons maintenant exécuter cette fonction:

>>> func()

Maintenant, voyons les __defaults__ encore:

>>> func.__defaults__
([5],)

Étonné? La valeur à l'intérieur de l'objet change! Les appels consécutifs à la fonction vont maintenant simplement s'ajouter à list objet:

>>> func(); func(); func()
>>> func.__defaults__
([5, 5, 5, 5],)

Donc, là vous l'avez, la raison pour laquelle cette 'défaut' arrive, parce que les arguments par défaut font partie de l'objet fonction. Il n'y a rien d'étrange ici, tout est un peu surprenant.

La solution commune pour combattre cela est à l'habitude None par défaut, puis initialiser dans le corps de la fonction:

def func(a = None):
    # or: a = [] if a is None else a
    if a is None:
        a = []

Comme le corps de la fonction est exécuté à chaque fois, vous obtenez toujours une nouvelle liste vide si aucun argument n'a été passé pour a.


Pour vérifier davantage que la liste dans __defaults__ est le même que celui utilisé dans la fonction func vous pouvez simplement changer votre fonction pour retourner le id de la liste a utilisé à l'intérieur du corps de la fonction. Ensuite, comparez-le à la liste dans __defaults__ (position [0] dans __defaults__) et vous verrez comment ils se réfèrent à la même instance de liste:

>>> def func(a = []): 
...     a.append(5)
...     return id(a)
>>>
>>> id(func.__defaults__[0]) == func()
True

Tous avec le pouvoir de l'introspection!


* Pour vérifier que Python évalue les arguments par défaut lors de la compilation de la fonction, essayez d'exécuter ce qui suit:

def bar(a=input('Did you just see me without calling the function?')): 
    pass  # use raw_input in Py2

comme vous le remarquerez, input() est appelé avant le processus de construction de la fonction et la lier au nom bar est fait.


42
2017-12-09 07:13



Ce comportement est facile à expliquer par:

  1. La fonction (classe, etc.) déclaration est exécutée une seule fois, la création de tous les objets de valeur par défaut
  2. tout est passé par référence

Alors:

def x(a=0, b=[], c=[], d=0):
    a = a + 1
    b = b + [1]
    c.append(1)
    print a, b, c
  1. a ne change pas - chaque appel d'affectation crée un nouvel objet int - un nouvel objet est imprimé
  2. b ne change pas - le nouveau tableau est construit à partir de la valeur par défaut et imprimé
  3. c changements - l'opération est effectuée sur le même objet - et il est imprimé

40
2017-07-15 19:15