楔子
我曾经写过一个系列,叫源码探秘 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 系列。