如果你写过一段时间 Python,并且在一个函数里试图去改某个外层变量,那你大概率见过这行红字:
UnboundLocalError: local variable 'xxx' referenced before assignment我第一次碰到这个报错的时候,人有点懵。当时我写了一个很简单的计数逻辑:函数外面定义了一个count = 0,函数里面每次调用就让count += 1,结果一运行直接炸了。我盯着代码看了半天,明明我在全局已经初始化过count了,凭什么说它“referenced before assignment”?
后来查文档、翻源码、做实验,才彻底搞明白:这个报错不是 Python 在跟你无理取闹,而是它的一套作用域规则在起作用。这篇文章我想把自己踩过的坑和总结出的排查思路完整写出来。无论你是刚入门 Python 不久的新手,还是已经被这个报错折磨过的老开发,看完应该都能搞清楚它到底从哪来、怎么解,以及以后怎么从代码结构上避开它。
1. 错误初体验:这个报错到底在说什么
1.1 一条报错引发的“名侦探”现场
先还原一个最典型的场景。你可能会写出类似这样的代码:
count = 0 def add_one(): count += 1 return count print(add_one())运行结果是什么呢?不是1,而是:
UnboundLocalError: local variable 'count' referenced before assignment很多人看到这个报错的第一反应是:我明明在函数外面定义了count,Python 怎么不去看外面的呢?这就要提到 Python 的一个核心设计:在函数体内,只要某个变量出现在赋值语句的左边,Python 就会在编译阶段把它当作这个函数的局部变量。也就是说,你的本意是“修改全局的 count”,但 Python 看到函数里有count += 1,这本质上包含了一个对count的赋值,于是 Python 立刻把count划归为局部变量。这样一来,count += 1这个操作的执行顺序就变成了:先读count的当前值,然后加 1,再写回去。问题是读的时候count作为局部变量还没被赋值过,于是直接抛错。
这里有个非常反直觉的点:报错发生在“读”的阶段,但问题根源其实在“写”的声明。这就好比你在一个盒子里找东西,盒子已经被提前标注成了“这个盒子只有某人能往里放东西”,可里面现在空空如也,你伸手一摸,自然什么都摸不到。
1.2 官方解释与核心机制
在 Python 的官方术语里,这个错误是UnboundLocalError,它其实是NameError的子类。NameError的意思是“这个名字我压根没见过”,而UnboundLocalError更精确一点:它的意思是“我知道这个名字是局部变量,但它还没被绑定(绑定就是指赋值或其它方式给它一个值)”。
理解这个机制,最关键的词是“编译期”和“运行期”的分工。Python 并不是一行一行边看边执行的,当你把一个函数定义交给 Python 解释器的时候,解释器会先对整个函数体做一次扫描,把函数内所有“被赋值”的变量名收集起来,形成一个局部变量集合。这个收集动作发生在代码真正跑起来之前,所以它只认“有没有出现在赋值左边”,而不会管“代码执行顺序是否合理”。
一旦某个变量名被归进了局部变量集合,函数里所有对这个名字的引用,都会被翻译成“读取局部变量”。哪怕你是先打印再赋值,打印的时候局部变量还没值,照样会炸。这就是为什么有些代码看起来“应该没问题”,实际一跑就出错。
下面这个例子最直观:
def demo(): print(x) # 这里会报错 x = 10 demo()你以为结果会是打印出某个全局x,然后再把局部x设为 10?不会的。因为x = 10出现在函数体里,Python 已经把x标记为局部变量了,所以print(x)实际上是在读一个尚未赋值的局部变量,结果就是:
UnboundLocalError: local variable 'x' referenced before assignment1.3 这个错误最常出现的三类场景
根据我这些年看到的情况,这个报错基本集中在下面三类场景:
| 场景类型 | 典型代码特征 | 触发原因 |
|---|---|---|
| 在函数里修改全局变量 | 函数外定义count = 0,函数内写count += 1 | 增强赋值被当作局部变量声明 |
| 在函数里使用与全局同名的变量,但后面又给这个变量赋值 | print(name)之后才写name = "xxx" | 同名变量被统一判断为局部变量 |
| 在嵌套函数里修改外层函数的变量 | 外层函数定义了total = 0,内层函数写total += 1 | 内层函数把total判为自己的局部变量 |
这三种场景底层原因是同一个,但表现形态完全不同,排查方向也不太一样。后面我分别拆开细讲。
2. 作用域规则:为什么 Python 会“睁眼说瞎话”
2.1 Python 的 LEGB 查找顺序
要彻底理解这个报错,得先了解 Python 查找变量名的顺序。Python 在读到某个变量名的时候,会按照一个固定层次逐层查找,这个层次就是常说的 LEGB:
- L,Local,当前函数(或局部作用域)内的变量
- E,Enclosing,外层函数的局部变量,也就是闭包环境里的变量
- G,Global,当前模块全局变量
- B,Built-in,Python 内置的名字,比如
len、print
正常情况下,你在函数里读一个变量,Python 会先找局部,找不到再找外层函数,再找不到找全局,最后找内置。如果全都找不到,就抛NameError。
听起来很简单对吧?但这里的坑在于:查找顺序遵守 LEGB,可“某个变量属于哪一层”这件事,在编译期就定死了。如果你在函数里给x赋过值,那么x在这个函数里的身份就是“局部变量”,后续对这个名字的读取都会直接去局部作用域里拿,根本不会向上继续找全局或者外层。
这就是为什么有时候你在函数里写了print(name),外面明明有一个name,却依然报 UnboundLocalError。因为函数后面某个位置写了name = ...,导致name被提前划为局部变量,print(name)这一步跳过了全局查找,直接去读还没赋值的局部变量。
2.2 赋值语句的“宣示主权”效果
Python 对“赋值”的定义比你想的要宽。只要一个变量出现在下面这些操作的左边,它就会被标记为局部变量:
- 普通赋值:
x = 10 - 增强赋值:
x += 1、x -= 1、x *= 2 - 解包赋值:
x, y = (1, 2) - 循环变量:
for x in items: - 函数参数:
def func(x): - 导入语句:
import os中的os - with 语句的 as 目标:
with open(f) as f:
这里的共同点都是“符号被写入了当前作用域的名字空间”。一旦写入,它就从这一时刻起,在编译期被认定为局部变量。
我经常跟人打比方:这就像你在小区公告栏上贴了一张纸,上面写着“快递柜 X 号归我使用”,那么从贴纸那一刻起,所有关于 X 号柜的存取操作都被引导到你这边的柜子,系统不会再去问别人“这个柜子是不是你的”。Python 的局部变量声明也是这么“霸道”,只看有没有贴纸,不看贴纸贴在代码的哪个位置。
2.3 可运行代码和编译期判断的区别
还有一个让很多人困惑的点:报错信息说referenced before assignment,但我的代码明明是先赋值后引用啊。
这里必须区分“代码阅读顺序”和“Python 的判断时机”。Python 判断变量是否局部,看的是整个函数体的“静态文本”,而不是“运行时执行路径”。哪怕x = 10写在if分支里,只要这个分支有可能执行,Python 都会把x当成局部变量:
flag = False def test(): if flag: x = 10 print(x) # 一样会报 UnboundLocalError这段代码里,如果flag是False,x = 10根本没执行,但 Python 还是把x当成了局部变量,因为从静态文本上看,函数体里确实有x = 10这个赋值语句。运行到print(x)时,局部变量x还没有值,于是报错。
理解了这一点,很多看似“玄学”的报错就能解释了。Python 不会去分析你的if条件到底成不成立,它只负责做一次最粗糙的静态扫描,凡是被赋值过的名字,全算局部。
3. 核心原因拆解:那些让你踩坑的典型写法
3.1 函数内先读后写的“死亡顺序”
最典型的坑就是先引用后赋值。哪怕你没有修改全局变量的意图,只是恰好用了一个和全局同名的变量,也可能中招:
name = "python" def greeting(): print("hello, " + name) # 想读全局 name name = "world" # 但又准备局部赋值 greeting()这段代码会抛错,因为函数体内有name = "world",所以name被认定为局部变量。print("hello, " + name)这一行读取的是局部的name,但此刻它还没有被赋值。很多人写代码时习惯先打印调试信息,再在后面做赋值,特别容易踩这个坑。
正确做法其实简单,但你得先意识到名字冲突。如果函数里真的需要局部name,那就换个变量名,不要和全局重名;如果确实想读全局的,那后面的赋值就要另想办法。
3.2 增强赋值运算符的隐蔽陷阱
+=、-=、*=这类的增强赋值,是最容易引发UnboundLocalError的操作。因为它在语义上等于“先读旧值,再计算,再写回”,天然就是一个“先引用后赋值”的过程。而它在语法上又有赋值动作,所以把变量标记为局部,两边一叠加,报错就成了必然。
total = 100 def add(n): total += n # 这里会炸:读 total 时,局部变量 total 还未绑定 return total这个例子太经典了。很多人用全局变量做累加统计,以为total += n会把外部 total 拿进来加,结果直接报错。这里如果改成global total,就能让 Python 知道“我就是要用全局那个 total,不要给我新建局部变量”。
但我要多说一句:global能解决报错,但不一定是最优解。函数里改全局变量,会带来隐式依赖,代码越写越难维护。这个问题我在第 4 章会展开说。
3.3 用了一个和全局同名的变量,还把它当全局用
还有一种情况,变量名在函数里没有被赋值,但你又想在函数里修改它——这种情况如果不用global,最终会变成NameError,而不是UnboundLocalError。很多初学者分不清这两种报错的差别,下面我用一个对照表说明:
| 情况 | 函数内代码 | 运行结果 |
|---|---|---|
| 只读全局变量 | print(count) | 正常输出全局值 |
| 读后又赋值 | print(count); count = 1 | UnboundLocalError |
| 直接赋值 | count = 1 | 正常,但函数内生成局部变量,外部不受影响 |
用global后赋值 | global count; count += 1 | 正常,外部值被修改 |
这里最让人迷惑的是第三种情况。你以为在函数里写了count = 1就能修改全局变量?实际上只是创建了一个局部变量,函数结束后这个变量就没了,外部count纹丝不动。而如果你在函数里写count += 1想模拟“累加”,又会因为局部变量未绑定而报错。两种写法各有各的坑,要么不生效,要么直接炸。
3.4 嵌套函数里的闭包变量
闭包场景下的 UnboundLocalError 更隐蔽。看这个例子:
def outer(): total = 0 def inner(step): total += step # 这里报错 return total return inner add = outer() print(add(5))这里的total定义在外层函数outer的作用域里,按 LEGB 原则,内层inner应该能读到它。但问题在于,inner里写了total += step,这是一个赋值动作,于是 Python 把total当成了inner的局部变量。读取的时候,inner的局部total还没值,于是报错。
这种场景的解决方案是nonlocal声明。nonlocal跟global的区别在于:global是跳过函数局部,直接到模块全局作用域找;nonlocal是跳到最近的外层函数局部作用域找。用nonlocal total修饰之后,inner里读写的total就是外层outer里的那个total了。
闭包变量的问题平时不容易遇到,但一旦遇到,排查难度会大于全局变量场景,因为报错信息里的“local variable”指的不是你最外层那个模块变量,而是某一个嵌套函数自己的局部变量。你得沿着函数嵌套层级一层层看,才能定位到到底是哪一层出了问题。
3.5 for 循环变量和异常处理变量带来的错觉
还有一种场景不太常见但确实存在:函数内部使用for循环,循环变量和函数外变量重名。比如:
item = None def process(items): print(item) # 想打印外面那个 None for item in items: # 循环变量也叫 item print(item)因为有for item in items,函数内就有了item的赋值动作,item被认定为局部变量,于是第一行的print(item)直接报 UnboundLocalError。这种问题在写脚本时特别容易忽略,因为循环变量太“随手了”,你根本没意识到它已经改变了函数的作用域格局。
异常处理的as语句同理。Python 规定except Exception as e:中的e在异常块结束后会被删除,但在函数内部,它同样会被标记为局部变量。如果你在更早的位置引用了同名变量,也会出现类似问题。这种场景我实际排查过几次,每次都是一点点翻代码才找到“罪魁祸首”是一个不起眼的循环变量。
4. 解决办法:从笨办法到优雅方案
4.1 方案一:global 声明,简单直接但有代价
最直接的解决方案就是在函数内部加上global声明:
count = 0 def add_one(): global count count += 1 return count print(add_one()) # 输出 1 print(add_one()) # 输出 2加了global count之后,Python 就不再把这个变量当作局部的了,函数里所有对count的读写都会直接落到模块全局作用域的那个变量上。
这个方案能解决眼前的问题,而且语法非常简单,适合脚本里快速操作全局计数器、全局配置项等场景。但我必须提醒你:global用多了,代码的可读性和可维护性会急剧下降。一旦函数之间通过全局变量隐式通信,你就很难判断某个变量的值是从哪一步改的,调试起来特别痛苦。很多函数式编程或者代码规范里甚至明确建议“尽量避免使用 global”。
所以我的建议是:简单脚本可以用,但如果有其他选择,优先用后面几种方案。
4.2 方案二:nonlocal 处理闭包
当你遇到的是嵌套函数修改外层局部变量的场景,global是不行的,必须用nonlocal。
def outer(): total = 0 def inner(step): nonlocal total total += step return total return inner add = outer() print(add(5)) # 输出 5 print(add(3)) # 输出 8nonlocal的语义是“让内层函数可以修改外层函数的局部变量”。它比global更精准,因为它并不跳到模块全局,只跳到最近的、包含定义关系的外层函数作用域。
在装饰器场景里,nonlocal的使用频率非常高。比如带计数功能的装饰器:
def call_counter(func): count = 0 def wrapper(*args, **kwargs): nonlocal count count += 1 print(f"{func.__name__} called {count} times") return func(*args, **kwargs) return wrapper @call_counter def hello(): print("hi") hello() hello()这里如果不用nonlocal,count += 1就会触发 UnboundLocalError。从实际体验来看,nonlocal是闭包场景的标准解法,而且比global要安全得多,因为它把变量限制在闭包内部,不会污染全局命名空间。
4.3 方案三:默认参数“曲线救国”
有一种更“Pythonic”的解法是用默认参数来保存状态:
def add_one(count=0): count += 1 return count看起来好像解决了问题,因为你不需要在函数之外维护变量了,每次调用add_one()都从 0 开始加,返回 1。但如果是想做一个跨多次调用持续累加的计数器,这个方案就不行了,因为默认参数是每次调用时重新传入的固定值,并不会在两次调用之间保存状态。
真正能起作用的默认参数写法是:把可变默认参数当作容器来用。这在实际中经常被用来实现“函数内修改外部状态”的效果:
def add_one(state=[]): if not state: state.append(0) state[0] += 1 return state[0]这里state是默认参数,它在函数定义时创建一次,之后每次调用如果没有传参,就复用同一个列表对象,所以可以跨调用累积状态。但这种写法有个臭名昭著的坑:可变默认参数是 Python 里著名的反模式,容易引起“你以为创建了新列表,结果大家都在共享同一个列表”的诡异问题。
我不太推荐用默认参数来“躲避” UnboundLocalError。它更适合的场景是:你本来就想让函数接受一个初始值,只是不想显式传全局变量。如果确实要用,一定要把可变默认参数当作一个有意为之的缓存容器,并且做好注释。
再往深一层说,默认参数真正能解决的是“避免局部变量未定义”的问题,但它改变的是函数的接口语义,而不是作用域。你最好想清楚自己到底是要“读取外部状态并修改”,还是“函数内部自己维护状态”,这两种需求对应不同的方案。
4.4 方案四:可变对象容器
如果你不想写global,又确实需要修改外部的变量,那还有一种经典思路:把变量装进可变对象里,比如列表或字典。这样你修改的不是“变量绑定”,而是“对象内容”,不会触发局部变量判断。
state = {"count": 0} def add_one(): state["count"] += 1 return state["count"]运行一下,完全正常。为什么?因为state这个变量名在函数体内没有出现在赋值左边,它只被读取了;真正发生修改的是字典内部的键值对。Python 只关心“变量绑定关系有没有变化”,不关心“对象内部的数值有没有变”。所以state依然是全局变量,整个函数都只是读取了全局的字典对象,再修改字典里的值。
这种写法的本质是:用对象的状态代替变量的绑定。它比global更隐蔽,也更灵活,在配置文件管理、全局状态管理等场景里非常常见。比如很多 Python 框架里的g、app.config就是这么干的。
但我也要提醒:使用全局可变对象虽然绕过了 UnboundLocalError,但它本质上仍然是“隐式共享状态”,如果大量使用,同样会让代码变得难以追踪。我建议把这种对象集中放在一个统一的地方管理,比如单独一个state.py模块里定义好所有共享状态,其他地方只读不写,或者只通过明确的接口函数去修改。
4.5 方案五:重构思路,用返回值代替副作用
前面几个方案都属于“如何在现有结构下绕过去”,但真正优雅的做法,往往是重新设计函数结构,让函数不要直接改外部变量,而是通过返回值传递结果。
拿最开始的计数器例子来说:
def add_one(count): return count + 1 count = 0 count = add_one(count) count = add_one(count) print(count) # 输出 2这种方式极其安全,函数没有任何副作用,你传什么进去,就返回什么结果,外部变量的更新由调用方自己负责。这也是函数式写法最推崇的风格。缺点是多出几步赋值,代码看起来可能不如global简洁,但调试起来非常舒服:每个函数的输入输出都是明确的,不会出现“这个变量到底是被谁改的”这种问题。
如果担心调用方忘记接收返回值,可以通过类或者其它方式进一步封装。但就“避免 UnboundLocalError”而言,返回值方案可以从根上让这个报错失去存在的机会——因为函数内部根本不需要访问外部变量了。
4.6 方案六:类属性与实例属性
最后,如果你发现自己在一个类的方法里遇到这个报错,那通常意味着变量应该设计成实例属性,而不是方法里的普通局部变量。
class Counter: def __init__(self): self.count = 0 def add_one(self): self.count += 1 return self.count c = Counter() print(c.add_one()) # 1 print(c.add_one()) # 2self.count的赋值发生在__init__里,后续方法通过self.count读写。这里self.count不是简单变量,它本质上是“对象属性访问”,不会触发局部变量的判断机制,所以也不会出现 UnboundLocalError。
当你的状态开始变多,或者多个函数都要操作同一组状态时,用类来组织是最清晰的选择。你甚至可以定义多个方法,比如add_one、reset、get_count,让状态的变化路径一目了然。
不过我也要提醒一点:如果你在方法内部写了count += 1,而不是self.count += 1,那同样会报错。因为count是一个普通局部变量名,它和self.count完全不是一回事。这个问题我在审查同学代码时见过很多次,属于把局部变量和实例属性搞混了。
5. 实际问题排查实录:五个真实案例
5.1 案例一:全局计数器累加
一个朋友写爬虫脚本,统计一共抓取了多少个页面。他的代码是:
pages = 0 def crawl(url): # 省略抓取逻辑 pages += 1 print(f"crawled {pages} pages")一运行就报 UnboundLocalError。我让他把第一行crawl函数里加一个global pages,马上就正常了。虽然我后来建议他用返回值的方式重构,但对这种一次性小脚本来说,global是最快的解法。
这个案例给我们的启发是:看到+=、-=这种操作时,要立刻条件反射——如果操作的对象是在函数外面定义的,那要么加global,要么改成对象属性/字典访问,要么用返回值。
5.2 案例二:装饰器里的计数逻辑
有个团队同事写了一个限流装饰器,想统计每个函数被调用的次数:
def limited_call(func): calls = 0 def wrapper(*args, **kwargs): calls += 1 if calls > 10: raise Exception("too many calls") return func(*args, **kwargs) return wrapper这里calls是存在于闭包里的变量,wrapper内直接calls += 1报了 UnboundLocalError。解决方案就是前面说的nonlocal calls。我给同事加了一行,问题立刻解决。
这个案例想说明的是:闭包里的状态更新非常容易被忽略。很多人一眼看到calls定义在外层函数,下意识以为内层能直接改,但 Python 的作用域规则要求:只要是“赋值”,就必须显式声明绑定关系。凡是要修改外层函数局部变量的,都必须nonlocal。
5.3 案例三:递归函数里的局部变量
递归函数里的 UnboundLocalError 则更容易让人发懵。看这个求斐波那契数列的例子:
def fib(n): if n <= 1: return n result = fib(n - 1) + fib(n - 2) return result这段代码没什么问题。但有些人会写成:
def fib(n): if n <= 1: return n print(result) result = fib(n - 1) + fib(n - 2) return result这当然会报错,因为print(result)在result = ...之前,result是局部变量且未赋值。但即使你把print放在递归调用之后,比如:
def fib(n): if n <= 1: return n result = fib(n - 1) + fib(n - 2) print(result) return result这没问题。所以递归场景下的报错,绝大多数是“先读后写”造成的。只要保持“先赋值后引用”的顺序,递归函数一般不会触发这个错误。
不过还有一种情况:你在递归函数里忘记初始化局部变量,直接用它来累加结果,也可能报错。这时候最好的做法是给变量一个初始值,或者把它设计成函数参数传递。
5.4 案例四:列表推导式的“隔离”假象
Python 3 的列表推导式有自己的局部作用域,循环变量不会泄漏到外部。这个特性本身不会直接导致 UnboundLocalError,但它会和其他作用域规则一起制造迷惑。
举个例子:
x = 100 def test(): print(x) y = [x for x in range(3)] test()这里的print(x)会报 UnboundLocalError,因为x在函数体里出现了赋值(虽然在列表推导式里),Python 把整个函数体内的x都视为局部变量。你可能会想:列表推导式不是有自己的作用域吗?怎么还会影响外面的x?
关键点是:在编译期,Python 只做“有没有赋值”的粗粒度扫描。列表推导式里的x在函数体内以语法形式存在,所以它参与了局部变量的判定。而y = [x for x in range(3)]这一整行,作为函数体的一部分,会让x被标记为局部变量,于是更早的print(x)就会读到未赋值的局部变量。
这个案例告诉我们:作用域的判定有时比我们想象得“粗糙”,它不看代码块的边界,只看整个函数体的静态文本。遇到 UnboundLocalError 时,别只盯着报错那一行,要把整个函数体里所有同名的赋值都找出来。
5.5 案例五:多线程/异步环境中的变量
多线程环境下的 UnboundLocalError 往往藏得更深。最常见的是你在线程的处理函数里试图修改一个全局的统计变量:
counter = 0 def worker(): counter += 1 # 报 UnboundLocalError threads = [Thread(target=worker) for _ in range(5)]这个报错的原因和单线程场景一样,和线程本身没关系。真正的陷阱在于:你以为加了global counter就能安全累加吗?不一定。多线程并发修改同一个变量会有竞态条件,最终计数值可能不对。但这已经是另一个坑了。
我的建议是:如果要在多线程里共享状态,优先使用threading.Lock或queue.Queue等线程安全设施,而不是裸用一个全局变量做计数。如果只是需要计数器,Python 标准库里的itertools.count也可以考虑,它本身就是线程安全的。这类问题不是本文重点,但我提出来是希望你不要以为“解决了 UnboundLocalError 就万事大吉”,并发场景还有更多需要考量的因素。
6. 从根源上避免:编码习惯与调试技巧
6.1 命名规范:私有前缀和全局常量
我见过太多 UnboundLocalError,归根结底是命名冲突造成的。函数内部用了count、total、name这种通用名,而全局正好也有同名变量,于是两边的使用意图在代码里纠缠在一起。
最简单的预防方法就是:全局变量和局部变量在命名上做区分。全局配置、全局状态可以用全大写加下划线命名,比如GLOBAL_COUNT、TOTAL_PAGES,这样即使函数内不小心用了count,也不会和全局变量撞名。也可以给全局变量加前缀,比如g_count,或者用模块级单例对象state.count。不同团队有不同规范,但核心思想是一致的:别让一个名字同时承担“全局”和“局部”两种含义。
6.2 保持“先赋值后引用”的代码顺序
从代码结构上,如果函数内部确实需要局部变量,尽量在函数的开头就完成初始化。这样即使后面有复杂的条件分支,也不会出现“某些路径下变量未绑定”的情况。
比如你写了一个if分支里才赋值的变量,逻辑上可能觉得“不满足条件就不需要这个变量”,但如果你在后面某个位置无条件地读了它,就一定会报错。所以更好的写法是:在分支之前先给变量设一个初始值,比如result = None,分支里再更新它。
这个习惯不仅对 UnboundLocalError 有用,对很多“明明有值为什么报 None”的 bug 也能起到预防作用。我在写逻辑复杂的函数时,会先列清楚哪些变量是函数要输出的,然后在开头就统一初始化,后面再分步填充。
6.3 traceback 的定位技巧
遇到 UnboundLocalError 时,traceback 信息其实已经给了很多线索。除了看最后一行报错,还要注意看报错位置的具体行号。通常在 PyCharm、VS Code 的调试器里,你甚至可以那一行代码上的变量值,会直接显示UnboundLocalError对应的变量。
我推荐的做法是:先看报错行的代码,判断它是在“读”还是“写”这个变量。如果是在读取的位置报错,那说明这个变量被提前标记成了局部变量,此时立刻搜索整个函数体内是否出现过该变量名在赋值左边。找到之后,再判断你到底想用的是哪个作用域里的同名变量。这个排查流程比瞎试要高效得多。
6.4 用 dis 模块看清字节码
如果你真想彻底搞懂 Python 为什么这么做,可以用标准库dis模块来反汇编函数,直接看字节码。举个例子:
import dis def demo(): print(x) x = 1 dis.dis(demo)你会看到类似这样的输出:
2 0 LOAD_FAST 0 (x) 2 CALL_PRINT_0 3 4 LOAD_CONST 1 (1) 6 STORE_FAST 0 (x) 8 LOAD_CONST 0 (None) 10 RETURN_VALUE注意LOAD_FAST这个指令。FAST 表示“从局部变量快速读取”,这证明 Python 已经把x当成局部变量处理了。如果它读的是全局变量,你会看到LOAD_GLOBAL。通过观察是LOAD_FAST还是LOAD_GLOBAL,你可以一眼确认某个变量到底被放在哪个作用域。
dis模块平时调试用不上,但当你和同事争论“Python 到底是怎么判定作用域”的时候,它就是终极裁判。我曾经在一次 code review 讨论中直接贴出字节码,争论立刻结束。
6.5 IDE 和静态检查工具的帮助
最后,把这套判断交给工具。现代 IDE 和静态检查工具对作用域问题非常敏感。PyCharm 会在代码里先写后赋值的位置画波浪线,并提示 “Unresolved attribute reference” 或 “Local variable not accessed” 等警告。pylint有一个规则叫used-before-assignment,正好就是捕捉这个问题的;flake8和ruff也有类似的检查。
我现在的习惯是:项目里配上ruff或pylint,把used-before-assignment这类错误当作严重级别处理。这样很多潜在问题在代码提交之前就会被检查到,根本轮不到运行时报错。
我个人在实际排查中最大的体会是:UnboundLocalError 从来不是一个“难”的报错,它只是提醒你,Python 对变量的作用域判定有一套自己的规则,而这套规则的核心其实非常简单——在函数里,一切赋值语句都会让变量“私有化”。想通这一条,再遇到它就不会慌了。
如果你已经排除了作用域问题,但代码仍然报错,那就要看看是不是某个变量在条件分支里没有初始化。这种情况和 UnboundLocalError 的底层机制相同,但在代码形态上更容易迷惑人。最后分享一个小技巧:写函数之前,可以先把函数需要用到的变量列出来,哪些是参数,哪些是返回值,哪些是内部临时变量,哪些要借助global或nonlocal从外部拿,一目了然。这个简单的动作,能帮你规避掉相当大一部分作用域相关的坑。