码农高天
Python在编译期将变量名映射为连续数组下标并根据作用域拆分出独立元组,使虚拟机免于在运行时维护哈希表即可实现高效的指令执行与闭包共享。
AI-assisted summary of 【python】B站没人讲过的CodeObject,python底层实现一点都不简单! — original source. Check the original for full context.
Source published:
Key ideas
- 所有 Python 代码在运行前都会被编译为 Code Object,它包含了二进制字节码、虚拟机执行配置以及高度优化的行号映射元数据。 (0:00)
- Python 虚拟机为了避免在运行时进行低效的字符串哈希查找,在编译期将变量名映射为静态整数索引,字节码直接通过数组下标实现 O(1) 访问。 (10:00)
- 闭包机制通过成对的 co_cellvars 与 co_freevars 实现:外层函数将跨作用域共享的变量封装为 cell object,字节码指令也随之从普通 FAST 指令切换为 DEREF 指令。 (15:00)
- Code Object 的常量池 co_consts 直接存储真实的 Python 对象而非字符串名字,且 None 默认始终常驻在常量元组中。 (15:00)
Detailed notes
## Background 本视频由技术博主“码农高天”于 2023 年 9 月 29 日发布在 YouTube 平台。视频简介指出,本期内容深入硬核剖析了所有 Python 代码在运行前编译成的 Code Object(代码对象),旨在梳理其内部包含的复杂组成与底层机制,并直言官方文档在这一主题上的阐述都未必清晰透彻。
## TL;DR 视频系统拆解了 Python 运行时的底层基石 Code Object,将其核心结构划分为二进制字节码、元数据、虚拟机执行配置以及参数统计四大类别。高天详细阐述了 Python 如何利用斜杠(/)与星号(*)区分仅限位置参数与仅限关键字参数,并映射到底层的不同参数计数属性中。随后,他从字节码设计原理推导了 Python 为什么将变量名映射为静态整数下标以优化访问性能,并澄清了容易混淆的 co_varnames、co_name 与 co_names 的实际职责。最后,视频通过嵌套函数与反编译实例,深度揭示了闭包机制下成对协作的 co_cellvars、co_freevars 与 Cell Object 底层指针封装,并完整呈现了固化 Python 对象的常量池 co_consts。
## Atmosphere & style 整期视频散发着纯粹的硬核极客气质与技术自信,语言风格直率幽默,既带有“连官方文档都没讲透”的犀利切入,也有对个别历史命名缺陷的坦率吐槽。高天不仅停留在打印属性的表象,而是带领观众站在语言设计者的视角推导虚拟机的实现权衡,结合即时的代码验证与 dis 字节码反编译层层推演。观看体验严谨烧脑却又脉络清晰,犹如置身于资深系统级工程师的白板演练现场,令人酣畅淋漓地看透 Python 虚拟机内部的精密齿轮。
## Bulletin - [00:00](https://www.youtube.com/watch?v=2ifX3yAnAFc&t=0s) 所有 Python 代码在运行前都会被编译为 Code Object,它包含了二进制字节码、虚拟机执行配置以及高度优化的行号映射元数据。 - [10:00](https://www.youtube.com/watch?v=2ifX3yAnAFc&t=600s) Python 虚拟机为了避免在运行时进行低效的字符串哈希查找,在编译期将变量名映射为静态整数索引,字节码直接通过数组下标实现 O(1) 访问。 - [15:00](https://www.youtube.com/watch?v=2ifX3yAnAFc&t=900s) 闭包机制通过成对的 co_cellvars 与 co_freevars 实现:外层函数将跨作用域共享的变量封装为 cell object,字节码指令也随之从普通 FAST 指令切换为 DEREF 指令。 - [15:00](https://www.youtube.com/watch?v=2ifX3yAnAFc&t=900s) Code Object 的常量池 co_consts 直接存储真实的 Python 对象而非字符串名字,且 None 默认始终常驻在常量元组中。
## Key takeaways - [00:00](https://www.youtube.com/watch?v=2ifX3yAnAFc&t=0s) Python 函数本质上是 Function Object,每个函数对象内部都持有一个对应的 Code Object(可通过 f.__code__ 访问)。 - [00:00](https://www.youtube.com/watch?v=2ifX3yAnAFc&t=0s) co_code 以二进制格式保存 Python 的底层字节码,开发者可通过标准库 dis 模块将其反编译为可读的汇编助记符。 - [00:00](https://www.youtube.com/watch?v=2ifX3yAnAFc&t=0s) co_lnotab(行号映射表)在 CPython 中经过了极致的体积压缩与优化,负责在二进制字节码与源码行号之间建立映射。 - [00:00](https://www.youtube.com/watch?v=2ifX3yAnAFc&t=0s) co_flags 是一个位图(Bitmap),用于标识函数是否包含变长参数、是否为生成器或协程,引导虚拟机采取不同的执行模式。 - [05:00](https://www.youtube.com/watch?v=2ifX3yAnAFc&t=300s) Python 形参语法通过斜杠 / 定义 Position-only 参数(对应 co_posonlyargcount),通过独立星号 * 定义 Keyword-only 参数(对应 co_kwonlyargcount)。 - [05:00](https://www.youtube.com/watch?v=2ifX3yAnAFc&t=300s) co_argcount 统计的是扣除了变长参数(*args、**kwargs)以及全部 Keyword-only 参数后剩余的常规参数数量。 - [10:00](https://www.youtube.com/watch?v=2ifX3yAnAFc&t=600s) 如果在字节码中直接存储变量名字符串,会导致字节码膨胀且运行时查询开销过大,因此 Python 采用编译期名字索引表配合 LOAD_FAST/STORE_FAST 指令。 - [10:00](https://www.youtube.com/watch?v=2ifX3yAnAFc&t=600s) co_varnames 存储所有的本地局部变量名(包含形参),其数量通常对应 co_nlocals。 - [10:00](https://www.youtube.com/watch?v=2ifX3yAnAFc&t=600s) co_name 与 co_names 概念截然不同:co_name 是函数自身的名字,而 co_names 保存作用域内使用的属性名、全局对象名或模块导入名。 - [10:00](https://www.youtube.com/watch?v=2ifX3yAnAFc&t=600s) import 语句在无别名时会同时向 co_names(模块查找)和 co_varnames(局部绑定)写入模块名;使用 as 别名后原模块名仅保留在 co_names 中。 - [15:00](https://www.youtube.com/watch?v=2ifX3yAnAFc&t=900s) co_cellvars 记录本层作用域中被内层嵌套闭包所引用的变量名,底层会将这些变量从常规栈帧剥离并封装为 cell object。 - [15:00](https://www.youtube.com/watch?v=2ifX3yAnAFc&t=900s) co_freevars 记录内层嵌套函数从外层非全局作用域捕获的自由变量名,虚拟机通过 LOAD_DEREF 指令对其进行解引用读取。 - [15:00](https://www.youtube.com/watch?v=2ifX3yAnAFc&t=900s) co_consts 保存代码涉及的所有字面常量实体对象(如整数、字符串、None),虚拟机执行 LOAD_CONST 时直接按索引载入。
## Detailed walkthrough ### [00:00](https://www.youtube.com/watch?v=2ifX3yAnAFc&t=0s) 什么是 Code Object:字节码、元数据与虚拟机配置
高天指出,Code Object(代码对象)在 Python 中是一个极其硬核但又底层的概念。他翻阅了 B 站后发现甚至没有一个视频专门讲解这个主题,官方文档对它的描述也相当模糊,但它对理解 Python 的底层运行机制至关重要。高天直言:“不是我夸张,一般人真的搞不明白。你去翻官方文档,都未必比我讲得清楚。” 许多使用了 Python 多年的开发者可能从未听过这个词,但只要在写 Python,其实时时刻刻都在与它打交道——所有编写的 Python 代码在运行前都会被编译为 Code Object。如果定义一个空的函数 `f`,通过打印其内部属性 `f.__code__`,就能拿到这个 Code Object。函数本身是一个 Function Object,而每个 Function Object 都有一个与之对应的 Code Object。
通过标准库的 `inspect` 模块文档以及直接在程序中打印对象属性,可以发现 Code Object 包含了一系列核心属性,高天将其分门别类进行了拆解:
1. **`co_code`**:保存的是该代码段真正的字节码(Bytecode),在底层以二进制(Binary)形式存在。高天解释称通俗理解这就是 Python 的“汇编”,普通开发者通常不直接读取这段二进制,而是通过内置的 `dis` 模块(如 `dis.dis(f)`)将其反编译为人类可读的助记符。 2. **元数据属性(Metadata)**:高天将这些属性归类为与实际代码逻辑运行关系不大、起到辅助作用的元数据。 - `co_name`:代码段的名称,通常就是定义的函数名。 - `co_filename`:定义该函数或代码段所在的文件路径。 - `co_lnotab`(或行号映射表):一个将二进制字节码与源代码行号一一映射的表。为了节省空间,CPython 对其进行了高度压缩与优化,人类无法直接阅读;高天提到 CPython 源码中甚至有专门的文本文件(text file)用来解释这种压缩算法。 3. **运行时与虚拟机属性**: - `co_stacksize`:Python 虚拟机(VM)在执行这段代码时所需的栈空间大小。 - `co_flags`:一个位图(Bitmap)。编译器在编译代码时会通过它标记代码的特殊属性,例如是否含有 `*args`、`**kwargs`,或者该函数是否为生成器(Generator)或协程(Coroutine)。Python 虚拟机会根据这些 flag 展现不同的执行行为(例如执行普通函数与生成器函数的逻辑截然不同)。
```mermaid flowchart TD accTitle: Python Code Object 核心属性分类结构图 accDescr: 展示 Code Object 内部的属性如何划分为字节码、元数据、虚拟机状态及参数统计四大类
subgraph CO["Code Object (f.__code__)"] subgraph BytecodeSec["字节码执行"] C1["co_code<br/>(二进制字节码 / dis 模块反编译)"] end
subgraph MetaSec["元数据 (Metadata)"] M1["co_name (函数名)"] M2["co_filename (文件名)"] M3["co_lnotab (字节码到行号压缩映射)"] end
subgraph VMSec["虚拟机状态 (Runtime)"] V1["co_stacksize (栈深度)"] V2["co_flags (Bitmap: 生成器/协程/变长参数标志)"] end
subgraph ArgSec["参数计数 (Argument Counts)"] A1["co_argcount"] A2["co_posonlyargcount"] A3["co_kwonlyargcount"] end end
BytecodeSec --> CO MetaSec --> CO VMSec --> CO ArgSec --> CO ```
---
[05:00](https://www.youtube.com/watch?v=2ifX3yAnAFc&t=300s) 参数计算机制:Position-only 与 Keyword-only Arguments
在进入更复杂的变量名讨论之前,高天重点剖析了 Code Object 中关于参数数量的三个属性:`co_argcount`、`co_posonlyargcount` 和 `co_kwonlyargcount`。这三个计数器决定了 Python 在函数调用时如何校验和传递参数,也是 Python 函数传参机制的核心基础。
为了厘清这三个计数的差别,高天详细梳理了 Python 参数定义的演进与语法: - 常规编写参数包括:必填参数(如 `a`)、带默认值的参数(如 `b=3`)、以及变长参数(`*args` 和 `**kwargs`)。变长参数无论传入多少都会被打包接收。但默认情况下,常规参数既不是纯位置参数,也不是纯关键字参数。 - **Position-only Arguments(仅限位置参数)**:Python 中引入了斜杠 `/` 语法。在函数形参列表中,**斜杠之前**的所有参数都必须以位置参数形式传入,绝对不能以关键字形式传参。例如定义 `def f(a, b=3, /): pass`: - 调用 `f(1)` 合法(`1` 赋值给 `a`,`b` 取默认值 3)。 - 调用 `f(1, 1)` 合法(分别按位置赋给 `a` 和 `b`)。 - 调用 `f(a=1)` 则直接抛出语法或类型异常,报错提示缺少必需的位置参数(required positional argument)。此时若查看 Code Object,其 `co_posonlyargcount` 为 2。只有去掉 `/`,该属性才会变回 0。 - **Keyword-only Arguments(仅限关键字参数)**:在形参列表中放一个单独的星号 `*`,例如 `def f(a, *, b=3): pass`。**星号之后**的所有参数都必须以关键字形式传递。若调用 `f(1, 1)` 会直接失败报错;必须写成 `f(1, b=1)` 才能正常执行。此时 Code Object 的 `co_kwonlyargcount` 变为 1。
由此,高天总结了这三个属性的精准定义: - `co_posonlyargcount`:纯位置参数的个数。 - `co_kwonlyargcount`:纯关键字参数的个数。 - `co_argcount`:排除掉 `*args`、`**kwargs` 以及所有 Keyword-only 参数之后,剩余参数的总数量。
---
[10:00](https://www.youtube.com/watch?v=2ifX3yAnAFc&t=600s) 字节码设计权衡与变量名管理:从 co_varnames 到 co_names
讲完参数数量后,高天进入了他称为“最复杂、最容易混淆,这个世界上都没有几个人搞得明白”的名称属性集合。在解释属性前,高天引导观众先从底层设计者的角度思考一个计算机体系结构问题:**字节码应该如何设计?**
假设函数内部写了一行代码 `b = a`,如果字节码里直接嵌入变量名字字符串 `"a"` 和 `"b"`,会带来严重缺陷:如果变量名极其冗长且频繁出现,字节码体积会急剧膨胀;更关键的是,在运行时 `"a"` 和 `"b"` 代表的是具体的 Python 对象,虚拟机若要通过字符串找到对象,就必须维护哈希表或字典映射,无论在时间复杂度还是空间复杂度上都极其低效。
高天强调了 Python 的破局思路:“在编译代码的时候,这个代码里面出现的变量名也好,或者是任何的名也好,他是有数的,也就是说它完全可以做到一个一一对应。” Python 在编译期维护数组:每见到一个新名字就分配一个整型下标(如 `a` 为 0,`b` 为 1)。字节码只需记录数字指令,例如“读取第 0 号位置的数据,存入第 1 号位置”(即 `LOAD_FAST 0`, `STORE_FAST 1`),从而实现 $O(1)$ 的快速数组索引访问。
```mermaid flowchart LR accTitle: Python 编译期变量名到字节码的静态映射机制 accDescr: 说明源码中的变量名如何被转化为整数索引,从而使字节码直接操作数组下标
subgraph Source["源代码 (AST 解析)"] S1["b = a"] end
subgraph Mapping["静态名字数组 (编译期)"] Arr0["Index 0: 'a'"] Arr1["Index 1: 'b'"] end
subgraph Bytecode["最终字节码指令"] B1["LOAD_FAST 0"] B2["STORE_FAST 1"] end
Source --> Mapping Mapping --> Bytecode ```
在这个机制下,Code Object 分离出了不同的名字属性: - **`co_varnames` 与 `co_nlocals`**: - `co_varnames` 保存的是所有的局部变量名(Local Variables),包括函数传入的形参(arguments)以及在函数本地作用域内定义的变量。 - `co_nlocals` 是局部变量的总数量。绝大多数情况下它等于 `len(co_varnames)`。高天坦诚分享,CPython 源码中对它们的大小做过校验,但他自己也没完全搞明白到底在什么极端特殊情况下这两者会不相等。 - 用 `dis.dis` 查看字节码时看到的 `LOAD_FAST 0 (a)`,括号内的 `(a)` 仅仅是反编译模块为了人类可读性附加的注释,实际二进制底层只存了操作码和操作数 `0`。 - **`co_names` 与 `co_name` 的陷阱**: - 高天特别吐槽道:“`co_names` 和 `co_name` 没有一毛钱的关系,这里这个名字的设计稍微有点愚蠢,它没有任何关系。” `co_name` 是当前函数自身的名字,而 `co_names` 是一个元组,保存了除局部变量(`co_varnames`)、外层闭包变量(`co_cellvars`)和引用的自由变量(`co_freevars`)之外,该作用域内用到的所有其他非保留字名字。 - 例如属性访问 `b = a.attr`,其中的属性名 `"attr"` 就保存在 `co_names` 中,字节码通过 `LOAD_ATTR` 读取;调用类方法 `a.method()` 时,`"method"` 字符串同样存放在 `co_names` 中。 - 高天还举了 `import` 语句的精妙例子:如果写 `import math`,`math` 会同时出现在 `co_varnames` 和 `co_names` 中。在 `co_names` 中是因为导入模块需要 `"math"` 字符串;在 `co_varnames` 中则是因为隐含着将导入的模块对象赋值给本地变量 `math`。如果改成 `import math as m`,则 `"math"` 仅出现在 `co_names` 中,而本地变量名 `"m"` 出现在 `co_varnames` 中。
---
[15:00](https://www.youtube.com/watch?v=2ifX3yAnAFc&t=900s) 闭包机制下的 cellvars 与 freevars,及常量池 co_consts
紧接着,高天拆解了另一对极易混淆的概念——`co_cellvars` 和 `co_freevars`。他用生动的比喻指出:“这两个概念有点像硬币的两面,它们经常成对出现,一般来说都是辅助完成闭包的。” - **`co_cellvars`**:记录的是本作用域(scope)内定义、但**被内部嵌套的其它作用域(闭包)所引用/修改**的变量。在 Python 底层实现闭包时,虚拟机不会把该变量当作普通局部变量存放在快速栈帧中,而是将其包装成一个 `cell object`(可以理解为一种引用或指针封装),使不同的作用域能够共享访问。 - **`co_freevars`**:记录的是当前作用域正在使用、但**定义在外层其他作用域**的变量(即自由变量)。
高天通过一段简单的嵌套闭包代码进行了实机演示: ```python def g(): d = {} def f(): d['key'] = 1 return f ``` - 查看外层函数 `g` 的 Code Object:因为内部函数 `f` 引用了 `d`,变量 `d` 就被放进了 `g.__code__.co_cellvars`。如果修改代码让 `f` 不引用 `d`,`d` 就会立刻退化回普通的 `co_varnames`。 - 查看内层函数 `f` 的 Code Object:其 `f.__code__.co_freevars` 中包含了 `('d',)`,代表它是从外层捕获的自由变量。 - 这一区分直接反映在字节码指令上:普通的局部变量读写使用的是 `LOAD_FAST` / `STORE_FAST`;而在闭包捕获场景下,外层使用的是 `STORE_DEREF` 和 `LOAD_CLOSURE`,内层使用的则是 `LOAD_DEREF`。编译器会根据变量的作用域来源自动调整生成的字节码指令。
```mermaid flowchart TD accTitle: 闭包中 cellvars 与 freevars 的对应关系 accDescr: 展示外层函数与内层嵌套函数如何通过 cellvars 和 freevars 共享 cell object
subgraph ScopeG["外层函数 g()"] VarD["定义变量 d = {}"] CellD["co_cellvars: ('d',)<br/>指令: STORE_DEREF / LOAD_CLOSURE"] end
subgraph ScopeF["内层嵌套函数 f()"] UseD["使用变量 d"] FreeD["co_freevars: ('d',)<br/>指令: LOAD_DEREF"] end
CellObj["Cell Object (堆内存指针/引用封装)"]
VarD --> CellD CellD -->|"封装并暴露"| CellObj CellObj -->|"跨作用域捕获"| FreeD FreeD --> UseD ```
最后,高天介绍了属性 **`co_consts`(常量元组)**。与前面存放字符串名字的属性完全不同,`co_consts` 保存的是真正的 **Python Object**。代码中出现的所有字面常量都会被打包固化在里面。例如在函数中写入 `a = 1; b = "ABCABC"`,打印 `co_consts` 会发现 `(None, 1, 'ABCABC')`,其中的 `1` 是整数对象,`'ABCABC'` 是字符串对象。高天特别指出,`None` 是常量池里的“常驻嘉宾”,始终默认存在于其中。Python 虚拟机在运行字节码(如 `LOAD_CONST`)时,就是直接从 `co_consts` 列表中按索引载入这些常量对象。
在视频尾声,高天总结道,虽然这期视频难度偏大,需要理解大量底层虚拟机的抽象概念,但如果开发者真正想弄懂 Python 底层的运行机制,Code Object 是绝对躲不开的一块基石。
- 关键局部变量与名字属性对照: - `co_varnames`:函数参数与本地局部变量名。 - `co_names`:除局部/闭包变量外,所有被引用的属性名、导入名或全局对象名。 - `co_cellvars`:本地创建并被内部嵌套闭包引用的变量名。 - `co_freevars`:引自外层非全局作用域的变量名。 - `co_consts`:代码中包含的实际 Python 常量对象集合(第一项通常是 `None`)。
## Notable quotes > [10:00](https://www.youtube.com/watch?v=2ifX3yAnAFc&t=600s) co_names 和 co_name 没有一毛钱的关系,这里这个名字的设计稍微有点愚蠢,它没有任何关系。 — 高天
## Mentioned 高天, Python, CPython, B站, Code Object, Function Object, inspect, dis
## Video description 这期视频讲个硬核的内容,Code Object。Python的代码最终都会被编译成code object,那code object里包含了什么内容呢?不是我夸张,一般人真的搞不明白。你去翻官方文档,都未必比我讲得清楚。
[Source](https://www.youtube.com/watch?v=2ifX3yAnAFc)