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)

因此,“运行一个函数”并不是简单地把函数体文本重新读取一遍,而是:

  1. 函数定义阶段,函数体已经被编译为一个代码对象。
  2. 调用函数时,根据这个代码对象创建或准备一个执行帧。
  3. 帧保存当前执行所需的局部状态。
  4. 解释器从帧中的指令位置开始执行字节码。
  5. 函数返回、抛出异常,或者在生成器与协程中暂时挂起。

可以用一个抽象状态表示正在执行的帧:

F=(C,L,G,B,I,S,K)F = (C, L, G, B, I, S, K)

其中:

  • CC:代码对象,即当前执行的程序;
  • LL:局部名称空间;
  • GG:全局名称空间;
  • BB:内置名称空间;
  • II:当前指令位置;
  • SS:解释器操作数栈;
  • KK:调用者或外层帧的连接。

这个公式不是 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
  • 右侧是乘法;
  • 乘法右操作数又是加法;
  • pricetax 是名称;
  • 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=[])

这里的 LoadStore 很重要:

  • 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

原始表达式是 2×3=62 \times 3 = 6,转换后两个整数分别变成 34,所以结果是 3×4=123 \times 4 = 12

但 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 没有在 innerouter 中绑定,因此运行时需要从全局名称空间查找;
  • youter 中绑定,但被 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)

可以观察缓存或自适应后的指令信息。

这带来两个边界:

  1. co_code 不应被当作稳定的二进制接口。
  2. 反汇编结果可能受执行次数、解释器版本和显示参数影响。

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

函数返回不仅返回一个对象,还意味着:

  1. 当前帧的执行结束;
  2. 当前帧的操作数栈被清理或释放;
  3. 调用者恢复执行;
  4. 返回值被放回调用者的求值上下文。

如果函数没有显式 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)

执行模块代码对象:

  1. 解释器执行函数定义对应的指令;
  2. 创建函数对象;
  3. 将名称 add 绑定到函数对象;
  4. 函数体尚未执行。

第四步:调用 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 加法指令”。运行时可能涉及:

  1. 从当前帧读取 ab
  2. 根据对象类型选择二元运算逻辑;
  3. 调用相应的特殊方法或内置实现;
  4. 创建或返回结果对象;
  5. 把结果放回操作数栈。

因此,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

这说明名称解析不是“执行到哪一行才临时决定”的简单过程。编译阶段会分析代码块中的绑定情况,而运行时帧负责保存这些局部绑定的实际值。名称绑定规则、globalnonlocal 和自由变量共同决定了后续使用哪一种字节码和哪一个名称空间。(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)

可以按以下顺序理解:

  1. 源码阶段:文本包含一个函数定义和一次调用。
  2. 解析阶段:源码被识别为模块代码块,函数体形成嵌套代码结构。
  3. AST 阶段:赋值、函数定义、返回、乘法和名称读取被表示为树节点。
  4. 编译阶段:模块和函数分别形成代码对象;参数被归入局部变量,乘法形成相应字节码。
  5. 定义阶段:执行模块代码时创建函数对象,并把 area 绑定到它。
  6. 调用阶段:执行 area(3, 4),创建该次调用的帧。
  7. 局部绑定:帧把 width 绑定到整数对象 3,把 height 绑定到整数对象 4
  8. 指令执行:读取局部对象,执行乘法,将结果 12 放入操作数栈。
  9. 返回阶段RETURN_VALUE12 交还给模块帧。
  10. 名称绑定:模块帧把名称 result 绑定到整数对象 12
  11. 调用栈恢复:函数帧结束,模块帧继续执行后续指令。

最终可以用一句更准确的话概括:

Python 源码先被组织成代码块并解析为 AST,再编译为代码对象;在 CPython 中,代码对象包含字节码,字节码在解释器帧中执行;函数调用通过帧连接成调用栈,返回、异常和生成器挂起则改变这些帧的生命周期与控制流。

这条链路也解释了为什么名称、对象与绑定属于语义基础,AST、代码对象与字节码属于编译观察层,帧与调用栈属于运行时状态层,而 inspecttracebackdis 则是连接这些层次的自省工具。


系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。