流畅的Python对象引用、可变性和垃圾回收
1. 变量不是盒子
人们经常使用“变量是盒子”这样的比喻解释变量,但是这有碍于理解面向对象语言中的引用式变量。Python 变量类似于 Java 中的引用式变量,因此最好把它们理解为附加在对象上的标注。
1 | class Gi(): |
变量只不过是标注,所以无法阻止为对象贴上多个标注。贴的多个标注,就是别名。
2. 标识、相等性和别名
1 | tom = {'name':'TOM', 'age':10, 'sex':'m'} |
tomy和tom 是别名,即两个变量绑定同一个对象{'name':'TOM', 'age':10, 'sex':'m'}。而 tam 不是 tom的别名,因为二者绑定的是不同的对象。tam 和 tom 绑定的对象具有相同的值(== 比较的就是值),但是它们的标识不同。
每个变量都有标识、类型和值。
- 对象一旦创建,它的标识绝不会变;
- 可以把标识理解为对象在内存中的地址。
- is 运算符比较两个对象的标识;
- id() 函数返回对象标识的整数表示。
编程中很少使用 id() 函数。标识最常使用 is 运算符检查,而不是直接比较 ID。
3. 在**==**和is之间选择
== 运算符比较两个对象的值(对象中保存的数据),而 is 比较对象的标识。
通常,我们关注的是值,而不是标识,因此 Python 代码中 == 出现的频率比 is 高。
在变量和单例值之间比较时,应该使用 is。目前,最常使用 is 检查变量绑定的值是不是 None。推荐的写法:x is None,否定的正确写法是:x is not None 。
is 运算符比 == 速度快,因为它不能重载,所以 Python 不用寻找并调用特殊方法,而是直接比较两个整数 ID。而 a == b 是语法糖,等同于 a.__eq__(b)。继承自 object 的 __eq__方法比较两个对象的 ID,结果与 is 一样。但是多数内置类型使用更有意义的方式覆盖了__eq__ 方法,会考虑对象属性的值。相等性测试可能涉及大量处理工作,例如,比较大型集合或嵌套层级深的结构时。
4. 元组的相对不可变性
元组与多数 Python 集合(列表、字典、集,等等)一样,保存的是对象的引用。如果引用的元素是可变的,即便元组本身不可变,元素依然可变。也就是说,元组的不可变性其实是指 tuple 数据结构的物理内容(即保存的引用)不可变,与引用的对象无关。
1 | t1 = (1,2,[3,4,5]) |
可以说,元组具有相对不可变性,这也是有些元组(元素中有可变的类型)不可散列的原因。
5. 默认做浅复制
复制列表(或多数内置的可变集合)最简单的方式是使用内置的类型构造方法。
1 | l1 = [1, [2, 3, 4], (5, 6, 7)] |
对列表和其他可变序列来说,还能使用简洁的 l2 = l1[:] 语句创建副本。然而,构造方法或 [:] 做的是浅复制(即复制了最外层容器,副本中的元素是源容器中元素的引用)。如果所有元素都是不可变的,那么这样没有问题,还能节省内存。但是,如果有可变的元素,可能就会导致意想不到的问题。
1 | l1 = [3, [66, 55, 44], (7, 8, 9)] |
6.为任意对象做深复制和浅复制
演示 copy() 和 deepcopy() 的用法:
1 | import copy |
1 | id(bus1), id(bus2), id(bus3) |
观察 passengers 属性后发现,bus1 和 bus2 共享同一个列表对象,因为 bus2 是 bus1 的浅复制副本。bus3 是 bus1 的深复制副本,因此它的 passengers 属性指代另一个列表。
一般来说,深复制不是件简单的事。如果对象有循环引用,那么这个朴素的算法会进入无限循环。deepcopy 函数会记住已经复制的对象,因此能优雅地处理循环引用。
1 | a = [1,2] |
7. 函数的参数作为引用时
Python 唯一支持的参数传递模式是 共享传参(call by sharing)。多数面向对象语言都采用这一模式,包括 Ruby、Smalltalk 和 Java(Java 的引用类型是这样,基本类型按值传参)。
共享传参指函数的各个形式参数获得实参中各个引用的副本。也就是说,函数内部的形参是实参的别名。
这种方案的结果是,函数可能会修改作为参数传入的可变对象,但是无法修改那些对象的标识(即不能把一个对象替换成另一个对象)。
1 | def test(a, b): |
不要使用可变类型作为参数的默认值
可选参数可以有默认值,这是 Python 函数定义的一个很棒的特性,这样我们的 API 在进化的同时能保证向后兼容。然而,我们应该避免使用可变的对象作为参数的默认值。
1 | class Bus(object): |
问题在于,没有指定初始乘客的 Bus 实例会共享同一个乘客列表。
实例化 Bus 时,如果传入乘客,会按预期运作。但是不为 Bus 指定乘客的话,奇怪的事就发生了,这是因为 self.passengers 变成了 passengers 参数默认值的别名。出现这个问题的根源是,默认值在定义函数时计算(通常在加载模块时),因此默认值变成了函数对象的属性。因此,如果默认值是可变对象,而且修改了它的值,那么后续的函数调用都会受到影响。
审 查Bus.__init__ 对 象, 看 看 它 的__defaults__属性中的元素:
1 | Bus.__init__.__defaults__ |
可以验证 bus2.passengers 是一个别名,它绑定到Bus.__init__.__defaults__ 属性的第一个元素上:
1 | Bus.__init__.__defaults__[0] is bus2.passagers |
可变默认值导致的这个问题说明了为什么通常使用 None 作为接收可变值的参数的默认值 (我的理解:之前的正确代码是通过 if passengers is None判断 passengers 是否为空,如果为空,通过self.passagers = [] 空列表赋值给self.passagers, 这样就在函数体里面执行,而不是在def定义时执行,空列表也就不会成为函数对象的属性了)。在之前正确的示例中,__init__ 方法检查 passengers 参数的值是不是 None,如果是就把一个新的空列表赋值给 self.passengers。如果 passengers 不是 None,正确的实现会把 passengers 的副本赋值给 self.passengers。
8. 防御可变参数
如果定义的函数接收可变参数,应谨慎考虑调用方是否期望修改传入的参数。
例如,若函数接收一个字典,且在处理的过程中要修改它,那么这个副作用要不要体现到函数外部?应该具体情况具体分析。
1 | class Bus(object): |
A、B从bus下车后,team的成员名单竟然也变了!
Bus 类中__init__方法中把 self.passengers 变成 passengers 的别名,而实例化bus后把self.passengers是传给__init__方法的实参的别名team。在 self.passengers 上调用 .remove() 和 .append() 方法其实会修改传给构造方法的那个列表。
这里的问题是: 校车Bus 为传给构造方法的列表创建了别名。正确的做法是,校车自己只维护乘客列表。修正的方法:在 __init__ 中,传入 passengers 参数时,应该把参数值的副本赋值给 self.passengers, 比如使用list(passengers)。另外,此时传给 passengers 参数的值可以是元组或任何其他可迭代对象,例如set 对象,甚至数据库查询结果,因为 list 构造方法接受任何可迭代对象。自己创建并管理列表可以确保支持所需的 .remove() 和 .append() 操作,这样 .pick() 和 .drop() 方法才能正常运作。
小结:除非当前方法确实想修改通过参数传入的对象,否则在类中直接把参数赋值给实例变量之前,一定要三思,因为这样会为参数对象创建 别名 。如果不确定,那就创建副本。
9. del和垃圾回收
对象绝不会自行销毁;然而,无法得到对象时,可能会被当作垃圾回收。
del 语句删除名称,而不是对象。del 命令可能会导致对象被当作垃圾回收,但是仅当删除的变量保存的是对象的最后一个引用,或者无法得到对象时。重新绑定也可能会导致对象的引用数量归零,导致对象被销毁。
有个__del__特殊方法,但是它不会销毁实例,不应该在代码中调用。即将销毁实例时,Python 解释器会调用 __del__ 方法,给实例最后的机会,释放外部资源。
在 CPython 中,垃圾回收使用的主要算法是引用计数。实际上,每个对象都会统计有多少引用指向自己。当引用计数归零时,对象立即就被销毁:CPython 会在对象上调用 __del__方法(如果定义了),然后释放分配给对象的内存。CPython 2.0 增加了分代垃圾回收算法,用于检测引用循环中涉及的对象组——如果一组对象之间全是相互引用,即使再出色的引用方式也会导致组中的对象不可获取。Python 的其他实现有更复杂的垃圾回收程序,而且不依赖引用计数,这意味着,对象的引用数量为零时可能不会立即调用__del__方法。
示例:使用 weakref.finalize 注册一个回调函数,在销毁对象时调用,来演示对象生命结束的情形。
1 | import weakref |
10. 弱引用
正是因为有引用,对象才会在内存中存在。当对象的引用数量归零后,垃圾回收程序会把对象销毁。但是,有时需要引用对象,而不让对象存在的时间超过所需时间。这经常用在缓存中。
弱引用不会增加对象的引用数量。引用的目标对象称为所指对象(referent)。因此我们说,弱引用不会妨碍所指对象被当作垃圾回收。
弱引用在缓存应用中很有用,因为我们不想仅因为被缓存引用着而始终保存缓存对象。
WeakValueDictionary简介:
WeakValueDictionary 类实现的是一种可变映射,里面的值是对象的弱引用。被引用的对象在程序中的其他地方被当作垃圾回收后,对应的键会自动从 WeakValueDictionary 中删除。因此,WeakValueDictionary 经常用于缓存。
1 | import weakref |
删除 catalog 之后,stock 中的大多数奶酪都不见了,这是 WeakValueDictionary 的预期行为。
for cheese in catalog,临时变量cheese引用了对象,这可能会导致该变量的存在时间比预期长。通常,这对局部变量来说不是问题,因为它们在函数返回时会被销毁。但是在示例中,for 循环中的变量 cheese 是全局变量,除非显式删除,否则不会消失。
11. 弱引用的局限
不是每个 Python 对象都可以作为弱引用的目标(或称所指对象)。基本的 list 和 dict 实例不能作为所指对象,但是它们的子类可以轻松地解决这个问题:
1 | li = range(10) |
set 实例可以作为所指对象,因此上面的那个示例才使用 set 实例。用户定义的类型也没问题,这就解释了为什么使用那个简单的 Cheese 类。但是,int 和 tuple 实例不能作为弱引用的目标,甚至它们的子类也不行。
12. Python对不可变类型施加的把戏
通过前面的学习,我知道对于列表,如列表li,li[:]会创建一个副本,而list(li)返回一个对象的引用。
1 | li = [1,2,3] |
对元组 t ,t[:] 不创建副本,而是返回同一个对象的引用。此外,tuple(t) 获得的也是同一个元组的引用。
1 | t = (1,2,3) |
str、bytes 和 frozenset 实例也有这种行为。注意,frozenset 实例不是序列,因此不能使用 fs[:](fs 是一个 frozenset 实例),但是fs.copy() 具有相同的效果:它会欺骗你,返回同一个对象的引用,而不是创建一个副本。
字符串字面量可能会创建共享的对象:
1 | t = (1,2,3) |
共享字符串字面量是一种优化措施,称为驻留(interning)。CPython 还会在小的整数上使用这个优化措施,防止重复创建“热门”数字,如 0、—1 和 42。注意,CPython 不会驻留所有字符串和整数,驻留的条件是实现细节,而且没有文档说明。
千万不要依赖字符串或整数的驻留!比较字符串或整数是否相等时,应该使用 ==,而不是 is。驻留是 Python 解释器内部使用的一个特性。
13. 小结
每个 Python 对象都有标识、类型和值。只有对象的值会不时变化。
如果两个变量指代的 不可变对象 具有相同的值(a == b 为 True),实际上它们指代的是副本还是同一个对象的别名基本没什么关系,因为不可变对象的值不会变。但有一个例外:不可变的集合,如 元组 和 frozenset :如果不可变集合保存的是可变元素的引用,那么可变元素的值发生变化后,不可变集合也会随之改变。实际上,这种情况不是很常见。不可变集合不变的是所含对象的标识。
变量保存的是引用,这一点对 Python 编程有很多实际的影响。
- 简单的赋值不创建副本。
- 对 += 或 *= 所做的增量赋值来说,如果左边的变量绑定的是不可变对象,会创建新对象;如果是可变对象,会就地修改。
- 为现有的变量赋予新值,不会修改之前绑定的变量。这叫重新绑定:现在变量绑定了其他对象。如果变量是之前那个对象的最后一个引用,对象会被当作垃圾回收。
- 函数的参数以别名的形式传递,这意味着,函数可能会修改通过参数传入的可变对象。这一行为无法避免,除非在本地创建副本,或者使用不可变对象(例如,传入元组,而不传入列表)。
- 使用可变类型作为函数参数的默认值有危险,因为如果就地修改了参数,默认值也就变了,这会影响以后使用默认值的调用。
在 CPython 中,对象的引用数量归零后,对象会被立即销毁。如果除了循环引用之外没有其他引用,两个对象都会被销毁。某些情况下,可能需要保存对象的引用,但不留存对象本身。例如,有一个类想要记录所有实例。这个需求可以使用弱引用实现,这是一种低层机制,是 weakref 模块中 WeakValueDictionary、WeakKeyDictionary 和 WeakSet 等有用的集合类,以及 finalize 函数的底层支持。