「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()把两个值放进完全独立的代码对象,才能验证纯粹的缓存/驻留效果:
defisolated_is(code_a,code_b):ns1,ns2={},{}exec(f"v ={code_a}",ns1)exec(f"v ={code_b}",ns2)returnns1["v"]isns2["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()隔离,直接在同一条语句里写两个超出缓存范围的整数,会得到一个反直觉的结果:
c,d=257,257print(cisd)# True! 257明明超出了-5~256的缓存范围用dis看字节码就能看穿真相:
importdisdefsame_stmt():c,d=257,257returncisd dis.dis(same_stmt)# LOAD_CONST 1 ((257, 257)) ← 编译器把 (257, 257) 直接折叠成了一个常量元组这里的True根本不是小整数缓存起的作用,而是编译器在编译这个函数时,发现257这个常量在同一个代码对象里出现了两次,做了"常量去重"——两个257在字节码层面从一开始就是同一个对象。这个优化只发生在同一个代码对象(同一个函数/模块)编译期就能确定的常量上,一旦这两个257来自运行时计算(比如从函数参数、input()、数据库查询结果里来),常量折叠完全不起作用,is判断会老老实实地变成False。
3. sys.intern():手动驻留
如果确实需要让运行时拼接出来的字符串也享受驻留的好处(比如高频比较同一批字符串、想用is加速比较),可以用sys.intern()手动驻留:
importsys part="hel"z=part+"lo"# 运行时拼接,不会自动驻留y="hello"print(zisy)# Falsez2=sys.intern(part+"lo")print(z2isy)# 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——这两个关键字分别解决什么问题,混用嵌套函数时容易踩的坑有哪些。