Python 基础体系 · 第 13/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。
Python 作用域与闭包:LEGB、nonlocal、global 和迟绑定
Python 中的“变量”更准确地说是名称绑定:名称指向对象,而赋值、函数定义、参数接收、import、for 循环目标等操作,都会在某个命名空间中建立或改变名称与对象之间的绑定关系。作用域解决的问题是:当解释器看到一个名称时,应当到哪里寻找它。(docs.python.org)
闭包则是在此基础上产生的机制:一个函数可以携带它定义位置附近的自由变量,即使外层函数已经返回,这些变量仍然可以被内部函数访问。迟绑定进一步说明了一个容易误解的事实:闭包捕获的通常是变量所在的绑定位置,而不是创建闭包那一刻的对象快照。
1. 从代码块和命名空间开始
1.1 作用域属于代码块
Python 的执行单位称为代码块。模块、函数体、类定义体、脚本文件,以及传给 eval() 和 exec() 的代码,都可以形成代码块。代码块执行时对应一个执行帧,执行帧保存了继续执行所需的运行时信息。(docs.python.org)
例如:
x = "module"
def outer():
x = "outer"
def inner():
x = "inner"
print(x)
inner()
outer()
这里至少有三个相关的代码块:
模块代码块
└── outer 函数代码块
└── inner 函数代码块
x 在三个代码块中分别绑定了三个名称。它们的拼写相同,但不代表同一个绑定。
1.2 赋值不是“修改变量”,而是重新绑定名称
a = []
b = a
a = [1]
执行过程是:
a = []:创建列表对象O1,让a指向O1。b = a:让b也指向O1。a = [1]:创建列表对象O2,让a改为指向O2。b仍然指向O1。
因此:
print(a) # [1]
print(b) # []
而下面的代码不是重新绑定 items,而是修改原列表对象:
items = []
alias = items
items.append(1)
print(items) # [1]
print(alias) # [1]
这个区别对于闭包非常重要:
x = new_value是改变闭包中的名称绑定;x.append(value)是通过闭包中的名称访问对象,再修改对象本身。
2. LEGB:名称解析的四个层次
LEGB 是 Python 名称解析顺序的记忆方式:
L - Local 当前局部作用域
E - Enclosing 外层函数作用域
G - Global 当前模块的全局作用域
B - Builtins 内置名称作用域
严格地说,名称会从当前作用域向最近的外层作用域查找;如果仍未找到,则查找模块全局命名空间,最后查找内置命名空间。(docs.python.org)
2.1 一个完整例子
value = "global"
def outer():
value = "enclosing"
def inner():
value = "local"
print(value)
inner()
outer()
输出:
local
如果去掉 inner() 中的局部绑定:
value = "global"
def outer():
value = "enclosing"
def inner():
print(value)
inner()
outer()
输出:
enclosing
如果再去掉 outer() 中的绑定:
value = "global"
def outer():
def inner():
print(value)
inner()
outer()
输出:
global
如果代码中没有自定义的 value:
def show():
print(len([1, 2, 3]))
show()
len 会在内置命名空间中找到。
2.2 LEGB 描述的是读取,不是所有赋值行为
下面的代码不会因为模块中存在 count 就修改模块变量:
count = 0
def increment():
count = count + 1
return count
increment()
运行结果是:
UnboundLocalError: cannot access local variable 'count' where it is not associated with a value
原因不是“局部作用域中找不到 count,然后应该继续查全局作用域”。原因是:
- 函数体中出现了
count = ...; - 因此整个函数代码块中的
count都被判定为局部名称; - 执行
count + 1时,局部count还没有绑定值; - 于是抛出
UnboundLocalError。
Python 不要求变量声明,解释器可以根据代码块中的绑定操作判定局部名称;这也意味着赋值语句即使位于函数后半部分,也会影响前面的同名引用。(docs.python.org)
count = 0
def show():
print(count) # 这里也被视为读取局部 count
count = 1
show()
这段代码同样会抛出 UnboundLocalError。
如果名称根本没有任何可用绑定,则是 NameError:
def show():
print(not_defined)
show()
NameError: name 'not_defined' is not defined
UnboundLocalError 是 NameError 的子类,但它专门表示“解释器已经把名称判定为局部名称,却在当前执行位置尚未得到绑定”。(docs.python.org)
3. 局部变量、自由变量与闭包
3.1 自由变量是什么
如果名称在当前函数中被使用,却没有在当前函数中定义,它可能是一个自由变量。
def outer():
message = "hello"
def inner():
print(message)
inner()
对 inner() 来说:
message没有在inner()内部绑定;message存在于外层函数outer();- 所以
message是inner()的自由变量。
当一个函数对象保存了对外层自由变量的引用,这个函数就形成了闭包。闭包不是“函数复制了一份外层变量”,而是函数对象关联了外层变量对应的闭包单元。
def make_greeter(prefix):
def greet(name):
return f"{prefix}, {name}"
return greet
hello = make_greeter("Hello")
hi = make_greeter("Hi")
print(hello("Ada")) # Hello, Ada
print(hi("Ada")) # Hi, Ada
make_greeter() 返回后,其执行过程已经结束,但 hello 仍然能访问 "Hello"。如果没有闭包保存这个绑定,prefix 的作用域就会随着 make_greeter() 返回而结束。
3.2 闭包保存的是绑定单元
可以通过函数对象的 __closure__ 和代码对象的 co_freevars 观察闭包:
def make_counter():
count = 0
def next_count():
return count + 1
return next_count
counter = make_counter()
print(counter.__code__.co_freevars)
print(counter.__closure__[0].cell_contents)
典型输出:
('count',)
0
这里:
co_freevars表示内部代码使用的自由变量名称;__closure__保存对应的闭包单元;cell_contents是单元当前引用的对象。
__closure__ 和 cell_contents 是很有用的诊断工具,但不应把它们当作日常修改闭包状态的主要接口。理解闭包的关键是:内部函数访问的是一个可持续存在的绑定单元,而非普通局部变量的临时副本。
4. nonlocal:重新绑定最近的外层函数变量
读取外层变量不需要 nonlocal:
def make_counter():
count = 0
def get():
return count
return get
但如果内部函数要对外层变量重新赋值,就必须声明 nonlocal:
def make_counter():
count = 0
def increment():
nonlocal count
count += 1
return count
return increment
counter = make_counter()
print(counter()) # 1
print(counter()) # 2
print(counter()) # 3
count += 1 可以展开理解为:
count = count + 1
因此它包含对 count 的重新绑定。没有 nonlocal 时,解释器会把 count 判定为 increment() 的局部变量:
def make_counter():
count = 0
def increment():
count += 1
return count
return increment
counter = make_counter()
counter()
结果是:
UnboundLocalError
有了 nonlocal 后,名称绑定目标变为最近的外层函数作用域中的 count。nonlocal 只能指向外层函数作用域中已经存在的绑定;如果不存在,代码在编译阶段就会产生 SyntaxError。(docs.python.org)
def invalid():
def inner():
nonlocal missing
return inner
这段代码无法正常定义,因为 missing 不存在于任何外层函数作用域。
4.1 多层嵌套时选择最近绑定
def outer():
value = "outer"
def middle():
value = "middle"
def inner():
nonlocal value
value = "changed"
inner()
return value
middle_result = middle()
return value, middle_result
print(outer())
输出:
('outer', 'changed')
inner() 中的 nonlocal value 修改的是 middle() 的 value,而不是 outer() 的 value。如果一个名称在多个外层函数中绑定,nonlocal 选择最近的一层。(docs.python.org)
4.2 修改对象不一定需要 nonlocal
def make_log():
records = []
def add(item):
records.append(item)
return add
add = make_log()
add("a")
add("b")
这里没有给 records 重新赋值,而是调用列表对象的 append() 方法,因此不需要 nonlocal。
但下面的版本需要:
def make_log():
records = []
def add(item):
nonlocal records
records = records + [item]
return add
records + [item] 创建了新列表,随后 records = ... 重新绑定了名称。
这也是“可变对象”和“名称绑定”经常混淆的地方:
| 操作 | 是否重新绑定外层名称 | 是否需要 nonlocal |
|---|---|---|
items.append(x) |
否 | 否 |
items[x] = value |
否 | 否 |
items += [x] |
对列表通常是原地修改,但语义仍属于赋值操作 | 在闭包内应声明 nonlocal |
items = items + [x] |
是 | 是 |
items = new_items |
是 | 是 |
对于 items += [x],即使某些对象类型会执行原地更新,语法上仍是增强赋值;在函数作用域中它会触发局部绑定规则,因此处理闭包变量时应明确写出 nonlocal。
5. global:把当前代码块中的名称指向模块全局绑定
global 用于声明:当前代码块中对指定名称的读取和绑定,都针对包含该代码块的模块全局命名空间。
count = 0
def increment():
global count
count += 1
return count
print(increment()) # 1
print(increment()) # 2
print(count) # 2
没有 global 时,count += 1 会被视为对函数局部名称的重新绑定,从而产生 UnboundLocalError。
global 与 nonlocal 的区别可以概括为:
| 声明 | 绑定目标 |
|---|---|
| 不声明 | 当前函数局部作用域 |
nonlocal x |
最近的外层函数作用域 |
global x |
当前模块的全局作用域 |
value = "module"
def outer():
value = "outer"
def inner():
global value
value = "changed"
inner()
return value
print(outer()) # outer
print(value) # changed
这里 inner() 修改的是模块级 value,所以 outer() 中同名的局部 value 不受影响。
global 和 nonlocal 都是解析器指令,作用范围是整个当前代码块,而不是从语句所在行开始。声明必须位于当前代码块中对该名称的使用或赋值之前;否则会产生 SyntaxError。此外,写在传给 exec() 的字符串中的 global 不会改变外层函数代码块的解析规则。(docs.python.org)
def invalid():
print(value)
global value
这段代码会在编译阶段失败,而不是运行到 print() 时才决定作用域。
5.1 global 不是跨模块全局变量
“全局”是相对于当前模块而言的,不是整个 Python 进程中的唯一变量。
假设有两个模块:
# settings.py
value = 1
# worker.py
value = 2
def show():
global value
return value
worker.py 中的 global value 指向 worker 模块的 value,不会指向 settings.value。如果需要修改另一个模块中的值,必须明确访问模块对象:
import settings
settings.value = 3
这仍然不是推荐用来共享复杂状态的方式,因为模块级可变状态会增加调用关系和测试隔离的难度。
6. 迟绑定:闭包读取的是调用时的当前绑定
迟绑定指的是:闭包中的自由变量通常不会在函数创建时立即复制成一个固定值;函数执行时,它会读取闭包单元当前保存的对象。
def make_reader():
value = "first"
def read():
return value
def replace():
nonlocal value
value = "second"
return read, replace
read, replace = make_reader()
print(read()) # first
replace()
print(read()) # second
执行过程如下:
make_reader()创建外层绑定value = "first"。read()捕获value对应的闭包单元。replace()通过nonlocal修改同一个闭包单元。- 再次调用
read()时,它读取到的是"second"。
这里的“迟”不是指函数代码延迟编译,而是指自由变量的对象值在函数调用时解析和读取。Python 文档也明确指出,自由变量的名称解析发生在运行时。(docs.python.org)
7. 循环变量导致的经典迟绑定问题
最常见的错误出现在循环中创建函数:
functions = []
for i in range(3):
functions.append(lambda: i)
print([func() for func in functions])
很多人预期输出:
[0, 1, 2]
实际输出是:
[2, 2, 2]
推导过程是:
- 第一次循环创建函数
lambda: i,它引用循环变量i。 - 第二次循环创建另一个函数,但仍然引用同一个循环变量
i。 - 第三次循环同样如此。
- 循环结束后,
i的当前值为2。 - 函数真正被调用时,三个函数都读取
i的当前绑定,因此都得到2。
这不是三个闭包各自保存了三个值,而是三个函数共享对同一个绑定的访问。
7.1 用默认参数在创建时保存值
函数参数的默认值是在函数定义执行时计算的,因此可以用默认参数把当前循环值固定下来:
functions = []
for i in range(3):
functions.append(lambda i=i: i)
print([func() for func in functions])
输出:
[0, 1, 2]
这里的 i=i 有两个不同角色:
- 左边的
i是参数名称; - 右边的
i是创建函数时从当前作用域读取的循环变量。
创建第一个函数时,等价于保存默认值 0;创建第二个函数时保存 1;创建第三个函数时保存 2。调用时,参数已经有默认值,不再依赖外层循环变量。
也可以使用工厂函数:
def make_reader(value):
def read():
return value
return read
functions = [make_reader(i) for i in range(3)]
print([func() for func in functions])
输出同样是:
[0, 1, 2]
工厂函数的优点是把“每次循环创建一个独立外层绑定”的意图表达得更清楚。
7.2 迟绑定不只发生在 lambda
下面的普通函数同样存在问题:
functions = []
for i in range(3):
def read():
return i
functions.append(read)
print([func() for func in functions]) # [2, 2, 2]
lambda 只是创建小函数的简写,并没有改变作用域规则。
8. 闭包与装饰器的关系
装饰器本质上经常同时使用:
- 函数对象;
- 外层函数参数;
- 内部包装函数;
- 闭包保存配置或被包装函数。
from functools import wraps
def repeat(times):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
result = None
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(3)
def greet(name):
print(f"Hello, {name}")
greet("Ada")
输出:
Hello, Ada
Hello, Ada
Hello, Ada
这里的变量关系是:
repeat(3)
└── times = 3
└── decorator(func)
└── func = greet
└── wrapper(*args, **kwargs)
wrapper() 使用了外层的 times 和 func,因此它们是自由变量。@repeat(3) 先调用 repeat(3) 得到 decorator,再把 greet 传给 decorator,最后用返回的 wrapper 替换原来的 greet 名称。
可以观察闭包:
print(greet.__name__) # greet
print(greet.__wrapped__.__name__) # greet
print(greet.__code__.co_freevars) # 可能包含 func、times
@wraps(func) 不负责实现闭包;它的作用是复制被包装函数的部分元数据,并设置 __wrapped__,避免包装后函数显示为普通的 wrapper。闭包机制本身来自 wrapper 对 times 和 func 的自由变量引用。
8.1 参数化装饰器中的状态
如果装饰器需要维护调用次数,可以使用 nonlocal:
from functools import wraps
def count_calls(func):
calls = 0
@wraps(func)
def wrapper(*args, **kwargs):
nonlocal calls
calls += 1
print(f"call #{calls}")
return func(*args, **kwargs)
return wrapper
@count_calls
def add(a, b):
return a + b
print(add(1, 2))
print(add(3, 4))
输出:
call #1
3
call #2
7
这里 calls 属于装饰器调用创建的外层作用域。每个被装饰函数都会得到自己的 calls 绑定,因此两个不同函数之间不会天然共享计数器。
9. 推导式、循环作用域与函数作用域
Python 3 中,列表推导式的循环变量不会泄漏到外部作用域:
values = [i for i in range(3)]
try:
print(i)
except NameError:
print("i is not defined")
输出:
i is not defined
但“推导式有自己的作用域”不意味着它会自动解决所有迟绑定问题:
functions = [lambda: i for i in range(3)]
print([func() for func in functions])
结果仍然是:
[2, 2, 2]
推导式内部的多个 lambda 仍然引用推导式作用域中的同一个 i 绑定。要固定值,仍然使用默认参数:
functions = [lambda i=i: i for i in range(3)]
print([func() for func in functions]) # [0, 1, 2]
生成器表达式还会延迟迭代和计算,因此更容易把“创建时间”和“执行时间”混在一起:
functions = (lambda: i for i in range(3))
for func in functions:
print(func())
这里每次取出一个函数后立即调用,可能依次输出:
0
1
2
但如果先把函数全部收集起来:
functions = list(lambda: i for i in range(3))
print([func() for func in functions])
则会得到:
[2, 2, 2]
关键不是 lambda 是否位于列表或生成器中,而是函数调用发生时,所引用的绑定当前是什么值。
10. 类作用域不是普通的外层函数作用域
类定义体是一个代码块,但类作用域不会像函数作用域那样自动成为方法的闭包外层作用域。(docs.python.org)
class Config:
prefix = "app"
def show(self):
return prefix
调用:
Config().show()
通常会得到:
NameError: name 'prefix' is not defined
方法中的 prefix 不会自动查找 Config 类命名空间。应当显式通过类或实例访问:
class Config:
prefix = "app"
def show(self):
return self.prefix
print(Config().show()) # app
或者:
class Config:
prefix = "app"
@classmethod
def show(cls):
return cls.prefix
类体中的名称可以直接被后续类体语句使用:
class Config:
prefix = "app"
name = prefix + "-worker"
但方法代码块在之后执行时,遵循自己的名称解析规则,而不是把类字典作为 LEGB 中的普通 E 层。
Python 3.14 还包含标注作用域这一版本相关变化:标注、类型形参列表和 type 语句等场景使用特殊的 annotation scope;它们大体类似函数作用域,但如果紧邻类作用域,则可以访问外层类命名空间。这不能推广到普通方法闭包。(docs.python.org)
11. 诊断作用域问题的方法
11.1 先检查名称在哪个代码块被绑定
看到 NameError 或 UnboundLocalError 时,先搜索当前代码块中所有可能的绑定操作:
- 普通赋值;
- 增强赋值;
- 参数;
for循环目标;with ... as;except ... as;- 函数或类定义;
import;global和nonlocal。
例如:
total = 10
def calculate():
if False:
total = 20
return total
即使 if False 永远不会执行,total = 20 仍然是当前函数代码块中的绑定操作,因此 return total 会读取尚未绑定的局部 total,最终抛出 UnboundLocalError。
11.2 用 inspect 查看闭包
import inspect
def make_multiplier(factor):
def multiply(value):
return value * factor
return multiply
double = make_multiplier(2)
print(inspect.getclosurevars(double))
典型结果类似:
ClosureVars(
nonlocals={'factor': 2},
globals={},
builtins={},
unbound=set()
)
inspect.getclosurevars() 可以把函数使用到的非局部变量、全局变量、内置变量和未绑定名称分类展示,适合诊断装饰器、回调和工厂函数。
11.3 用 dis 观察局部变量和闭包变量的区别
import dis
def make_reader():
value = 10
def read():
return value
return read
reader = make_reader()
dis.dis(reader)
在 CPython 中,闭包变量通常会通过 LOAD_DEREF 一类的字节码访问,而局部变量通常使用 LOAD_FAST。这属于 CPython 的实现观察,不应把具体字节码名称当作所有 Python 实现都必须保持的公共语言规范。
更可靠的判断仍然是:
print(reader.__code__.co_freevars)
它直接说明代码对象需要哪些自由变量名称。
12. 闭包、生命周期与资源保留
闭包会延长其捕获对象的生命周期:
def make_handler():
large_data = bytearray(10_000_000)
def handler():
return len(large_data)
return handler
handler = make_handler()
只要 handler 仍然存活,large_data 就可能继续被闭包引用。这里没有内存泄漏这一必然结论,但有一个明确的生命周期关系:闭包引用会阻止相关对象因“没有引用”而被回收。
如果只需要少量配置,不应无意中捕获巨大的对象:
def make_handler(config):
timeout = config["timeout"]
def handler():
return timeout
return handler
这个版本只捕获 timeout,而不是整个 config。当然,若 timeout 本身是复杂对象,仍然需要根据实际引用关系判断生命周期。
闭包捕获的也可能是资源对象:
def make_reader(file):
def read_one():
return file.readline()
return read_one
返回的函数依赖外部文件对象是否仍然打开。调用者如果提前关闭文件,闭包并不会自动恢复资源:
with open("data.txt", encoding="utf-8") as file:
reader = make_reader(file)
# 文件已经关闭
reader() # ValueError: I/O operation on closed file
闭包保存的是引用和绑定,不是资源生命周期管理器。资源边界仍应通过 with、上下文管理器或明确的打开与关闭协议处理。
13. 闭包状态与并发
nonlocal 让多个调用共享同一个闭包状态,但它不会自动提供并发安全性:
def make_counter():
count = 0
def increment():
nonlocal count
count += 1
return count
return increment
在单线程顺序调用时,它表现为简单计数器。若多个线程共享同一个 increment,则多个调用会访问同一个闭包单元。线程之间共享进程资源和对象,访问共享可变状态需要显式协调,否则可能出现竞态或非预期结果。(docs.python.org)
可以用锁保护状态:
from threading import Lock
def make_counter():
count = 0
lock = Lock()
def increment():
nonlocal count
with lock:
count += 1
return count
return increment
这里锁和计数值都被闭包捕获:
count是需要重新绑定的非局部变量;lock是被读取并调用的非局部对象;with lock保证同一时刻只有一个线程执行受保护区域。
这并不意味着所有闭包都需要锁。只有当多个执行流共享同一个闭包实例,并且至少一个执行流会修改共享状态时,才需要考虑同步。
14. 常见误解对照
14.1 “函数定义在哪里,就一定能访问那里的所有名称”
不准确。函数可以访问其定义位置的外层函数变量,但类作用域对普通方法不是这样的闭包外层:
class A:
x = 1
def method(self):
return x # 不会自动读取 A.x
应写成:
class A:
x = 1
def method(self):
return self.x
14.2 “global 表示全局唯一变量”
不准确。global 指向当前模块的全局命名空间,不是整个进程、所有模块共享的命名空间。
14.3 “只要读取外层变量就需要 nonlocal”
不准确:
def outer():
value = 1
def inner():
return value # 只读,不需要 nonlocal
return inner
只有内部代码需要对外层名称重新绑定时,才需要 nonlocal。
14.4 “列表推导式解决了循环闭包问题”
不准确。推导式避免了循环变量泄漏,但其中创建的函数仍可能迟绑定同一个推导式变量:
funcs = [lambda: i for i in range(3)]
print([f() for f in funcs]) # [2, 2, 2]
应使用默认参数或工厂函数固定值。
14.5 “闭包保存了变量创建时的值”
不准确。默认参数通常保存创建时的值;普通闭包通常保存对绑定单元的访问。
def make_functions():
value = 1
def read():
return value
def change():
nonlocal value
value = 2
return read, change
read() 的结果会随 change() 改变,正是因为它读取的是同一个外层绑定。
15. 一套可执行的判断流程
遇到作用域问题时,可以按以下顺序推导,而不是先凭经验添加 global 或 nonlocal:
- 确定当前代码块:名称出现在模块、函数、类体、推导式还是动态执行代码中?
- 扫描当前代码块的绑定操作:当前块是否对该名称赋值、接收为参数、导入或用作循环目标?
- 判断当前引用类型:
- 当前块有绑定:局部名称;
- 当前块无绑定,但外层函数有:自由变量;
- 外层函数也没有:继续到模块全局;
- 模块全局没有:查内置名称。
- 如果要重新绑定外层函数变量:使用
nonlocal。 - 如果要重新绑定模块变量:使用
global。 - 如果函数在循环中创建:检查调用发生时循环变量是否已经改变;必要时用默认参数或工厂函数固定值。
- 如果是类方法:不要把类命名空间当作普通闭包外层,使用
self、cls或类名显式访问。 - 如果仍然不确定:检查
__code__.co_freevars、__closure__和inspect.getclosurevars()。
作用域的核心不是“变量属于哪一行”,而是“名称在什么代码块中绑定,以及引用发生时沿什么环境链查找”。nonlocal 和 global 改变的是绑定目标;闭包保存的是外层绑定的可访问性;迟绑定说明读取发生在调用时,而不是函数创建时。掌握这三层关系,装饰器、回调、工厂函数和异步任务中的大多数名称问题都可以按规则逐步推导,而不必依赖记忆某个特殊写法。
系列导航与关联阅读
- 系列入口:Python 完整学习路线:从语言模型、并发到 Web、数据、AI 与生产交付
- 上一篇:Python 函数与参数:位置、关键字、默认值、解包和返回
- 下一篇:Python 装饰器:函数包装、参数化、类装饰器与元数据
官方资料
本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。

评论
0 条讨论