Python对象引用、可变性和垃圾回收

本文是《流畅的Python》第八章的读书笔记,本文先讨论对象标识、值和别名等概念。随后,会揭露元组的一个神奇特性:元组是不可变的,但是其中的值可以改变。然后就引申到浅复制和深复制。接下来的话题是引用和函数参数:可变的参数默认值导致的问题,以及如何安全地处理函数的调用者传入的可变参数。最后讨论垃圾回收、 del命令

本文的内容有点儿枯燥,但是这些话题却是解决 Python 程序中很多不易察觉的 bug 的关键。

标识、相等性和别名

在Python中,每个变量都有标识、类型和值。对象一旦创建,它的标识绝不会变;可以把标识理解为对象在内存中的地址。 is 运算符比较两个对象的标识,而==比较两个对象的值; id() 函数返回对象的内存地址。

1
2
t1 = {'name': 'Lili', 'born': 2001}
t2 = {'name': 'Lili', 'born': 2001}
1
t1 == t2
True
1
t1 is t2
False
1
(id(t1), id(t2))
(140059319293992, 140059319293848)

当然,你可以在自己的类中定义 __eq__ 方法,从而决定 == 如何比较实例。如果不覆盖 __eq__ 方法,那么从 object 继承的方法比较对象的 ID,因此这种后备机制认为用户定义的类的各个实例是不同的。

元组与多数 Python 集合(列表、字典、集,等等)一样,保存的是对象的引用。 如果引用的元素是可变的,即便元组本身不可变,元素依然可变。也就是说,元组的不可变性其实是指tuple 数据结构的物理内容(即保存的引用)不可变,与引用的对象无关。

1
2
3
t1 = (1, 2, [30, 40])
t1[2].append(100)
t1
(1, 2, [30, 40, 100])

默认做浅复制

复制列表(或多数内置的可变集合)最简单的方式是使用内置的类型构造方法。例如:

1
2
3
l1 = [3, [66, 55, 44], (7, 8, 9)]
l2 = list(l1)
(l2 == l1, l2 is l1)
(True, False)
1
(id(l1), id(l2))
(140059319253960, 140059319197384)

可以看出,副本与源列表相等,但是二者指代不同的对象。对列表和其他可变序列来说,还能使用简洁的 l2 = l1[:] 语句创建副本。

然而,构造方法或 [:] 做的是浅复制(即复制了最外层容器,副本中的元素是源容器中元素的引用)。如果所有元素都是不可变的,那么这样没有问题,还能节省内存。但是,如果有可变的元素,可能就会导致意想不到的问题。看看下面的代码:

1
2
3
4
5
6
7
8
9
10
11
l1 = [3, [66, 55, 44], (7, 8, 9)]
l2 = list(l1) # ➊
l1.append(100) # ➋

l1[1].remove(55) # ➌
print('l1:', l1)
print('l2:', l2)
l2[1] += [33, 22] # ➍
l2[2] += (10, 11) # ➎
print('l1:', l1)
print('l2:', l2)
l1: [3, [66, 44], (7, 8, 9), 100]
l2: [3, [66, 44], (7, 8, 9)]
l1: [3, [66, 44, 33, 22], (7, 8, 9), 100]
l2: [3, [66, 44, 33, 22], (7, 8, 9, 10, 11)]

l2l1 的浅复制副本。此时的状态如图。
图片
➋ 把 100 追加到 l1 中,对 l2 没有影响。
图片
➌ 把内部列表 l1[1] 中的 55 删除。这对 l2 有影响,因为 l2[1] 绑定的列表与 l1[1]是同一个。
图片
➍ 对可变的对象来说,如 l2[1] 引用的列表, += 运算符就地修改列表。这次修改在l1[1] 中也有体现,因为它是 l2[1] 的别名。
图片
➎ 对元组来说, += 运算符创建一个新元组,然后重新绑定给变量 l2[2]。现在, l1 和 l2 中最后位置上的元组不是同一个对象。
图片

为任意对象做深复制和浅复制

有时我们需要的是深复制(即副本不共享内部对象的引用)。copy 模块提供的 deepcopycopy 函数能为任意对象做深复制和浅复制。

为了演示 copy()deepcopy() 的用法,定义了一个简单的类 Bus。这个类表示运载乘客的校车,在途中乘客会上车或下车。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import copy

class Bus:
def __init__(self, passengers=None):
if passengers is None:
self.passengers = []
else:
self.passengers = list(passengers)

def pick(self, name):
self.passengers.append(name)

def drop(self, name):
self.passengers.remove(name)

bus1 = Bus(['Alice', 'Bill', 'David'])
bus2 = copy.copy(bus1)
bus3 = copy.deepcopy(bus1)
id(bus1), id(bus2), id(bus3) # ➊
(140059318933488, 140059318933600, 140059318933264)
1
id(bus1.passengers), id(bus2.passengers), id(bus3.passengers) # ➋
(140059319442504, 140059319442504, 140059319440968)
1
2
bus1.drop('Bill')
bus2.passengers # ❸
['Alice', 'David']
1
bus3.passengers # ❹
['Alice', 'Bill', 'David']

➊ 使用 copy 和 deepcopy,创建 3 个不同的 Bus 实例。
➋ 审查 passengers 属性后发现,bus1 和 bus2 共享同一个列表对象,因为 bus2 是bus1 的浅复制副本。
❸ bus1 中的 ‘Bill’ 下车后,bus2 中也没有他了。
❹ bus3 是 bus1 的深复制副本,因此它的 passengers 属性指代另一个列表。

深复制有时可能太深了。例如,对象可能会引用不该复制的外部资源或单例值。我们可以实现特殊方法 __copy__()__deepcopy__(),控制 copydeepcopy 的行为。

函数的参数传递

Python 唯一支持的参数传递模式是共享传参(call by sharing),共享传参指函数的各个形式参数获得实参中各个引用的副本。也就是说,函数内部的形参是实参的别名。函数可能会修改接收到的任何可变对象。

不要用可变类型作为参数的默认值

默认参数最好指向不可变对象,否则会引起难以调试的问题。

1
2
3
4
5
6
7
8
class HauntedBus:
"""备受幽灵乘客折磨的校车"""
def __init__(self, passengers=[]): # ➊
self.passengers = passengers # ➋
def pick(self, name):
self.passengers.append(name) # ➌
def drop(self, name):
self.passengers.remove(name)

❶ 如果没传入 passengers 参数,使用默认绑定的列表对象,一开始是空列表。
❷ 这个赋值语句把 self.passengers 变成 passengers 的别名,而没有传入passengers 参数时,后者又是默认列表的别名。
❸ 在 self.passengers 上调用 .remove() 和 .append() 方法时,修改的其实是默认列表,它是函数对象的一个属性。

1
2
3
4
5
6
bus1 = HauntedBus() 
bus1.pick('Carrie')

bus2 = HauntedBus()
print(bus2.passengers) # ['Carrie']
bus2.passengers is bus1.passengers
['Carrie']





True

可以看出 bus2 的列表竟然不为空。这种问题很难发现。实例化 HauntedBus 时,如果传入乘客,会按预期运作。但是不为 HauntedBus 指定乘客的话,奇怪的事就发生了,这是因为 self.passengers 变成了 passengers 参数默认值的别名。出现这个问题的根源是,默认值在定义函数时计算(通常在加载模块时),因此默认值变成了函数对象的属性。因此,如果默认值是可变对象,而且修改了它的值,那么后续的函数调用都会受到影响。

防御可变参数

如果定义的函数接收可变参数,应该谨慎考虑调用方是否期望修改传入的参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class TwilightBus:
"""让乘客销声匿迹的校车"""
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.drop('Tina') # ➌
bus.drop('Pat')
basketball_team # ➍
['Sue', 'Maya', 'Diana']

basketball_team 中有 5 个学生的名字,使用这队学生实例化 TwilightBus。两个学生下车了,下车的学生从篮球队中消失了!

当 passengers 不为 None 时,self.passengers 变成 passengers 的别名,而后者是传给 __init__ 方法的实参的别名。在 self.passengers 上调用 .remove() 和 .append() 方法其实会修改传给构造方法的那个列表。

这里的问题是,校车为传给构造方法的列表创建了别名。正确的做法是,校车自己维护乘客列表。修正的方法很简单:在 init 中,传入 passengers 参数时,应该把参数值的副本赋值给 self.passengers。

正确的做法应该如下:

1
2
3
4
5
def __init__(self, passengers=None):
if passengers is None:
self.passengers = []
else:
self.passengers = list(passengers)

del 和垃圾回收

对象绝不会自行销毁;然而,无法得到对象时,可能会被当作垃圾回收。—— Python 语言参考手册中“Data Model”一章

Python 没有直接销毁对象的机制,del 语句删除名称,而不是对象。 del 命令可能会导致对象被当作垃圾回收,但是仅当删除的变量保存的是对象的最后一个引用,或者无法得到对象时。重新绑定也可能会导致对象的引用数量归零,导致对象被销毁。

CPython 中,垃圾回收使用的主要算法是引用计数。实际上,每个对象都会统计有多少引用指向自己。当引用计数归零时,对象立即就被销毁: CPython 会在对象上调用 __del__ 方法(如果定义了),然后释放分配给对象的内存。但是这种引用计数的方式遇到引用循环容易泄露内存。 CPython 2.0 增加了分代垃圾回收算法,用于检测引用循环中涉及的对象组,它能把引用循环中不可获取的对象销毁。

引用计数意味着,在 CPython 中,这样写是安全的(至少目前如此):
open('test.txt', 'wt', encoding='utf-8').write('1, 2, 3')
因为文件对象的引用数量会在 write 方法返回后归零, Python在销毁内存中表示文件的对象之前,会立即关闭文件。然而,这行代码在 JythonIronPython 中却不安全,因为它们使用的是宿主运行时(Java VM 和 .NET CLR)中的垃圾回收程序,那些回收程序更复杂,但是不依靠引用计数,而且销毁对象和关闭文件的时间可能更长。在任何情况下,包括 CPython,最好显式关闭文件;而关闭文件的最可靠方式是使用 with 语句,它能保证文件一定会被关闭,即使打开文件时抛出了异常也无妨。使用 with,上述代码片段变成了:

1
2
with open('test.txt', 'wt', encoding='utf-8') as fp:
fp.write('1, 2, 3')

总结

变量保存的是引用,这一点对 Python 编程有很多实际的影响。

  1. 简单的赋值不创建副本。
  2. +=*= 所做的增量赋值来说,如果左边的变量绑定的是不可变对象,会创建新对象;如果是可变对象,会就地修改。
  3. 为现有的变量赋予新值,不会修改之前绑定的变量。这叫重新绑定:现在变量绑定了其他对象。如果变量是之前那个对象的最后一个引用,对象会被当作垃圾回收。
  4. 函数的参数以别名的形式传递,这意味着,函数可能会修改通过参数传入的可变对象。这一行为无法避免,除非在本地创建副本,或者使用不可变对象(例如,传入元组,而不传入列表)。
  5. 使用可变类型作为函数参数的默认值有危险,因为如果就地修改了参数,默认值也就变了,这会影响以后使用默认值的调用。
持续技术分享,您的支持将鼓励我继续创作!