为什么在列表上使用+=会表现出意外的行为?

172

在Python中,+= 运算符对列表的操作表现出意外的结果。有人能告诉我这是怎么回事吗?

class foo:  
     bar = []
     def __init__(self,x):
         self.bar += [x]


class foo2:
     bar = []
     def __init__(self,x):
          self.bar = self.bar + [x]

f = foo(1)
g = foo(2)
print f.bar
print g.bar 

f.bar += [3]
print f.bar
print g.bar

f.bar = f.bar + [4]
print f.bar
print g.bar

f = foo2(1)
g = foo2(2)
print f.bar 
print g.bar 

输出

[1, 2]
[1, 2]
[1, 2, 3]
[1, 2, 3]
[1, 2, 3, 4]
[1, 2, 3]
[1]
[2]

foo += bar 看起来会影响类的每个实例,而 foo = foo + bar 的行为符合我的预期。

+= 运算符被称为 "复合赋值运算符"。


请看一下列表中“extend”和“append”的区别。 - N 1.1
3
我认为这并不意味着Python有什么问题。大多数语言甚至不允许你在数组上使用+运算符。我认为在这种情况下,+=追加是完全合理的。 - Skilldrick
7
官方称其为“增强赋值”。 - Martijn Pieters
3
顺便提一下,使用 my_list += [x] 是很糟糕的风格。请不要这样做。 - anon01
9个回答

195
一般来说,+= 会尝试调用 __iadd__ 特殊方法。如果没有定义该方法,则会改为使用 __add__ 方法。因此问题在于这些特殊方法之间的差异。 __iadd__ 特殊方法是用于原地加法的,它会改变其所作用的对象。而 __add__ 特殊方法则返回一个新对象,也用于标准的 + 运算符。
因此,当在定义了 __iadd__ 的对象上使用 += 运算符时,对象将被就地修改。否则,它将尝试使用纯粹的 __add__ 并返回一个新对象。
这就是为什么对于可变类型(如列表),+= 会更改对象的值,而对于不可变类型(如元组、字符串和整数)则会返回一个新对象(a += b 相当于 a = a + b)。
对于既支持 __iadd__ 又支持 __add__ 的类型,你必须小心使用哪个方法。 a += b 会调用 __iadd__ 并改变 a,而 a = a + b 则会创建一个新对象并将其分配给 a。它们不是相同的操作!
>>> a1 = a2 = [1, 2]
>>> b1 = b2 = [1, 2]
>>> a1 += [3]          # Uses __iadd__, modifies a1 in-place
>>> b1 = b1 + [3]      # Uses __add__, creates new list, assigns it to b1
>>> a2
[1, 2, 3]              # a1 and a2 are still the same list
>>> b2
[1, 2]                 # whereas only b1 was changed

对于不可变类型(没有__iadd__方法的类型),a += ba = a + b是等效的。这就是允许您在不可变类型上使用+=运算符的原因,这可能看起来是一个奇怪的设计决策,但如果您考虑到否则您将无法像数字那样在不可变类型上使用+=,那么它就有了意义!


4
还有一个__radd__方法,有时可能会被调用(它主要与涉及子类的表达式有关)。 - jfs
3
从角度来看:如果内存和速度很重要,+= 是很有用的。 - Norfeldt
8
因为知道 += 实际上是扩展列表,这就解释了为什么 x = []; x = x + {} 会报错 TypeError,而 x = []; x += {} 只会返回 [] - zezollo
1
这个答案缺少一个非常重要的事实,即bar是一个类变量。这个事实连同这个答案一起,实际上解释了观察到的行为。@AndiDog的答案在这方面更好。 - borlafu

110

对于一般情况,请参见 Scott Griffith 的回答。不过,当处理像您这样的列表时,+= 运算符是 someListObject.extend(iterableObject) 的简写。请参阅 extend()函数的文档

extend 函数将把参数的所有元素附加到列表中。

使用 foo += something 时,你实际上是直接修改了列表 foo,因此并没有改变名称 foo 指向的引用,而是直接更改了列表对象。使用 foo = foo + something,你实际上是创建了一个新的列表。

以下示例代码将说明这一点:

>>> l = []
>>> id(l)
13043192
>>> l += [3]
>>> id(l)
13043192
>>> l = l + [3]
>>> id(l)
13059216
请注意当您将新的列表重新分配给l时引用会发生变化。
由于bar是类变量而不是实例变量,原地修改将影响该类的所有实例。但是,当重新定义self.bar时,该实例将具有单独的实例变量self.bar,而不会影响其他类实例。

7
这并不总是正确的: a = 1; a += 1; 是有效的Python代码,但整数类型没有任何"extend()"方法。你不能一概而论。 - Bite code
2
经过一些测试,Scott Griffiths 是对的,所以你要扣掉1分。 - Bite code
17
@e-statis:OP 明显在谈论列表,而我也明确表示我也在谈论列表。我没有概括任何内容。 - AndiDog
1
去掉了 -1,答案已经足够好了。不过我仍然认为 Griffiths 的答案更好。 - Bite code
1
一开始,想到a += ba = a + b对于两个列表ab是不同的,感觉很奇怪。但这是有道理的;extend更常用于列表而不是创建整个列表的新副本,后者时间复杂度更高。如果开发人员需要注意不要在原地修改原始列表,则元组是更好的选择,因为它们是不可变对象。对于元组的+=操作无法修改原始元组。 - Pranjal Mittal

22

这里的问题是bar被定义为类属性而不是实例变量。

foo中,类属性在init方法中被修改,这就是为什么所有实例都受到影响。

foo2中,使用(空)类属性定义了一个实例变量,并且每个实例都有自己的bar

“正确”的实现方式应该是:

class foo:
    def __init__(self, x):
        self.bar = [x]

当然,类属性是完全合法的。事实上,您可以像这样访问和修改它们,而无需创建类的实例:

class foo:
    bar = []

foo.bar = [x]

11

这里涉及到两个问题:

1. class attributes and instance attributes
2. difference between the operators + and += for lists

+ 运算符调用列表的 __add__ 方法。它获取其操作数中的所有元素,并创建一个新列表,以保持这些元素的顺序。

+= 运算符调用列表的 __iadd__ 方法。它获取可迭代对象并将可迭代对象的所有元素原地附加到列表中。它不会创建新的列表对象。

在类 foo 中,语句 self.bar += [x] 不是赋值语句,而实际上相当于

self.bar.__iadd__([x])  # modifies the class attribute  

这个函数会直接修改列表,类似于列表方法extend

相反,在foo2类中,init方法中的赋值语句则不同。

self.bar = self.bar + [x]  

可以拆分为:
实例没有属性bar(虽然有一个同名的类属性),因此它访问了类属性bar并通过将x附加到其中创建了一个新列表。该语句翻译为:

self.bar = self.bar.__add__([x]) # bar on the lhs is the class attribute 

然后它创建一个实例属性"bar"并将新创建的列表分配给它。请注意,赋值符号右侧的"bar"与左侧的"bar"不同。
对于类"foo"的实例,"bar"是一个类属性而不是实例属性。因此,对类属性"bar"的任何更改都将反映在所有实例中。
相反,类"foo2"的每个实例都有自己的实例属性"bar",该属性与同名的类属性"bar"不同。
f = foo2(4)
print f.bar # accessing the instance attribute. prints [4]  
print f.__class__.bar # accessing the class attribute. prints []  

希望这能澄清事情。

7
虽然时间已经过去了很久,也说了很多正确的话,但是没有一个答案能够将这两个效应结合起来。
你有两个影响:
1. 列表使用 `+=` 时可能会出现“特殊”的、不被注意的行为(如 Scott Griffiths 所述); 2. 类属性和实例属性都会被涉及到(如 Can Berk Büder 所述)。
在类 `foo` 中,`__init__` 方法修改了类属性。这是因为 `self.bar += [x]` 被翻译成了 `self.bar = self.bar.__iadd__([x])`。`__iadd__()` 是用于原地修改的,所以它会修改列表并返回一个引用。
请注意,尽管类字典已经包含了相同的赋值语句,实例字典仍然被修改了。所以这个细节几乎不被注意到——除非你之后执行了 `foo.bar = []`。在这种情况下,由于上述事实,实例的 `bar` 保持不变。
在类foo2中,类的bar被使用但没有被修改。相反,一个[x]被添加到它上面,形成一个新对象,并在此处调用self.bar.__add__([x]),这不会修改该对象。结果被放入实例字典中,将新列表作为字典赋给实例,而类的属性保持不变。 ... = ... + ...... += ...之间的区别也影响后续的赋值:
f = foo(1) # adds 1 to the class's bar and assigns f.bar to this as well.
g = foo(2) # adds 2 to the class's bar and assigns g.bar to this as well.
# Here, foo.bar, f.bar and g.bar refer to the same object.
print f.bar # [1, 2]
print g.bar # [1, 2]

f.bar += [3] # adds 3 to this object
print f.bar # As these still refer to the same object,
print g.bar # the output is the same.

f.bar = f.bar + [4] # Construct a new list with the values of the old ones, 4 appended.
print f.bar # Print the new one
print g.bar # Print the old one.

f = foo2(1) # Here a new list is created on every call.
g = foo2(2)
print f.bar # So these all obly have one element.
print g.bar 

您可以使用 print id(foo), id(f), id(g) 验证对象的标识符(如果您在使用Python3,请不要忘记附加的())。
顺便说一下:+= 运算符被称为“增强赋值”,通常用于尽可能地进行就地修改。

6
其他答案似乎已经涵盖了大部分内容,但是引用和参考增强赋值PEP 203似乎很值得一提:
“它们[增强赋值运算符]实现与其正常二进制形式相同的操作符,只是当左侧对象支持时,操作是‘就地’完成的,并且左侧只被评估一次。”
Python中增强赋值的思想不仅仅是简化将二元运算的结果存储在其左侧操作数中的常见做法,还是使左侧操作数知道它应该“自行操作”,而不是创建一个修改后的副本。

1
>>> elements=[[1],[2],[3]]
>>> subset=[]
>>> subset+=elements[0:1]
>>> subset
[[1]]
>>> elements
[[1], [2], [3]]
>>> subset[0][0]='change'
>>> elements
[['change'], [2], [3]]

>>> a=[1,2,3,4]
>>> b=a
>>> a+=[5]
>>> a,b
([1, 2, 3, 4, 5], [1, 2, 3, 4, 5])
>>> a=[1,2,3,4]
>>> b=a
>>> a=a+[5]
>>> a,b
([1, 2, 3, 4, 5], [1, 2, 3, 4])

1
>>> a = 89
>>> id(a)
4434330504
>>> a = 89 + 1
>>> print(a)
90
>>> id(a)
4430689552  # this is different from before!

>>> test = [1, 2, 3]
>>> id(test)
48638344L
>>> test2 = test
>>> id(test)
48638344L
>>> test2 += [4]
>>> id(test)
48638344L
>>> print(test, test2)  # [1, 2, 3, 4] [1, 2, 3, 4]```
([1, 2, 3, 4], [1, 2, 3, 4])
>>> id(test2)
48638344L # ID is different here


我们可以看到,当我们试图修改一个不可变对象(在这种情况下是整数)时,Python只会给我们返回一个不同的对象。另一方面,我们能够对可变对象(如列表)进行更改,并使其保持相同的对象。

参考链接:https://medium.com/@tyastropheus/tricky-python-i-memory-management-for-mutable-immutable-objects-21507d1e5b95

还请参考以下链接以了解浅拷贝和深拷贝。

https://www.geeksforgeeks.org/copy-python-deep-copy-shallow-copy/


列表的ID相同

- roshan ok

-3

listname.extend()非常适合这个目的 :)


这与问题的背景完全无关。 - yabhishek

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接