`is` 运算符对非缓存整数的行为异常
- 2025-01-09 08:47:00
- admin 原创
- 94
问题描述:
在使用 Python 解释器时,我偶然发现了有关is
运算符的这个冲突情况:
如果求值发生在函数中,则返回True
,如果求值发生在函数外部,则返回False
。
>>> def func():
... a = 1000
... b = 1000
... return a is b
...
>>> a = 1000
>>> b = 1000
>>> a is b, func()
(False, True)
由于运算符对所涉及的对象is
进行评估,这意味着当在函数内部声明时,和指向同一个实例,但相反,当在函数外部声明时,它们指向不同的对象。id()
`ab
int`func
为什么会这样?
注意:我知道身份(is
)和相等(==
)运算之间的区别,如理解 Python 的“is”运算符中所述。此外,我还知道 Python 对范围内的整数执行的缓存,如“is”运算符对整数的行为异常[-5, 256]
中所述。
这里的情况并非如此,因为数字超出了该范围,我确实想评估身份而不是相等性。
解决方案 1:
总结:
正如参考手册所述:
块是一段作为一个单元执行的 Python 程序文本。以下是块:模块、函数体和类定义。
以交互方式输入的每个命令都是一个块。
这就是为什么在函数的情况下,有一个包含数字文字的
单个对象的单个代码块,因此会产生。1000
`id(a) == id(b)`True
在第二种情况下,您有两个不同的代码对象,1000
每个代码对象对于文字so都有自己不同的对象id(a) != id(b)
。
请注意,这种行为不仅仅体现在int
文字上,您还可以使用例如float
文字获得类似的结果(参见此处)。
当然,比较对象(除了明确的测试)应该始终使用相等运算符而不是来is None
完成。==
is
此处所述的所有内容均适用于最流行的 Python 实现 CPython。其他实现可能有所不同,因此在使用它们时不应做任何假设。
较长的答案:
为了获得更清晰的视图并进一步验证这种看似奇怪的行为,我们可以使用模块直接查看code
每种情况的对象dis
。
对于该函数func
:
除了所有其他属性外,函数对象还具有一个__code__
属性,可让您查看该函数的已编译字节码。使用dis.code_info
我们可以很好地查看给定函数的代码对象中存储的所有属性:
>>> print(dis.code_info(func))
Name: func
Filename: <stdin>
Argument count: 0
Kw-only arguments: 0
Number of locals: 2
Stack size: 2
Flags: OPTIMIZED, NEWLOCALS, NOFREE
Constants:
0: None
1: 1000
Variable names:
0: a
1: b
我们只对Constants
函数的条目感兴趣func
。在其中,我们可以看到我们有两个值None
(始终存在)和1000
。我们只有一个表示常量的 int 实例。这是在调用函数时将要分配给和1000
的值。a
`b`
通过以下方式访问该值很容易func.__code__.co_consts[1]
,因此,a is b
在函数中查看我们的评估的另一种方法是这样的:
>>> id(func.__code__.co_consts[1]) == id(func.__code__.co_consts[1])
当然,True
因为我们指的是同一个对象,所以它将会被评估。
对于每个交互命令:
如前所述,每个交互命令都被解释为单个代码块:独立解析、编译和评估。
compile
我们可以通过内置函数获取每个命令的代码对象:
>>> com1 = compile("a=1000", filename="", mode="single")
>>> com2 = compile("b=1000", filename="", mode="single")
对于每个赋值语句,我们将得到一个类似的代码对象,如下所示:
>>> print(dis.code_info(com1))
Name: <module>
Filename:
Argument count: 0
Kw-only arguments: 0
Number of locals: 0
Stack size: 1
Flags: NOFREE
Constants:
0: 1000
1: None
Names:
0: a
相同的命令com2
看起来相同,但有一个根本区别:每个代码对象com1
和com2
都有不同的 int 实例来表示文字1000
。这就是为什么在这种情况下,当我们a is b
通过co_consts
参数执行时,我们实际上会得到:
>>> id(com1.co_consts[0]) == id(com2.co_consts[0])
False
这与我们实际得到的结果一致。
代码对象不同,内容也不同。
注意:我有点好奇这在源代码中究竟是如何发生的,经过深入研究后,我相信我终于找到了它。
在编译阶段,该co_consts
属性由字典对象表示。compile.c
我们实际上可以看到初始化:
/* snippet for brevity */
u->u_lineno = 0;
u->u_col_offset = 0;
u->u_lineno_set = 0;
u->u_consts = PyDict_New();
/* snippet for brevity */
在编译期间会检查是否存在常量。有关更多信息,请参阅下面@Raymond Hettinger 的回答。
注意事项:
链式语句将评估身份检查
True
现在应该更清楚为什么以下内容的计算结果为True
:
>>> a = 1000; b = 1000;
>>> a is b
在这种情况下,通过将两个赋值命令链接在一起,我们告诉解释器将它们一起1000
编译。与函数对象的情况一样,只会创建一个文字对象,并True
在评估时产生一个值。
在模块级别执行
True
再次产生:
如前所述,参考手册指出:
...以下是块:模块...
因此同样的前提适用:我们将有一个单一的代码对象(用于模块),因此,每个不同的文字都会存储单一的值。
但对于可变对象则不然:
这意味着,除非我们明确地初始化为同一个可变对象(例如用a = b = []
),否则对象的标识永远不会相等,例如:
a = []; b = []
a is b # always evaluates to False
再次,在文档中,有如下指定:
在 a = 1; b = 1 之后,a 和 b 可能会或可能不会引用具有值 1 的同一个对象,这取决于实现方式,但在 c = []; d = [] 之后,c 和 d 保证引用两个不同的、唯一的、新创建的空列表。
解决方案 2:
在交互式提示下,条目以单一模式编译,每次处理一个完整语句。编译器本身(在Python/compile.c中)跟踪名为u_consts的字典中的常量,该字典将常量对象映射到其索引。
在compiler_add_o()函数中,你会看到在添加新常量(并增加索引)之前,会检查字典以查看常量对象和索引是否已存在。如果存在,则重用它们。
简而言之,这意味着一条语句中的重复常量(例如函数定义中的常量)将被折叠为一个单例。相比之下,a = 1000
和b = 1000
是两个单独的语句,因此不会发生折叠。
无论如何,这只是 CPython 实现细节(即语言不保证)。这就是为什么这里给出的参考资料是 C 源代码,而不是语言规范,后者对这个问题不作任何保证。
希望你喜欢这篇关于 CPython 底层工作原理的文章 :-)