写 Python 的人大多遇到过这类诡异时刻:变量明明赋值了,函数里却报 UnboundLocalError;嵌套函数改外层变量死活不生效;默认参数列表居然在多次调用间"共享"了状态。这些问题都指向同一个根——作用域规则。理解 LEGB、搞清参数与实参的区别、掌握闭包的边界,才能写出不靠运气运行的代码。
LEGB:Python 查找变量的四层顺序
Python 遇到一个变量名时,按 Local → Enclosing → Global → Built-in 四层依次搜索:
- L — Local:当前函数体内的局部命名空间。
- E — Enclosing:外层嵌套函数的命名空间(如果有闭包)。
- G — Global:模块级命名空间,即
.py文件顶层定义的变量。 - B — Built-in:
builtins模块,如len、print、Exception。
找到即停;找不到则抛 NameError。一个经典坑:
x = 10
def update():
x = x + 1 # ❌ UnboundLocalError
return x
update()
函数体内出现了赋值语句 x = ...,Python 编译器在定义时就把 x 标记为局部变量。执行到 x + 1 时,局部 x 还没赋值,于是报错。修复方式是显式声明:
x = 10
def update():
global x # 告诉编译器:x 是全局的,不要当局部
x = x + 1 # ✅ 正常
return x
print(update()) # 11
print(x) # 11
同理,嵌套函数想改外层变量用 nonlocal,而不是 global——两者作用层级不同,混用是另一个常见 bug。
参数 vs 实参:别把形参当变量类型
函数定义里的 parameter(形参) 是名字占位符;调用时传入的 argument(实参) 才是具体值。区分这两件事有助于理解默认参数陷阱:
def append_item(item, bucket=[]):
bucket.append(item)
return bucket
print(append_item("a")) # ['a']
print(append_item("b")) # ['a', 'b'] —— 不是 ['b']!
默认值 [] 在函数定义时一次性求值并绑定到形参 bucket,后续调用共享同一个列表对象。正确做法是把默认值换成不可变对象,在函数体内再创建:
def append_item(item, bucket=None):
if bucket is None:
bucket = []
bucket.append(item)
return bucket
print(append_item("a")) # ['a']
print(append_item("b")) # ['b'] ✅
这条规则对所有可变默认值(dict、set、用户自定义对象)都适用。
内嵌函数与闭包:状态捕获的边界
内嵌函数(inner function)可以捕获外层函数的变量,形成闭包。但捕获的是变量引用,不是值的快照:
def make_counters():
counters = []
def counter(i):
counters.append(i)
return counters
return counter
c = make_counters()
print(c(1)) # [1]
print(c(2)) # [1, 2]
闭包 counter 持有外层 counters 列表的引用,每次调用都往同一个列表追加。如果你想让每个闭包持有独立状态,需要用默认参数做"值快照":
def make_adders():
adders = []
for n in range(3):
# 用默认参数 n_val=n 在定义时冻结当前 n 的值
def adder(x, n_val=n):
return x + n_val
adders.append(adder)
return adders
fns = make_adders()
print(fns[0](10)) # 10 + 0 = 10
print(fns[1](10)) # 10 + 1 = 11
print(fns[2](10)) # 10 + 2 = 12
如果不加 n_val=n,三个 adder 都会捕获同一个变量 n,循环结束后 n=2,结果全部返回 12——这是闭包循环陷阱的经典表现。
实战速查:作用域诊断 checklist
遇到变量行为异常时,按以下顺序排查:
- 函数体内有没有赋值语句? 有赋值 → Python 默认当局部变量;想改外层加
global/nonlocal。 - 默认参数是不是可变对象? 是 → 改成
None,在函数体内初始化。 - 闭包在循环里创建? → 用默认参数冻结迭代变量,或用
functools.partial。 - 变量名和 built-in 重名? → LEGB 的 B 层会被覆盖;避免用
list、dict、id做变量名。 - 类方法里的 self.xxx 和模块级 xxx 混淆? → 类体本身是一个命名空间,
self是实例属性,不带self的是类属性,三者查找路径不同。
一个综合示例,把上述要点串起来:
# scope_demo.py —— 可直接运行
total = 0 # 模块级全局变量
def register(name, tags=None):
"""演示默认参数、global、闭包"""
global total
total += 1
if tags is None: # ✅ 避免可变默认值陷阱
tags = []
def badge(level, _name=name): # ✅ 闭包冻结 name
return f"[{_name}|L{level}]"
tags.append(badge(1))
return tags
# 三次独立调用,tags 不共享
print(register("alice")) # ['[alice|L1]']
print(register("bob")) # ['[bob|L1]']
print(register("carol")) # ['[carol|L1]']
print("total registrations:", total) # 3
运行方式:
python scope_demo.py
作用域规则不复杂,但编译期标记和运行期查找的分离容易让人踩坑。记住:赋值决定归属,LEGB 决定查找顺序,默认参数在定义时求值——这三条足以覆盖日常绝大多数作用域问题。