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)不创建副本,而是返回同一个元组对象的引用。
然而,这两种方法做的都是浅复制,即只复制了最外层的容器,副本中的元素是源容器中元素的引用,见下图。如果容器内所有元素均为不可变的,这样做没问题,还能节省内存。但是如果有可变元素,就会导致意想不到的问题。
浅复制,内层可变对象并没有复制,依旧是同一个对象浅复制,内层可变对象并没有复制,依旧是同一个对象
浅复制,内层可变对象并没有复制,依旧是同一个对象
内存模型如下图所示
notion imagenotion image
l1 l2 指代不同的列表,但是二者引用同一个列表[55, 44][6, 7, 8]]
为任意对象做深复制和浅复制
copy模块提供的deepcopycopy函数可以为任意对象做深复制和浅复制.
一般来说,深复制不是件简单的事。如果对象有循环引用,那么这个朴素的算法会进入无限循环。不过deepcopy函数会记住已经复制的对象,因此能优雅地处理循环引用,如下图:
循环引用与深复制循环引用与深复制
循环引用与深复制
深复制有时可能太深了。例如,对象可能会引用不该复制的外部资源或单例值。可以实现特殊方法__copy__()__deepcopy__(),控制copydeepcopy的行为。

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()方法时, # 修改的其实是默认列表,它是函数对象的一个属性。
定义校车函数,参数默认值为空列
传入参数时:
notion imagenotion image
使用默认参数时
notion imagenotion image
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"没有引用,内存会自动释放