写 Python 时,你一定遇到过这种困惑:明明在函数外定义了变量,函数内却读不到;或者想在函数里修改外部变量,结果要么静默失败,要么抛出 UnboundLocalError。这些问题的根源都在一条规则——LEGB。理解它,作用域相关的 bug 就不再是玄学。
LEGB:四个字母,四层查找顺序
Python 遇到一个变量名时,按 Local → Enclosing → Global → Built-in 的顺序逐层查找,找到就停,找不到就报 NameError。
- Local(L):当前函数体内的局部命名空间。
- Enclosing(E):外层嵌套函数的命名空间——只有闭包/嵌套函数才会产生这一层。
- Global(G):模块顶层(即
.py文件)的命名空间。 - Built-in(B):Python 内置命名空间,如
len、print、int。
下面用一个可运行的例子,把四层全部展示出来:
# global_scope.py
from builtins import len as builtin_len # 演示 B 层被覆盖
total = 100 # G 层:模块全局变量
def outer():
subtotal = 50 # E 层:outer 的局部变量,对 inner 来说是 enclosing
def inner(count):
# L 层:inner 的局部变量
discount = 10
# 逐层查找演示
print("discount (L):", discount) # Local
print("subtotal (E):", subtotal) # Enclosing
print("total (G):", total) # Global
print("len (B):", builtin_len) # Built-in(被我们 import 后改名)
return subtotal - discount + count
return inner
fn = outer()
print("result:", fn(5))
运行:
python global_scope.py
输出:
discount (L): 10
subtotal (E): 50
total (G): 100
len (B): <built-in function len>
result: 45
每一层都找到了对应的变量,顺序清晰可见。
两个最容易踩的坑
坑一:赋值即局部,哪怕还没执行到那行
Python 在编译函数时,只要发现函数体内有对某变量名的 赋值(包括 =、+=、def、import 等),就把该变量标记为 Local。这意味着——即使赋值语句在引用之后,引用也会直接找 Local 层,而此时 Local 层还没绑定值,于是抛出 UnboundLocalError。
x = 42
def broken():
print(x) # 想读全局 x,但下一行有赋值,x 已被标记为 Local
x = 99 # 赋值导致 x 变成局部变量
broken() # UnboundLocalError: cannot access local variable 'x'
修复方式:用 global 声明告诉 Python "这个 x 我要的是全局层"。
x = 42
def fixed():
global x
print(x) # 现在明确指向 Global 层的 x
x = 99 # 修改的是全局 x
fixed()
print("x after fixed():", x) # 99
坑二:嵌套函数里想改外层变量,global 不够用
global 只能跳到模块顶层。如果你想修改的是 Enclosing 层(外层函数的变量),需要用 nonlocal。
def make_counter():
count = 0 # E 层变量
def increment():
nonlocal count # 声明 count 来自 Enclosing 层
count += 1
return count
return increment
c = make_counter()
print(c()) # 1
print(c()) # 2
print(c()) # 3
如果去掉 nonlocal,count += 1 会把 count 标记为 Local,同样触发 UnboundLocalError。
实战:用 LEGB 知识排查真实问题
下面模拟一个常见场景——配置字典在多层函数间传递时,不小心被局部赋值覆盖。
# config_bug_demo.py
CONFIG = {"timeout": 30, "retries": 3}
def process_request(url):
# 想根据 url 调整 timeout,但写成了赋值而非修改
if "slow" in url:
CONFIG = {"timeout": 120, "retries": 5} # 整个 CONFIG 变成局部变量!
print("timeout:", CONFIG["timeout"])
return CONFIG
result = process_request("https://slow-api.example.com")
print("global CONFIG unchanged?", CONFIG) # 全局 CONFIG 还是原值
运行后你会发现:函数内的 CONFIG 是一个全新的局部字典,全局 CONFIG 完全没被影响。这往往不是你想要的。
正确做法——修改字典内容而非重新赋值:
# config_fix_demo.py
CONFIG = {"timeout": 30, "retries": 3}
def process_request(url):
if "slow" in url:
CONFIG["timeout"] = 120 # 修改已有键,不触发"赋值即局部"
CONFIG["retries"] = 5
print("timeout:", CONFIG["timeout"])
return CONFIG
process_request("https://slow-api.example.com")
print("global CONFIG now:", CONFIG) # 全局 CONFIG 已被修改
对可变对象(dict、list)做 原地修改(CONFIG["key"] = ...、items.append(...))不会触发"赋值即局部"规则,因为这不是对变量名本身的赋值,而是对对象的方法/索引操作。这是 Python 作用域里一个关键的区别。
闭包与延迟绑定:LEGB 的查找时机
LEGB 查找发生在 运行时,不是定义时。这意味着闭包中的变量引用是"延迟绑定"的——它拿到的是最终值,而非定义时的值。
funcs = []
for i in range(3):
funcs.append(lambda: i) # 所有 lambda 共享同一个 i
print([f() for f in funcs]) # [2, 2, 2],不是 [0, 1, 2]
循环结束时 i 的值是 2,三个 lambda 在被调用时才去查找 i,都找到同一个 Global 层的 2。
修复:用默认参数在定义时捕获值(默认参数在函数定义时求值,属于 Local 层)。
funcs = []
for i in range(3):
funcs.append(lambda i=i: i) # i 的默认值在定义时绑定
print([f() for f in funcs]) # [0, 1, 2]
作用域排查清单
遇到变量找不到或行为异常时,按这个顺序检查:
| 检查项 | 怎么看 |
|---|---|
| 函数体内是否有对该变量名的赋值? | 有赋值 → 变量被标记为 Local,即使赋值在引用之后 |
| 想修改全局变量? | 加 global 声明 |
| 想修改外层函数变量? | 加 nonlocal 声明 |
| 对 dict/list 做原地修改? | 不需要声明,直接 obj[key] = ... 或 obj.append(...) |
| 闭包里循环变量延迟绑定? | 用默认参数 lambda x=x: x 在定义时捕获 |
| 确认查找层级? | 在可疑位置加 print(locals()) / print(globals()) 观察命名空间 |
最后一条是最直接的调试手段——打印命名空间,看变量到底在哪一层、绑定的是什么值。比猜来猜去高效得多。
LEGB 规则本身不复杂,但"赋值即局部"和"运行时查找"这两个机制组合起来,会产生不少反直觉的行为。记住这两点,再配合上面的排查清单,作用域问题就能快速定位,不再靠运气写代码。