Python 基础体系 · 第 3/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。
Python 执行模型:源码、AST、字节码、解释器帧与调用栈
理解 Python 的执行过程,不能停留在“源码被解释器逐行执行”这句简化描述上。对 CPython 而言,一段 Python 程序通常要经历:
源码文本
↓
词法分析与语法分析
↓
AST(抽象语法树)
↓
代码对象(code object)
↓
字节码指令
↓
解释器帧(frame)
↓
解释器循环执行
↓
调用栈、异常传播、返回或挂起
这条链路中有两类概念必须区分:
- 语言规范层:代码块、名称绑定、作用域、函数调用、异常传播等语义。
- CPython 实现层:AST 编译器、代码对象、字节码、解释器帧、内联缓存、解释器循环等实现细节。
Python 语言规范并不要求所有 Python 实现都使用 CPython 的字节码或相同的帧结构;而 dis 文档明确说明,Python 字节码是 CPython 的实现细节,不保证跨 Python 版本或跨虚拟机保持稳定。(docs.python.org)
一、先建立总模型:Python 执行的对象是什么
Python 语言参考将程序划分为若干代码块。模块、函数体、类定义、交互式输入、传递给 exec() 的代码,以及传递给 eval() 的表达式,都可以构成代码块。代码块在执行帧中执行。(docs.python.org)
因此,“运行一个函数”并不是简单地把函数体文本重新读取一遍,而是:
- 函数定义阶段,函数体已经被编译为一个代码对象。
- 调用函数时,根据这个代码对象创建或准备一个执行帧。
- 帧保存当前执行所需的局部状态。
- 解释器从帧中的指令位置开始执行字节码。
- 函数返回、抛出异常,或者在生成器与协程中暂时挂起。
可以用一个抽象状态表示正在执行的帧:
其中:
- :代码对象,即当前执行的程序;
- :局部名称空间;
- :全局名称空间;
- :内置名称空间;
- :当前指令位置;
- :解释器操作数栈;
- :调用者或外层帧的连接。
这个公式不是 Python 公开 API,而是帮助理解运行过程的模型。实际 CPython 的内部布局会随版本变化。
二、源码不是执行单位,代码块才是
2.1 源码是文本,代码块是语义单位
例如下面的文件:
# demo.py
x = 10
def add(a, b):
return a + b
y = add(x, 5)
它首先是一个源码文件,也就是字符序列。解释器不会直接把字符 a、+、b 当作运行时操作,而是先把文本分析成程序结构。
这个文件至少包含两个层次的代码块:
模块代码块
├── x = 10
├── def add(...): ...
└── y = add(...)
函数代码块 add
└── return a + b
模块代码块在加载模块时执行。函数代码块在调用 add() 时执行。def add(...) 这条语句本身执行时,主要作用是创建函数对象,并将名称 add 绑定到该函数对象;函数体并不会在定义阶段执行。
这也解释了一个常见现象:
def fail():
return missing_name
print("function defined")
执行文件时可以正常打印:
function defined
因为函数体只是被编译并封装为函数对象。只有调用:
fail()
才会进入函数代码块,并在执行 missing_name 时产生 NameError。
2.2 语法错误和运行时错误发生在不同阶段
def broken(
return 1
这是语法结构不完整,解释器无法构建有效的程序结构,因此在解析或编译阶段产生 SyntaxError。
而:
def broken():
return 1 / 0
broken()
语法是合法的,函数也可以被定义;错误发生在调用后的运行阶段,产生 ZeroDivisionError。
可以把错误阶段简化为:
文本读取失败 → 文件或编码错误
词法/语法分析失败 → SyntaxError
编译阶段失败 → SyntaxError、相关编译错误
字节码执行失败 → NameError、TypeError、ValueError 等
用户代码主动抛出 → raise 的异常
“能解析”也不等于“能执行”。官方 ast 文档特别指出:源码成功解析为 AST,并不保证后续编译一定成功,编译阶段仍可能发现额外的 SyntaxError。(docs.python.org)
三、AST:保留程序结构,抹去大部分表面形式
3.1 AST 是什么
AST 是 Abstract Syntax Tree,即抽象语法树。
“树”表示程序的嵌套结构;“抽象”表示它不保留所有原始文本细节,例如许多括号、空白和注释不会成为执行语义的一部分。
源码:
total = price * (1 + tax)
可以抽象成:
Assign
├── target: Name("total")
└── value: BinOp("*")
├── left: Name("price")
└── right: BinOp("+")
├── left: Constant(1)
└── right: Name("tax")
AST 关心的是:
- 这是一次赋值;
- 左侧目标是名称
total; - 右侧是乘法;
- 乘法右操作数又是加法;
price、tax是名称;1是常量。
它不关心源码中是否使用了额外括号,也不直接表示名称当前绑定了哪个对象。名称解析和对象查找发生在后续编译与执行阶段。
3.2 使用 ast.parse() 查看 AST
import ast
source = """
total = price * (1 + tax)
"""
tree = ast.parse(source, mode="exec")
print(ast.dump(tree, indent=2))
典型输出的结构类似:
Module(
body=[
Assign(
targets=[
Name(id='total', ctx=Store())],
value=BinOp(
left=Name(id='price', ctx=Load()),
op=Mult(),
right=BinOp(
left=Constant(value=1),
op=Add(),
right=Name(id='tax', ctx=Load()))))],
type_ignores=[])
这里的 Load 和 Store 很重要:
Name(id="price", ctx=Load())表示读取price;Name(id="total", ctx=Store())表示将结果绑定到total。
这不是“变量盒子”的操作。执行赋值时,Python 通常是计算右侧对象,然后把名称绑定到该对象:
a = []
b = a
AST 能表达两个赋值目标,但对象共享关系要到运行时才能确定:
a ─┐
├──> 同一个列表对象
b ─┘
因此,AST 解决的是“程序写了什么结构”,不是“运行时每个名称最终指向什么对象”。
3.3 AST 变换不是安全执行
AST 适合做代码分析、重写和静态检查。例如,下面的转换器把所有整数常量加一:
import ast
class AddOne(ast.NodeTransformer):
def visit_Constant(self, node):
if isinstance(node.value, int):
return ast.copy_location(
ast.Constant(value=node.value + 1),
node,
)
return node
source = "result = 2 * 3"
tree = ast.parse(source, mode="exec")
tree = AddOne().visit(tree)
tree = ast.fix_missing_locations(tree)
namespace = {}
code = compile(tree, filename="<transformed>", mode="exec")
exec(code, namespace)
print(namespace["result"])
输出:
12
原始表达式是 ,转换后两个整数分别变成 3 和 4,所以结果是 。
但 AST 转换并不等于安全沙箱。攻击者提交的代码仍然可能包含属性访问、函数调用、导入或其他危险行为。eval() 和 exec() 官方文档都明确警告:执行不可信输入会造成安全漏洞;限制 __builtins__ 也不是可靠的安全机制。(docs.python.org)
四、从 AST 到代码对象:编译阶段确定执行策略
4.1 compile() 的三个常见模式
compile() 将源码或 AST 编译成代码对象。最常见的 mode 有三种:
| 模式 | 输入形态 | 典型用途 |
|---|---|---|
exec |
一组语句 | 模块、脚本、动态执行 |
eval |
一个表达式 | 计算表达式结果 |
single |
交互式单条输入 | REPL 风格执行 |
示例:
expr_code = compile("1 + 2", "<expr>", "eval")
print(eval(expr_code))
输出:
3
eval() 接收的是表达式,而 exec() 接收的是语句序列;两者也都可以接受已经编译好的代码对象。(docs.python.org)
statement_code = compile(
"x = 10\nprint(x)",
"<exec>",
"exec",
)
exec(statement_code)
输出:
10
exec() 的返回值始终是 None;即使代码对象内部执行了赋值或打印,也不会把最后一个表达式的结果作为返回值交给调用者。
4.2 代码对象是什么
代码对象是编译后的、可执行的程序描述。函数对象通常持有一个代码对象:
def add(a, b):
return a + b
print(add.__code__)
print(add.__code__.co_name)
print(add.__code__.co_varnames)
print(add.__code__.co_consts)
代码对象中常见的信息包括:
co_name:代码对象名称;co_qualname:限定名称;co_varnames:局部变量和参数名称;co_names:需要从全局或其他名称空间查找的名称;co_consts:常量;co_code:字节码序列;co_stacksize:解释器操作数栈所需的最大深度;co_freevars:从外层闭包读取的自由变量;co_cellvars:需要被内部函数捕获的变量。
inspect 文档列出了这些代码对象属性,并将 co_lines()、co_positions() 等接口用于把字节码位置映射回源代码位置。(docs.python.org)
4.3 编译阶段会分析名称属于哪一类
考虑:
x = "global"
def outer():
y = "outer"
def inner():
return x, y
return inner
对于 inner():
x没有在inner或outer中绑定,因此运行时需要从全局名称空间查找;y在outer中绑定,但被inner使用,因此它是inner的自由变量;outer必须把y保存在可被内部函数捕获的 cell 中。
可以查看:
fn = outer()
print(fn.__code__.co_names)
print(fn.__code__.co_freevars)
print(fn.__closure__)
大致可以观察到:
co_names 包含 x
co_freevars 包含 y
co_names 不是局部变量表
这与 Python 执行模型中的名称绑定规则一致:如果名称在一个代码块中绑定,它通常是该块的局部变量;如果名称在代码块中被使用但没有在那里定义,则可能成为自由变量或通过全局、内置名称空间解析。(docs.python.org)
五、字节码:解释器执行的低级指令
5.1 字节码不是机器码
字节码是面向 Python 虚拟机的指令序列,不是 CPU 直接执行的机器指令。
例如:
def add(a, b):
return a + b
可以使用 dis 查看:
import dis
dis.dis(add)
Python 3.14 中,输出可能类似:
2 RESUME 0
3 LOAD_FAST_BORROW 0 (a)
LOAD_FAST_BORROW 1 (b)
BINARY_OP 0 (+)
RETURN_VALUE
具体指令名称、参数编号和布局属于 CPython 实现细节,不能把某一次 dis.dis() 的输出当作跨版本协议。Python 3.14 的 dis 支持显示位置、偏移量、缓存和专门化字节码等信息;字节码本身仍不保证在不同 Python 版本之间稳定。(docs.python.org)
5.2 栈式虚拟机如何计算表达式
CPython 字节码使用解释器操作数栈。以:
def calc(a, b):
return a * (b + 1)
为例,抽象指令可能对应以下步骤:
初始 STACK = []
LOAD_FAST a
STACK = [a]
LOAD_FAST b
STACK = [a, b]
LOAD_CONST 1
STACK = [a, b, 1]
BINARY_OP +
取出 b 和 1,压入 b + 1
STACK = [a, b + 1]
BINARY_OP *
取出 a 和 b + 1,压入 a * (b + 1)
STACK = [a * (b + 1)]
RETURN_VALUE
弹出栈顶值,作为函数返回值
字节码文档用类似的 STACK 记法描述指令行为。例如二元操作会弹出右操作数和左操作数,再把计算结果压回栈顶。(docs.python.org)
这解释了为什么表达式的求值顺序可以被精确描述。对于:
result = f() + g()
解释器必须先执行函数调用并得到两个对象,再执行加法。若 f() 抛出异常,g() 不会被调用;若 g() 抛出异常,加法也不会发生。
5.3 Python 3.11 以后存在自适应与内联缓存
现代 CPython 不只是读取固定的通用字节码。部分指令会根据运行时观察到的对象类型进行专门化,并使用字节码附近的缓存保存信息。
例如,属性访问可能在多次执行后针对稳定的对象布局进行优化。使用:
dis.dis(func, show_caches=True, adaptive=True)
可以观察缓存或自适应后的指令信息。
这带来两个边界:
co_code不应被当作稳定的二进制接口。- 反汇编结果可能受执行次数、解释器版本和显示参数影响。
CACHE 在逻辑上属于前一条指令的附加空间;直接修改自适应字节码或把缓存区域误认为普通指令,可能得到错误结论。(docs.python.org)
六、解释器帧:一次代码执行的运行时状态
6.1 帧不是函数对象,也不是调用栈本身
三个概念经常被混淆:
- 函数对象:可调用对象,通常持有代码对象、全局名称空间、默认参数和闭包等。
- 代码对象:描述“要执行什么”。
- 解释器帧:描述“当前执行到哪里,以及当前执行的局部状态”。
可以类比为:
函数对象 = 可调用的程序包装
代码对象 = 编译后的程序内容
解释器帧 = 一次具体调用的运行现场
同一个函数可以被递归调用多次。每次调用都有自己的局部参数和执行位置,因此必须有彼此独立的运行状态。
def countdown(n):
if n:
return countdown(n - 1)
return 0
调用 countdown(3) 时,逻辑上会形成:
countdown(n=3)
└── countdown(n=2)
└── countdown(n=1)
└── countdown(n=0)
每一层都有自己的 n。如果所有调用共享同一个局部变量槽位,递归就无法保持正确状态。
6.2 帧的关键属性
Python 层面的帧对象可以通过 inspect、异常回溯或跟踪函数获得。常见属性包括:
f_code:当前执行的代码对象;f_locals:当前帧用于查找局部变量的映射;f_globals:当前帧的全局名称空间;f_builtins:当前帧可见的内置名称空间;f_back:调用者方向的上一帧;f_lasti:当前或最近执行的精确指令位置;f_lineno:当前源代码行;f_generator:拥有该帧的生成器或协程;普通函数帧中为None,该属性在 Python 3.14 中加入。(docs.python.org)
下面的程序可以观察调用链:
import inspect
def leaf():
frame = inspect.currentframe()
try:
print("当前函数:", frame.f_code.co_name)
print("当前行号:", frame.f_lineno)
print("局部变量:", dict(frame.f_locals))
print("调用者:", frame.f_back.f_code.co_name)
finally:
# 避免 frame 与局部变量形成不必要的引用环
del frame
def middle():
value = 42
leaf()
middle()
输出形式类似:
当前函数: leaf
当前行号: ...
局部变量: {'frame': <frame ...>}
调用者: middle
行号会随文件布局变化。inspect.currentframe() 在 CPython 中依赖解释器对 Python 帧的支持,并非所有 Python 实现都保证提供;如果实现不支持,可能返回 None。此外,保存帧对象可能延长局部对象生命周期,因此调试代码应注意释放帧引用。(docs.python.org)
6.3 f_locals 不是普通字典的简单别名
优化作用域中的局部变量通常不会简单地存放在一个普通字典里,而可能使用更高效的局部存储。Python 3.13 起,优化作用域的 frame.f_locals 可以返回一个写穿代理;这意味着不应依赖“修改 f_locals 字典就一定能改变正在执行的局部变量”这种旧的、过度简化的假设。(docs.python.org)
因此下面的思路不应作为可靠的运行时改变量机制:
frame.f_locals["x"] = 100
它适合观察和调试,不适合作为业务代码中的动态赋值协议。
七、调用栈:帧如何连接成执行路径
7.1 调用栈记录“谁调用了谁”
当函数调用另一个函数时,解释器需要保存当前函数的恢复位置:
def a():
return b()
def b():
return c()
def c():
raise RuntimeError("failed")
a()
异常发生时,逻辑上的调用链是:
a
└── b
└── c ← RuntimeError
对应的帧链可以抽象为:
当前帧 c
f_back → b
f_back → a
f_back → 模块帧
f_back 的方向是从当前帧指向调用者。异常对象本身通常会关联 traceback,而 traceback 又包含发生异常的帧、指令位置和源代码行等信息。(docs.python.org)
7.2 返回时发生了什么
设:
def square(x):
return x * x
value = square(4)
执行流程可以写成:
模块帧:
准备参数 4
调用 square
保存返回后的继续位置
────────────────────────
square 帧:
局部绑定 x = 4
读取 x
执行乘法
得到 16
RETURN_VALUE
────────────────────────
模块帧:
取回 16
将名称 value 绑定到 16
函数返回不仅返回一个对象,还意味着:
- 当前帧的执行结束;
- 当前帧的操作数栈被清理或释放;
- 调用者恢复执行;
- 返回值被放回调用者的求值上下文。
如果函数没有显式 return,其代码路径会产生 None 返回值:
def nothing():
pass
print(nothing())
输出:
None
7.3 异常不是普通返回值
执行:
def divide(a, b):
return a / b
def wrapper():
return divide(1, 0)
wrapper()
当 divide() 产生异常时,解释器不会把一个普通对象返回给 wrapper()。异常会沿着调用链向外传播:
divide 帧:创建或传播 ZeroDivisionError
↓
wrapper 帧:没有匹配的 except,继续传播
↓
模块帧:没有匹配的 except,终止当前代码块
如果中间某层捕获异常:
def wrapper():
try:
return divide(1, 0)
except ZeroDivisionError:
return 0
传播路径就在 wrapper 帧内终止,wrapper() 最终正常返回 0。
八、生成器和协程:帧可以暂停,而不是立即销毁
普通函数通常从调用开始执行,到 return 或异常结束。生成器不同:
def numbers():
yield 1
yield 2
调用:
g = numbers()
并不会立即执行函数体。此时得到的是生成器对象。
print(next(g)) # 1
print(next(g)) # 2
第一次 next(g) 执行到第一个 yield 后暂停;第二次 next(g) 从保存的执行位置继续,而不是从函数开头重新执行。
生成器因此需要保存:
- 代码对象;
- 局部变量;
- 当前指令位置;
- 操作数栈中仍然需要保留的状态;
- 暂停和恢复所需的运行信息。
可以观察其帧:
def numbers():
current = 1
yield current
current += 1
yield current
g = numbers()
print(g.gi_frame.f_lasti)
print(next(g))
print(g.gi_frame.f_lasti)
print(next(g))
print(g.gi_frame)
指令位置的具体数值是实现细节,但在两次 next() 之间可以看到帧状态发生了变化。Python 3.14 的帧对象还提供了 f_generator,用于指向拥有该帧的生成器或协程。(docs.python.org)
这里需要区分两个“栈”:
- 调用栈:表示当前活动调用路径;
- 生成器对象保存的帧状态:表示暂停后将来恢复的位置。
生成器暂停后,它不再像普通函数那样持续占用活动执行路径,但其帧状态仍然由生成器对象持有。
九、从源码到字节码的完整观察实验
下面的程序把源码、AST、代码对象和字节码串起来:
import ast
import dis
source = """
def add(a, b):
result = a + b
return result
"""
# 1. 解析为 AST
tree = ast.parse(source, filename="<demo>", mode="exec")
print("=== AST ===")
print(ast.dump(tree, indent=2))
# 2. 编译为模块代码对象
module_code = compile(tree, filename="<demo>", mode="exec")
print("\n=== module code ===")
print(module_code)
print("常量:", module_code.co_consts)
# 3. 执行模块代码,创建 add 函数对象
namespace = {}
exec(module_code, namespace)
add = namespace["add"]
print("\n=== function code ===")
print("名称:", add.__code__.co_name)
print("局部变量:", add.__code__.co_varnames)
print("常量:", add.__code__.co_consts)
print("\n=== bytecode ===")
dis.dis(add)
每一步的因果关系是:
第一步:ast.parse()
源码被解析成树。此时可以检查语法结构,也可以变换节点,但还没有执行 add()。
第二步:compile()
AST 被编译成模块代码对象。模块代码对象的常量表中通常会包含嵌套函数的代码对象,因为函数定义本身需要在模块执行时创建函数对象。
第三步:exec(module_code, namespace)
执行模块代码对象:
- 解释器执行函数定义对应的指令;
- 创建函数对象;
- 将名称
add绑定到函数对象; - 函数体尚未执行。
第四步:调用 add
只有执行:
add(2, 3)
才会进入 add 对应的代码对象,并为这次调用准备帧。
第五步:dis.dis(add)
dis 读取函数关联的代码对象,展示其字节码。展示的指令名称可能因 CPython 3.14 的专门化状态和显示选项变化,不应把输出文本硬编码到程序逻辑中。(docs.python.org)
十、为什么“逐行解释执行”是一个危险的模型
下面这句常见说法:
Python 是解释型语言,所以解释器逐行执行源码。
它有一定的教学价值,但不适合解释真实行为。
10.1 函数体不是调用时重新解析
def f(x):
return x + 1
函数定义阶段通常已经完成语法分析和编译。调用 f(10) 时,解释器执行的是关联代码对象中的指令,而不是重新扫描字符串 return x + 1。
10.2 一行源码不一定对应一条指令
result = f(g(x), h(y))
一行源码可能包含:
- 多次名称读取;
- 多次函数调用;
- 参数组装;
- 返回值处理;
- 最终赋值;
- 异常边界。
反过来,某些字节码指令也可能没有直接对应的可见源码行。调试器和性能分析器需要使用代码对象中的位置映射,把指令范围映射到源代码行、列和结束位置。co_lines()、co_positions() 以及 dis 的位置显示功能就是为此提供支持。(docs.python.org)
10.3 解释器执行的是对象操作
a + b
并不等价于“把两个整数放进 CPU 加法指令”。运行时可能涉及:
- 从当前帧读取
a和b; - 根据对象类型选择二元运算逻辑;
- 调用相应的特殊方法或内置实现;
- 创建或返回结果对象;
- 把结果放回操作数栈。
因此,Python 的运行速度、异常行为和副作用都不能只用源代码表面结构解释。
十一、名称解析发生在帧的名称空间中
考虑:
x = "module"
def f():
x = "local"
return x
print(f())
f() 返回 "local",因为函数帧中的局部绑定遮蔽了模块级名称。
再看:
x = "module"
def f():
return x
print(f())
函数帧中没有绑定 x,解释器会继续按照名称解析规则查找外层可见环境,最终找到模块全局名称。
而下面的代码会失败:
x = "module"
def f():
print(x)
x = "local"
f()
即使运行时第一条语句前模块中已有 x,编译器仍会根据函数代码块中出现的绑定操作,把 x 判定为函数局部变量。于是 print(x) 试图读取一个尚未绑定的局部变量,产生:
UnboundLocalError
这说明名称解析不是“执行到哪一行才临时决定”的简单过程。编译阶段会分析代码块中的绑定情况,而运行时帧负责保存这些局部绑定的实际值。名称绑定规则、global、nonlocal 和自由变量共同决定了后续使用哪一种字节码和哪一个名称空间。(docs.python.org)
十二、如何用反射工具诊断执行状态
12.1 查看代码对象与字节码
import dis
def process(value):
doubled = value * 2
return doubled + 1
print(process.__code__.co_varnames)
print(process.__code__.co_names)
dis.dis(process)
适合回答:
- 哪些名称被视为局部变量?
- 函数是否包含闭包自由变量?
- 表达式大致被分解成了哪些指令?
- 某次 Python 版本是否改变了指令布局?
不适合回答:
- 某个名称当前绑定了哪个对象;
- 某条指令在所有 Python 版本中都一定存在;
- 字节码是否可以安全地跨版本持久化。
12.2 查看当前调用栈
import inspect
def show_stack():
for item in inspect.stack(context=0):
print(
item.function,
item.filename,
item.lineno,
)
def service():
show_stack()
service()
inspect.stack() 返回当前调用者方向的帧信息列表,inspect.getouterframes() 可以从一个帧继续查看外层调用。发生异常时,inspect.trace() 和 traceback 对象则适合查看异常传播路径。(docs.python.org)
这类工具适合:
- 调试框架调用链;
- 打印异常上下文;
- 编写调试器和性能分析器;
- 确认当前代码处于哪个模块、函数和源代码位置。
但它们会访问帧和源代码信息,可能带来额外开销。尤其是保存帧、回溯或 FrameInfo,可能使局部对象继续存活,形成引用环或延长资源生命周期。
12.3 使用跟踪功能观察行级或指令级执行
帧提供了:
f_trace;f_trace_lines;f_trace_opcodes。
这些属性用于调试器和跟踪函数。开启逐指令跟踪可以观察更细粒度的执行过程,但跟踪函数会显著改变执行成本,也更容易暴露解释器内部边界。帧文档明确提醒,在请求逐操作码事件时,如果跟踪函数抛出的异常逃逸,可能导致未定义的解释器行为。(docs.python.org)
因此,生产环境通常不会长期启用逐指令跟踪;需要诊断时,应限制范围、持续时间和采样对象。
十三、导入、缓存与“执行一次”的边界
当 Python 导入一个模块时,模块源码会被解析、编译并执行模块代码块。CPython 通常还会使用字节码缓存文件帮助后续启动,但缓存不是“源码的永久等价物”:
- Python 版本变化可能使缓存失效;
- 源码或文件状态变化可能触发重新编译;
- 不同解释器实现不一定使用相同缓存格式;
.pyc中保存的是实现相关的代码对象序列化结果,不是跨版本稳定的公共协议。
因此,工程代码不应依赖手工修改 .pyc 或假定其字节码可以跨 Python 版本运行。字节码和代码对象的稳定性边界仍由具体解释器版本决定。(docs.python.org)
十四、常见误解与对应的真实边界
误解一:AST 就是字节码
不是。
AST:表达程序结构和语义类别
字节码:表达解释器要执行的低级操作
一个 AST 节点可能对应多条字节码;编译器还可能进行常量折叠、控制流处理、名称分类和缓存布局安排。
误解二:代码对象就是函数
不是。
代码对象描述可执行代码,但它本身不等于完整的函数调用对象。函数对象还需要关联全局名称空间、默认参数、关键字默认值、闭包等运行环境。
def f(x=10):
return x
print(type(f))
print(type(f.__code__))
通常得到:
<class 'function'>
<class 'code'>
误解三:调用栈等于源码中的函数列表
不完全是。
调用栈是运行时活动帧的连接。生成器和协程可以暂停并保存帧状态;异常回溯还可能保留已经退出的执行路径。源码中看似没有普通函数调用的框架代码,也可能通过装饰器、迭代器协议、上下文管理器或特殊方法产生额外调用。
误解四:f_locals 可以随时修改局部变量
不应这样假设。局部变量可能以优化形式存储,f_locals 是观察接口,而不是通用的动态局部变量写入协议。(docs.python.org)
误解五:限制 eval() 的 __builtins__ 就实现了沙箱
不是。官方文档明确警告,eval() 和 exec() 执行不可信输入会导致安全漏洞;限制内置名称不能构成可靠的安全边界。真正的隔离需要进程、权限、资源限制和操作系统级安全策略,而不是只传入一个裁剪后的字典。(docs.python.org)
误解六:dis 输出可以作为稳定测试快照
不可靠。字节码是 CPython 实现细节,版本升级可能改变指令、参数、缓存和专门化形式。若测试的是语言行为,应测试输入、输出、异常和副作用;若测试的是解释器优化,则必须明确绑定 CPython 版本、构建方式和显示选项。(docs.python.org)
十五、用一条主线记住整个执行模型
对于:
def area(width, height):
return width * height
result = area(3, 4)
可以按以下顺序理解:
- 源码阶段:文本包含一个函数定义和一次调用。
- 解析阶段:源码被识别为模块代码块,函数体形成嵌套代码结构。
- AST 阶段:赋值、函数定义、返回、乘法和名称读取被表示为树节点。
- 编译阶段:模块和函数分别形成代码对象;参数被归入局部变量,乘法形成相应字节码。
- 定义阶段:执行模块代码时创建函数对象,并把
area绑定到它。 - 调用阶段:执行
area(3, 4),创建该次调用的帧。 - 局部绑定:帧把
width绑定到整数对象3,把height绑定到整数对象4。 - 指令执行:读取局部对象,执行乘法,将结果
12放入操作数栈。 - 返回阶段:
RETURN_VALUE把12交还给模块帧。 - 名称绑定:模块帧把名称
result绑定到整数对象12。 - 调用栈恢复:函数帧结束,模块帧继续执行后续指令。
最终可以用一句更准确的话概括:
Python 源码先被组织成代码块并解析为 AST,再编译为代码对象;在 CPython 中,代码对象包含字节码,字节码在解释器帧中执行;函数调用通过帧连接成调用栈,返回、异常和生成器挂起则改变这些帧的生命周期与控制流。
这条链路也解释了为什么名称、对象与绑定属于语义基础,AST、代码对象与字节码属于编译观察层,帧与调用栈属于运行时状态层,而 inspect、traceback 和 dis 则是连接这些层次的自省工具。
系列导航与关联阅读
- 系列入口:Python 完整学习路线:从语言模型、并发到 Web、数据、AI 与生产交付
- 上一篇:Python 3.14 工具链:安装、解释器、REPL、脚本与版本管理
- 下一篇:Python 词法与语法基础:缩进、Token、表达式、语句和注释
- 延伸:Python 名称、对象与绑定:变量不是盒子,赋值不是复制
- 延伸:Python 反射与自省:inspect、签名、Frame、AST 和安全边界
官方资料
本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。

评论
0 条讨论