码农高天
Python仅自动驻留符合标识符命名规则的字符串以加速内部属性指针比对,由于跨编译周期及特殊字符会打破底层缓存共享,开发者判定字符串相等必须使用==而严禁使用is。
AI-assisted summary of 【python】你绝对不知道的字符串缓存机制!这个知识点有点太偏了 — original source. Check the original for full context.
Source published:
Key ideas
- Python 字符串作为不可变对象,其底层通过编译期常量折叠、全切片复用、const cache 与运行期字符串驻留(String Interning)等多层机制实现内存共享。 (0:00)
- 编译阶段会通过常量折叠将 "x" + "y" 直接合成为常量 "xy",而变量拼接(x + y)因生成 BINARY_ADD 指令必须在运行时分配新对象。 (5:00)
- 运行期自动字符串驻留依赖底层的 all_name_chars 检查,仅对纯由大小写字母、数字和下划线构成的合法标识符生效,带有空格的字符串在跨编译单元(如 exec 或 REPL)时不会自动复用。 (7:30)
- 因字符串对象复用受底层编译优化与驻留规则严格约束,业务代码中判断字符串相等性应严格使用 ==,切忌依赖 is 指针比较。 (7:30)
Detailed notes
## Background 该视频由 YouTube 频道“码农高天”于 2024 年 4 月 26 日发布。视频简介指出本期内容将深度探讨 Python 中相对冷门偏门的字符串缓存机制,并调侃称如果观众在看视频之前全都知道这些机制,就无需再看他的视频了。
## TL;DR 视频以一个七种字符串构造方式的对比小测验切入,深入剖析了 Python 针对不可变字符串的底层复用设计。高天通过字节码分析,逐步拆解了全切片原地直返、编译期常量表合并、常量折叠以及跨代码对象的 const cache 优化机制。随后,他进一步解释了在动态执行场景下生效的运行期字符串驻留(String Interning),揭示了其依赖合法标识符检查(all_name_chars)以加速解释器属性查找的核心逻辑。最后,视频强调业务代码中绝对不能依赖字符串缓存的偶然性,比较字符串值必须始终使用 == 而非 is。
## Atmosphere & style 视频整体呈现出硬核硬派、条理分明且极具启发性的极客讲堂风格。开篇的反直觉测验瞬间拉满观众的好奇心与求知欲,随后借助反编译指令与底层 C 逻辑层层抽丝剥茧,带来通透解谜般的顿悟感。主讲人语调从容流畅、技术剖析直击本质,既展现了对 CPython 底层架构的透彻理解,又适时给出了清晰落地的工程实操建议。
## Bulletin - [00:00](https://www.youtube.com/watch?v=KGZ9gZqcCo0&t=0s) Python 字符串作为不可变对象,其底层通过编译期常量折叠、全切片复用、const cache 与运行期字符串驻留(String Interning)等多层机制实现内存共享。 - [05:00](https://www.youtube.com/watch?v=KGZ9gZqcCo0&t=300s) 编译阶段会通过常量折叠将 "x" + "y" 直接合成为常量 "xy",而变量拼接(x + y)因生成 BINARY_ADD 指令必须在运行时分配新对象。 - [07:30](https://www.youtube.com/watch?v=KGZ9gZqcCo0&t=450s) 运行期自动字符串驻留依赖底层的 all_name_chars 检查,仅对纯由大小写字母、数字和下划线构成的合法标识符生效,带有空格的字符串在跨编译单元(如 exec 或 REPL)时不会自动复用。 - [07:30](https://www.youtube.com/watch?v=KGZ9gZqcCo0&t=450s) 因字符串对象复用受底层编译优化与驻留规则严格约束,业务代码中判断字符串相等性应严格使用 ==,切忌依赖 is 指针比较。
## Key takeaways - [00:00](https://www.youtube.com/watch?v=KGZ9gZqcCo0&t=0s) 在纯字母字符串测试中,字面量赋值、全切片、字面量相加、exec 执行与函数返回均可指向同一内存对象,唯独变量相加会产生独立对象。 - [00:00](https://www.youtube.com/watch?v=KGZ9gZqcCo0&t=0s) 字符串的不可变性(immutability)是其能够在多处安全共享同一内存地址、避免深拷贝开销的底层前提。 - [02:30](https://www.youtube.com/watch?v=KGZ9gZqcCo0&t=150s) 对字符串执行起始为 0、结束为末尾的全切片(如 a[:])时,底层直接返回原对象指针,并不会像列表切片那样分配新内存。 - [02:30](https://www.youtube.com/watch?v=KGZ9gZqcCo0&t=150s) 同一作用域内多次书写相同字符串字面量时,编译器仅在代码对象的常量表(co_consts)中记录一次,统一通过 LOAD_CONST 加载复用。 - [05:00](https://www.youtube.com/watch?v=KGZ9gZqcCo0&t=300s) 字面量相加操作("x" + "y")在编译阶段由编译器常量折叠提前计算,不产生任何运行期加法计算开销。 - [05:00](https://www.youtube.com/watch?v=KGZ9gZqcCo0&t=300s) 变量拼接(x + y)无法在静态阶段推导结果,生成的 BINARY_ADD 字节码必须在运行时动态分配内存并生成全新对象。 - [05:00](https://www.youtube.com/watch?v=KGZ9gZqcCo0&t=300s) 编译器内部维护了 const cache 字典,使得跨 code object(如全局作用域与独立函数内部)的相同字面量也能共享常量实例。 - [05:00](https://www.youtube.com/watch?v=KGZ9gZqcCo0&t=300s) Python 编译器的符号缓存统一处理标识符与常量,使得同名的局部变量名、函数名与字面量常量在底层共享同一字符串对象。 - [07:30](https://www.youtube.com/watch?v=KGZ9gZqcCo0&t=450s) exec() 调用与 REPL 交互式回车属于独立的编译生命周期,先前基于代码文件的编译期缓存机制在此类场景下全部失效。 - [07:30](https://www.youtube.com/watch?v=KGZ9gZqcCo0&t=450s) 运行期字符串驻留(String Interning)机制会在加载 code object 时将符合规则的常量与符号注册到底层全局驻留池中。 - [07:30](https://www.youtube.com/watch?v=KGZ9gZqcCo0&t=450s) 全局驻留设有准入门槛,仅通过 all_name_chars 校验(仅含字母、数字、下划线的合法标识符)的字符串才会被自动全局驻留,含空格字符串则被排除。 - [07:30](https://www.youtube.com/watch?v=KGZ9gZqcCo0&t=450s) 字符串驻留的核心价值在于将 Python 频繁发生的属性与字典键查找从耗时的逐字符 strcmp 比较降维为极速的指针地址比较。 - [07:30](https://www.youtube.com/watch?v=KGZ9gZqcCo0&t=450s) 开发者在任何业务逻辑中比较字符串内容时,必须使用 == 比较值,绝不能使用 is 判定相等。
## Detailed walkthrough ### [00:00](https://www.youtube.com/watch?v=KGZ9gZqcCo0&t=0s) 字符串小测验与不可变对象的缓存原理
视频伊始,高天抛出了一个针对 Python 字符串底层机制的测验。他在屏幕左侧展示了一段代码,分别通过七种不同的方式构造出值为 `"xy"` 的字符串变量: 1. 变量 `a` 直接赋值为 `"xy"`; 2. 变量 `b` 通过切片操作 `a[:]` 生成; 3. 变量 `c` 再次直接赋值为 `"xy"`; 4. 变量 `d` 由两个字符串字面量相加拼接而成(`"x" + "y"`); 5. 变量 `e` 由保存了字符串 `"x"` 和 `"y"` 的两个独立变量相加生成; 6. 变量 `f` 通过 `exec("f = 'xy'")` 动态执行代码生成; 7. 变量 `g` 则是调用一个返回 `"xy"` 的函数所得到的返回值。
在 Python 中,如果两个变量的 `id()` 相同,则代表它们在内存中指向同一个对象。面对“这七个变量里究竟有几个会指向同一个对象”的疑问,测试结果令人惊讶:除了通过变量动态相加的 `e` 拥有独立的对象地址之外,其余六个变量竟然全部指向了同一个内存对象。更耐人寻味的是,如果在 `"x"` 与 `"y"` 之间加入一个空格将其变成 `"x y"`,输出结果立刻发生了变化——不仅变量 `e` 依然独立,原本指向同一对象的 `f`(通过 `exec` 生成)也变成了一个全新的对象。高天指出:“在这简单又稍显离谱的现象背后,实际上隐藏了大约四五种 Python 对 String 的缓存机制,而今天我们就要把这些缓存机制一一讲明白。”
从基本特性来看,Python 中的字符串是不可变对象(immutable)。正因为不可变对象的内容无法被篡改,当多个上下文需要使用完全相同的值时,解释器完全可以让它们安全地共享同一块内存,这在字符串被极其频繁使用的 Python 程序中能够节省海量的内存开销。最基础的引用传递便体现了这一点:当把 `a` 赋值给 `b` 时,Python 并不会完整复制一份字符串,而是直接让两个变量指向同一对象。
```mermaid flowchart TD accTitle: Python 字符串 7 种构造方式的对象指向对比 accDescr: 展示了 7 种不同构造方式下字符串对象的内存复用情况,区分了完全复用、编译期折叠与运行时独立分配。
subgraph Inputs["7 种构造方式(以 'xy' 为例)"] A["a = 'xy'"] B["b = a[:]"] C["c = 'xy'"] D["d = 'x' + 'y'"] F["exec('f = xy')"] G["g = func() 返回 'xy'"] E["e = x + y (变量拼接)"] end
subgraph Objects["内存对象指向"] SharedObj["同一内存对象 ('xy')"] NewObj["独立运行时对象 (e)"] end
A --> SharedObj B -->|"全切片返回自身"| SharedObj C -->|"常量复用"| SharedObj D -->|"编译期常量折叠"| SharedObj F -->|"运行期标识符驻留"| SharedObj G -->|"编译期 Const Cache"| SharedObj E -->|"运行时 BINARY_ADD"| NewObj ```
- **测验结果对比**:对于纯字母字符串 `"xy"`,只有变量 `e`(变量相加)拥有不同的 `id`,其余 6 个变量共享同一个对象;而一旦带有空格变成 `"x y"`,`e` 和 `f`(`exec` 生成)均指向独立对象。 - **不可变性与内存复用**:不可变类型无需深复制即可安全共享指针,这是 Python 各层字符串缓存机制的存在基石。
---
[02:30](https://www.youtube.com/watch?v=KGZ9gZqcCo0&t=150s) 全切片直返自身与编译期常量复用
为了拆解上述现象背后的具体机制,高天首先解释了切片操作 `a[:]` 为什么没有产生新对象。在开发者的直觉中,切片往往伴随着复制;例如对一个列表执行 `list[:]` 会触发浅拷贝并产生新列表。但对字符串执行子串截取(substring)时,解释器内部存在一个专门的优化逻辑:“当你做 substring 这个操作的时候,如果你的起始点是 0、结束点是最后,它就会直接返回本身这个没有改变过的 String。”因此,看似执行了切片复制,底层其实仅仅是把原始对象的指针直接传递出来。
接着,高天转向了字面量重复赋值的机制:为什么在代码中分别书写 `a = "xy"` 和 `c = "xy"`,它们不会创建两个分离的字符串对象?高天借助 `dis` 模块分析了生成的 Python 字节码。在 Python 代码编译生成代码对象(code object)的过程中,编译器会对当前作用域内的所有常量进行收集,将提取到的字符串 `"xy"` 作为常量存入该代码对象的常量表 `co_consts` 中。当后续代码再次遇到完全相同的字面量时,编译器生成的字节码指令依然是加载同一张常量表中的对应项(如 `LOAD_CONST 0`),随后分别执行 `STORE_NAME a` 和 `STORE_NAME c`。这意味着在代码编译阶段,Python 就已经确定这两个变量绑定的是同一个编译期常量。
- **全切片优化**:字符串切片若覆盖从 `0` 到结尾的完整区间,直接返回原字符串对象本身,绝不分配新内存。 - **列表与字符串的切片差异**:列表执行 `l[:]` 会强制分配新的列表对象,而字符串切片因不可变特性享有就地复用优化。 - **字节码级常量载入**:多次书写相同字符串字面量在同一个编译单元内只会在 `co_consts` 记录一次,统一通过 `LOAD_CONST` 索引加载。
---
[05:00](https://www.youtube.com/watch?v=KGZ9gZqcCo0&t=300s) 常量折叠与编译期 Const Cache 跨作用域优化
紧接着,高天探讨了字面量拼接(`d = "x" + "y"`)与变量拼接(`e = x + y`)在行为上的巨大差异。反编译结果显示,`"x" + "y"` 编译出的字节码与直接赋值为 `"xy"` 完全一致,中间根本没有字符串加法操作。这是因为 Python 编译器内建了常量折叠(constant folding)优化:在编译阶段,如果表达式的各个操作数都是可以直接计算的字面常量,编译器就会提前算出结果 `"xy"` 并写入常量表。相反,当计算 `x + y` 时,编译器无法在静态阶段断定变量的实际运行值,字节码中必须生成明确的 `BINARY_ADD` 指令;该指令在运行时动态分配内存并创建新字符串,因而其对象地址必然与其他常量不同。
更深一层的机制体现在跨作用域的常量共享上:为什么外部全局变量 `a = "xy"` 与一个局部函数 `def func(): return "xy"` 的返回值能够共享同一个对象?从常理推断,函数体会被编译为一个独立的 code object,理论上拥有各自独立的常量表。高天解释道,这是因为 Python 编译器内部维护着一个名为 `const cache` 的常量缓存字典:“也就是说在 Python 编译代码的时候,每当他需要用一个新的常量的时候,他就会先回头找一找之前是不是也用到了同样的常量,如果之前用到了,它就会让这个新的常量直接指向之前已经开辟的那个常量。”这种机制避免了在为每个 code object 分配私有常量表时产生冗余的内存分配。
更为巧妙的是,code object 内部的常量表与变量名池复用了同一套符号缓存逻辑。高天在现场做了一个趣味拓展:在一个函数内部声明一个名为 `xy` 的局部变量,再声明一个名为 `xy` 的函数,最后在外层定义字符串常量 `"xy"`。检查这三者所持有的底层字符串会发现,存储局部变量名的字符串、存储函数名的字符串以及字符串字面量常量,最终在内存中全都指向同一个底层对象。
- **常量折叠(Constant Folding)**:字面量计算(如 `"x" + "y"`)在编译阶段由编译器提前求值,不消耗运行时计算开销。 - **运行时加法(BINARY_ADD)**:涉及变量引用的加法操作必须在运行时动态评估,因此每次都会分配新的独立对象。 - **编译期常量缓存(const cache)**:同一编译会话中跨 code object 共享字面常量,杜绝重复创建相同的常量实例。 - **标识符与常量的统一缓存**:Python 底层将变量名、函数名与字面量常量在编译符号缓存中统一处理,同名标识符与常量底层共享同一字符串对象。
---
[07:30](https://www.youtube.com/watch?v=KGZ9gZqcCo0&t=450s) 运行期字符串驻留(String Interning)与标识符限制
最后,高天解析了最违背直觉的现象:为什么在使用 `exec()` 或 Python 交互式命令行(REPL)时,`"xy"` 的引用一致,而带有空格的 `"x y"` 却分道扬镳?高天指出,`exec()` 接收字符串参数并在运行时动态触发全新的编译,交互式解释器(REPL)中每敲击一次回车也是一次独立的编译过程。由于编译上下文完全隔离,先前介绍的所有“编译期机制”(如常量表合并、const cache)在此全部失效。
在此类跨编译生命周期的场景下发挥作用的,是运行期的字符串驻留(String Interning)机制。在 Python 运行时,每当解释器生成或加载一个新的 code object,都会尝试将该代码对象内部涉及的名字和常量注册到底层的全局驻留字典中。然而,并不是所有字符串都有资格被全局驻留。
在对常量进行驻留筛选时,Python 解释器会调用底层 C 语言的 `all_name_chars` 函数进行前置检查。“本质上它检查的就是你这个 string 能不能成为一个合法的名字,也就是你这个 string 里是不是只包含了大小写字母、数字和下划线。”高天解释了这一设计的深层动机:在 Python 内部架构中,字典键查找、对象属性访问、模块符号查找等核心操作全都依赖字符串比较;如果能把所有长得像 Python 标识符的变量名/属性名在全局进行唯一化驻留,解释器在属性寻址时就能把低效的逐字符内容比较(`strcmp`)直接优化为极速的指针地址比较(`is`)。然而,系统不能无节制地缓存所有内容,否则不仅不会节省空间,反而会因哈希冲突和表膨胀导致内存飙升与性能倒退。
回到最初的代码,`"xy"` 纯由合法标识符字符构成,顺理通过了 `all_name_chars` 检查,因而在运行时被强制全局驻留;哪怕 `exec` 处于不同编译期,依然查到了已驻留的全局单例。而 `"x y"` 中间包含非法标识符字符(空格),被排除在自动驻留机制之外,因此两次独立编译分别分配了两个地址不同的独立对象。
在视频结尾,高天为不同阶段的 Python 学习者给出了核心建议:“如果你是一个 Python 初学者的话,你只需要记住一件事,就是永远不要用 is 来比较两个 string 是否相等。”因为字符串缓存机制受底层优化规则、编译作用域及解释器具体实现的深刻影响,业务逻辑中比较字符串的值应始终坚持使用 `==`。
```mermaid flowchart TD accTitle: 运行时字符串驻留检查逻辑 accDescr: 展示 Python 运行时加载 code object 时对常量字符串是否进行 intern 驻留的条件判断与流程。
Start["运行时加载 Code Object 中的常量"] --> Check{"检查 all_name_chars(str)"} Check -- "是(仅含字母、数字、下划线)\n如 'xy'" --> ValidIdent["符合合法标识符规则"] ValidIdent --> Intern["执行 String Interning\n存入全局驻留池"] Intern --> Share["跨编译单元共享同一内存指针 (is 为 True)"]
Check -- "否(含空格、特殊符号)\n如 'x y'" --> InvalidIdent["不符合标识符规则"] InvalidIdent --> NoIntern["不执行自动驻留"] NoIntern --> Separate["独立分配内存 (跨编译单元 is 为 False)"] ```
- **编译边界隔离**:`exec()` 动态执行与 REPL 单行回车均属于全新的独立编译生命周期,彻底绕过原代码文件的编译期缓存。 - **`all_name_chars` 准入检查**:只有纯由大小写字母、数字及下划线构成的合法 Python 标识符字符串,才会在运行期自动触发驻留。 - **驻留的核心价值**:服务于解释器内部快速属性查找,使高频的符号名字符串比对降维成快速的指针比较。 - **最佳工程实践**:任何场景下比较字符串的值均应严格使用 `==`,切忌依赖 `is` 判定相等性。
## Notable quotes > [07:30](https://www.youtube.com/watch?v=KGZ9gZqcCo0&t=450s) 如果你是一个 Python 初学者的话,你只需要记住一件事,就是永远不要用 is 来比较两个 string 是否相等。 — 码农高天
## Mentioned Python, 码农高天, dis, all_name_chars
## Video description 这次聊一下字符串缓存机制,这些机制如果你在看视频之前就全都知道,那你就不再需要看我视频了……
[Source](https://www.youtube.com/watch?v=KGZ9gZqcCo0)