「Python 进阶之路」系列 Day31
写在前面
模块六写完,从今天开始进入模块七------面试高频题串讲,接下来几篇不再引入全新知识点,而是把前面容易被单独拎出来问、又特别容易"感觉懂了但一深挖就露馅"的话题重新过一遍。第一篇是 is/== 和小整数缓存、字符串驻留------Day01 讲过基础概念,今天要挖穿一个反直觉的现象:257 is 257 为什么会是 True,而 257 明明已经超出了 -5~256 的缓存范围。
一、是什么:两个"幕后优化"
Day01 讲过 is 比较身份(id())、== 比较值(__eq__),也提过 -5~256 的小整数缓存。今天重新拎出来讲透------不只是"是什么",而是深挖为什么有的地方 is 判断"看起来"符合直觉,有的地方又不符合,这背后是两套独立的机制在起作用:
- 小整数缓存:CPython 对 -5 到 256 之间的整数提前创建好并常驻内存,任何地方用到这个范围内的整数值,都直接复用这些现成对象
- 字符串驻留(interning):对"看起来像标识符"的字符串(只包含字母、数字、下划线,且不以数字开头),CPython 会尝试复用已有的相同字符串对象,而不是重新创建
二、为什么:省内存 + 加速比较
这两个优化的动机是一致的:整数和字符串在程序里被大量重复使用(变量名、字典 key、小的计数值),如果每次都创建新对象,既浪费内存,也让 == 比较变慢。驻留之后,== 比较可以先做一次廉价的 id() 比较,命中就直接返回 True,不用再逐字符/逐位比较(这只是 == 实现里的一个内部优化,不代表 == 变成了 is)。
范围和规则的设计也有取舍:小整数缓存选 -5~256 是因为这个区间覆盖了绝大多数业务代码里常见的计数、索引、状态码;字符串驻留只对"标识符样式"的字符串生效,是因为源码里大量重复出现的字符串常量主要就是变量名、属性名、关键字参数名这一类,把所有字符串都无条件驻留反而会增加不必要的内存和维护开销。
三、怎么用
1. 真正隔离出小整数缓存和字符串驻留
这里有个容易被绕进去的坑:如果两个字面量写在同一条语句/同一个函数里,is 判断会被"编译器常量折叠"干扰,看不出真正的缓存边界。 用 exec() 把两个值放进完全独立的代码对象,才能验证纯粹的缓存/驻留效果:
python
def isolated_is(code_a, code_b):
ns1, ns2 = {}, {}
exec(f"v = {code_a}", ns1)
exec(f"v = {code_b}", ns2)
return ns1["v"] is ns2["v"]
print(isolated_is("256", "256")) # True ------ 在缓存范围内
print(isolated_is("257", "257")) # False ------ 超出缓存范围
print(isolated_is("'hello'", "'hello'")) # True ------ 标识符样式,自动驻留
print(isolated_is("'hello world'", "'hello world'")) # False ------ 带空格,不驻留
print(isolated_is("'hello!'", "'hello!'")) # False ------ 带特殊符号,不驻留
结果完全符合预期:256 在缓存范围内是 True,257 超出范围是 False;"hello" 这种标识符样式的字符串自动驻留是 True,带空格或特殊符号的字符串不驻留是 False。
2. 关键陷阱:常量折叠和"缓存"是两回事
如果不用 exec() 隔离,直接在同一条语句里写两个超出缓存范围的整数,会得到一个反直觉的结果:
python
c, d = 257, 257
print(c is d) # True! 257明明超出了-5~256的缓存范围
用 dis 看字节码就能看穿真相:
python
import dis
def same_stmt():
c, d = 257, 257
return c is d
dis.dis(same_stmt)
# LOAD_CONST 1 ((257, 257)) ← 编译器把 (257, 257) 直接折叠成了一个常量元组
这里的 True 根本不是小整数缓存起的作用,而是编译器在编译这个函数时,发现 257 这个常量在同一个代码对象里出现了两次,做了"常量去重"------两个 257 在字节码层面从一开始就是同一个对象。这个优化只发生在同一个代码对象(同一个函数/模块)编译期就能确定的常量上,一旦这两个 257 来自运行时计算(比如从函数参数、input()、数据库查询结果里来),常量折叠完全不起作用,is 判断会老老实实地变成 False。
3. sys.intern():手动驻留
如果确实需要让运行时拼接出来的字符串也享受驻留的好处(比如高频比较同一批字符串、想用 is 加速比较),可以用 sys.intern() 手动驻留:
python
import sys
part = "hel"
z = part + "lo" # 运行时拼接,不会自动驻留
y = "hello"
print(z is y) # False
z2 = sys.intern(part + "lo")
print(z2 is y) # True ------ 手动驻留后和字面量"hello"共享了同一个对象
四、面试追问
Q1:is 和 == 的核心区别是什么?
is 比较的是身份,本质是比较两个对象的 id();== 比较的是值,调用的是对象的 __eq__ 方法。判断值相等永远应该用 ==,判断是不是同一个对象(尤其是判断 None/True/False 这类单例)用 is。
Q2:小整数缓存的范围是多少,为什么要跨代码对象验证?
范围是 -5 到 256。如果两个整数字面量写在同一条语句或同一个函数里,编译器的常量折叠会让结果看起来像"缓存生效了",哪怕数值已经超出了缓存范围;必须用 exec() 之类的手段,把两者放进完全独立的代码对象,才能验证真实的缓存边界。
Q3:什么样的字符串会被自动驻留?
符合标识符格式的字符串------只包含字母、数字、下划线,且不以数字开头,比如 "hello"。带空格、标点等特殊字符的字符串(比如 "hello world"、"hello!")默认不会被自动驻留。
Q4:为什么同一条语句里两个超出缓存范围的整数,is 判断却是 True?
这是编译器的常量折叠/常量去重在起作用,不是小整数缓存------编译器发现同一个代码对象里出现了两次相同的字面量常量,会在字节码层面把它们直接合并成同一个常量对象,用 dis 反汇编能直接看到这个折叠痕迹(比如 (257, 257) 被编译成一个共享同一个 257 对象的常量元组)。
Q5:这些行为能不能依赖用来做业务判断?
不能。小整数缓存、字符串驻留、常量折叠都是 CPython 的实现细节和性能优化,不是 Python 语言规范承诺的行为,不同版本、不同实现(比如 PyPy)可能表现不一样。判断值相等永远应该用 ==,不能依赖 is 在特定场景下"碰巧"表现出来的相等结果。
下一篇预告
Day32 讲全局变量与 global/nonlocal------这两个关键字分别解决什么问题,混用嵌套函数时容易踩的坑有哪些。