为什么Python在迭代列表时仅复制单个元素?


31

我只是意识到在Python中,如果有人写

for i in a:
    i += 1

a实际上,原始列表的元素根本不会受到影响,因为该变量i原来只是中原始元素的一个副本a

为了修改原始元素,

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

将需要。

我真的为这种行为感到惊讶。这似乎很违反直觉,似乎与其他语言不同,并且导致了我的代码错误,而今天我不得不调试了很长时间。

我之前阅读过Python教程。可以肯定的是,我刚才再次检查了这本书,它甚至根本没有提到这种行为。

这种设计背后的原因是什么?难道它会成为许多语言的标准做法,以便本教程相信读者应该自然地获得它吗?以后我应该注意哪些其他语言的迭代行为?


19
仅当它i是不可变的或您正在执行非变异操作时才如此。嵌套列表for i in a: a.append(1)会产生不同的行为。Python 不会复制嵌套列表。但是,整数是不可变的,加法会返回一个新对象,它不会更改旧对象。
jonrsharpe

10
一点也不奇怪。我想不出对于整数等基本类型数组不完全相同的语言。例如,尝试使用javascript a=[1,2,3];a.forEach(i => i+=1);alert(a)。与C#相同
-edc65

7
您希望i = i + 1影响a吗?
deltab

7
请注意,此行为在其他语言中没有不同。C,Javascript,Java等都是这种方式。
slebetman '17

1
@jonrsharpe的列表“ + =“更改了旧列表,而“ +”创建了一个新列表
Vasily Alexeev

Answers:


68

我最近已经回答了一个类似的问题,意识到这+=可能具有不同的含义非常重要:

  • 如果数据类型实现就地加法(即具有正确的工作__iadd__功能),则i引用的数据将更新(无论是在列表中还是其他位置都无关紧要)。

  • 如果数据类型没有实现__iadd__方法,则该i += x语句只是语法糖i = i + x,因此将创建一个新值并分配给变量名称i

  • 如果数据类型实现了,__iadd__但它做了一些奇怪的事情。它可能会更新还是不更新-取决于此处执行的内容。

Python的整数,浮点数和字符串未实现,__iadd__因此它们不会就地更新。但是,像numpy.arraylists 这样的其他数据类型将实现它,并且其行为将与您预期的一样。因此,在进行迭代时,这不是复制或无复制的问题(通常它不会为lists和tuples 进行复制-但这也取决于容器__iter____getitem__方法的实现!)-而是数据类型的问题您已存储在自己的帐户中a


2
这是对问题中描述的行为的正确解释。
pabouk '17

19

澄清-术语

Python不区分引用指针的概念。它们通常只使用术语引用,但是如果与确实具有这种区别的C ++之类的语言进行比较,则它更接近于指针

由于提问者显然来自C ++背景,并且由于这种区别(解释所必需的)在Python 中不存在,因此我选择使用C ++的术语,即:

  • :位于存储器中的实际数据。void foo(int x);按值接收整数的函数的签名。
  • 指针:将内存地址视为值。可以推迟访问它所指向的内存。void foo(int* x);通过指针接收整数的函数的签名。
  • 参考:指针周围的糖。幕后有一个指针,但是您只能访问延迟的值,并且不能更改其指向的地址。void foo(int& x);通过引用接收整数的函数的签名。

您的意思是“与其他语言不同”?我知道,大多数支持for-each循环的语言都会复制该元素,除非另有特别说明。

专门针对Python(尽管许多原因可能适用于具有类似架构或哲学概念的其他语言):

  1. 此行为可能会导致不了解此问题的人发现错误,但是替代行为甚至可能会给那些意识到它的带来错误。当您分配一个变量(i)时,通常不会停下来考虑所有因变量(a)而要更改的其他变量。限制您正在使用的范围是防止意大利面条代码的主要因素,因此即使在支持按引用进行迭代的语言中,按副本进行的迭代也是通常的默认设置。

  2. Python变量始终是单个指针,因此按副本进行迭代很便宜-比按引用进行迭代便宜,这将需要在每次访问该值时进行额外的延迟。

  3. Python没有引用变量的概念,例如C ++。也就是说,Python中的所有变量实际上都是引用,但从某种意义上讲它们是指针,而不是像C ++ type& name参数这样的幕后constat引用。由于此概念在Python中不存在,因此请通过引用实现迭代-更不用说使其成为默认值了!-将需要增加字节码的复杂性。

  4. Python的for声明不仅适用于数组,而且适用于生成器的更一般概念。在幕后,Python调用iter您的数组以获得一个对象,当您对其调用时,该对象next要么返回下一个元素,要么返回raisesa StopIteration。在Python中有多种实现生成器的方法,要实现按引用迭代的实现要困难得多。


感谢您的回答。似乎我对迭代器的理解还不够扎实。默认情况下,C ++中的迭代器不是参考吗?如果取消引用迭代器,则始终可以立即更改原始容器的元素的值?
xji

4
Python 确实按引用进行迭代(当然,按值进行迭代,但该值是一个引用)。尝试使用可变对象列表将快速证明没有复制发生。
jonrsharpe

C ++中的迭代器实际上是可以延迟访问数组中值的对象。要修改原始元素,您可以使用*it = ...-但这种语法已经表明您在其他地方进行了修改-这使原因1的问题减少了。原因#2和#3也不适用,因为在C ++中复制是昂贵的,并且存在引用变量的概念。由于原因4-返回引用的能力允许在所有情况下都简单实现。
伊丹·阿里

1
@jonrsharpe是的,它是按引用调用的,但是在任何将指针和引用区分开的语言中,这种迭代将是按指针进行的迭代(并且由于指针是值-按值进行迭代)。我将进行澄清。
伊丹·阿里

20
您的第一段建议Python与其他语言一样,将元素复制到for循环中。没有。它不限制您对该元素所做的更改的范围。OP只看到这种行为,因为它们的元素是不可变的。甚至没有提到区别,您的答案充其量是不完整的,而且在最坏的情况下会引起误解。
jonrsharpe

11

这里的答案都没有给您提供任何可用来真正说明为什么在Python领域发生这种情况的代码。因此,从更深的角度来看这很有趣。

不能按预期工作的主要原因是因为在Python中,当您编写时:

i += 1

它没有按照您认为的去做。整数是不可变的。当您查看Python中的对象实际是什么时,可以看出这一点:

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

id函数代表对象在其生存期内的唯一且恒定的值。从概念上讲,它松散地映射到C / C ++中的内存地址。运行上面的代码:

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

这意味着第一个a不再与第二个相同a,因为它们的ID不同。实际上,它们位于内存中的不同位置。

但是,对于一个对象,事物的工作方式有所不同。我在+=这里覆盖了运算符:

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

运行此命令将产生以下输出:

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

请注意,即使对象的值不同,这种情况下的id属性在两次迭代中实际上也是相同的(您还可以找到id对象持有的int值的in ,该值随着变量的变化而变化-因为整数是不可变的)。

与使用不可变对象进行相同练习时进行比较:

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

输出:

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

这里有几件事要注意。首先,在带有的循环中+=,您不再添加到原始对象。在这种情况下,因为ints是Python中不可变的类型之一,所以python使用了不同的id。同样有趣的是,Python id对具有相同不可变值的多个变量使用相同的基础:

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具有少数不可变类型,这些类型会导致您看到的行为。对于所有可变类型,您的期望都是正确的。


6

@Idan的答案很好地解释了为什么Python不像您在C语言中那样将循环变量视为指针,但是值得更深入地解释代码段的解压缩方式,例如Python中许多简单的位的代码实际上是对内置方法的调用。举第一个例子

for i in a:
    i += 1

有两件事需要解压缩:for _ in _:语法和_ += _语法。与其他语言一样,首先要使用for循环,Python的for-each循环本质上是迭代器模式的语法糖。在Python中,迭代器是一个对象,它定义一个.__next__(self)方法,该方法返回序列中的当前元素,前进到下一个元素,并在序列StopIteration中没有更多项时引发a 。一个可迭代是一个对象,定义了一个.__iter__(self)方法,该方法返回迭代器。

(注意:an Iterator也是an,Iterable并从其.__iter__(self)方法返回自身。)

Python通常将具有一个内置函数,该函数委派给自定义的double下划线方法。因此它具有iter(o)解析为o.__iter__()next(o)解析为的结果o.__next__()。请注意,如果未定义将委托给它们的方法,则这些内置函数通常会尝试使用合理的默认定义。例如,len(o)通常解析为,o.__len__()但是如果未定义该方法,则将尝试iter(o).__len__()

for循环基本上在来定义next()iter()并且更基本的控制结构。一般代码

for i in %EXPR%:
    %LOOP%

将解压到类似

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

所以在这种情况下

for i in a:
    i += 1

打开包装

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

这是另一半i += 1。一般情况下%ASSIGN% += %EXPR%,请打开%ASSIGN% = %ASSIGN%.__iadd__(%EXPR%)。在这里进行__iadd__(self, other)加法并返回自身。

(注意,这是另一种情况,如果未定义main方法,Python会选择其他方法。如果该对象未实现__iadd__,它将落在后面__add__。实际上,在这种情况下,int它会实现为未实现__iadd__,这是有意义的,因为它们是不可变的,因此无法就地修改。)

所以你的代码看起来像

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

我们可以在哪里定义

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

您的第二段代码中还有更多内容。我们需要了解的两个新事物是%ARG%[%KEY%] = %VALUE%拆包(%ARG%).__setitem__(%KEY%, %VALUE%)%ARG%[%KEY%]拆包(%ARG%).__getitem__(%KEY%)。将这些知识放在一起,我们就可以a[ix] += 1解包了a.__setitem__(ix, a.__getitem__(ix).__add__(1))(再次:__add__而不是__iadd__因为__iadd__它不是由int实现的)。我们的最终代码如下:

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

要真正回答你的问题,为什么而第二次却第一个不修改清单,在我们得到我们的第一个片段inext(_a_iter)装置,该装置i将是一个int。由于int无法原地修改,因此i += 1不会对清单做任何动作。在第二种情况下,我们不再修改,int而是通过调用修改列表__setitem__

进行整个精心设计的原因是因为我认为它教了以下有关Python的课程:

  1. Python可读性的代价是它一直在调用这些神奇的双得分方法。
  2. 因此,要想有机会真正理解任何Python代码,就必须了解这些翻译的作用。

双重下划线方法在开始时是一个障碍,但是对于支持Python的“可运行伪代码”声誉来说,它们是必不可少的。体面的Python程序员将对这些方法以及如何调用它们有透彻的了解,并将在合理的地方进行定义。

编辑:@deltab更正了我对“集合”一词的草率使用。


2
“迭代器也集合”是不完全正确的:他们也可迭代的,但藏品也有__len____contains__
deltab

2

+=根据当前值是可变的还是不可变的,其工作方式有所不同。这是在Python中实现它需要很长时间的主要原因,因为Python开发人员担心它会造成混淆。

如果i是int,则由于int是不可变的,因此无法更改,因此,如果change的值i必须必然指向另一个对象:

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

但是,如果左侧是可变的,则+ =实际上可以更改它;就像是列表一样:

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

在for循环中,依次i引用每个元素a。如果这些是整数,则第一种情况适用,并且的结果i += 1必须是它引用了另一个整数对象。a当然,列表仍然具有与以往相同的元素。


我不了解可变对象与不可变对象之间的区别:如果i = 1将其设置i为不可变的整数对象,i = []则应将其设置i为不可变的列表对象。换句话说,为什么整数对象是不可变的,而列表对象却是可变的?我看不出背后有任何逻辑。
Giorgio

@ Giorgio:这些对象来自不同的类,list实现更改其内容的方法,int没有。[] 一个可变列表对象,i = []让我们i引用该对象。
RemcoGerlich

@Giorgio在Python中没有不可变列表。列表是可变的。整数不是。如果您想要类似列表但不可变的内容,请考虑使用元组。至于原因,目前尚不清楚您希望在什么水平上回答。
jonrsharpe

@RemcoGerlich:我知道不同的类表现不同,我不理解为什么以这种方式实现它们,即我不理解这种选择背后的逻辑。我将实现+=两种类型的操作符/方法具有相似的行为(最不惊奇的原则):要么更改原始对象,要么为整数和列表返回修改后的副本。
Giorgio

1
@Giorgio:+=在Python中这确实是令人惊讶的,但是您认为您提到的其他选项也会令人惊讶,或者至少不太实用(更改原始对象无法使用最常见的值类型来完成)您可以将+ =与int一起使用。复制整个列表要比对其进行更改要昂贵得多,除非明确告知,否则Python不会复制列表和字典之类的东西。那时是一个巨大的辩论。
RemcoGerlich

1

这里的循环是无关紧要的。与函数参数或参数非常相似,像这样设置for循环实质上只是花哨的分配。

整数是不可变的。修改它们的唯一方法是创建一个新的整数,并将其分配给与原始整数相同的名称。

Python的赋值语义直接映射到C上(对于CPython的PyObject *指针而言不足为奇),唯一的警告是,一切都是指针,并且不允许使用双指针。考虑以下代码:

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

怎么了?它打印1。为什么?它实际上大致等效于以下C代码:

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

在C代码中,很明显,的值a完全不受影响。

至于为什么列表似乎起作用,答案基本上就是您要分配给相同的名称。列表是可变的。命名对象的身份a[0]将更改,但a[0]仍然是有效名称。您可以使用以下代码进行检查:

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

但是,这对于列表并不特殊。用替换a[0]该代码,y您将获得完全相同的结果。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.