你在函数里写了一句 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
- 根因:CPython 编译一个函数时,会对整个函数体 做一次静态扫描。只要某个名字在函数内任意位置 出现过赋值(或
del),整个函数体内这个名字就一律被判定为局部变量。 - 判定在编译期 完成,报错发生在运行时。所以「赋值语句写在读取后面」「那条赋值根本没被执行到」,都不改变判定结果。
- 三种能直接抄的改法:① 真要用外部变量就加
global;② 让变量在所有分支上都有初值;③ 别跨作用域读写同一个名字,用参数传入、用返回值传出。 - 一条命令定位:
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。
出处
- CPython 源码:
Python/symtable.c、Python/compile.c、Python/bytecodes.c - 文案变更:bpo-17792、PR GH-24976、commit 7b1f527d
- 作用域演进:PEP 667(locals 一致视图)、PEP 709(推导式内联)
- 真实案例:markitdown#40、crawl4ai#290、ComfyUI#7680、llama_index#13133、shap#1586、pandas#47203
本文首发于 CSDN 专栏《Python 生产环境报错速查:从崩溃到修复》。