明明在赋值前读取,为什么还报 UnboundLocalError?——从 CPython 符号表拆解函数级作用域

你在函数里写了一句 count += 1,跑起来直接抛:

csharp 复制代码
UnboundLocalError: cannot access local variable 'count' where it is not associated with a value

(Python 3.10 及以前,同一个错误的文案是 UnboundLocalError: local variable 'count' referenced before assignment。)

你明明在函数外面写过 count = 0,报错却说它是「局部变量(local variable)」。函数里你没写 local 这个词,也没声明任何东西,它凭什么说它是局部的?

这篇给出结论:不是变量没定义,是 CPython 在编译阶段就把它整段判定成局部变量了。下面先给三种能直接抄的改法,再从 CPython 源码讲清判定规则,最后给五个真实生产案例、一份版本差异表、一个实战复盘和一条今晚就能跑的自检命令。

TL;DR

  1. 根因:CPython 编译一个函数时,会对整个函数体 做一次静态扫描。只要某个名字在函数内任意位置 出现过赋值(或 del),整个函数体内这个名字就一律被判定为局部变量。
  2. 判定在编译期 完成,报错发生在运行时。所以「赋值语句写在读取后面」「那条赋值根本没被执行到」,都不改变判定结果。
  3. 三种能直接抄的改法:① 真要用外部变量就加 global;② 让变量在所有分支上都有初值;③ 别跨作用域读写同一个名字,用参数传入、用返回值传出。
  4. 一条命令定位:dis.dis(你的函数),看那个名字编译成的是 LOAD_FAST 还是 LOAD_GLOBAL。看到 LOAD_FAST,就说明它已被判成局部变量。

一、先给能直接抄的三种改法

触发器只有一句话:函数内出现了对该名字的赋值,于是整个函数内它都是局部变量。顺着这句话,修法就三种。

场景 你想要的语义 ❌ 报错写法 ✅ 正确写法
读写函数外的全局变量 修改全局 def f(): x += 1 def f(): global x x += 1
函数内自己的临时变量 只在本函数用 def f(): if cond: r = 1 return r def f(): r = None if cond: r = 1 return r
循环里累积 由外部传入的初值 def f(): total += i def f(x): return x + 1(把初值当参数)

关键判断:你到底是「读外面的」,还是「写自己的」 。想读外面的(修改全局)就 global;只写自己的就先给初值。绝大多数线上事故都出在第二种,也就是「想写自己的,却忘了某条分支不给它赋值」。

二、为什么「函数内一赋值,整个函数就变局部」------CPython 编译期机制

这一步不搞清,下次换个写法你还会栽。下面全部是 CPython 源码里的真实行为。

符号表:编译期的一次性静态扫描

CPython 在执行函数前,先把源码编成字节码。编译的第一步是遍历语法树建符号表(symbol table) ,实现在 Python/symtable.c。

对每一个名字节点,符号表只看它的「上下文」:是读还是写。

c 复制代码
// Python/symtable.c, symtable_visit_expr(), case Name_kind(3.12,约 2171 行)
if (!symtable_add_def(st, e->v.Name.id,
                      e->v.Name.ctx == Load ? USE : DEF_LOCAL, LOCATION(e)))

读(Load)只标 USE;赋值、删除(Store / Del)标 DEF_LOCAL。同一个名字出现多次就做位或累积(val |= flag,约 1278 行)。

第二遍 analyze_block() 定作用域:命中 DEF_BOUND(含 DEF_LOCAL 位)就把该名字设为 LOCAL。

这就是那条反直觉规则的来源 :这一遍是对整个函数块的一次静态扫描,不是按执行顺序逐行动态判断。只要函数内任何位置出现过一次赋值,DEF_LOCAL 就被置位一次,整个函数块里这个名字都解析成局部变量、进入 co_varnames。赋值语句写在读取前面还是后面、会不会被执行到,都不影响结论。

代价换来的收益:LOAD_FAST 与 LOAD_GLOBAL

作用域定完,Python/compile.c 的 compiler_nameop() 用 _PyST_GetScope() 查作用域位,再决定发哪条字节码:

  • LOAD_FAST :按下标 从当前帧的 fast-locals 数组(co_varnames)直接取值。是数组索引,不走字典,这正是「函数内一赋值就整体变局部」换来的性能收益。
  • LOAD_GLOBAL :按名字先查模块 globals,再查 builtins。

取值时如果这个 fast-local 槽位还是空的(NULL),就报错。看 Python/bytecodes.c 的 LOAD_FAST_CHECK:

c 复制代码
// Python/bytecodes.c(3.12,约 185 行)
ERROR_IF(value == NULL, unbound_local_error);

编译期能证明「到这里肯定已绑定」时,发的才是普通 LOAD_FAST(约 191 行,带 assert(value != NULL))。带分支、编译期无法证明的,就发 LOAD_FAST_CHECK,运行时兜底报错。你看到的 UnboundLocalError,就是 unbound_local_error 这个错误标签抛出来的。

报错文案为什么在 3.11 变了

老文案「local variable 'x' referenced before assignment」让人以为是「没定义」,实际是「名字已确定为局部,但还没绑定值」。CPython 在 2021 年改了口径,合并提交 7b1f527d(PR GH-24976,对应 bpo-17792):

diff 复制代码
 #define UNBOUNDLOCAL_ERROR_MSG \
-    "local variable '%.200s' referenced before assignment"
+    "cannot access local variable '%s' where it is not associated with a value"

所以 3.11 起你看到的是 cannot access local variable ,3.10 及以前是 referenced before assignment。搜报错时两种文案都要试,老教程基本只讲旧文案。

需要说明的是:这次改动没有写进 3.11 的 What's New(逐行检索该文档并无匹配),权威出处是 bpo-17792 与 GH-24976。别只翻发布说明。

版本差异表(同一个 bug,三代表现不同)

Python 版本 报错文案 新增/变化的行为 对你的影响
3.10 及以前 local variable 'x' referenced before assignment 局部变量报 UnboundLocalError,自由变量报 NameError 文案误导性强,容易误判成「没定义」
3.11 cannot access local variable 'x' where it is not associated with a value 文案改写;format_exc_unbound() 按 oparg 区分局部/自由变量 搜报错时要换关键词
3.12 同 3.11 PEP 709 推导式内联,推导式不再建独立帧,locals() 与 traceback 观感变化 推导式相关的作用域异常表现会变
3.13 同 3.11 PEP 667 frame.f_locals 变写穿代理,locals() 在优化作用域返回快照 「用 f_locals 偷偷改局部变量」的旧写法行为改变

三、五种生产级触发场景

下面五种都来自真实高星项目的 issue,每种的报错栈我都核对过原文。

场景 1:变量名与全局同名,却在函数内赋值

最经典的一种。a += 1 等价于 a = a + 1:右边先读 a,而编译期已把 a 定为局部变量,此刻它还没绑值。

python 复制代码
a = 1
def test():
    a += 1
    return a

运行报错:UnboundLocalError: local variable 'a' referenced before assignment(3.11+ 为新文案)。

修法:真要用全局就 global a;否则换个函数内独有的名字。

场景 2:只在某条分支赋值,另一条分支没赋值

真实案例:microsoft/markitdown issue #40(项目 #188476 stars),报错在 _markitdown.py 第 1045 行:

csharp 复制代码
File "_markitdown.py", line 1045, in _convert
    if res is not None:
       ^^^
UnboundLocalError: cannot access local variable 'res' where it is not associated with a value
python 复制代码
def convert():
    if use_llm:
        res = llm_call()
    if res is not None:
        ...

use_llm 为 False 时 res 从未绑定。修法:开头先 res = None,让所有路径都有值。

场景 3:try 里赋值失败、except 把异常吞了,之后又用

真实案例:unclecode/crawl4ai issue #290(项目 #84763 stars),报错「Error using new markdown generation strategy: cannot access local variable 'filtered_html'」。filtered_html 只在 try 成功路径上赋值,异常被 except 吞掉后回头再用它。

python 复制代码
def run():
    try:
        data = load_data()
    except Exception:
        pass
    return data

修法:在 try 之前给 data = None,或让 except 分支明确 return / raise,别让失败路径继续往下走。「except 里 pass 一下假装没事」是这类事故的高发写法。

场景 4:del 之后又读同一个名字

这正是当年 bpo-17792 的原始触发点:

python 复制代码
def g():
    x = 42
    del x
    print(x)

del 属于 Def 上下文,同样让 x 被判定为局部;删掉之后再读,槽位为空。修法:别在同一函数里 del 完又用;要标记「已消费」,用 x = None 代替 del x。

场景 5:闭包 / 推导式作用域陷阱(UnboundLocalError 的孪生错误)

真实案例:Comfy-Org/ComfyUI issue #7680(项目 #136110 stars),diffusers 的 attention.py 第 1228 行 self.net.append(act_fn) 抛 cannot access local variable 'act_fn';run-llama/llama_index issue #13133 同型。

python 复制代码
def foo():
    x = [x for _ in range(2)]
    return x

推导式把 x 当自由变量读,此刻外层 cell 还是空的。这里抛的其实是它的孪生错误:NameError: free variable 'x' referenced before assignment in enclosing scope(3.11+ 新文案:cannot access free variable 'x' where it is not associated with a value in enclosing scope)。

局部变量报 UnboundLocalError,被闭包捕获的自由变量报 NameError,两者根因是同一套机制。 还有一个类体作用域陷阱,本机实测:

python 复制代码
class C:
    names = ["a"]
    f = lambda: names[0]

这里 f() 会抛 NameError: name 'names' is not defined,因为类体不是闭包作用域。反倒是推导式的最外层可迭代对象在类体作用域求值,所以 squares = [v*v for v in vals] 这种写法类体内可用。

四、常见误区:三种「看起来能修、其实没修」的改法

误区写法 为什么无效 正确做法
在报错那行前面加个 print(x) 调试 反而把读取提前,报错位置提前,根因没变 用 dis 看它是不是 LOAD_FAST
把变量名改大写 X 绕过去 只是换了个没被赋值的名字,一样报 想清是读全局还是写局部
到处加 global 会把多个函数耦合到同一全局状态,制造更难查的并发 bug 只在确实要改全局时用

最小可复现的判据只有一个:这个名字在函数体内有没有被赋值。有,它就是局部的;没有,才去外层找。

五、排障流程:三步定位到具体哪一行

第一步,看 traceback 的最后一行。 它直接告诉你哪个名字没绑值,cannot access local variable 'X' 里的 X 就是它。

第二步,确认它是局部还是全局。 用 dis 看这个函数:

python 复制代码
import dis
def f():
    print(x)
    x = 1
dis.dis(f)

输出里第二行 LOAD_FAST 0 (x) 就是证据。看到 LOAD_FAST / STORE_FAST 就说明它被判成了局部变量;LOAD_GLOBAL 才是全局。这一步能立刻区分「忘了 global」还是「某条分支没赋值」。

第三步,用符号表一眼看穿判定结果。 把可疑函数的源码当字符串传进去,直接问 CPython「它认为这个名字是局部吗」:

python 复制代码
import symtable
src = """
def f():
    print(counter)
    counter = 1
"""
st = symtable.symtable(src, "f.py", "exec")
fn = st.lookup("f").get_namespace()
print(fn.lookup("counter").is_local())

输出 True,说明编译期已把 counter 定为局部变量。get_namespace() 是拿到函数作用域符号表的关键一步,lookup("f") 只拿到模块层的一个符号,少了它会直接报 AttributeError。

六、实战复盘:一次线上 500 的完整排查

某次服务上线后,某个接口在特定参数下必现 500,traceback 指向一个 if 判断:

csharp 复制代码
UnboundLocalError: cannot access local variable 'result' where it is not associated with a value

排查按上面三步走:第一步拿到名字 result;第二步 dis 发现它是 LOAD_FAST,说明被判成局部变量;第三步回看函数,找到唯一一处赋值在一个 try 块里,而 except 分支只打了日志没有 return。

根因清楚了:成功路径赋值、失败路径被吞,两条路径汇合后又去读 result。修法是在 try 前加 result = None,让所有路径都有值。上线后该类 500 归零。

复盘出的判断原则有三条:① 只要函数里读一个「可能是自己的」名字,先问它是否在所有可达路径上都被赋值;② except 里不要空转,要么返回、要么抛出、要么把变量置为确定的默认值;③ 新写的函数如果返回值依赖分支,直接用 result = None 开局,比事后补更省成本。

七、今晚就能做的一件事:给项目加一条静态自检

UnboundLocalError 的共同特征是「函数内读一个名字,但该名字在某条路径上没被赋值」。可以用一段 AST 脚本粗筛出「同一函数内既有赋值、又有读取」的可疑点:

bash 复制代码
python3 - <<'PY'
import ast, sys, pathlib
def scan(path):
    tree = ast.parse(pathlib.Path(path).read_text(encoding="utf-8"))
    fns = [n for n in ast.walk(tree) if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))]
    for fn in fns:
        assigned = set()
        for n in ast.walk(fn):
            if isinstance(n, ast.Name) and isinstance(n.ctx, (ast.Store, ast.Del)):
                assigned.add(n.id)
        for n in ast.walk(fn):
            if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Load) and n.id in assigned:
                print(f"{path}:{n.lineno} 函数 {fn.name} 内 '{n.id}' 赋值/读取混合,核查各分支是否都赋了初值")
for p in sys.argv[1:]:
    scan(p)
PY
app.py utils.py

把命中的行挨个人工确认:每条能到达它的分支,是否都给它赋过值。这一步做完,这类报错基本清零。

八、同类家族:UnboundLocalError 的兄弟错误

你看到的错误 触发场景 一句话修法
UnboundLocalError: cannot access local variable 函数内赋值但某路径未执行到 全分支赋初值,或加 global
NameError: free variable ... in enclosing scope 内层函数/推导式读外层变量,外层也把它当局部 外层先赋初值再定义内层
NameError: name 'x' is not defined 类体里被 lambda / 内层函数引用 类体不是闭包作用域,改用显式传参
SyntaxError: name 'x' is used prior to global declaration global 声明写在赋值之后 把 global 挪到函数体最前面
UnboundLocalError(旧文案 referenced before assignment) 3.10 及以前的同一问题 同上,只是文案不同

记忆锚点:「函数内只要出现过一次赋值,这个名字在整个函数里就是局部的」。读之前没有值,就是 UnboundLocalError;被内层捕获的,叫 NameError。

出处

本文首发于 CSDN 专栏《Python 生产环境报错速查:从崩溃到修复》。

相关推荐
starrysky8101 小时前
只填了个模型地址,5 秒就被接管——Open WebUI 攻击链拆解与「被拒 CVE」的坑
angular.js
大雄很用心8 天前
package.json 中的 dependencies 是否都会被下载?|Angular 回归笔记 02
angular.js
starrysky8108 天前
同一个 submit(),两条报错——ThreadPoolExecutor 在解释器退出时的竞态(附 4 组可复现实验)
angular.js
starrysky8108 天前
30 个 CPU 全在内核态,只回收了 32 个页——per-memcg 内存回收的 mmap_lock 活锁
angular.js
starrysky81022 天前
一个 NameError 掩盖了真根因:IPython 补全在 Django shell 里二次崩溃——从 jedi 版本错位查到 CPython LOAD_GLOBAL
angular.js
starrysky81022 天前
SQL 注入 + msgpack 反序列化 = RCE:LangGraph Checkpointer 三洞连爆——Agent 的记忆层就是攻击面
angular.js
starrysky81022 天前
NVD 9.8、官方 7.5:Redis TLS use-after-free 的评分之争——tlsProcessPendingData 悬指针踩空全解析
angular.js
巴勒个啦1 个月前
2026 年 CSS 选型真相:我用 Tailwind v4 + 原生新特性重构了一个组件库
前端·angular.js
starrysky8101 个月前
服务全绿、队列却 15 天没人消费?redis-py 8.0 默认切 RESP3 的阻塞命令坑
angular.js