解释器维护的整数缓存是怎么回事?

114 投票
1 回答
18330 浏览
提问于 2025-04-17 17:40

在深入研究Python的源代码后,我发现它维护了一个从 int(-5)int(256)PyInt_Object 数组(在 @src/Objects/intobject.c 中)。

我做了一个小实验来验证这一点:

>>> a = 1
>>> b = 1
>>> a is b
True
>>> a = 257
>>> b = 257
>>> a is b
False

但是如果我把这些代码放在一个py文件里一起运行(或者用分号连接起来),结果就会不同:

>>> a = 257; b = 257; a is b
True

我很好奇为什么它们仍然是同一个对象,所以我进一步研究了语法树和编译器,得出了下面的调用层级:

PyRun_FileExFlags() 
    mod = PyParser_ASTFromFile() 
        node *n = PyParser_ParseFileFlagsEx() //source to cst
            parsetoke() 
                ps = PyParser_New() 
                for (;;)
                    PyTokenizer_Get() 
                    PyParser_AddToken(ps, ...)
        mod = PyAST_FromNode(n, ...)  //cst to ast
    run_mod(mod, ...)
        co = PyAST_Compile(mod, ...) //ast to CFG
            PyFuture_FromAST()
            PySymtable_Build()
            co = compiler_mod()
        PyEval_EvalCode(co, ...)
            PyEval_EvalCodeEx()

然后我在 PyInt_FromLongPyAST_FromNode 的前后添加了一些调试代码,并执行了一个 test.py:

a = 257
b = 257
print "id(a) = %d, id(b) = %d" % (id(a), id(b))

输出结果看起来是这样的:

DEBUG: before PyAST_FromNode
name = a
ival = 257, id = 176046536
name = b
ival = 257, id = 176046752
name = a
name = b
DEBUG: after PyAST_FromNode
run_mod
PyAST_Compile ok
id(a) = 176046536, id(b) = 176046536
Eval ok

这意味着在 cst 转换为 ast 的过程中,创建了两个不同的 PyInt_Object(实际上是在 ast_for_atom() 函数中执行的),但它们后来被合并了。

我发现理解 PyAST_CompilePyEval_EvalCode 中的源代码很困难,所以我来这里寻求帮助,如果有人能给我一点提示,我将非常感激。

1 个回答

136

在Python中,整数在范围 [-5, 256] 之间会被缓存,所以这个范围内的整数通常是 但并不总是 一样的。

当你看到257时,实际上是Python编译器在优化相同的字面量,这些字面量在同一个代码对象中被编译。

在Python的交互式命令行中,每一行都是一个完全不同的语句,分别解析和编译,因此:

>>> a = 257
>>> b = 257
>>> a is b
False

但是如果你把相同的代码放到一个文件里:

$ echo 'a = 257
> b = 257
> print a is b' > testing.py
$ python testing.py
True

每当编译器有机会一起分析字面量时,就会发生这种情况,比如在交互式解释器中定义一个函数时:

>>> def test():
...     a = 257
...     b = 257
...     print a is b
... 
>>> dis.dis(test)
  2           0 LOAD_CONST               1 (257)
              3 STORE_FAST               0 (a)

  3           6 LOAD_CONST               1 (257)
              9 STORE_FAST               1 (b)

  4          12 LOAD_FAST                0 (a)
             15 LOAD_FAST                1 (b)
             18 COMPARE_OP               8 (is)
             21 PRINT_ITEM          
             22 PRINT_NEWLINE       
             23 LOAD_CONST               0 (None)
             26 RETURN_VALUE        
>>> test()
True
>>> test.func_code.co_consts
(None, 257)

注意编译后的代码中只包含一个常量用于 257

总的来说,Python的字节码编译器并不能像静态类型语言那样进行大规模的优化,但它做的事情比你想的要多。其中之一就是分析字面量的使用,避免重复。

需要注意的是,这与缓存无关,因为它也适用于浮点数,而浮点数是没有缓存的:

>>> a = 5.0
>>> b = 5.0
>>> a is b
False
>>> a = 5.0; b = 5.0
>>> a is b
True

对于更复杂的字面量,比如元组,它“就不行了”:

>>> a = (1,2)
>>> b = (1,2)
>>> a is b
False
>>> a = (1,2); b = (1,2)
>>> a is b
False

但是元组内部的字面量是共享的:

>>> a = (257, 258)
>>> b = (257, 258)
>>> a[0] is b[0]
False
>>> a[1] is b[1]
False
>>> a = (257, 258); b = (257, 258)
>>> a[0] is b[0]
True
>>> a[1] is b[1]
True

(注意常量折叠和窥视优化器的行为可能会在修复版本之间改变,所以哪些例子返回 TrueFalse 基本上是任意的,并且将来会改变)。


关于你为什么看到创建了两个 PyInt_Object,我猜测这是为了避免字面量比较。例如,数字 257 可以通过多个字面量表示:

>>> 257
257
>>> 0x101
257
>>> 0b100000001
257
>>> 0o401
257

解析器有两个选择:

  • 在创建整数之前,将字面量转换为某个公共基数,看看这些字面量是否相等,然后创建一个整数对象。
  • 创建整数对象,看看它们是否相等。如果相等,就只保留一个值并将其分配给所有字面量;否则,你已经有了要分配的整数。

可能Python解析器使用第二种方法,这样可以避免重写转换代码,而且更容易扩展(例如,它也适用于浮点数)。


在阅读 Python/ast.c 文件时,解析所有数字的函数是 parsenumber,它调用 PyOS_strtoul 来获取整数值(对于整数),最终调用 PyLong_FromString

    x = (long) PyOS_strtoul((char *)s, (char **)&end, 0);
    if (x < 0 && errno == 0) {
        return PyLong_FromString((char *)s,
                                 (char **)0,
                                 0);
    }

如你所见,解析器并检查是否已经找到具有给定值的整数,这就解释了为什么你看到创建了两个int对象,这也意味着我的猜测是正确的:解析器首先创建常量,然后再优化字节码以使用相同的对象来表示相等的常量。

执行此检查的代码必须在 Python/compile.cPython/peephole.c 中,因为这些文件负责将抽象语法树(AST)转换为字节码。

特别是,compiler_add_o 函数似乎是执行此操作的函数。在 compiler_lambda 中有这样的注释:

/* Make None the first constant, so the lambda can't have a
   docstring. */
if (compiler_add_o(c, c->u->u_consts, Py_None) < 0)
    return 0;

所以看起来 compiler_add_o 被用来为函数/匿名函数等插入常量。compiler_add_o 函数将常量存储到一个 dict 对象中,因此可以推断出相等的常量会落在同一个槽中,最终在字节码中只会有一个常量。

撰写回答