Python 对象的引用和可变性
对象引用、可变性以及回收机制
代码案例,图片来自于 FluentPython-version 2
变量就是一个标签,贴在了某个对象上;而非 变量是一个盒子,用来存放元素。
a = [1, 2, 3]
b = a
a.append(4)
print(b) # [1, 2, 3, 4]

上图的例子中,如果我们把 b 看作一个盒子,里面存放着从 a 盒子复制来的 [1, 2, 3],那么这种行为就无法解释。因为,b 就只是一个标签和 a 一样贴在了 数组 [1, 2, 3] 这个对象上面。只要 a 和 b 所指向的那个对象发生了改变,所以无论通过 a 还是 b 访问,看到的都是改变后的对象。
Identity, Equality, and Aliases — 标识、相等性与别名
用一个例子来引入
charles = {'name': 'Charles L. Dodgson', 'born': 1832}
lewis = charles # lewis is an **alias** for charles.
print(lewis == charles) # True
print(id(charles), id(lewis)) # 1872554149376 1872554149376
lewis['balance'] = 950 # Adding an item to lewis is the same as adding an item to charles.
print(lewis) # {'name': 'Charles L. Dodgson', 'born': 1832, 'balance': 950}
好,现在假设又有一个人 alex 他的个人资料也是 {'name': 'Charles L. Dodgson', 'born': 1832} ,那 charles 和 alex 会是一个人吗?明显不是!

alex = {'name': 'Charles L. Dodgson', 'born': 1832, "balance": 950}
print(alex == charles) # True
print(alex is charles) # False
值得注意的是:
- 之所以
alex == charles是因为 字典类型 dict 底层的__eq__方法的实现。 - 但是这两者是不同的对象,因此当我们执行
alex is charles时,python 返回了 false,因为这两个标签代表的不是同一个对象。
总结一下:
Aliases:在最开始例子中lewis就是charles的别名,因为这两个变量绑定是同一个对象。Equality:绑定给 alex 和 charles 的对象的 值 是相等的,因为他们拥有相同的字段,每个字段的值也是相等的。但是二者的标识并不相同。Identity:对象一经创建,其标识就不会改变;你可以将它理解为对象在内存中的地址。is运算符用于比较两个对象的标识;id()函数会返回一个代表该标识的整数。
对象 ID 的实际含义取决于具体实现。在
CPython中,id()返回对象的内存地址,但在其他Python解释器中,它可能代表别的内容。核心要点是:ID 一定是一个唯一的整数标记,并且在对象的整个生命周期内不会发生变化。
关于 == 和 is 运算符的选择,当我们明确知道要使用 is 对比时才使用该运算符,其他情况或者在你不确定的情况下,尽量使用 == 运算符,因为这个运算符也能和 None 一起工作 —— 尽管这会有一点点慢。
The Relative Immutability of Tuples
元组和 Python 中的大多数容器(列表,字典,集合等)一样,都是 容器类型,而且它们存储的是对象的引用。如果这些引用的对象发生改变了那容器也会改变(尽管 Tuple 是不可变的容器,但是这些元素的值依然可以发生变化)。
换句话说,元组的不可变性实际指的是元组数据结构本身的物理内容(也就是它保存的引用)不可变,并不延伸到这些引用所指向的对象。
a = 7
tuples = (9, 8, a, 6, 5)
print(id(a) == id(tuples[2])) # True
print(tuples) # (9, 8, 7, 6, 5)
a = 10
print(id(a) == id(tuples[2])) # False
print(tuples)# (9, 8, 7, 6, 5)
其实这个牵扯到这个运算符的特性, =(赋值)永远只是在移动“标签”,创造新对象或切换指向,绝不修改原对象的内容。
但是如果存了一个可变的对象呢?
t1 = (1, 2, [1, 2])
t2 = (1, 2, [1, 2])
print(t1 == t2) # True
c = t2[-1]
print(id(c) == id(t2[-1])) # True
c.append(3)
print(id(c) == id(t2[-1])) # True
print(t1, t2) # (1, 2, [1, 2]) (1, 2, [1, 2, 3])
t1, t2 是元组,是不可变的,但是他们包含了一个 list ,这个 list 是可变的。一开始 t1 和 t2 的值是相等的,但是修改了以后是 两个不同的元组了(只是值不相同,因为 id 本来就不同)。
Copies Are Shallow by Default
生成一个 列表的 副本的简单方式是 l1 = list(l2) 或者 l1 = l2[:] ,虽然这样复制后是两个不同的 对象,但是这种复制方式本质上还是 浅拷贝,因为这种拷贝方式仅复制最外层容器,副本里面存放的是指向原容器中相同元素的引用。
l1 = [1, 2, 3]
l2 = list(l1)
l3 = l1[:]
print(l1 is l2) # False
print(l1 is l3) # False
上述例子展示了 确实是两个不同的对象,但是如果是嵌套的容器呢?
l1 = [3, [66, 55, 44], (7, 8, 9)]
l2 = list(l1)
l1.append(100)
l1[1].remove(55)
print(l1 is l2, l1[1] is l2[1]) # False True
print(f"l1:{l1}") # [3, [66, 44], (7, 8, 9), 100]
print(f"l2:{l2}") # [3, [66, 44], (7, 8, 9)]
print(l1[2] is l2[2]) # True
l2[1] += [33, 22]
l2[2] += (10, 11)
print(f"l1:{l1}") # [3, [66, 44, 33, 22], (7, 8, 9), 100]
print(f"l2:{l2}") # [3, [66, 44, 33, 22], (7, 8, 9, 10, 11)]
print(l1[2] is l2[2]) # False
因为 l2 是 l1 的浅拷贝,只会复制最外层的容器,所以 l1 和 l2 是两个不同的对象,但是其内部的那个列表又是同一个对象。

上面的图配上代码就能很好的理解一开始的那句话了。当进行下述更改时:
l1.append(100), l1[1].remove(55)
因为 l1, l2 是不同的列表,所以 100 只会出现在 l1 中,但是如果修改 l1 中的那个列表,就会导致 l2 中的列表一起改变,因为深层的引用还是同一个列表对象。
l2[1] += [33, 22],l2[2] += (10, 11)
因为 列表是可变对象,所以进行 += 操作后调用的是 __iadd__ 方法,所以还是在原来的对象上进行操作,因此 l1 也能看到变化的结果,但是 元组则是不可变对象,因此执行 += 操作时只能通过创建一个新的对象来完成这个运算,导致最终 l2 的元组变成一个新的元组对象。

Deep and Shallow Copies of Arbitrary Objects
对任意对象的深拷贝和浅拷贝,深拷贝没什么好说的全部复制一份。但是对于浅拷贝:
最开始二者的内部元素都是共用的,只有执行赋值,或者修改不可变对象等需要创建新对象的操作时,此时就会断开共用的引用链条。对于可变的对象,如果是原地修改则还是共用同一个对象。
注意:
copy.deepcopy()会递归复制需要复制的嵌套对象,但不保证所有对象都会创建新实例。也就是说,深拷贝递归复制对象结构,通常使副本不再与原对象共享内部的可变对象。deepcopy会记录已经复制过的对象,避免递归复制陷入无限循环,也能处理重复引用。
Function Parameters as References
Python 中唯一的参数传递方式是共享传参。共享传参指函数的每个形参都会获得实参中各个引用的副本,换句话说,函数内部的形参成了实参的别名。
def f(a, b):
a += b
return a
x, y = 1, 2
(f(x, y)) # return 3
print(x, y) # 1, 2
a, b = [1, 2], [3, 4]
f(a, b) # return [1, 2, 3, 4]
print(a, b) # [1, 2, 3, 4] [3, 4]
t, u = (10, 20), (30, 40)
f(t, u) # (10, 20, 30, 40)
print(t, u)# (10, 20), (30, 40)
按照之前的思路可以知道为什么。因为 number x 、 tuple t 是不可变对象,但是 list a 是可变对象,虽然在进行赋值时,a, b 是和最开始传入的参数共享同一个对象,但是除了 list 之外,其他对象进行赋值改动后,a 就变成另一个对象的标签了,所以并没对不可变对象进行实质性的改变。
Mutable Types as Parameter Defaults: Bad Idea
带默认值的可选参数是 Python 函数定义中一项很实用的特性,能够让我们的接口在迭代更新的同时保持向后兼容。不过,应当避免将可变对象用作参数的默认值。
考虑这样一份代码:
class HauntedBus:
"""A bus model haunted by ghost passengers"""
# When the passengers argument is not passed,
# this parameter is bound to the default list object, which is initially empty.
def __init__(self, passengers=[]):
# This assignment makes self.passengers an alias for passengers,
# which is itself an alias for the default list, when no passengers argument is given.
self.passengers = passengers
def pick(self, name):
# When the methods .remove() and .append() are used with self.passengers,
# we are actually mutating the default list, which is an attribute of the function object.
self.passengers.append(name)
def drop(self, name):
self.passengers.remove(name)
bus1 = HauntedBus(['A', 'B'])
print(bus1.passengers) # ['A', 'B']
bus1.pick("C")
bus1.drop("A")
print(bus1.passengers) # ['B', 'C']
bus2 = HauntedBus()
print(bus2.passengers) # []
bus2.pick("D")
print(bus2.passengers) # ['D']
bus3 = HauntedBus()
print(bus3.passengers) # ['D']
bus3.pick("E")
print(bus2.passengers) # ['D', 'E']
print(bus2.passengers is bus3.passengers) # True
print(bus1.passengers) # ['B', 'C']
上述案例就说明了为什么不能用 可变对象作为默认值了。
在 Python 中,所有默认值都会在函数定义时(通常是模块加载时)完成求值,并且这些默认值会成为函数对象的属性。因此,如果某个默认值是可变对象,一旦对它做出修改,该改动就会影响后续每一次对这个函数的调用。
所以,如果我们用 可变对象 作为默认参数,那当我们不显示传 乘客参数,就会给实例自动绑定同一个空数组作为乘客列表,这就是为什么 bus2 bus3 的操作二者都能看到。
我们可以检查 HauntedBus.__init__ 对象,就能看到 幽灵学生(乘客) 会出现在它的 __defaults__ 属性中:print(HauntedBus.__init__.__defaults__) # (['D', 'E'],)
除此之外,我们还能验证 bus2.passengers 是一个绑定在 HauntedBus.__init__.__defaults__ 第一个属性上的别名。
print(HauntedBus.__init__.__defaults__[0] is bus2.passengers) # True
这里也直接说明了,为什么在 使用 @dataclass 创建数据类时不要将 可变对象 直接作为字段的默认值。
Defensive Programming with Mutable Parameters
当你编写接收可变参数的函数代码时,应当仔细考虑调用方是否期望传入的实参发生改变。
举个例子,如果你的函数接收一个字典,并且在处理过程中需要修改它,那么这个副作用是否要在函数外部可见?这实际上取决于具体场景,本质上是要让函数编写者和调用方的预期保持一致。
话不多说,来个例子。
class TwilightBus:
""" A bus model that makes pasengers vanish """
def __init__(self, passengers = None):
if passengers is None:
self.passengers = []
else:
self.passengers = passengers
def pick(self, name):
self.passengers.append(name)
def drop(self, name):
self.passengers.remove(name)
basketball_team = ['Sue', 'Tina', 'Maya', 'Diana', 'Pat']
bus = TwilightBus(basketball_team) # 此时 bus.passengers 和 basketball_team 指向同一个对象
print(bus.passengers is basketball_team) # True
bus.drop("Tina")
bus.drop("Pat")
print(basketball_team) # ['Sue', 'Maya', 'Diana']
如果我们不想让 bus 这个实例修改原来的 篮球队中的成员呢?很简单,我们之前就实现过了。用该列表的副本进行初始化就行了。
def __init__(self, passengers = None):
if passengers is None:
self.passengers = []
else:
self.passengers = list(passengers)
除非某个方法明确设计为修改传入的参数对象,否则不要轻易直接将该参数对象赋值给类的实例变量,造成对象别名引用。
浙公网安备 33010602011771号