web-dev-qa-db-fra.com

Pourquoi Python ne fait-il qu'une copie de l'élément individuel lors de l'itération d'une liste?

Je viens de réaliser qu'en Python, si l'on écrit

for i in a:
    i += 1

Les éléments de la liste d'origine a ne seront en fait pas affectés du tout, car la variable i se révèle être simplement une copie de l'élément d'origine dans a.

Afin de modifier l'élément d'origine,

for index, i in enumerate(a):
    a[index] += 1

serait nécessaire.

J'ai été vraiment surpris par ce comportement. Cela semble être très contre-intuitif, apparemment différent des autres langages et a entraîné des erreurs dans mon code que j'ai dû déboguer pendant longtemps aujourd'hui.

J'ai lu Python Tutorial avant. Juste pour être sûr, j'ai relu le livre tout à l'heure, et il ne mentionne même pas du tout ce comportement.

Quel est le raisonnement derrière cette conception? Est-il censé être une pratique standard dans de nombreuses langues, de sorte que le didacticiel estime que les lecteurs devraient le comprendre naturellement? Dans quelles autres langues le même comportement à l'itération est-il présent, auquel je devrais faire attention à l'avenir?

31
xji

J'ai déjà répondu à une question similaire récemment et il est très important de réaliser que += peut avoir différentes significations:

  • Si le type de données implémente l'ajout sur place (c'est-à-dire qu'il a un fonctionnement correct __iadd__ fonction), les données auxquelles i fait référence sont mises à jour (peu importe qu'elles soient dans une liste ou ailleurs).

  • Si le type de données n'implémente pas un __iadd__ méthode la i += x l'instruction n'est que du sucre syntaxique pour i = i + x, donc une nouvelle valeur est créée et affectée au nom de variable i.

  • Si le type de données implémente __iadd__ mais ça fait quelque chose de bizarre. Il pourrait être possible qu'il soit mis à jour ... ou non - cela dépend de ce qui est mis en œuvre là-bas.

Les pythons entiers, flottants, chaînes n'implémentent pas __iadd__ afin qu'ils ne soient pas mis à jour sur place. Cependant, d'autres types de données comme numpy.array ou lists l'implémentent et se comporteront comme prévu. Il ne s'agit donc pas de copier ou de ne pas copier lors de l'itération (normalement, il ne fait pas de copies pour lists et Tuples - mais cela dépend aussi de l'implémentation des conteneurs __iter__ et __getitem__ méthode!) - il s'agit plus du type de données que vous avez stocké dans votre a.

68
MSeifert

Clarification - terminologie

Python ne fait pas de distinction entre les concepts de référence et pointeur . Ils utilisent généralement le terme référence , mais si vous comparez avec des langages comme C++ qui ont cette distinction - c'est beaucoup plus proche d'un pointeur .

Étant donné que le demandeur vient clairement de l'arrière-plan C++, et puisque cette distinction - qui est requise pour l'explication - n'existe pas en Python, j'ai choisi d'utiliser la terminologie de C++, qui est:

  • Valeur : données réelles qui se trouvent dans la mémoire. void foo(int x); est la signature d'une fonction qui reçoit un entier par valeur.
  • Pointeur : Une adresse mémoire traitée comme une valeur. Peut être différé pour accéder à la mémoire vers laquelle il pointe. void foo(int* x); est une signature d'une fonction qui reçoit un entier par pointeur.
  • Référence : Sucre autour des pointeurs. Il y a un pointeur dans les coulisses, mais vous ne pouvez accéder qu'à la valeur différée et ne pouvez pas modifier l'adresse vers laquelle il pointe. void foo(int& x); est une signature d'une fonction qui reçoit un entier par référence.

Que voulez-vous dire par "différent des autres langues"? La plupart des langues que je connais qui prennent en charge chaque boucle copient l'élément, sauf indication contraire.

Spécifiquement pour Python (bien que plusieurs de ces raisons puissent s'appliquer à d'autres langages avec des concepts architecturaux ou philosophiques similaires):

  1. Ce comportement peut provoquer des bogues pour les personnes qui ne le connaissent pas, mais le comportement alternatif peut provoquer des bogues même pour ceux qui en sont conscients. Lorsque vous affectez une variable (i), vous ne vous arrêtez généralement pas et ne considérez pas toutes les autres variables qui seraient modifiées à cause de cela (a). Limiter la portée sur laquelle vous travaillez est un facteur majeur pour empêcher le code spaghetti, et donc l'itération par copie est généralement la valeur par défaut même dans les langues qui prennent en charge l'itération par référence.

  2. Les variables Python sont toujours un pointeur unique, il est donc bon marché d'itérer par copie - moins cher que d'itérer par référence, ce qui nécessiterait un report supplémentaire chaque fois que vous accédez à la valeur.

  3. Python n'a pas le concept de variables de référence comme - par exemple - C++. Autrement dit, toutes les variables dans Python sont en fait des références, mais dans le sens où elles sont des pointeurs - pas des références de constat en arrière-plan comme C++ type& name arguments. Puisque ce concept n'existe pas en Python, implémenter l'itération par référence - et encore moins en faire la valeur par défaut! - nécessitera d'ajouter plus de complexité au bytecode.

  4. L'instruction for de Python fonctionne non seulement sur les tableaux, mais sur un concept plus général de générateurs. Dans les coulisses, Python appelle iter sur vos tableaux pour obtenir un objet qui - lorsque vous appelez next dessus - retourne l'élément suivant ou raises a StopIteration. Il existe plusieurs façons d'implémenter des générateurs en Python, et il aurait été beaucoup plus difficile de les implémenter pour l'itération par référence.

19
Idan Arye

Aucune des réponses ici ne vous donne de code avec lequel travailler pour vraiment illustrer pourquoi cela se produit dans Python land. Et c'est amusant à regarder dans un plus approche profonde alors voilà.

La principale raison pour laquelle cela ne fonctionne pas comme prévu est parce qu'en Python, lorsque vous écrivez:

i += 1

il ne fait pas ce que vous pensez qu'il fait. Les entiers sont immuables. Cela peut être vu lorsque vous regardez ce qu'est réellement l'objet en Python:

a = 0
print('ID of the first integer:', id(a))
a += 1
print('ID of the first integer +=1:', id(a))

La fonction id représente une valeur unique et constante pour un objet dans sa durée de vie. Conceptuellement, il mappe de manière lâche à une adresse mémoire en C/C++. Exécution du code ci-dessus:

ID of the first integer: 140444342529056
ID of the first integer +=1: 140444342529088

Cela signifie que le premier a n'est plus le même que le second a, car leurs identifiants sont différents. En fait, ils se trouvent à différents emplacements en mémoire.

Avec un objet, cependant, les choses fonctionnent différemment. J'ai remplacé le += opérateur ici:

class CustomInt:
  def __iadd__(self, other):
    # Override += 1 for this class
    self.value = self.value + other.value
    return self

  def __init__(self, v):
    self.value = v

ints = []
for i in range(5):
  int = CustomInt(i)
  print('ID={}, value={}'.format(id(int), i))
  ints.append(int)


for i in ints:
  i += CustomInt(i.value)

print("######")
for i in ints:
  print('ID={}, value={}'.format(id(i), i.value))

L'exécution de cette opération entraîne la sortie suivante:

ID=140444284275400, value=0
ID=140444284275120, value=1
ID=140444284275064, value=2
ID=140444284310752, value=3
ID=140444284310864, value=4
######
ID=140444284275400, value=0
ID=140444284275120, value=2
ID=140444284275064, value=4
ID=140444284310752, value=6
ID=140444284310864, value=8

Notez que l'attribut id dans ce cas est en fait le même pour les deux itérations, même si la valeur de l'objet est différente (vous pouvez également trouver le id de la valeur int l'objet tient, ce qui serait en train de changer car il mute - parce que les entiers sont immuables).

Comparez cela à lorsque vous exécutez le même exercice avec un objet immuable:

ints_primitives = []
for i in range(5):
  int = i
  ints_primitives.append(int)
  print('ID={}, value={}'.format(id(int), i))

print("######")
for i in ints_primitives:
  i += 1
  print('ID={}, value={}'.format(id(int), i))


print("######")
for i in ints_primitives:
  print('ID={}, value={}'.format(id(i), i))

Cela produit:

ID=140023258889248, value=0
ID=140023258889280, value=1
ID=140023258889312, value=2
ID=140023258889344, value=3
ID=140023258889376, value=4
######
ID=140023258889280, value=1
ID=140023258889312, value=2
ID=140023258889344, value=3
ID=140023258889376, value=4
ID=140023258889408, value=5
######
ID=140023258889248, value=0
ID=140023258889280, value=1
ID=140023258889312, value=2
ID=140023258889344, value=3
ID=140023258889376, value=4

Quelques choses ici à remarquer. Tout d'abord, dans la boucle avec le +=, vous n'ajoutez plus à l'objet d'origine. Dans ce cas, parce que les entiers sont parmi les types immuables en Python , python utilise un identifiant différent. Également intéressant de noter que Python = utilise le même id sous-jacent pour plusieurs variables avec la même valeur immuable:

a = 1999
b = 1999
c = 1999

print('id a:', id(a))
print('id b:', id(b))
print('id c:', id(c))

id a: 139846953372048
id b: 139846953372048
id c: 139846953372048

tl; dr - Python a une poignée de types immuables, qui provoquent le comportement que vous voyez. Pour tous les types mutables , votre attente est correcte.

11
enderland

La réponse de @ Idan explique bien pourquoi Python ne traite pas la variable de boucle comme un pointeur comme vous le feriez en C, mais cela vaut la peine d'expliquer plus en détail comment les extraits de code déballent, comme dans Python beaucoup de bits de code apparents seront en fait des appels à méthodes intégrées . Pour prendre votre premier exemple

for i in a:
    i += 1

Il y a deux choses à décompresser: la syntaxe for _ in _: Et la syntaxe _ += _. Pour prendre la boucle for en premier, comme d'autres langages Python a une boucle for-each Qui est essentiellement du sucre de syntaxe pour un modèle d'itérateur. En Python, un itérateur est un objet qui définit un .__next__(self) méthode qui retourne l'élément courant dans la séquence, passe au suivant et lève un StopIteration lorsqu'il n'y a plus d'éléments dans la séquence. An - Iterable est un objet qui définit une méthode .__iter__(self) qui retourne un itérateur.

(NB: un Iterator est également un Iterable et se renvoie de sa méthode .__iter__(self).)

Python aura généralement une fonction intégrée qui délègue à la méthode de soulignement double personnalisée. Il a donc iter(o) qui se résout en o.__iter__() et next(o) qui se résout en o.__next__(). Notez que ces fonctions intégrées essaient souvent une définition par défaut raisonnable si la méthode à laquelle elles délégueraient n'est pas définie. Par exemple, len(o) se résout généralement en o.__len__() mais si cette méthode n'est pas définie, elle essaiera alors iter(o).__len__().

Une boucle for est essentiellement définie en termes de next(), iter() et des structures de contrôle plus basiques. En général, le code

for i in %EXPR%:
    %LOOP%

sera déballé à quelque chose comme

_a_iter = iter(%EXPR%)
while True:
    try:
        i = next(_a_iter)
    except StopIteration:
        break
    %LOOP%

Donc dans ce cas

for i in a:
    i += 1

se déballe à

_a_iter = iter(a) # = a.__iter__()
while True:
    try: 
        i = next(_a_iter) # = _a_iter.__next__()
    except StopIteration:
        break
    i += 1

L'autre moitié est i += 1. En général, %ASSIGN% += %EXPR% Est décompressé dans %ASSIGN% = %ASSIGN%.__iadd__(%EXPR%). Ici __iadd__(self, other) fait l'ajout en place et se retourne.

(NB C'est un autre cas où Python choisira une alternative si la méthode principale n'est pas définie. Si l'objet n'implémente pas __iadd__ Il retombera sur __add__. Il le fait en fait dans ce cas car int n'implémente pas __iadd__ - ce qui est logique car ils sont immuables et ne peuvent donc pas être modifiés sur place.)

Donc, votre code ressemble ici

_a_iter = iter(a)
while True:
    try:
        i = next(_a_iter)
    except StopIteration:
        break
    i = iadd(i,1)

où nous pouvons définir

def iadd(o, v):
    try:
        return o.__iadd__(v)
    except AttributeError:
        return o.__add__(v)

Il se passe un peu plus dans votre deuxième morceau de code. Les deux nouvelles choses que nous devons savoir sont que %ARG%[%KEY%] = %VALUE% Est décompressé en (%ARG%).__setitem__(%KEY%, %VALUE%) Et %ARG%[%KEY%] Est décompressé en (%ARG%).__getitem__(%KEY%). En rassemblant ces connaissances, nous obtenons a[ix] += 1 Décompressé dans a.__setitem__(ix, a.__getitem__(ix).__add__(1)) (encore une fois: __add__ Plutôt que __iadd__ Car __iadd__ N'est pas implémenté par des entiers ). Notre code final ressemble à:

_a_iter = iter(enumerate(a))
while True:
    try:
        index, i = next(_a_iter)
    except StopIteration:
        break
    a.__setitem__(index, iadd(a.__getitem__(index), 1))

Pour répondre à votre question sur la raison pour laquelle le premier ne modifie pas la liste tandis que le second le fait, dans notre premier extrait, nous obtenons i de next(_a_iter), ce qui signifie i sera un int. Comme int ne peut pas être modifié sur place, i += 1 Ne fait rien à la liste. Dans notre deuxième cas, nous ne modifions pas à nouveau le int mais modifions la liste en appelant __setitem__.

La raison de tout cet exercice élaboré est parce que je pense qu'il enseigne la leçon suivante sur Python:

  1. Le prix de la lisibilité de Python est qu'il appelle tout le temps ces méthodes magiques de double score.
  2. Par conséquent, pour avoir une chance de vraiment comprendre n'importe quel morceau de code Python, vous devez comprendre ces traductions.

Les méthodes de double soulignement sont un obstacle au début, mais elles sont essentielles pour soutenir la réputation de "pseudocode exécutable" de Python. Un programmeur Python décent aura une compréhension approfondie de ces méthodes et de la façon dont elles sont invoquées et les définira partout où cela sera judicieux de le faire.

Edit : @deltab a corrigé mon utilisation bâclée du terme "collection".

6
walpen

+= fonctionne différemment selon que la valeur actuelle est mutable ou immuable. C'était la principale raison pour laquelle il fallait attendre longtemps pour qu'elle soit implémentée en Python, car les développeurs de Python craignaient que ce soit déroutant.

Si i est un int, il ne peut pas être changé car les entiers sont immuables, et donc si la valeur de i change, alors elle doit nécessairement pointer vers un autre objet:

>>> i=3
>>> id(i)
14336296
>>> i+=1
>>> id(i)
14336272   # Other object

Cependant, si le côté gauche est mutable, alors + = peut réellement le changer; comme si c'était une liste:

>>> i=[]
>>> id(i)
140257231883944
>>> i+=[1]
>>> id(i)
140257231883944  # Still the same object!

Dans votre boucle for, i fait référence à chaque élément de a tour à tour. S'il s'agit d'entiers, le premier cas s'applique et le résultat de i += 1 doit être qu'il fait référence à un autre objet entier. La liste a a bien sûr toujours les mêmes éléments qu'elle avait toujours.

2
RemcoGerlich

La boucle ici est un peu hors de propos. Tout comme les paramètres de fonction ou les arguments, la configuration d'une boucle for comme celle-ci est essentiellement une affectation de fantaisie.

Les entiers sont immuables. La seule façon de les modifier est de créer un nouvel entier et de l'affecter au même nom que l'original.

La sémantique de Python pour l'affectation est directement mappée sur les C (sans surprise étant donné les pointeurs PyObject * de CPython), avec les seules mises en garde étant que tout est un pointeur, et vous n'êtes pas autorisé à avoir des pointeurs doubles. Considérez le code suivant:

a = 1
b = a
b += 1
print(a)

Ce qui se produit? Il imprime 1. Pourquoi? Il est en fait à peu près équivalent au code C suivant:

i64* a = malloc(sizeof(i64));
*a = 1;
i64* b = a;
i64* tmp = malloc(sizeof(i64));
tmp = *b + 1;
b = tmp;
printf("%d\n", *a);

Dans le code C, il est évident que la valeur de a n'est pas du tout affectée.

Quant à savoir pourquoi les listes semblent fonctionner, la réponse est essentiellement que vous attribuez le même nom. Les listes sont modifiables. L'identité de l'objet nommé a[0] va changer, mais a[0] est toujours un nom valide. Vous pouvez vérifier cela avec le code suivant:

x = 1
a = [x]
print(a[0] is x)
a[0] += 1
print(a[0] is x)

Mais ce n'est pas spécial pour les listes. Remplacer a[0] dans ce code avec y et vous obtenez exactement le même résultat.

1
Kevin