本文目录
解释器遇到裸名字 x 时,不会在整个项目里乱搜。它沿固定路径找当前生效的绑定:先局部,再一层层向外,最后到内建命名空间。这条路径有个助记符:LEGB——Local、Enclosing、Global、Built-in。
可以把 LEGB 想成从里向外剥洋葱:每一层都有自己的命名空间字典;查找时从当前层开始,命中即停,找不到再往外。下面每一节都在标“这一层的 x 从哪来”。
读取与赋值不对称:读取沿 LEGB 向上;赋值则默认在当前函数局部创建新绑定。许多“我明明外层有变量,为什么报错”的困惑,都来自把两种规则当成一回事。
L:局部(Local)
函数体、lambda 体、推导式(在 Python 3 里)执行时,都会有一个局部命名空间。当前层已有同名绑定,读取就直接用它:
def demo() -> None:
msg: str = "局部"
print(msg) # 局部
demo()对 demo 里的 print(msg):L 层有 msg → 命中,不再往外找。函数每次调用都会刷新这一层的局部绑定;一次调用结束,这层局部可被回收(除非被闭包引用留住——后面章节会讲)。
E:闭包外层(Enclosing)
函数嵌套时,内层可以读外层函数的局部名字。中间这些外层函数的局部层,统称 Enclosing:
def outer() -> callable[[], None]:
label: str = "外层"
def inner() -> None:
print(label) # 从 Enclosing 读到
return inner
outer()() # 外层inner 自己没有 label,L 未命中 → 向外到 outer 的局部(E)→ 找到。多层嵌套就一层层向外剥,直到 Global 或 Built-in。
若 Enclosing 层也没有,继续到 G、B。闭包能“记住”外层局部,是因为内层函数对象仍引用外层帧的命名空间——不是把外层变量复制一份快照。外层后来改了 label,内层再读会看到新值(只要内层自己没有同名局部遮蔽)。
G:模块全局(Global)
模块顶层(缩进为 0)的绑定在全局命名空间。函数内读取全局名,不需要声明;只要你在该函数里没有给同名变量赋值:
VERSION: str = "1.0"
DEBUG: bool = False
def show_version() -> None:
print(VERSION) # Global
def is_debug() -> bool:
return DEBUG
show_version()
print(is_debug()) # False多个函数共享同一模块全局,适合放配置常量、模块级单例。若全局变量会被多处改写,数据流很快变得难追踪——能局部就局部,必须共享时再考虑模块级状态。
B:内建(Built-in)
再往外是 Python 内建名:len、print、range、Exception 等,由解释器预先准备好。局部或全局都可以遮蔽内建名——语法合法,可读性通常很差:
def bad() -> None:
len = 3 # 局部遮蔽内建 len
print(len)
bad() # 3
# len([1, 2]) # 若在此函数内调用,会报错:len 已不是 callable查找路径串起来:同一名字,不同层
x: str = "global"
def outer() -> callable[[], None]:
x: str = "enclosing"
def inner() -> None:
x: str = "local"
print("inner", x) # local — L 命中
return inner
def read_global() -> None:
print("read_global", x) # global — L 无,E 无,G 命中
read_global()
outer()()对 inner 里的 x:L 有 → "local"。对 read_global 里的 x:L 无(函数内没给 x 赋值)→ E 无(不是嵌套在其他函数的内层里)→ G 有模块级的 x → "global"。
赋值规则:函数里写 x = … 默认创建局部
读取走 LEGB;赋值的规则不同——这是作用域里最容易踩坑的一点。
在函数体内出现 x = ...(或对 x 的 augmented assignment 如 +=),Python 在编译阶段就把整个函数里的 x 标记为局部,除非写了 global x 或 nonlocal x:
count: int = 0
def bump_wrong() -> None:
count = count + 1 # UnboundLocalError
def bump_ok() -> None:
global count
count = count + 1
bump_ok()
print(count) # 1bump_wrong 因赋值语句,编译器认定 count 是局部;右边 count + 1 读取局部 count 时它尚未绑定,于是 UnboundLocalError。global count 声明:此处的 count 指模块全局那份绑定。
只读全局则不必写 global:
total: int = 100
def report() -> int:
return total + 1 # 只读,OK
print(report()) # 101nonlocal:修改 Enclosing 层的局部
global 跳到模块顶;nonlocal 只向外找最近一层 Enclosing 的局部(不能跳过中间层,也不能指模块 global):
def make_counter() -> callable[[], int]:
n: int = 0
def inc() -> None:
nonlocal n
n += 1
def read() -> int:
return n
inc()
inc()
return read
print(make_counter()()) # 2没有 nonlocal n 时,n += 1 等价于 n = n + 1,会创建 inc 的局部 n,同样触发 UnboundLocalError。工厂函数、装饰器内部状态,经常靠 nonlocal 更新外层局部。
多层 Enclosing 时,nonlocal 绑定最近一层外层里已有该名的局部:
def level_a() -> callable[[], None]:
x: str = "a"
def level_b() -> callable[[], None]:
x: str = "b"
def level_c() -> None:
nonlocal x
x = "c"
return level_c
return level_b()
fn = level_a()
fn()()
# level_c 的 nonlocal x 改的是 level_b 的 x,不是 level_a 的若中间某层没有 x,nonlocal x 在编译或运行阶段会报错——它不能“跳过”中间层去指更外层的同名变量。
global 与 nonlocal 对照实验
| 关键字 | 赋值目标 |
|---|---|
| (无) | 当前函数的局部 |
global name | 模块全局的 name |
nonlocal name | 外层函数局部里的 name |
g: int = 0
def outer() -> None:
g: int = 10
def touch_global() -> None:
global g
g = 99
print("touch_global", g)
def touch_enclosing() -> None:
nonlocal g
g = 20
print("touch_enclosing", g)
touch_global()
print("outer after global", g) # 仍是 10
touch_enclosing()
print("outer after nonlocal", g) # 20
outer()
print("module g", g) # 99touch_global 改的是模块级 g,不会动 outer 的局部 g(10 不变)。touch_enclosing 改的是 outer 的局部 g(变 20)。模块级 g 只在 global 路径上变成 99。
推导式与 LEGB
Python 3 里,列表推导式的循环变量不会泄漏到外层——它们有自己的局部层:
squares = [n * n for n in range(4)]
# print(n) # NameError: n 不在外层生成器表达式、集合/字典推导式同理。这与 Python 2 的老行为不同;写嵌套推导式时,内层变量仍各自局部,不会意外遮蔽外层同名绑定(除非你在推导式外再用同名)。
在推导式内部引用外层函数局部变量则是允许的——那时外层局部充当 Enclosing:
def scale(factor: int) -> list[int]:
return [n * factor for n in range(4)]
print(scale(3)) # [0, 3, 6, 9]这里 factor 从 scale 的局部(E)读取;n 仍是推导式自己的 L。
模块导入与“全局”的直觉
import math 会在当前模块全局绑定名字 math。函数里读 math.pi 时,先 LEGB 找到全局的 math,再属性查找 pi——LEGB 只管名字,不管点号后面的属性:
import math
def circle_area(r: float) -> float:
return math.pi * r * r
print(circle_area(1)) # 3.141592...若在函数里写 math = None,同样会创建局部 math 并遮蔽全局导入;一般只在测试替身(mock)场景故意这么做。
类体与 LEGB:属性查找是另一条路
类定义块有自己的命名空间,用于存放类属性。但实例方法里 LEGB 的 L 是方法局部,E 是包裹方法的外层函数(若存在),不是类命名空间:
class Config:
theme: str = "dark"
def label(self) -> str:
# print(theme) # NameError — 类属性不是 Enclosing
return self.theme
print(Config().label()) # dark读类属性要写 self.theme 或 Config.theme。obj.x 走属性查找协议(实例 dict → 类 dict → 父类链…),与 LEGB 是两套机制。搞混“名字查找”和“属性查找”,是读类代码时常见的困惑来源。
循环与闭包:延迟绑定
for 循环本身不创建新作用域(循环变量在 Python 3 的 for 语句里仍是外层函数的局部,除非在推导式里)。若在循环里定义函数并捕获循环变量,常见陷阱是全部捕获同一个名字的最终值:
def make_wrong() -> list[callable[[], int]]:
funcs: list[callable[[], int]] = []
for i in range(3):
funcs.append(lambda: i)
return funcs
print([f() for f in make_wrong()]) # 可能全是 3修复方式与 lambda 篇相同:默认参数冻结当前值,或工厂 def 包一层。根因仍是 LEGB:lambda 读的是 Enclosing 里同一个 i 绑定,循环结束后 i 已是 2(或 3,取决于 range)。
常见误解
| 误解 | 实际 |
|---|---|
| 内层自动“继承”外层局部变量 | 读可以沿 E 向上;写默认创建内层局部 |
global 能改外层函数的变量 | 只指向模块 global;改外层局部用 nonlocal |
LEGB 适用于 obj.attr | 属性走 descriptor / __getattribute__,不是 LEGB |
读全局前必须先 global | 只有赋值才需要 global / nonlocal |
| 嵌套越深,查找越慢到可感知 | 实现上有优化;语义上理解 LEGB 即可 |
搞清 LEGB,就搞清了“这个名字绑在哪一层”。上一篇的函数当值传递、下一篇 lambda 捕获外层名字,都建立在这套规则上:先定位绑定所在层,再谈闭包保留了哪一层的环境。
调试作用域问题时,可以在可疑函数里临时 print(locals()) 或 print(globals().keys()) 观察当前层有哪些名字——生产代码别留,但在 REPL 里对照 LEGB 四层,比空背规则快得多。
也可以把 LEGB 画成一张四格表:当前函数局部、外层函数局部(可多层)、模块全局、内建。每次 NameError 或 UnboundLocalError,先问“这个名字在哪一层应该存在、我有没有在某一层赋值把它变成局部”。作用域问题很少是解释器 bug,多半是赋值规则与直觉不一致。
小结
LEGB 描述的是裸名字的查找顺序:L → E → G → B,命中即停。读取与赋值不对称——函数里赋值默认创建局部,除非 global 或 nonlocal 指明目标层。global 指向模块全局;nonlocal 指向最近的外层函数局部。属性访问 obj.attr、类方法里的 self.x 不走 LEGB,那是另一套协议。写嵌套函数时,先画每层有哪些名字,再写 nonlocal,比事后追 UnboundLocalError 省时间。模块级常量用大写命名是习惯,不是语法;LEGB 只认绑定在哪一层,不认名字长什么样。内建层被遮蔽后,该函数里就暂时用不了同名内建函数,直到局部绑定被删除或函数返回。搞清这张查找表,闭包与装饰器里的变量捕获就不会显得神秘莫测。下一篇的 lambda 在表达式里读自由变量,同样服从 Enclosing 层规则,不再重复展开。