weakref:对象的弱引用
楔子
这里我们介绍一下 Python 中的一个标准库:weakref,它是和对象的弱引用相关的,但在介绍弱引用之前,我们首先需要了解 Python 中的引用计数。
引用与弱引用
我们知道 Python 中的变量本质上是一个 PyObject * 泛型指针,它是一个和对象关联的名字,我们通过这个名字可以找到其引用的对象。比如 a = 666,可以理解为 a 引用了 666 这个对象,而一个对象被多少个变量引用,那么该对象的引用计数就是多少。同理如果 b = a,那么代表的是 b 也引用了 a 所引用的对象。因此 b = a 之后,两者没有什么直接的关系,只是这两个变量都引用了同一个对象罢了,而此时 666 这个整数对象的引用计数就是 2。
当我们 del a 之后,并不代表要删除 666 这个对象,只是将 a 这个变量给删除了,让 a 不再引用 666 这个对象,但是 b 还在引用它;如果 del b 之后,那么 b 也不再引用 666 这个对象了,所以此时它的引用计数变成了 0,而一旦一个对象的引用计数变成了 0,那么它就会被 Python 解释器给回收掉。
class A:
def __init__(self, obj):
self.obj = obj
def __del__(self):
print("当实例对象被回收时, 会触发我的执行······")
# 显然我们创建了一个对象 A(123), 然后让变量 a 指向(引用)它
# 然后 b = a, 让 b 也指向 a 指向的对象
a = A(123)
b = a
# 此时对象的引用计数为 2, 这个是我们将 a 删除掉
del a
print("无事发生, 一切正常")
# 如果再 del b, 那么 A(123) 的引用计数就变成了 0, 那么它就该被回收了
# 一旦被回收, 就会触发析构函数 __del__
del b
print("触发完析构函数, 这里会打印")
"""
无事发生, 一切正常
当实例对象被回收时, 会触发我的执行······
触发完析构函数, 这里会打印
"""
逻辑很简单,但是问题来了。虽然引用计数很简单、也比较直观,但是它无法解决循环引用的问题。
class A:
def __init__(self, obj):
self.obj = obj
def __del__(self):
print("当实例对象被回收时, 会触发我的执行······")
# 创建两个对象, 分别让 a 和 b 取引用
a = A(123)
b = A(123)
# 然后, 重点来了
a.obj = b
b.obj = a
# 此时 a 引用的实例对象被 b.obj 引用了, b 引用的实例对象被 a.obj 引用了
# 这个时候, 两个对象的引用计数都为 2, 然后我们 del a, b
# 这个时候能把对象删除掉吗? 显然是不能的, 因为它们的引用计数都变成了 1, 不是 0, 只要不为 0, 就不会被回收
del a, b
以上这种情况被称之为循环引用,而这也正是引用计数所无法解决的痛点,所以 Python 中的 gc 就出现了,它的目的正是为了解决循环引用而出现的。上面这段程序其实执行之后,会发现两个对象还是会被回收的,因为程序一旦结束,Python会释放所有对象。当然即便程序不结束,我们在 del a, b 之后,对象也是会被删掉的,只不过需要等到 gc 发动的时候了,因为 Python 的 gc 可以找出那些发生循环引用的对象,并减少它们的引用计数。
import gc
class A:
def __init__(self, obj):
self.obj = obj
def __del__(self):
print("当实例对象被回收时, 会触发我的执行······")
a = A(123)
b = A(123)
a.obj = b
b.obj = a
del a, b
print("析构函数没有被执行, 因为引用计数不为零")
# gc 触发是需要条件的, 但是 Python 支持我们手动引发 gc
# gc 发动之后会找出发生循环引用的对象, 由于这里的两个对象只有互相引用, 并没有外部的变量引用, 所以它们都是要被回收的
gc.collect()
print("两个对象都被回收了")
"""
析构函数没有被执行, 因为引用计数不为零
当实例对象被回收时, 会触发我的执行······
当实例对象被回收时, 会触发我的执行······
两个对象都被回收了
"""
所以 Python 的垃圾回收机制就是为了解决循环引用的,上面的两个对象已经没有外部引用了,因为 a 和 b 两个变量都被删除了。但由于这两个老铁还在彼此抱团取暖,导致引用计数机制没有识别出来,而当垃圾回收的时候,垃圾回收器会找到发生循环引用的对象,并手动将它们的引用计数减一。所以上面在 gc.collect() 之后,它们的引用计数就从 1 变成了 0,因此就被回收了。
但 gc 是怎么找到这些发生了循环引用的对象呢?答案是通过三色标记模型的方式,具体可以去看我的剖析Python解释器系列。当 gc 发动之后,对象会被分为 可达 和 不可达,凡是不可达的对象都会被回收。
下面关键来了,如果 a 强引用 b,但 b 对 a 是弱引用呢?
Python 中变量直接引用对象是强引用,会增加对象的引用计数;而所谓弱引用,就是一个变量在引用一个对象的时候,不会增加这个对象的引用计数。
a 强引用于 b,b 弱引用于 a,此时 a 指向的对象的引用计数为 1,b 指向的对象的引用计数为 2。当 del a 的时候,显然 a 指向的对象就会被回收,因为 b 对 a 指向的对象是一个弱引用,同时 b 指向的对象的引用计数减一;再 del b 的时候,显然 b 指向的对象的引用计数为 0,此时也会被回收。
如何实现弱引用
下面我们来看看如何 weakref 模块实现弱引用,一般来说这个模块用的比较少,因为弱引用本身用的就不多。但是弱引用在很多场景中,是可以发挥出很神奇的功能的。
import weakref
class RefObject:
def __del__(self):
print("del executed")
obj = RefObject()
# 对象的弱引用要通过 weakref.ref 类来管理, 要获取原对象, 可以调用引用对象
# 创建弱引用
r = weakref.ref(obj)
print(obj) # <__main__.RefObject object at 0x000001B7C573A5E0>
# 显示关联 RefObject
print(r) # <weakref at 0x000001B7DCAE19A0; to 'RefObject' at 0x000001B7C573A5E0>
# 对引用进行调用的话, 即可得到原对象
print(r() is obj) # True
# 删除 obj 会执行析构函数
del obj # del executed
# 之前说过 r() 等价于 obj, 但是obj被删除了, 所以返回None
# 从这里返回 None 也能看出这个弱引用是不会增加引用计数的
print("r():", r()) # r(): None
# 打印弱引用, 告诉我们状态已经变成了 dead
print(r) # <weakref at 0x000001B7DCAE19A0; dead>
通过弱引用我们可以实现缓存的效果,当它弱引用的对象存在时,则对象可用;当对象不存在时,则返回 None,程序不会因此而报错。这个缓存本质上是一样的,也是一个有则用、无则重新获取的技术。
weak.ref 的构造函数还可以接受一个可选的回调函数,删除引用所指向的对象时就会调用这个回调函数。
import weakref
class RefObject:
def __del__(self):
print("del executed")
obj = RefObject()
r = weakref.ref(obj, lambda ref: print("引用被删除了", ref))
del obj
print("r():", r())
"""
del executed
引用被删除了 <weakref at 0x0000021A69681900; dead>
r(): None
"""
# 回调函数会接收一个参数, 也就是死亡之后的弱引用;
最终化对象
清理弱引用时要对资源完成更健壮的管理,可以使用 finalize 将回调与对象关联。finalize 实例会一直保留(直到所关联的对象被删除),即使没有保留最终化对象的引用。
import weakref
class RefObject:
def __del__(self):
print("del executed")
def on_finalize(*args):
print(f"on_finalize", args)
obj = RefObject()
weakref.finalize(obj, on_finalize, "arg1", "arg2", "arg3")
del obj
"""
del executed
on_finalize ('arg1', 'arg2', 'arg3')
"""
finalize 的参数包括要跟踪的对象,对象被垃圾回收时要调用的 callback,以及传递给 callback 的参数(可以是位置参数,也可以是关键字参数)。
代理
有时候使用代理比使用弱引用更方便,使用代理可以像使用原对象一样,而且不要求在访问对象之前先调用代理。这说明,可以将代理传递到一个库,而这个库并不知道它接收的是一个代理而不是一个真正的对象。
import weakref
class RefObject:
def __init__(self, name):
self.name = name
def __del__(self):
print("del executed")
obj = RefObject("my obj")
r = weakref.ref(obj)
p = weakref.proxy(obj)
# 可以看到引用加上()才相当于原来的对象
# 而代理不需要,直接和原来的对象保持一致
print(obj.name) # my obj
print(r().name) # my obj
print(p.name) # my obj
# 但是注意: 弱引用再调用之后就是原对象, 而代理不是
print(r() is obj) # True
print(p is obj) # False
del obj # del executed
try:
# 删除对象之后, 再调用引用, 打印为None
print(r()) # None
# 如果是使用代理, 则会报错
print(p)
except Exception as e:
print(e) # weakly-referenced object no longer exists
weakref.proxy 和 weakref.ref 一样,也可以接收一个额外的回调函数。
此外我们还可以查看一个对象被弱引用的次数,以及被弱引用的对象列表。
import weakref
class RefObject:
def __del__(self):
print("del executed")
obj = RefObject()
r1 = weakref.ref(obj)
r2 = weakref.ref(obj)
print(weakref.getweakrefcount(obj)) # 1
# 可能有人好奇为什么是 1, 那是因为 r1 和 r2 指向的是同一个对象
print(r1 is r2) # True
p1 = weakref.proxy(obj)
p2 = weakref.proxy(obj)
# 此时变成了 2, 被代理的话也可以看成是被弱引用了
print(weakref.getweakrefcount(obj)) # 2
print(p1 is p2) # True
# 查看 obj 被弱引用的对象
print(weakref.getweakrefs(obj))
# [<weakref at 0x000001EFD79A1900; to 'RefObject' at 0x000001EFC060A5E0>,
# <weakproxy at 0x000001EFD79A1950 to RefObject at 0x000001EFC060A5E0>]
"""
del executed
"""
字典的弱引用
我们可以创建一个 key 为弱引用或者 value 为弱引用的字典。
class A:
def __del__(self):
print("__del__")
a = A()
# 创建一个普通字典
d = {}
# 由于 a 作为了字典的 key, 那么 a 指向的对象引用计数会加 1, 变成 2
d[a] = "xxx"
# 删除 a, 对对象无影响, 不会触发析构函数
del a
print(d)
"""
{<__main__.A object at 0x000002092669A5E0>: 'xxx'}
__del__
"""
# 最后打印的 __del__ 是程序结束时, 将对象回收时打印的
但如果是对 key 为弱引用的字典的话,就不一样了。
import weakref
class A:
def __del__(self):
print("__del__")
a = A()
# 创建一个弱引用字典, 它的 api 和普通字典一样
d = weakref.WeakKeyDictionary()
print("d:", d)
# 此时 a 指向的对象的引用计数不会增加
d[a] = "xxx"
print("before del a:", list(d.items()))
# 删除 a, 对象会被回收
del a
print("after del a:", list(d.items()))
"""
d: <WeakKeyDictionary at 0x2a2dc048700>
before del a: [(<__main__.A object at 0x000002A2DC15A5E0>, 'xxx')]
__del__
after del a: []
"""
# 并且我们看到也没有 a 这个 key 了
注意:这里只是对 key 进行弱引用,但是 value 不会;如果想对 value 进行弱引用的话,我们需要使用 WeakValueDictionary,方法是一样的。当然还有 WeakSet,用法也是类似的,只不过它是一个集合,放入该集合中的元素不会增加引用计数。
自定义类的弱引用
当我们自定义一个类的时候,如果为了省内存,那么会不使用 __dict__属性,因为每一个类或者实例都会有一个自己的属性字典 __dict__。而我们知道字典使用的是哈希表,这是一个使用空间换时间的数据结构,因此如果想省内存的话,那么我们通常的做法是指定 __slots__属性,这样就不会再有 __dict__ 属性了。
不过这样会带来一个问题:
class A:
__slots__ = ("name", "age")
def __init__(self):
self.name = "夏色祭"
self.age = 16
import weakref
a = A()
try:
weakref.ref(a)
except Exception as e:
print(e) # cannot create weak reference to 'A' object
try:
weakref.proxy(a)
except Exception as e:
print(e) # cannot create weak reference to 'A' object
try:
d = weakref.WeakSet()
d.add(a)
except Exception as e:
print(e) # cannot create weak reference to 'A' object
此时我们发现,A 的实例对象没办法被弱引用,因为我们指定了 __slots__。那么要怎么解决呢?很简单,直接在 __slots__ 里面加一个属性就好了。
class A:
# 多指定一个__weakref__, 表示支持弱引用
__slots__ = ("name", "age", "__weakref__")
def __init__(self):
self.name = "夏色祭"
self.age = 16
import weakref
a = A()
weakref.ref(a)
weakref.proxy(a)
d = weakref.WeakSet()
d.add(a)
没有报错,可以看到此时就支持弱引用了。
C 的角度来看强引用和弱引用
首先 C 源代码变成可执行文件会经历如下几个步骤:
1. 预处理: 进行头文件展开, 宏替换等等;
2. 编译: 通过词法分析和语法分析, 将预处理之后的文件翻译成汇编代码, 内存分配也是在此过程完成的;
3. 汇编: 将汇编代码翻译成目标文件, 目标文件中存放的也就是和源文件等效的机器代码;
4. 链接: 程序中会引入一些外部库, 需要将目标文件中的符号与外部库的符号链接起来, 最终形成一个可执行文件;
而链接这一步,这些符号必须要能够被正确决议,如果没有找到某些符号的定义,连接器就会报错,这种就是强引用;而对于弱引用,如果该符号有定义,则链接器将该符号的引用决议,如果该符号未被定义,则链接器也不会报错。
链接器处理强引用和弱引用的过程几乎一样,只是对于未定义的弱引用,链接器不认为它是一个错误的值。一般对于未定义的弱引用,链接器默认其为0,或者是一个其它的特殊的值,以便于程序代码能够识别。
弱引用确实是一个比较复杂的地方,尽管 weakref 这个模块用起来比较简单,但是在解释器层面,弱引用还是不简单的。
如果觉得文章对您有所帮助,可以请囊中羞涩的作者喝杯柠檬水,万分感谢,愿每一个来到这里的人都生活愉快,幸福美满。
微信赞赏
支付宝赞赏