Pyhton 对象引用、可变性和垃圾回收
date
Jun 1, 2021
slug
object_python
status
Published
tags
Python
summary
《流畅的Python》 第八章学习笔记
type
Post
本章内容包括:
- 对变量的理解:变量是标注而不是盒子。
- 讨论对象标识、值、别名的概念
- 深复制、浅复制
- 引用和函数参数
- Python垃圾回收
1. 对变量的理解
我们常用 “变量是盒子”这个比喻来理解变量的存在,但是对于面向对象语言(such as java,Python and so on)中的引用式变量,却不能将变量比喻为盒子,而只能将变量理解为盒子上的标注(or 便利贴)
对于引用式变量来说,应该说把变量分配给对象更为合理,因为赋值语句右边先执行,也就是对象在赋值之前就创建好了,然后才将变量分配给所创建的对象
例如新建一个对象时,如
s = "HelloWorld"
,是将变量s
分配给了该字符串对象“HelloWorld”
,绝不会能说把字符串对象“HelloWorld”
分配给变量s
。为了理解Python的赋值语句,应该始终先读右边。对象在右边创建或者获取,在此之后左边的变量才会绑定到对象上,就像为对象贴上标注。
因为变量只不过是标注,所以无法阻止为一个示例对象贴上多个标注。而所贴的多个标注,就是别名。
2. 标识、相等性和别名
- 别名:多个变量绑定到同一个对象上,那么其中任一变量称为其余变量的别名
- 标识:对象一旦创建,它的标识肯定不会变。
id()
函数返回对象标识的整数表示。可以把标识理解为对象在内存中的地址。
对象之间的相等性(
==
)是通过对象所属类的__eq__()
魔法方法比较的is
运算符比较的则是两个对象的标识,即用来判断两个变量所表示的是否为同一个对象对象ID的真正意义在不同的实现中有所不同。在CPython中,
id()
返回对象的内存地址,但是在其他Python解释器中可能是别的值。关键是,ID一定是唯一的数值标注,而且在对象的生命周期中绝不会变。==
和is
如何选择
==运算符比较两个对象的值,即对象中保存的数据,而
is
比较对象的标识is
运算符比==
速度快,因为它不能重载,所以Python不用寻找并调用特殊方法,而是直接比较两个整数ID
而
a==b
是语法糖,等同于a.eq(b)
。继承自object
的__eq__
方法比较两个对象的ID
,结果与is
一样。但是多数内置类型使用更有意义的方式覆盖了__eq__
方法,会考虑对象属性的值。相等性测试可能涉及大量处理工作,例如,比较大型集合或嵌套层级深的结构时。通常,我们关注的是值,而不是标识,因此Python代码中
==
出现的频率比is
高。然而,在变量和单例值之间比较时,应该使用is
。目前,最常使用is检查变量绑定的值是不是None
元组的相对不可变性
元组与多数Python集合(列表、字典、集,等等)一样,保存的是对象的引用。
注:
str,bytes,array.array
等单一类型序列是扁平的,他们保存的不是引用,而是在连续的内存中保存的数据本身如果引用的元素是可变的,即便元组本身不可变,元素依然可变。也就是说,元组的不可变性其实是指
tuple
数据结构的物理内容(即保存的引用)不可变,与引用的对象无关。
在上述示例中,
a,b
均为不可变元组,- 元组不可变,但是元组内的引用对象
a[-1]
可变
a[-1]
改变后,id(a[-1])
依旧没变
所以说元组不可变是指元组内的引用(id)不可变
3. 深复制、浅复制
复制列表(或者多数内置可变集合)最简单的方法如下:

可见,复制出来的副本与源列表相等,但指代不同的对象。
对元组
t
来说,t[:]
和tuple(t)
不创建副本,而是返回同一个元组对象的引用。然而,这两种方法做的都是浅复制,即只复制了最外层的容器,副本中的元素是源容器中元素的引用,见下图。如果容器内所有元素均为不可变的,这样做没问题,还能节省内存。但是如果有可变元素,就会导致意想不到的问题。

内存模型如下图所示

l1 l2
指代不同的列表,但是二者引用同一个列表[55, 44]
和 [6, 7, 8]]
为任意对象做深复制和浅复制
copy模块提供的
deepcopy
和copy
函数可以为任意对象做深复制和浅复制.一般来说,深复制不是件简单的事。如果对象有循环引用,那么这个朴素的算法会进入无限循环。不过deepcopy函数会记住已经复制的对象,因此能优雅地处理循环引用,如下图:

深复制有时可能太深了。例如,对象可能会引用不该复制的外部资源或单例值。可以实现特殊方法
__copy__()
和__deepcopy__()
,控制copy
和deepcopy
的行为。4. 函数的参数作为引用
Python唯一支持的参数传递模式为共享传参(call by sharing)。共享传参是指函数的各个形参获得实参中各个引用的副本,也就是说,函数内部的形参是实参的别名。
这种方案的缺点就是函数可能会修改作为参数传入的可变对象,但是无法修改这些对象的标识。例:

不要使用可变类型作为参数的默认值
默认值在定义函数时计算(通常在加载模块时),因此默认值变成了函数对象的属性。因此,如果默认值是可变对象,而且修改了它的值,那么后续的函数调用都会受到影响。
例子:
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) # 1. 如果实例化该类时没有传进来参数,则使用默认绑定的列表对象,初始为空列表 # 2. __init__()函数把self.passengers变成passengers的别名, # 而当没有传入passengers参数时,后者又是默列表的别名 # 3. 在self.passengers上调用.remove()和.append()方法时, # 修改的其实是默认列表,它是函数对象的一个属性。
传入参数时:

使用默认参数时

bus3
采用默认参数实例化,理论上bus3.passengers
应该为空列表,但是却不为空,这是因为此时默参数已经不是空列表了。由最后输入
HauntedBus.__init__.__defaluts__ = (['rui', 'na'],)
可知bus2
的实例对象把默认空列表修改为了['rui', 'na']
, 所以之后的函数调用受到了影响。所以通常使用
None
作为接收可变值的参数的默认值。防御可变参数
如果定义的函数接收可变参数,应该谨慎考虑调用方是否期望修改传入的参数。例如,如果函数接收一个字典,而且在处理的过程中要修改它,那么这个副作用要不要体现到函数外部?
所以,如果某个类的方法并不想修改通过参数传进来的对象,那么在类中就不能把参数直接赋值给实例变量,因为这样只会为参数创建别名。而应该创建参数副本。例如,如果可变参数为列表,则应该通过
list()
构造函数或者[:]
方法来创建副本5.del和垃圾回收
在CPython中,垃圾回收使用的主要算法是引用计数。实际上,每个对象都会统计有多少引用指向自己。当引用计数归零时,对象立即就被销毁:CPython会在对象上调用
__del__
方法(如果定义了),然后释放分配给对象的内存。
__del__
方法一般不需要用户实现。del
语句删除对象的引用(变量名称),而不是对象。除非删除的变量保存的是对象的最后一个引用,或者无法得到对象时(无法获得对象是指该对象最后一个引用重新绑定到别的对象上了,那么该对象就不可获得了),del
命令会导致对象被当做垃圾回收。注:重新绑定可能会导致对象的引用数量归零,导致对象被销毁。例:
s1 = "HelloWoeld" s2 = s1 # 将变量s2和s1是别名,指向同一个字符串对象"HelloWoeld" del s1 # 删除 s1 变量,此时s2还指向字符串对象"HelloWoeld" s2 = "Hahaha" # 将变量s2 重新绑定给另一个字符串变量,此时对象"HelloWoeld"没有引用,内存会自动释放