一文解密Python的弱引用

 更新时间:2023年09月14日 14:26:45   作者:古明地觉的编程教室  
弱引用在很多语言中都存在,最常用来解决循环引用问题,本文就来和大家一起探索一下python中的弱引用,感兴趣的小伙伴可以跟随小编一起学习一下

本次我们来聊一聊对象的弱引用,但在此之前,我们首先需要了解 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("触发完析构函数, 这里会打印")
"""
无事发生, 一切正常
当实例对象被回收时, 会触发我的执行······
触发完析构函数, 这里会打印
"""

所以对象是否被回收的唯一准则就是它的引用计数是否为 0,只要为 0 就被回收。然而引用计数虽然简单、也比较直观,但是它无法解决循环引用的问题。

循环引用

什么是循环引用呢?

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 的垃圾回收机制就是为了解决循环引用的,从根节点出发,采用三色标记模型对 Python 对象进行标记清除,找出可达与不可达的对象。凡是不可达的对象,说明已经没有外部变量引用它们了。

就比如代码中的两个对象已经没有外部引用了,因为 a 和 b 两个变量都已被删除,但由于这两个老铁还在彼此抱团取暖,导致引用计数机制没有识别出来。而当垃圾回收的时候,垃圾回收器会找到发生循环引用的对象,并手动将它们的引用计数减一。所以上面在 gc.collect() 之后,它们的引用计数就从 1 变成了 0,因此就被回收了。

但需要注意的是,对象是否被回收取决于它的引用计数是否为 0,而垃圾回收只是负责修正引用计数,让引用计数机制能够正常工作。

而且对于那些有能力产生循环引用的对象,解释器都会将它们挂在单独的链表上,也就是所谓的零代链表、一代链表、二代链表。垃圾回收器会负责定期检测这些链表,看是否有产生循环引用的对象,因此链表中的对象越多,那么检测一次的代价就越大。

如果你能确保某个对象一定不会发生循环引用,那么也可以不让它参与垃圾回收,当然只有在写 C 扩展的时候才能这么做。

强引用与弱引用

Python 变量直接引用对象是强引用,会增加对象的引用计数;而所谓弱引用,就是变量在引用一个对象的时候,不会增加这个对象的引用计数。

而 Python 也是支持弱引用的,对象的所有弱引用都会保存在该对象的一个字段里面。举个例子:

对象本质上是一个结构体实例,结构体内部会有一个字段专门负责维护该对象的弱引用,从注释可以看出这个字段就是一个列表。

如何实现弱引用

如果想实现弱引用,需要使用 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
"""
# 回调函数会接收一个参数, 也就是死亡之后的弱引用; 

前面我们说了,对象的弱引用会由单独的字段保存,也就是保存在列表中。当对象被删除时,会遍历这个列表,依次执行弱引用绑定的回调函数。

创建弱引用除了通过 weakref.ref 之外,还可以使用代理。有时候使用代理比使用弱引用更方便,使用代理可以像使用原对象一样,而且不要求在访问对象之前先调用代理。这说明,可以将代理传递到一个库,而这个库并不知道它接收的是一个代理而不是一个真正的对象。

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 一样,也可以接收一个额外的回调函数。

字典的弱引用

weakref 专门提供了 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)  
"""
d: <WeakKeyDictionary at 0x7f8a581a0d30>
"""
# 此时 a 指向的对象的引用计数不会增加
d[a] = "xxx"
print("before del a:", list(d.items()))
"""
before del a: [(<__main__.A object at 0x7f8a581a0d60>, 'xxx')]
"""
# 删除 a, 对象会被回收
del a
"""
__del__
"""
print("after del a:", list(d.items()))
"""
after del a: []
"""

key 为弱引用的字典不会增加 key 的引用计数,并且当对象被回收时,会自动从字典中消失。

除了可以创建 key 为弱引用的字典,还可以创建 value 为弱引用的字典。

import weakref
class A:
    def __del__(self):
        print("__del__")
a = A()
d = weakref.WeakValueDictionary()
# value 为弱引用
d["xxx"] = a
print("before del a:", list(d.items()))
"""
before del a: [('xxx', <__main__.A object at 0x7f89580a7d60>)]
"""
# 删除 a, 对象会被回收
del a
"""
__del__
"""
print("after del a:", list(d.items()))
"""
after del a: []
"""

整个过程是一样的,当对象被回收时,键值对会自动从字典中消失。

除了字典,我们还可以创建弱引用集合,将对象放入集合中不会增加对象的引用计数。

import weakref
class A:
    def __del__(self):
        print("__del__")
a = A()
s = weakref.WeakSet()
s.add(a)
print(len(s))
del a
print(len(s))
"""
1
__del__
0
"""

让自定义类支持弱引用

每一个自定义类的实例,都会有自己的属性字典 __dict__。而我们知道字典使用的是哈希表,这是一个使用空间换时间的数据结构,因此如果想省内存的话,那么我们通常的做法是指定 __slots__ 属性,这样实例就不会再有属性字典 __dict__ 了。

import weakref
class A:
    __slots__ = ("name", "age")
    def __init__(self):
        self.name = "古明地觉"
        self.age = 17
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__ 里面加一个属性就好了。

import weakref
class A:
    # 多指定一个__weakref__, 表示支持弱引用
    __slots__ = ("name", "age", "__weakref__")
    def __init__(self):
        self.name = "古明地觉"
        self.age = 17
a = A()
weakref.ref(a)
weakref.proxy(a)
d = weakref.WeakSet()
d.add(a)

没有报错,可以看到此时就支持弱引用了。

C 的角度来看强引用和弱引用

首先 C 源代码变成可执行文件会经历如下几个步骤:

  • 预处理:进行头文件展开,宏替换等等;
  • 编译:通过词法分析和语法分析,将预处理之后的文件翻译成汇编代码,内存分配也是在此过程完成的;
  • 汇编:将汇编代码翻译成目标文件,目标文件中存放的也就是和源文件等效的机器代码;
  • 链接:程序中会引入一些外部库,需要将目标文件中的符号与外部库的符号链接起来,最终形成一个可执行文件;

而在链接这一步,这些符号必须能够被正确决议,如果没有找到某些符号的定义,连接器就会报错,这种就是强引用。而对于弱引用,如果该符号有定义,则链接器将该符号的引用决议,如果该符号未被定义,则链接器也不会报错。

链接器处理强引用和弱引用的过程几乎一样,只是对于未定义的弱引用,链接器不认为它是一个错误的值。一般对于未定义的弱引用,链接器默认其为 0,或者是一个其它的特殊的值,以便于程序代码能够识别。

弱引用确实是一个比较复杂的地方,尽管 weakref 这个模块用起来比较简单,但是在解释器层面,弱引用还是不简单的。

到此这篇关于一文解密Python的弱引用的文章就介绍到这了,更多相关Python弱引用内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

相关文章

  • YOLOv5部署到web端详细过程(flask+js简单易懂)

    YOLOv5部署到web端详细过程(flask+js简单易懂)

    YOLOv5是一个目标检测模型,Flask是一个Python的Web框架,下面这篇文章主要给大家介绍了关于YOLOv5部署到web端(flask+js简单易懂)的相关资料,文中通过实例代码介绍的非常详细,需要的朋友可以参考下
    2023-04-04
  • python 牛顿法实现逻辑回归(Logistic Regression)

    python 牛顿法实现逻辑回归(Logistic Regression)

    这篇文章主要介绍了python 牛顿法实现逻辑回归(Logistic Regression),帮助大家更好的进行机器学习,感兴趣的朋友可以了解下
    2020-10-10
  • python 分离文件名和路径以及分离文件名和后缀的方法

    python 分离文件名和路径以及分离文件名和后缀的方法

    今天小编就为大家分享一篇python 分离文件名和路径以及分离文件名和后缀的方法,具有很好的参考价值,希望对大家有所帮助。一起跟随小编过来看看吧
    2018-10-10
  • Python使用Pickle库实现读写序列操作示例

    Python使用Pickle库实现读写序列操作示例

    这篇文章主要介绍了Python使用Pickle库实现读写序列操作,结合实例形式分析了pickle模块的功能、常用函数以及序列化与反序列化相关操作技巧,需要的朋友可以参考下
    2018-06-06
  • Python实现微信机器人的方法

    Python实现微信机器人的方法

    这篇文章主要介绍了Python实现微信机器人的方法,非常不错,具有一定的参考借鉴价值,需要的朋友可以参考下
    2019-09-09
  • Python 函数参数11个案例分享

    Python 函数参数11个案例分享

    大家好,今天给大家分享一下明哥整理的一篇 Python 参数的内容,内容非常的干,全文通过案例的形式来理解知识点,自认为比网上 80% 的文章讲的都要明白,如果你是入门不久的 python 新手,相信本篇文章应该对你会有不小的帮助,需要的朋友可以参考下
    2023-02-02
  • python3实现表白神器

    python3实现表白神器

    这篇文章主要为大家详细介绍了python3实现表白神器,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2019-04-04
  • 浅析python中的del用法

    浅析python中的del用法

    python中的del用法比较特殊,新手学习往往产生误解,弄清del的用法,可以帮助深入理解python的内存方面的问题。这篇文章主要介绍了python中的del用法,需要的朋友可以参考下
    2020-09-09
  • 基于Python实现一个自动关机程序并打包成exe文件

    基于Python实现一个自动关机程序并打包成exe文件

    这篇文章主要介绍了通过Python创建一个可以自动关机的小程序,并打包成exe文件。文中的示例代码讲解详细,对我们学习Python有一定的帮助,感兴趣的同学可以了解一下
    2021-12-12
  • Python爬虫通过替换http request header来欺骗浏览器实现登录功能

    Python爬虫通过替换http request header来欺骗浏览器实现登录功能

    这篇文章主要介绍了Python爬虫通过替换http request header来欺骗浏览器实现登录功能,需要的朋友可以参考下
    2018-01-01

最新评论