Bizarreries Pythoniques : arguments par défaut mutables

En Python, il est courant d’attribuer une valeur par défaut à un paramètre :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
>>> def f(x=0):
...     x += 1
...     return x
...
>>> f()
1
>>> f()
1
>>> f()
1

Seulement, si on ne prête pas attention au type de cette valeur par défaut, on s’expose à quelques surprises :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
>>> def g(x=[]):
...     x.append(1)
...     return x
...
>>> g()
[1]
>>> g() 
[1, 1]  # Hein ?! Ce n'est pas [1] ?
>>> g()
[1, 1, 1]  # Hein ?! Ce n'est toujours pas [1] ?

En effet, le comportement attendu par beaucoup de débutants est le suivant :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
>>> def h(x=None):
...     if x is None:
...         x = []
...     x.append(1)
...     return x
... 
>>> h()
[1]
>>> h()
[1]

Pour comprendre l’étrangeté de g, intéressons-nous à x plus en détail :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
>>> def g(x=[]):
...     print('Identité de x : ', id(x))
...     x.append(1)
...     return x
... 
>>> g()
Identité de x : 139908346248072
[1]
>>> g()
Identité de x : 139908346248072
[1, 1]

L’identité de x ne change pas d’un appel à l’autre quand on n’attribut pas manuellement de valeur au paramètre. Autrement dit, x désigne le même objet en mémoire. En effet, Python exécute l’instruction x = [] une seule fois, au moment de la définition de la fonction, c’est-à-dire quand le module comportant g est importé. Rendons-nous en compte avec l’exemple suivant :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
>>> def generer_valeur_par_defaut():
...     print('Génération de la valeur par défaut')
...     return []
... 
>>> def i(x=generer_valeur_par_defaut()):
...     x.append(1)
...     return x
... 
Génération de la valeur par défaut
>>> i()
[1]
>>> i()
[1, 1]

Bien sûr, si vous spécifiez une valeur pour x son identité change (la variable ne désigne plus le même objet en mémoire) :

1
2
3
4
5
6
7
8
9
>>> x = []
>>> id(x)
139908346248264
>>> g(x)
Identité de x : 139908346248264
[1]
>>> g()
Identité de x : 139908346248072
[1, 1, 1]

Mais pourtant, f a bien le comportement attendu, non ?

La différence de comportement entre f et g provient de la nature des valeurs par défaut. 0 est un entier, donc immutable. [] est mutable. Je vous renvoie vers le cours Notions de Python avancées si ces concepts ne vous sont pas familiers.

Bien, j’ai compris qu’il valait mieux éviter les valeurs par défaut mutables. Mais comment je fais si j’ai quand même besoin d’en avoir une ?

La méthode conventionnelle est d’utiliser None, comme je l’ai fait pour h :

1
2
3
4
5
6
7
8
9
>>> def j(x=None):
...     x = [] if x is None else x
...     x.append(1)
...     return x
... 
>>> j()
[1]
>>> j()
[1]

Pas très intuitif tout ça… Pourquoi donc Python est-il conçu ainsi ?

La première raison est une question de performance. Il va de soi qu’il est plus économique de générer la valeur par défaut une seule fois, à la définition de la fonction, qu’à chaque appel sans valeur attribuée au paramètre.

La deuxième explication est plus complexe. Considérons le code suivant :

1
2
3
4
5
6
7
8
9
>>> def multiplicateurs():         
...     return [(lambda x : i*x) for i in range(3)]
... 
>>> for mul in multiplicateurs():
...     print(mul(2))
... 
4
4
4

Etrange, non ? On se serait plutôt attendu à :

1
2
3
0  # 0*2
2  # 1*2
4  # 2*2

Ce qu’il se passe, c’est que la multiplication par i n’est faite que ligne 5. Or la boucle ligne 2 a déjà été exécutée ligne 4, au moment de l’appel à multiplicateurs(). Ainsi, quand on effectue le produit (via l’instruction mul(2)) i possède sa valeur de fin de boucle, soit 2. On parle de late binding : on associe une valeur à la variable au moment de l’exécution de la fonction.

Mais Python nous permet de remédier à ça et de faire du early binding, c’est-à-dire de fixer la valeur au moment de la définition de la fonction. Voyez-vous comment ?

Comment ?

En utilisant la propriété des valeurs par défaut qui nous perturbait plus haut !

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
>>> def multiplicateurs():
...      return [(lambda x, i=i: i*x) for i in range(3)]
... 
>>> for mul in multiplicateurs():
...      # Comme on n'attribue pas de valeur au paramètre i, il prend celle par défaut.
...      # Cette valeur est fixée à la définition de la fonction et non plus à son exécution.
...      print(mul(2))
... 
0
2
4

Notez au passage qu’avec cette propriété des valeurs par défaut, il est très simple d’implémenter un système de cache :

1
2
3
4
5
6
def f(a, b, _cache={}):
    if (a, b) in _cache:
        return _cache[(a, b)]
    res = un_gros_calcul_avec_a_et_b(a, b)
    _cache[(a, b)] = res
    return res

Si vous voyez d’autres justifications à ce choix de conception, partagez-les dans les commentaires !



2 commentaires

Merci pour ce billet, il est vrai que ce comportement génère souvent des incompréhensions.

Attention tout de même au x = x or [] dans ta fonction j, il est préférable de faire référence à None comme dans h (que l’on peut raccourcir en x = [] if x is None else x).

Dans l’état actuel de ta fonction j, si tu l’appelles avec une liste vide en paramètre, en espérant qu’une valeur y soit ajoutée en sortie, le comportement ne sera pas le bon. En effet, x étant vide, elle s’évaluera à False, et x = x or [] assignera une nouvelle liste à x. Ce qui fait que la valeur sera insérée dans cette nouvelle liste et non dans celle passée en paramètre.

Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte