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

31

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 ane seront en fait pas affectés du tout, car la variable ise 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 langues et a entraîné des erreurs dans mon code que j'ai dû déboguer pendant longtemps aujourd'hui.

J'ai déjà lu le didacticiel Python. Juste pour être sûr, j'ai revu 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?

xji
la source
19
Cela n'est vrai que si iest immuable ou si vous effectuez une opération sans mutation. Avec une liste imbriquée for i in a: a.append(1)aurait un comportement différent; Python ne copie pas les listes imbriquées. Cependant, les entiers sont immuables et l'addition renvoie un nouvel objet, il ne change pas l'ancien.
jonrsharpe
10
Ce n'est pas surprenant du tout. Je ne peux pas penser à un langage qui ne soit pas exactement le même pour un tableau de types de base comme entier. Par exemple, essayez en javascript a=[1,2,3];a.forEach(i => i+=1);alert(a). Idem en C #
edc65
7
Vous attendriez-vous i = i + 1à affecter a?
deltab
7
Notez que ce comportement n'est pas différent dans d'autres langues. C, Javascript, Java etc. se comportent de cette façon.
slebetman
1
@jonrsharpe pour les listes "+ =" modifie l'ancienne liste, tandis que "+" en crée une nouvelle
Vasily Alexeev

Réponses:

68

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

  • Si le type de données implémente l'ajout sur place (c'est-à-dire qu'il a une __iadd__fonction qui fonctionne correctement ), les données auxquelles il est ifait 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 de __iadd__méthode, l' i += xinstruction n'est que du sucre syntaxique i = i + x, donc une nouvelle valeur est créée et affectée au nom de la variable i.

  • Si le type de données implémente __iadd__mais 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 entiers Pythons, les flottants et les chaînes ne sont pas implémentés __iadd__, ils ne seront donc pas mis à jour sur place. Cependant, d'autres types de données comme numpy.arrayou lists l'implémentent et se comporteront comme prévu. Donc, ce n'est pas une question de copie ou de non-copie lors de l'itération (normalement, cela ne fait pas de copies pour lists et tuples - mais cela dépend aussi de l'implémentation des conteneurs __iter__et de la __getitem__méthode!) - c'est plus une question de type de données vous avez stocké dans votre a.

MSeifert
la source
2
Il s'agit de l'explication correcte du comportement décrit dans la question.
pabouk
19

Clarification - terminologie

Python ne fait pas de distinction entre les concepts de référence et de 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 provient clairement de l'arrière-plan C ++ et que 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 la 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 la 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 considérez 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 en Python sont en fait des références, mais dans le sens où ce sont des pointeurs - pas des références de constat en arrière-plan comme des type& namearguments C ++ . 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' forinstruction 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 itervos tableaux pour obtenir un objet qui - lorsque vous l'appelez next- renvoie soit l'élément suivant, soit raisesa 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.

Idan Arye
la source
Merci d'avoir répondu. Semble que ma compréhension sur les itérateurs n'est pas encore assez solide alors. Les itérateurs ne sont-ils pas en référence C ++ par défaut? Si vous déréférencez l'itérateur, vous pouvez toujours changer immédiatement la valeur de l'élément du conteneur d'origine?
2017
4
Python ne itérer par référence (bien, en termes de valeur, mais la valeur est une référence). Essayer ceci avec une liste d'objets mutables démontrera rapidement qu'aucune copie ne se produit.
jonrsharpe
Les itérateurs en C ++ sont en fait des objets qui peuvent être différés pour accéder à la valeur du tableau. Pour modifier l'élément d'origine, vous utilisez *it = ...- mais ce type de syntaxe indique déjà que vous modifiez quelque chose ailleurs - ce qui rend la raison n ° 1 moins problématique. Les raisons # 2 et # 3 ne s'appliquent pas aussi bien, car en C ++ la copie est chère et le concept de variables de référence existe. Comme pour la raison # 4 - la possibilité de renvoyer une référence permet une implémentation simple pour tous les cas.
Idan Arye
1
@jonrsharpe Oui, il est appelé par référence, mais dans tout langage qui fait une distinction entre pointeurs et références, ce type d'itération sera une itération par pointeur (et puisque les pointeurs sont des valeurs - itération par valeur). J'ajouterai une précision.
Idan Arye
20
Votre tout premier paragraphe suggère que Python, comme ces autres langages, copie l'élément dans une boucle for. Ce n'est pas. Il ne limite pas la portée des modifications que vous apportez à cet élément. L'OP ne voit ce comportement que parce que leurs éléments sont immuables; sans même mentionner cette distinction, votre réponse est au mieux incomplète et au pire trompeuse.
jonrsharpe
11

Aucune des réponses ici ne vous donne de code à utiliser pour illustrer vraiment pourquoi cela se produit en terre Python. Et c'est amusant à regarder dans une approche plus 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 au cours de 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 an'est plus le même que le second a, car leurs identifiants sont différents. En fait, ils se trouvent à différents endroits de la mémoire.

Avec un objet, cependant, les choses fonctionnent différemment. J'ai remplacé l' +=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 la idvaleur int de l'objet, qui changerait au fur et à mesure de sa mutation - car 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, comme les entiers font partie des types immuables de Python , python utilise un identifiant différent. Il est également intéressant de noter que Python utilise le même sous-jacent idpour 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.

Enderland
la source
6

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 sont décompressés, comme dans Python, beaucoup de bits d'apparence simple de code seront en fait des appels à des méthodes intégrées . Pour prendre votre premier exemple

for i in a:
    i += 1

Il y a deux choses à décompresser: la for _ in _:syntaxe et la _ += _syntaxe. Pour prendre la boucle for en premier, comme d'autres langages, Python a une for-eachboucle 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 une .__next__(self)méthode qui renvoie l'élément actuel dans la séquence, passe au suivant et déclenche un StopIterationlorsqu'il n'y a plus d'éléments dans la séquence. Un Iterable est un objet qui définit une .__iter__(self)méthode qui renvoie un itérateur.

(NB: an Iteratorest aussi un Iterableet revient de sa .__iter__(self)méthode.)

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 à o.__iter__()et next(o)qui se résout à 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 est essentiellement défini 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

est déballé à

_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, il %ASSIGN% += %EXPR%est déballé %ASSIGN% = %ASSIGN%.__iadd__(%EXPR%). Voici l' __iadd__(self, other)ajout en place et se retourne.

(NB Il s'agit d'un autre cas où Python choisira une alternative si la méthode principale n'est pas définie. Si l'objet ne l'implémente pas, __iadd__il retombera __add__. Il le fait dans ce cas comme intne l'implémente pas __iadd__- ce qui est logique car 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 celles qui sont %ARG%[%KEY%] = %VALUE%déballées (%ARG%).__setitem__(%KEY%, %VALUE%)et %ARG%[%KEY%]déballées (%ARG%).__getitem__(%KEY%). En rassemblant ces connaissances, nous sommes a[ix] += 1décompressés a.__setitem__(ix, a.__getitem__(ix).__add__(1))(encore une fois: __add__plutôt que __iadd__parce que __iadd__n'est pas implémenté par ints). 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 à réellement votre question de savoir pourquoi le premier ne modifie pas la liste tandis que le second fait, dans notre premier extrait que nous obtenons à ipartir next(_a_iter), ces moyens iseront un int. Puisque ints ne peut pas être modifié sur place, i += 1ne fait rien à la liste. Dans notre deuxième cas, nous ne modifions pas le intmais 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".

walpen
la source
2
"les itérateurs sont aussi des collections" n'est pas tout à fait __len____contains__
exact
2

+=fonctionne différemment selon que la valeur actuelle est modifiable 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 iest un int, alors il ne peut pas être changé car les entiers sont immuables, et donc si la valeur de ichange alors il 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 modifiable , 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, ifait référence à chaque élément de atour à tour. S'il s'agit d'entiers, le premier cas s'applique et le résultat de i += 1doit être qu'il se réfère à un autre objet entier. aBien sûr, la liste contient toujours les mêmes éléments qu'elle avait toujours.

RemcoGerlich
la source
Je ne comprends pas cette distinction entre les objets mutables et immuables: si i = 1définit isur un objet entier immuable, alors i = []devrait définir isur un objet de liste immuable. En d'autres termes, pourquoi les objets entiers sont-ils immuables et les objets de liste sont-ils mutables? Je ne vois aucune logique derrière cela.
Giorgio
@Giorgio: les objets sont de classes différentes, listimplémente des méthodes qui changent son contenu, intnon. [] est un objet liste mutable, et i = []permet de ifaire référence à cet objet.
RemcoGerlich
@Giorgio, il n'y a pas de liste immuable en Python. Les listes sont modifiables. Les entiers ne le sont pas. Si vous voulez quelque chose comme une liste mais immuable, pensez à un tuple. Quant à savoir pourquoi, il n'est pas clair à quel niveau vous souhaitez que cela réponde.
jonrsharpe
@RemcoGerlich: Je comprends que les différentes classes se comportent différemment, je ne comprends pas pourquoi elles ont été implémentées de cette façon, c'est-à-dire que je ne comprends pas la logique derrière ce choix. J'aurais implémenté l' +=opérateur / la méthode pour se comporter de manière similaire (principe de la moindre surprise) pour les deux types: soit changer l'objet d'origine soit renvoyer une copie modifiée pour les entiers et les listes.
Giorgio
1
@Giorgio: il est absolument vrai que cela +=est surprenant en Python, mais on a estimé que les autres options que vous mentionnez auraient également été surprenantes, ou du moins moins pratiques (changer l'objet d'origine ne peut pas être fait avec le type de valeur le plus courant vous utilisez + = avec, ints. Et copier une liste entière est beaucoup plus cher que de la muter, Python ne copie pas des choses comme des listes et des dictionnaires, sauf indication contraire explicite). C'était un énorme débat à l'époque.
RemcoGerlich
1

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 consiste à créer un nouvel entier et à lui attribuer le 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 an'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]changera, mais a[0]reste 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. Remplacez a[0]ce code par yet vous obtenez exactement le même résultat.

Kevin
la source