本文主要是 Fluent Python 第 8 章的学习笔记。这部分主要是介绍了变量、引用、对象、深拷贝、浅拷贝、垃圾回收等。本章虽然枯燥,但是非常有用。
《Fluent Python》学习笔记:第 8 章 对象引用、可变性和垃圾回收
-
- 8.1 变量不是盒子
- 8.2 标识、相等性和别名
- 8.3 默认浅拷贝
- 8.5 del 和垃圾回收
- 8.6 弱引用
- 巨人的肩膀
8.1 变量不是盒子
在 Python 中变量是标签(label),不是盒子(box)。Python 中的变量是引用式变量,类似于 Java 中的引用式变量,因此把 Python 中的变量理解为附加在对象上的标签。
通过一个例子感受一下:
a = [1, 2, 3] # a 标签指向了 [1, 2, 3] 这个列表
b = a # 把 b 标签也指向了 [1, 2, 3] 列表
a.append(4)
print(b)
[1, 2, 3, 4]
赋值语句的右边先执行,对象在赋值之前就创建了。因此,对于引用式变量,说把变量分配给对象更合理。看下面这个例子:
class Gizmo(object):
def __init__(self):
print('Gizmo id: %d' % id(self))
x = Gizmo()
y = Gizmo() * 10 # 可以看得到 Gizmo() 会创建一个新的 Gizmo 实例,求积失败,所以 y 变量不会创建
Gizmo id: 2236148132296
Gizmo id: 2236148132168
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
<ipython-input-6-91f1348d782b> in <module>
6
7 x = Gizmo()
----> 8 y = Gizmo() * 10 # 可以看得到 Gizmo() 会创建一个新的 Gizmo 实例,求积失败,所以 y 变量不会创建
TypeError: unsupported operand type(s) for *: 'Gizmo' and 'int'
因此,为了理解 Python 中的赋值语句,应该始终先读右边。对象在右边创建或获取,在此之后左边的变量才会绑定到对象上,这就像为对象贴上标签。
8.2 标识、相等性和别名
每个对象(object)都有标识(identity)、类型(type)和值(value)。
- 标识(identity):对象一旦创建,标识就不会改变。对象的标识具有唯一性,并且在对象的生命周期中不会改变。可以把标识理解为对象在内存中的地址。CPython 中,可以用 id() 返回对象的内存地址,用 is 比较两个对象的标识,判断它们是否是同一个对象。
- 类型:可以用 type() 查看对象的类型。
- 值。
别名(alias):一个对象可以有多个标签,每个标签就是一个别名。
is
和
==
比较:
is
:比较对象的标识。
==
:比较对象的值。通常我们比较关注值,而不是标识,所以在 Python 代码中
==
出现的频率比
is
高。
is 运算符比
==
速度快,因为它不能重载,所以 Python 不用寻找并调用特殊方法,而是直接比较两个整数 ID。而
a == b
是语法糖,等同于
a.__eq__(b)
,相等性测试可能涉及大量处理工作。
注意:在变量和单例值之间比较时,应该使用 is。目前,最常使用 is 检查变量绑定的值是不是 None。下面是推荐的写法:
x is None
# 否定的正确写法
x is not None
# 比较 is 和 ==
a = [1, 2, 3]
b = a # a, b 都是 [1, 2, 3] 对象的别名
c = [1, 2, 3] # 注意,这里新创建了一个 [1, 2, 3]列表对象
print(f'id(a)={id(a)}, id(b)={id(b)}, id(c)={id(c)}')
print(f'a is b: {a is b}; a == b: {a == b}')
print(f'a is c: {a is c}; a == c: {a == c}')
id(a)=2236148131656, id(b)=2236148131656, id(c)=2236156692488
a is b: True; a == b: True
a is c: False; a == c: True
重新理解元组的不可变:
元组和 Python 大多数集合类型(列表、字典、集合等)一样,都是保存的对象的引用(reference)。如果引用的对象是可变的,即便元组本身不可变,引用的对象依然可变。换句话说就是,元组的不可变性实际上是指元组数据结构的物理内容(即保存的引用)不可变,与引用的对象无关。
看下面的例子:
t1 = (1, 2, [30, 40])
t2 = (1, 2, [30, 40])
print(f'id(t1)={id(t1)}; id(t2)={id(t2)}, t1 is t2: {t1 is t2}; t1 == t2: {t1 == t2}')
print(id(t1[-1]))
t1[-1].append(99)
print(t1)
print(id(t1[-1]))
print(t1 == t2) # t1 和 t2 值不同了
id(t1)=2236146528568; id(t2)=2236148887864, t1 is t2: False; t1 == t2: True
2236150622728
(1, 2, [30, 40, 99])
2236150622728
False
上述例子说明,元组的值会随着引用的可变对象的变化而变。元组中不可变的是元素的标识。所以有些元组是不可散列的。
8.3 默认浅拷贝
Python 中使用构造方法和切片默认做浅拷贝(shallow copy),即复制了最外层容器,副本中的元素是源容器中元素的引用。如果所有元素都是不可变的,这么做没有问题,还能节省内存。但是如果有可变元素,可能就会导致意想不到的问题。
深拷贝(deep copy):即副本不共享内部对象的引用。
copy 模块中的 deepcopy 和 copy 函数能够为任意对象做深拷贝或者浅拷贝。
关于深浅拷贝,看个例子:
# 校车乘客在途中上车和下车
import copy
class Bus(object):
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', 'Claire', 'David'])
bus2 = copy.copy(bus1) # 浅拷贝
bus3 = copy.deepcopy(bus1) # 深拷贝
print(f' id(bus1)={id(bus1)} \n id(bus2)={id(bus2)} \n id(bus3)={id(bus3)}') # 深浅拷贝都和原对象的 ID 不同
bus1.drop('Bill') # bus2 也会受影响
print(f' bus1.passengers={bus1.passengers} \n bus2.passengers={bus2.passengers} \n bus3.passengers={bus3.passengers}')
# bus1 和 bus2 共用同一个passengers 列表对象
print(f''' id(bus1.passengers)={id(bus1.passengers)} \n id(bus2.passengers)={id(bus2.passengers)} \n id(bus3.passengers)={id(bus3.passengers)}''')
id(bus1)=2236156482888
id(bus2)=2236144713544
id(bus3)=2236156999176
bus1.passengers=['Alice', 'Claire', 'David']
bus2.passengers=['Alice', 'Claire', 'David']
bus3.passengers=['Alice', 'Bill', 'Claire', 'David']
id(bus1.passengers)=2236144711688
id(bus2.passengers)=2236144711688
id(bus3.passengers)=2236156482632
注意:一般来说,深拷贝不是件简单的事。如果有对象有循环引用(cyclic reference),那么这个朴素的算法会进入无限循环。 deepcopy 函数会记住已经复制的对象,因此能够优雅的处理循环引用。
如下面这个例子:
# 循环引用:b 引用 a,然后追加到 a 中;deepcopy 会想办法复制 a
from copy import deepcopy
a = [10, 20]
b = [a, 30]
a.append(b)
print(a)
c = deepcopy(a)
print(c)
[10, 20, [[...], 30]]
[10, 20, [[...], 30]]
深拷贝有时可能太深,对象可能会引用不该赋值的外部资源或单例值。我们可以实现
__copy__()
和
__deepcopy__()
特殊方法,控制 copy 和 deepcopy 的行为。具体参考 copy 模块文档。
Python 唯一支持的参数传递模式是共享传参(call by sharing)。Java 中引用类型是传引用,基本类型是按值传参。
共享传参值函数的各个形参获得实参中各个引用的副本。也就是说,函数内部的形参是实参的别名。
这个方案的结果是:函数可能会修改作为参数传入的可变对象,但是无法修改这些对象的标识(即不能把一个对象替换成另一个对象)。下面这个例子展示了把数字、列表、元组传入函数,实际传入的实参会以不同的方式受到影响:
# 函数可能会修改接收到的人和可变对象
def f(a, b):
a += b
return a
x, y = 1, 2
print(f(x, y))
print(x, y) # 数字 x 不变
a, b = [1, 2], [3, 4]
print(f(a, b))
print(a, b) # 列表 a 变了
t, u = (10, 20), (30, 40)
print(f(t, u))
print(t, u) # 元组 t 不变
3
1 2
[1, 2, 3, 4]
[1, 2, 3, 4] [3, 4]
(10, 20, 30, 40)
(10, 20) (30, 40)
不要使用可变类型作为参数的默认值。
以下例子说明可变默认值的危险:
# 一个简单的类,说明可变默认值的危险
class HauntedBus(object):
"""备受幽灵乘客折磨的校车"""
def __init__(self, passengers=[]): # 没有传入passengers参数,使用默认绑定的列表对象,一开始是空列表
self.passengers = passengers
def pick(self, name):
self.passengers.append(name)
def drop(self, name):
self.passengers.remove(name)
bus1 = HauntedBus(['Alice', 'Bill'])
print(bus1.passengers)
bus1.pick('Charlie')
bus1.drop('Alice')
print(bus1.passengers) # 到这里都一切正常
bus2 = HauntedBus() # 一开始bus2是空的,因此会把默认的空列表赋值给self.passengers
bus2.pick('Carrie')
print(bus2.passengers)
bus3 = HauntedBus() # bus3一开始也是空的,因此还是赋值默认的列表
print(bus3.passengers) # 但是默认列表不为空!
bus3.pick('Dave')
print(bus2.passengers) # 登上bus3 的Dave也出现在了bus2
print(bus2.passengers is bus3.passengers) # bus2.passengers 和 bus3.passengers 指向同一个列表
print(bus1.passengers) # 但是bus1.passengers是不同的列表
['Alice', 'Bill']
['Bill', 'Charlie']
['Carrie']
['Carrie']
['Carrie', 'Dave']
True
['Bill', 'Charlie']
这里的问题在于,没有指定初始乘客的 HauntedBus 实例会共享同一个乘客列表。这种问题很难发现。出现这个问题的根源是,默认值在定义函数时计算(通常在加载模块时),因此默认值变成了函数对象的属性。因此,如果默认值是可变对象,而且修改了它的值,那么后续的函数调用都会受到影响。
我们可以审查
HauntedBus.__init__
对象,看看它的
__defaults__
属性有哪些幽灵学生。
print(dir(HauntedBus.__init__))
print(HauntedBus.__init__.__defaults__)
['__annotations__', '__call__', '__class__', '__closure__', '__code__', '__defaults__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__get__', '__getattribute__', '__globals__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__kwdefaults__', '__le__', '__lt__', '__module__', '__name__', '__ne__', '__new__', '__qualname__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__']
(['Carrie', 'Dave'],)
最后,可以验证 bus2.passengers 是一个别名,它绑定到
HauntedBus.HauntedBus.__init__.__defaults__
属性的第一个元素上。
True
因此通常使用 None 作为接收可变值参数的默认值。因此我们需要在
__init__
方法做检查,如果 passengers 是 None,就把一个新的空列表赋值给
self.passengers
,如果不是,正确的实现是
把passengers
的副本赋值给
self.passengers
。
下面这个 TwilightBus 实例与客户共享乘客列表,这会产生意外的结果,如下:
# 一个简单的类,说明接收可变参数的风险
class TwilightBus(object):
"""让乘客销声匿迹的校车"""
def __init__(self, passengers=None):
if passengers is None:
self.passengers = [] # 这里谨慎处理,当passengers为None时,创建一个新的空列表
else:
self.passengers = passengers # self.passengers 变成了passengers的别名,即实参的别名
def pick(self, name):
self.passengers.append(name) # 会修改传入的 passengers 列表
def drop(self, name):
self.passengers.remove(name) # 会修改传入的 passengers 列表
basketball_team = ['Sue', 'Tina', 'Maya', 'Diana', 'Pat']
bus = TwilightBus(basketball_team)
bus.drop('Tina')
bus.drop('Pat')
print(basketball_team) # basketball_team 被改变了
['Sue', 'Maya', 'Diana']
这里的 TwilightBus 违反了设计接口的最佳实践,即“最少惊讶原则”。学生下车后,她的名字就从篮球队的名单消失了,这不是我们想要的。
这里的问题是,校车为传给构造方法的列表创建了别名。
正确做法是,校车自己维护乘客列表。因此我们只需要把参数值的副本赋值给 self.passengers 就可以了。
def __init__(self, passengers=None):
if passengers is None:
self.passengers = []
else:
self.passengers = list(passengers) # 创建passengers列表的副本,如果不是列表,就把它转换成列表
这种处理方式更加灵活,passengers 可以是任何可迭代对象。
建议:除非这个方法确实想修改通过参数传入的对象,否则在类中直接把参数赋值给实例变量之前一定要三思,因为这样会为参数对象创建别名。如果不确定,那就创建副本。减少客户的麻烦。
8.5 del 和垃圾回收
del 语句时删除名称,而不是对象。del 命令可能会导致对象被当做垃圾回收,但是仅当删除的变量保存的是对象最后一个引用,或者无法得到对象时。
重新绑定也可能会导致对象的引用数量归零,导致对象被销毁。
在 CPython 中垃圾回收使用的主要算法是引用计数。每个对象都会统计有多少个引用指向自己。当引用计数归 0 时,对象立即被销毁:CPython 会在对象上调用
__del__
方法(如果定义了),然后释放分配给对象的内存。CPython 2.0 增加了分代垃圾回收算法,用于检测引用循环中涉及的对象组——如果一组对象之间全是相互引用,即使再出色的引用方式也会导致组中的对象不可获取。
8.6 弱引用
弱引用(weak references):弱引用不会增加对象的引用数量。弱引用的目标对象称为所指对象(referent)。所以弱引用不会妨碍所指对象被当做垃圾回收。
弱引用在缓存应用中非常有用。因为我们不想仅因为被缓存引用着而始终保存缓存对象。
弱引用是可调用的对象,返回的是被引用的对象;如果所指对象不存在了,返回 None。
弱引用的局限:不是每个 Python 对象都能作为弱引用的目标。基本的 list 和 dict 实例不能作为所指对象,但它们的子类可以轻松解决这个问题。
set 实例和用户定义的类型可以作为弱引用的目标。但是 int 和 tuple 实例不能作为弱引用的目标,甚至它们的子类也不行。
巨人的肩膀
- 《Fluent Python》
- 《流畅的 Python》
后记:
我从本硕药学零基础转行计算机,自学路上,走过很多弯路,也庆幸自己喜欢记笔记,把知识点进行总结,帮助自己成功实现转行。
2020下半年进入职场,深感自己的不足,所以2021年给自己定了个计划,每日学一技,日积月累,厚积薄发。
如果你想和我一起交流学习,欢迎大家关注我的微信公众号
每日学一技
,扫描下方二维码或者搜索
每日学一技
关注。
这个公众号主要是分享和记录自己每日的技术学习,不定期整理子类分享,主要涉及 C – > Python – > Java,计算机基础知识,机器学习,职场技能等,简单说就是一句话,成长的见证!