由一个简单的引用计数问题引发的一些思考

楔子

我曾经写过一个系列,叫源码探秘 CPython,专门讲解虚拟机的。最近一个读者阅读完第 9 篇文章之后,在微信上问了我这样一个问题:

图片

他好奇为什么一个打印的是 2,另一个打印的是 4。

我们先来看交互式,首先变量 e 引用了 2.71,然后变量 e 又作为函数 getrefcount 的参数,所以打印结果是 2。显然这没有问题,但为什么放在 PyCharm 里面执行就变成了 4,还有两个引用在什么地方呢?下面就来分析一下。

py 文件也是需要编译的

Python 虽然是解释型语言,但也有一个编译的过程,会将 py 文件编译成 PyCodeObject 对象(pyc 文件里面存储的就是它)。而在编译的过程中,会做很多事情,比如:

  • 语法检测,如果代码不符合语法规则,抛出 SyntaxError;
  • 确定函数类型,是普通函数,还是生成器函数,亦或是协程函数,提前做好标记;
  • 收集代码中出现的常量,包括整数、浮点数、字符串、元组等等;
  • ………

对于 Python 而言,每一个独立的代码块(比如函数、方法、类)都会对应一个独立的编译单元,编译之后会得到一个独立的 PyCodeObject。然后它内部有一个常量池,里面包含了该代码块中出现的所有常量,并且常量池里面的常量只会保存一份。

def foo():
    e1 = 2.71
    e2 = 2.71
    return id(e1) == id(e2)

# foo.__code__ 便是函数对应的 PyCodeObject
# 调用 co_consts 属性即可拿到常量池
print(foo.__code__.co_consts)
"""
(None, 2.71)
"""
# 我们还没有执行此函数,常量就已经在里面了
# 因为这在编译阶段就已经确定了

# 同一个常量只会出现 1 次
print(foo())  # True

我们说函数、类需要编译,但除了它们,模块也是需要编译的。

import sys 

e = 2.71
print(sys.getrefcount(e))

这部分全局区域,也要经过编译,而且事实上 py 文件在编译的时候就是从模块开始的。所以它内部也有一个常量池,包含了 2.71 这个常量,我们验证一下:

code = """
import sys

e = 2.71
print(sys.getrefcount(e))
"""
co = compile(code, "", "exec")
print(2.71 in co.co_consts)  # True

好了,到目前为止我们已经找到 3 个引用了,还差最后一个。

而这最后一个引用就隐藏的比较深了,首先源代码在编译阶段要有一个分词的过程:

图片

而在分词之后,会将代码中的符号、常量都保存在一个列表中,此时又会多一个引用。

所以这 4 个引用,就都被我们找到了,下面再通过代码验证一下:

import gc

e = 2.71

# gc.get_referents(obj):获取所有被 obj 引用的对象
# gc.get_referrers(obj):获取所有引用了 obj 的对象
print(gc.get_referrers(e))
"""
[
    ['gc', 'e', 2.71, 'print', 'gc', 'get_referrers', 'e'], 
    (0, None, 2.71), 
    {'__name__': '__main__', ... , 'e': 2.71}
]
"""
# 第一个元素:分词之后,将所有符号、常量都存储起来的列表
# 第二个元素:模块对应的 PyCodeObject 里面的常量池
# 第三个元素:全局名字空间,因为全局变量是通过字典存储的
#           变量名和变量值会作为键值对,存储在全局名字空间中
#           e = 2.71 等价于 globals()["e"] = 2.71
#           print(e) 等价于 print(globals()["e"])

上面三个对象都保存了对 2.71 的引用,再加上调用 sys.getrefcount(e) 也会增加引用计数,所以打印的结果是 4。

编译单元的销毁

那么问题来了,为啥在交互式环境里面打印的是 2 呢?首先,如果是在 PyCharm 里面右键单击执行的话,那么 py 文件会作为一个整体编译。然后查看引用计数的时候,它所在的编译单元并没有被销毁,因为它们在同一个编译单元当中。

但如果是交互式环境,那么每一行独立的可执行语句都会对应一个独立的编译单元。

图片

e = 2.71 是一个独立的可执行语句,在交互式环境下执行完之后,它所在的编译单元就被销毁了。或者说对应的 PyCodeObject 就被销毁了,常量池啥的都没有了,只有变量 e(或者说全局名字空间)和函数 sys.getrefcount 保存了对 2.71 的引用。

但如果是下面这种情况:

图片

如果将赋值语句和查看引用计数写在同一行,那么结果也是 4。相信原因你一定清楚,因为第一次查看引用计数的时候,e = 2.71 所在的编译单元还没有被销毁,常量池和分词列表保存了对 2.71 的引用。

而第二次查看引用计数的时候,e = 2.71 所在的编译单元已经被销毁,所以结果是 2。

为了更彻底地弄懂它,我们再举个例子:

import sys

e = 2.71
tpl = (1, 2, 3)
lst = [1, 2, 3]
print(sys.getrefcount(e))  # 4
print(sys.getrefcount(tpl))  # 4
print(sys.getrefcount(lst))  # 2

tpl2 = (1, [2], 3)
print(sys.getrefcount(tpl2))  # 2

结果有点出乎意料,lst 指向的对象的引用计数居然是 2,不是 4。原因很简单,lst 指向的是列表,它不属于常量,而是需要在运行时动态构建,所以它不会在编译时被作为常量收集起来。

然后是元组,如果元组里面的元素都是常量,那么该元组也是常量,会在编译时期被解释器静态收集到常量池中;如果元组里面出现了不是常量的元素,那么它同样需要在运行时动态构建。所以 tpl 指向的元组的引用计数是 4,tpl2 指向的元组的引用计数是 2。

小结

以上就是本文的内容,通过一个简单的引用计数问题,引出解释器相关的一些知识。如果你对解释器感兴趣的话,可以阅读我的源码探秘 CPython 系列。

2022-08-05 09:00 发表于北京

阅读原文

简介:醉心于 Python 的东方厨,致力于提供最好的 Python 文章,点个关注吧(#^.^#)公众号:古明地觉的编程教室
(0)
打赏 喜欢就点个赞支持下吧 喜欢就点个赞支持下吧

声明:本文来自“古明地觉的编程教室”,分享链接:https://www.zyxiao.com/p/309926    侵权投诉

网站客服
网站客服
内容投稿 侵权处理
分享本页
返回顶部