Python Namespace 与 Scope:从名字绑定、LEGB 到闭包与递归栈帧
沿着今天的追问,讲清名字、对象与绑定,E 和 G 的区别,results.clear() 与重新赋值,global / nonlocal,导入后函数的 __globals__,以及闭包和递归如何保留状态。包含可复现实验、15 步递归演示和折叠复盘答案。
复习路线:先认清名字,再追踪一次执行
这篇笔记整理了 2026 年 10 月 19 日围绕 Namespace、Scope、LEGB、global / nonlocal、闭包与递归调用的讨论,并合并了另一会话中关于 function.__globals__、跨模块导入和名字异常的追问。内容按问题之间的因果关系重新组织,代码改写为可以独立复现的小实验。
第一次读可以顺着目录走。回来快速复习时,可以直接跳到 results 的三种写法、E 与 G 同名时如何选择、函数导入后的 G、递归逐步演示,最后做 复盘题。
| 概念 | 它回答的问题 |
|---|---|
| 名字、对象、绑定 | 这个标识符指向什么?重新赋值究竟改了什么? |
| Namespace · 命名空间 | 一组名字与对象的对应关系保存在哪里? |
| Scope · 作用域 | 在这段代码里,哪些名字绑定可见? |
| LEGB | 普通函数中的裸名,按照怎样的作用域关系解析? |
| global / nonlocal | 需要把赋值指向模块或外层函数的绑定时,怎样声明? |
| 闭包与栈帧 | 绑定如何跨调用继续存在?每次调用又怎样保存自己的执行状态? |
文中的“名字”通常指 results 这样的裸名。obj.results 还包含属性访问,两套规则会在后面分开说明。各个完整实验请分别运行,避免前一个实验的全局变量影响后一个实验。
复习之后怎样继续:打开 《从 Namespace 出发的 Python 进阶 Guidebook》,按入口诊断、闭包、装饰器等阶段推进;每阶段都有实验、产出与通过标准。
01 · 名字、对象与绑定:赋值建立了什么
今天的讨论从 namespace 开始,真正贯穿后续问题的是同一件事:一个名字,当前关联哪个对象?先把“名字”“对象”“绑定”分开,后面讨论作用域、global 和递归时,就能说清究竟什么发生了变化。
以 results = ["PASS"] 为例:
| 概念 | 在这个例子里是什么 | 它描述什么 |
|---|---|---|
| 名字(name) | results | 代码中用来引用对象的标识符。 |
| 对象(object) | 内容为 ["PASS"] 的列表 | 具有类型、内容和身份的运行时对象。 |
| 绑定(binding) | results 与这个列表的关联 | 在某个命名空间中,这个名字当前关联哪个对象。 |
这里的赋值会求出右侧表达式的结果,再把左侧名字绑定到那个对象。绑定发生在哪个命名空间,还要看这条语句处于模块代码、函数体还是其他执行环境;后面会逐步展开。
命名空间也管理函数名、类名和导入的模块名
“变量”容易让人只想到数字或列表,但名字可以绑定到许多种对象。函数和类本身也是对象,模块也能通过名字引用。下面四个名字都在模块代码执行时建立绑定:
retries = 2
def run_case():
return "PASS"
class Runner:
pass
import math
print(retries)
print(run_case())
print(Runner.__name__)
print(math.__name__)输出:
2
PASS
Runner
math
| 名字 | 绑定到的对象 | 建立绑定的操作 |
|---|---|---|
retries | 整数对象 2 | 赋值语句 |
run_case | 函数对象 | def 定义执行 |
Runner | 类对象 | class 定义执行 |
math | math 模块对象 | import 语句 |
函数调用时绑定参数,同样会建立名字绑定。例如某个函数定义为 def run_case(case_id): ...,调用时,参数名 case_id 会在该次调用的局部环境中绑定到实参提供的对象。名字的拼写相同,并不保证它们属于同一份绑定。
官方参考:Python 执行模型:名字绑定。
b = a 不会自动复制 a 指向的对象
a = [1, 2]
b = a
print(a is b)
b.append(3)
print(a)
b = [99]
print(a)
print(b)
print(a is b)输出:
True
[1, 2, 3]
[1, 2, 3]
[99]
False
执行 b = a 后,两个名字关联的是同一个列表,所以通过 b.append(3) 修改列表,再通过 a 观察,也会看到新增元素。随后执行 b = [99],会让 b 关联一个新列表;a 仍然关联原来的列表。
官方参考:Python 教程:名字与对象。
02 · 谁拥有命名空间?函数只是其中一种情况
你曾问:“命名空间只对函数说吗?最小的拥有命名空间的实体是什么?”命名空间并不专属于函数,也没有一个适用于所有情况的‘最小拥有者’。它是把名字与对象关联起来的机制;模块、函数调用、类和普通实例的属性,都涉及不同形式的名字管理。
| 常见环境 | 管理什么 | 需要记住的区别 |
|---|---|---|
| 模块 | 模块顶层的变量名、函数名、类名、导入名等 | 对定义在这个模块中的普通函数来说,它提供全局环境 G。 |
| 一次函数调用 | 该次调用的参数和局部名字绑定 | 同一个函数被调用多次,每次调用都有自己的局部绑定。 |
| 类定义及类对象 | 类体执行时建立的名字,以及据此形成的类属性 | 类的属性命名空间与方法调用的局部环境是两回事。 |
| 普通实例 | 例如 device.host 中的实例属性 | 许多实例通过 __dict__ 保存属性,但并非所有对象都具有可写的 __dict__。 |
| 内建环境 | print、len 等内建名字 | 普通函数的名字查找可以用到 B 这一层。 |
因此,“命名空间”不等于“一个函数”。更具体地问,通常更有帮助:这里的绑定存放在哪个环境中?这一行代码读取名字时,会使用哪些环境?命名空间描述的是名字到对象的映射关系,也不要求每一种实现都真的使用一张普通字典存储全部局部变量。
官方参考:Python 教程:作用域与命名空间。
函数对象的属性,与调用函数时的局部名字,分开理解
def run_case(case_id):
result = "PASS"
print(case_id, result)
run_case.label = "smoke"
run_case("case-001")
print(run_case.__dict__)输出:
case-001 PASS
{'label': 'smoke'}
run_case.label 是加在函数对象上的属性,能在 run_case.__dict__ 中看到。参数 case_id 和局部名字 result 属于这次函数调用的局部环境,因此不会因为在函数体中出现,就自动成为 run_case.__dict__ 的条目。
以后看到“函数有命名空间”,要先确认谈的是函数对象的属性,还是执行函数体时,该次调用的局部名字绑定。
官方参考:Python 数据模型:用户定义的函数。
不同实例可以保存同名属性
class Device:
pass
a = Device()
b = Device()
a.host = "lab-A"
b.host = "lab-B"
print(a.host)
print(b.host)
print(vars(a))
print(vars(b))输出:
lab-A
lab-B
{'host': 'lab-A'}
{'host': 'lab-B'}
a.host 和 b.host 使用了相同的属性名,但分别访问不同实例上的属性。这里的 vars(a) 和 vars(b) 展示了这两个普通实例的属性字典。这个例子并不意味着任何对象都允许任意增加属性;对象的类型可以限制属性存储和访问方式。
普通 if、for、while 语句不会仅凭缩进创建新作用域
if True:
note = "visible"
for step in range(2):
last = step
print(note)
print(last)输出:
visible
1
这些语句直接写在模块顶层,所以其中的赋值仍然建立模块中的名字绑定。如果把同样的语句放进函数体,赋值通常就属于那个函数的局部作用域。不能根据‘又缩进了一层’就断定新增了一个命名空间或作用域。
普通字典的键不会自动成为裸名字
settings = {"timeout": 30}
print(settings["timeout"])
try:
print(timeout)
except NameError:
print("NameError")输出:
30
NameError
这里建立的名字是 settings,它关联一个字典。字符串 "timeout" 是这个字典里的键;读取它需要 settings["timeout"]。仅仅创建这个键,并不会在当前环境中自动绑定一个裸名字 timeout。
03 · Namespace 与 Scope:名字的关联,和代码的访问规则
两个概念关系紧密,但回答的问题不同:
| 概念 | 主要回答的问题 | 例子 |
|---|---|---|
| 命名空间(namespace) | 这里有哪些名字,它们各自关联哪个对象? | 某次调用的局部环境中,retries 关联整数 2。 |
| 作用域(scope) | 在这段代码中直接使用某个名字时,能够访问哪一份绑定? | 内层函数读取 retries 时,可以使用外层函数中的绑定。 |
命名空间关注绑定关系;作用域关注代码中名字的可访问范围与解析规则。实际运行时,同一段函数代码还可以被调用多次,形成不同的局部环境。后面的递归例子会把这件事展开。
同一份绑定,在不同观察位置可以分别叫 L 和 E
def run_suite():
retries = 2
def run_case():
print(retries)
run_case()
run_suite()输出:
2
在 run_suite() 的函数体中,retries 是这次调用的局部名字。从内部的 run_case() 看,它来自包围自己的函数作用域。名字没有因此被复制一份;变化的是我们站在哪段代码中观察它。
LEGB:普通函数中理解裸名字查找的顺序
| 字母 | 英文 | 在普通函数中对应什么 |
|---|---|---|
| L | Local | 当前这次函数调用的局部绑定,包括参数和局部名字。 |
| E | Enclosing | 从定义位置向外,包围当前函数的外层函数作用域;可以没有,也可以有多层。 |
| G | Global | 当前函数所属的定义模块的全局环境。 |
| B | Builtins | 对应执行环境中的内建名字,例如 print、len。 |
以刚才的内层 run_case() 为观察点:它没有自己的局部 retries,但外层 run_suite() 有这份绑定,因此读取它时使用 E。print 在这个例子中没有被其他绑定遮蔽,因而使用内建的 print。
官方参考:Python 教程:作用域与命名空间。
此外,LEGB 讨论的是 results 这样的裸名字。results.clear 中的 clear、device.host 中的 host 属于属性访问,不能把整条表达式都理解成对同一条 LEGB 链的搜索。
官方参考:Python 执行模型:名字解析。
04 · E 可以有多少层?为什么 timeout 属于 G?
你问过:“retries 来自 E,timeout 为什么来自 G?它们不都在外面吗?E 只能有一层吗?”把定义位置完整摆出来,这个区别就清楚了。
timeout = 30
def run_project():
host = "lab-A"
def run_suite():
retries = 2
def run_case():
case_id = "case-001"
print(case_id, retries, host, timeout)
run_case()
run_suite()
run_project()输出:
case-001 2 lab-A 30
站在最内层的 run_case() 中,看它读取的名字:
| 名字 | 来自哪里 | 从 run_case() 看属于哪一层 |
|---|---|---|
case_id | 当前 run_case() 调用 | L |
retries | 直接包围它的 run_suite() 调用 | E 的第一层 |
host | 更外层的 run_project() 调用 | E 的第二层 |
timeout | 模块顶层 | G |
print | 内建环境 | B |
E 可以有多层,G 表示另一种环境
E 表示词法上包围当前函数的外层函数作用域。因此上面有两个 E:先是 run_suite,再是 run_project。为了画清楚次序,可以叫它们 E1、E2,但这只是讲解时的编号。
timeout = 30 写在模块顶层,不属于某个外层函数的局部绑定,所以它属于 G。虽然模块代码在排版上也位于函数外侧,G 仍然专门指模块全局环境,不把它算成又一层 E。
在这个例子中,run_case 没有局部的 timeout,两层 E 中也没有这个名字,最终使用模块中的绑定。
如果多个 E 都绑定了同一个名字,优先使用更近的一层
timeout = 30
def run_project():
timeout = 20
def run_suite():
timeout = 10
def run_case():
print(timeout)
run_case()
run_suite()
run_project()输出:
10
对这次 run_case() 的读取来说,最近的外层函数 run_suite() 已经提供了 timeout,所以不会再使用更远的 20 或模块中的 30。
| 对上面代码作出的修改 | run_case() 使用哪份绑定 | 输出 |
|---|---|---|
| 保持原样 | run_suite() 中的 timeout | 10 |
只删去 run_suite() 中的 timeout = 10 | run_project() 中的 timeout | 20 |
再删去 run_project() 中的 timeout = 20 | 模块中的 timeout | 30 |
在原版 run_case() 的 print 前增加 timeout = 5 | 当前 run_case() 调用自己的局部绑定 | 5 |
表中最后一行特意把赋值放在读取之前;如果放在读取之后,就会涉及“已被判定为局部名字,但尚未绑定”的错误,不能据此简单地说会先读到外层值。
官方参考:Python 执行模型:名字解析。
调用者的局部变量,不会自动成为被调用函数的 E
timeout = 30
def report():
print(timeout)
def run_case():
timeout = 5
report()
run_case()
timeout = 40
run_case()输出:
30
40
report 定义在模块顶层,定义它时外面没有包围它的函数作用域。即使由 run_case() 调用,它也不会因此把调用者的局部 timeout = 5 当作 E。它读取的是自己定义模块中的全局绑定。
第二次调用输出 40,也说明“使用定义模块的全局环境”并不意味着把定义那一刻的值冻结下来。模块中的绑定后来更新了,下一次读取就能得到更新后的值。
还要注意,普通方法中,类体的属性命名空间不会作为常规的 E 层参与方法内的裸名字查找。读取实例或类的属性,需要结合 self.name、ClassName.name 等属性访问来理解。
05 · results.clear() 与 results = []:对象和绑定分别发生了什么
这组例子是今天讨论中的关键。日常说“修改变量”时,经常把两件事放在一起:修改某个名字指向的对象,以及让某个名字改为指向另一个对象。下面把每种情况分开运行。
第一种:在函数体中执行 results = []
results = ["PASS"]
def reset():
results = []
print("inside:", results)
reset()
print("outside:", results)输出:
inside: []
outside: ['PASS']
这里没有 global 或 nonlocal 声明。函数体中的赋值使 results 被判定为这个函数的局部名字;调用 reset() 并执行这条赋值时,会创建一个新空列表,把这次调用中的局部 results 绑定到它。
模块中的 results 是另一份绑定,仍然关联原来的 ["PASS"]。所以你当时的理解是准确的:这条语句在本次调用的局部环境中建立或更新绑定,作用域类别是 L。
还要把时间点说准确:执行 def reset(): ... 会创建函数对象并绑定函数名;函数体里的 results = [] 要等到调用执行到那里时才会运行。
第二种:在函数体中调用 results.clear()
results = ["PASS"]
def reset():
results.clear()
reset()
print(results)输出:
[]
这个函数中没有给裸名字 results 赋值,也没有名为 results 的参数。在这个完整例子中,它没有自己的局部 results,也没有包含同名绑定的外层函数,于是使用模块 G 中的 results。
接下来还要完成属性访问和调用:
- 解析名字
results,得到模块绑定所指的列表。 - 在这个列表对象上解析属性
clear,得到相应方法。 - 调用该方法,删除列表中的全部元素。
因此,你问“它能生效,是不是因为根据 LEGB 的 G 找到了 results = ["PASS"]?”——在这个例子里,是的。更精确地说,找到了模块命名空间中 results 的绑定及其关联对象;之后 .clear() 的部分属于属性访问与方法调用。
整个过程没有把名字 results 重新绑定到另一个列表。原来的列表仍然是原来的对象,只是内容变空了。读取全局名字,以及通过该名字修改可变对象,都不因为“在函数内部发生”就自动需要 global。
官方参考:Python 教程:list.clear()。
第三种:用 global 改写模块中的名字绑定
results = ["PASS"]
def reset():
global results
results = []
reset()
print(results)输出:
[]
global results 告诉 Python:当前函数代码块中的这个名字按全局名字处理。随后执行 results = [] 时,赋值更新的是这个函数所属模块里的 results 绑定,让它关联一个新建的空列表。
这里的 global 是名字作用域声明。它的关键作用不是“只有写了它才允许读全局变量”,而是让这里的赋值作用到 G 中的名字绑定。
官方参考:Python 参考:global 语句。
输出都是 [],为什么仍然要区分?看另一个引用就知道了
假设还有 other = results。它建立第二个名字,让 other 也关联原来的列表。清空原对象与重新绑定 results,此时就能观察到不同结果。
展开运行:clear() 会让两个名字都观察到空列表
results = ["PASS"]
other = results
def reset():
results.clear()
reset()
print(results)
print(other)
print(results is other)输出:
[]
[]
True
只有一个列表对象。清空它之后,两个名字仍然关联它,所以从两边看到的都是空列表。
展开运行:global 重新绑定后,other 仍关联原列表
results = ["PASS"]
other = results
def reset():
global results
results = []
reset()
print(results)
print(other)
print(results is other)输出:
[]
['PASS']
False
这次创建了一个新空列表,并更新了模块中的 results 绑定。other 没有被重新绑定,因此仍然关联内容为 ["PASS"] 的原列表。
| 函数体中的操作 | 名字绑定发生什么 | 原列表的内容发生什么 |
|---|---|---|
results = []无声明 | 建立或更新本次调用中的局部 results | 保持 ["PASS"] |
results.clear() | 不重新绑定 results | 原列表被清空 |
global resultsresults = [] | 把模块中的 results 绑定到新空列表 | 原列表内容保持不变;其他引用仍可访问它 |
这张表针对上面的完整例子。实际判断时,仍然先看当前代码块是否存在参数、赋值或声明,确定 results 的作用域,再判断后续操作修改对象还是更新绑定。
06 · global 对应 G,nonlocal 对应最近有绑定的 E
你接着问:“如果 E 和 G 中都有 results,使用 global 时会绑定哪一个?”答案是:赋值会更新 G,也就是当前函数所属模块中的绑定。同名的外层函数局部绑定不会被这个 global 声明选中。
这里也纠正一下当时顺手写出的字母:nonlocal 对应 E,global 对应 G。
两边都叫 results:global 更新模块中的那一个
results = ["G"]
def outer():
results = ["E"]
def reset():
global results
results = []
reset()
print("outer:", results)
outer()
print("module:", results)输出:
outer: ['E']
module: []
在 reset() 中,声明已经明确了赋值目标是模块全局名字,因此不会因为 E 更近,就先更新 outer() 里的 results。外层函数仍然使用自己的 ["E"],模块绑定则改为关联新空列表。
改成 nonlocal,就给外层函数的 results 赋值
下面是一个完整独立的例子,重新把两边的初始值设为 ["G"] 和 ["E"]:
results = ["G"]
def outer():
results = ["E"]
def reset():
nonlocal results
results = []
reset()
print("outer:", results)
outer()
print("module:", results)输出:
outer: []
module: ['G']
nonlocal results 指向最近一个在代码中定义了 results 绑定的外层函数作用域;本例中就是 outer。于是 reset() 中的赋值更新了 outer() 的那份绑定;模块中的 results 仍然关联 ["G"]。
官方参考:Python 参考:nonlocal 语句。
有多层 E 时,nonlocal 选择最近包含这个绑定的一层
def outer():
results = ["outer"]
def middle():
results = ["middle"]
def reset():
nonlocal results
results = ["changed"]
reset()
print("middle:", results)
middle()
print("outer:", results)
outer()输出:
middle: ['changed']
outer: ['outer']
这里 middle() 和 outer() 都提供了名为 results 的外层函数绑定。nonlocal results 选择最近的 middle()。
如果删除 middle() 中的 results = ["middle"],这个中间函数就不再提供自己的 results 绑定;nonlocal 会继续使用更外层 outer() 中的绑定。因此,“最近”指最近的、确实包含这个名字绑定的外层函数作用域,并不限定只能跨过一层。
nonlocal 有一个前提:外层函数中必须能找到相应名字的绑定定义
nonlocal 不能凭空为你挑一个外层函数并新建名字。如果所有包围它的函数作用域中都没有对应绑定,声明会导致 SyntaxError。即使模块中有同名全局变量,也不能把它当作 nonlocal 的兜底目标。
global 则不要求模块中提前存在这个名字。声明之后对这个名字赋值,可以在模块命名空间中建立新的绑定。两种声明都针对所在代码块中的特定名字,并不会把该函数中的所有变量一起改成全局或外层变量。
| 函数体中的写法 | 对这个裸名字赋值时更新哪里 | 对目标名字的要求 |
|---|---|---|
无声明,直接 results = ... | 当前函数的局部名字 L | 不要求提前有值;执行赋值会建立或更新本次调用中的绑定 |
global results | 当前函数所属模块中的全局名字 G | 不要求模块预先存在这个名字 |
nonlocal results | 最近包含该绑定的外层函数作用域 E | 必须能在外层函数代码中找到对应绑定定义,不要求外层赋值已经执行 |
这里判断“外层是否有这个名字”,依据代码中能否找到相应的绑定定义,不要求那条外层赋值一定已经执行过。若随后读取时仍未绑定值,还会发生未绑定名字的错误。本文的示例都先完成外层赋值,再调用内层函数。
在实际代码里,把相应声明放在函数体中使用该名字之前。它不是执行到某一行才临时开启的“开关”,而是影响整个当前代码块对该名字的处理规则。
官方参考:Python 参考:global 的作用与限制。
+= 带有赋值语义,不能直接套用 append() 的判断
先看普通方法调用。函数没有给裸名字 results 赋值,可以读取模块绑定并修改列表:
results = ["PASS"]
def add_result():
results.append("FAIL")
add_result()
print(results)输出:
['PASS', 'FAIL']
把它改成 results += ["FAIL"],却会出现不同结果:
results = ["PASS"]
def add_result():
results += ["FAIL"]
try:
add_result()
except UnboundLocalError:
print("UnboundLocalError")
print(results)输出:
UnboundLocalError
['PASS']
+= 包含赋值操作,因此在没有声明的函数代码块中,results 被判定为局部名字。增强赋值又需要先取得目标原来的值,但这个局部名字尚未绑定,于是发生 UnboundLocalError,不会转而读取同名全局列表。
列表的 += 在成功执行时可以原地修改列表,这与“它在语法上仍包含对名字赋值”并不矛盾。判断是否需要 global 或 nonlocal,不能只看操作最终有没有创建新对象;还要看语句是否对这个名字实施了赋值。
如果这里的目标就是使用模块中的 results,在 add_result() 中先声明 global results,增强赋值就会按全局名字处理。对于外层函数中的绑定,则相应使用 nonlocal。
官方参考:Python 参考:增强赋值语句。
07 · 函数被导入后,G 为什么仍属于定义它的模块?
现在把“G 是模块全局环境”再说精确一点:对于这里用 def 定义的 Python 函数,G 是定义该函数的模块所关联的全局命名空间。把函数交给别的模块调用,不会把 G 换成调用者的模块。
在同一个空目录里建立下面两个文件。第一个文件定义函数:
timeout = 10
def get_timeout():
return timeout
第二个文件导入并调用这个函数:
import settings
from settings import get_timeout
timeout = 99
print(timeout)
print(get_timeout())
print(get_timeout.__globals__ is settings.__dict__)
settings.timeout = 20
print(get_timeout())
print(timeout)
输出:
99
10
True
20
99
在这两个文件所在目录执行 python main.py。每个输出都对应一个可以单独解释的事实:
| 表达式 / 操作 | 原因 |
|---|---|
print(timeout) → 99 | 正在执行 main.py 的顶层代码;本次直接运行时模块名为 __main__,这里的名字属于它的全局环境。 |
get_timeout() → 10 | 函数体中的 timeout 从 settings 的全局环境读取。 |
get_timeout.__globals__ is settings.__dict__ → True | 函数的 __globals__ 关联的,正是 settings 模块的字典。 |
设置 settings.timeout = 20 后,函数返回 20 | 该环境中的绑定发生了变化,下一次调用读到当前值。 |
| main 中的 timeout 仍为 99 | 两个模块的同名绑定互相独立。 |
如果把 global timeout 写进 get_timeout(),之后对 timeout 的赋值也会落在 settings 的全局环境里。
导入的名字,本身也是当前命名空间中的绑定
| 语句 | 在当前环境建立的名字 | 绑定的对象 |
|---|---|---|
import math | math | math 模块对象 |
import math as m | m | math 模块对象 |
from math import sqrt | sqrt | math 模块中的 sqrt 函数对象 |
from settings import timeout 也不会建立一个自动跟随 settings.timeout 重新绑定的“同步变量”。仍使用上面的 settings.py,把下面内容保存为另一个文件,在新进程中运行:
import settings
from settings import timeout
settings.timeout = 20
print(timeout)
print(settings.timeout)
输出:
10
20
导入时,本地模块的 timeout 也指向原来的整数 10;随后重新绑定 settings.timeout,不会重新绑定导入方的 timeout。如果导入的是同一个可变对象,对对象做原地修改则可能被两边同时看到,这仍然是 共享对象与独立绑定的区别。
08 · 明明 G 中有 timeout,为什么仍然会报错?
LEGB 容易让人形成一个误解:运行到一行代码时,先随便找 L,找不到就一定继续找 E、G、B。普通函数中的名字,还会先按代码中的绑定操作确定其作用域类别。被确定为局部名后,读取时若尚未绑定,不会转而借用同名的全局值。
下面把另一会话中的两种报错放在同一个完整实验中:
timeout = 30
def run_cases_1():
print(timeout)
timeout = 10
try:
run_cases_1()
except UnboundLocalError:
print("UnboundLocalError occurred.")
def run_cases_2():
print(timeout_2)
try:
run_cases_2()
except NameError:
print("NameError occurred.")
print(issubclass(UnboundLocalError, NameError))
输出:
UnboundLocalError occurred.
NameError occurred.
True
第一种:已经是局部名字,但读得太早
- 在
run_cases_1的同一个函数体内,存在timeout = 10,且没有 global / nonlocal 声明。 - 因此,这个函数体中的 timeout 被确定为局部名;赋值位于 print 后面也一样。
- 执行到
print(timeout)时,本次调用的局部 timeout 尚未绑定对象。 - 于是产生
UnboundLocalError。模块里的 30 不会被拿来补位,后面的赋值也没有执行。
这条规则针对当前函数代码块。内层另一个函数中有同名赋值,并不等于外层当前函数也做了赋值。即使当前函数的某个赋值分支最终没有运行,静态局部名分类仍可能已经成立。
第二种:可访问的环境里找不到这个名字
run_cases_2 没有给 timeout_2 建立局部绑定,也没有包围它的函数环境。读取时从关联的模块全局环境、内置环境查找;这个独立实验中两处都没有 timeout_2,因此产生 NameError。
| 异常 | 本例中的含义 |
|---|---|
| UnboundLocalError | 知道它是本次函数调用的局部名,但此刻没有绑定的值。 |
| NameError | 按照适用的名字解析规则,没有找到该名字。 |
UnboundLocalError 是 NameError 的子类。如果同一个 try 后同时列出两种 except,想分别处理它们,应先写更具体的 UnboundLocalError。实际异常消息的措辞会随 Python 版本变化,这里核验的是异常类型和触发原因。
修正代码时先确定意图:只读取全局值,就不要在该函数里又把同名名字当作局部赋值;确实要重新绑定模块中的名字,可以声明 global;若参数和返回值更适合这个接口,也可以显式传入、返回结果。
09 · 外层函数已经返回,闭包为什么还能使用 count?
把 nonlocal 放进一个计数器,就能把作用域规则和绑定的存活时间联系起来。这里 count 对 make_counter 来说是局部名字,对 increase 来说来自 E。
def make_counter():
count = 0
def increase():
nonlocal count
count += 1
return count
return increase
counter = make_counter()
print(counter())
print(counter())
another_counter = make_counter()
print(another_counter())
print(counter())
print(counter.__code__.co_freevars)
print(counter.__closure__[0].cell_contents)
输出:
1
2
1
3
('count',)
3
先分清两次不同的函数调用
make_counter()开始执行,建立 count → 0 的绑定。- 执行内层 def,创建 increase 函数对象。increase 的代码引用了外层 count。
return increase把函数对象交给调用者,此时没有执行 increase 的函数体。外层名字 counter 随后绑定到这个返回的函数对象。- make_counter 这次调用已经结束。以后执行
counter(),才会进入 increase 的函数体。 nonlocal count使这里的赋值使用捕获的外层绑定;每次 count += 1 后返回当前整数值。
cell:让后续调用继续访问同一份绑定
函数的 __closure__ 可以包含保存这些绑定的 cell;__code__.co_freevars 给出对应的自由变量名字。这里仅捕获 count,所以可以查看第一个 cell 的 cell_contents。在其他函数中,不应假定第一个 cell 总是某个特定名字。
两次 counter() 得到 1、2;再次执行 make_counter() 创建了另一份 count 绑定,所以 another_counter 从 1 开始。回头继续调用原来的 counter,它使用自己的绑定并得到 3。相同函数代码可以创建多份独立的闭包状态。如果多个内层函数捕获同一次外层调用的同一个名字,它们也可以共享该绑定。
对象什么时候消失,需要再看引用关系
| 问题 | 这段代码中的答案 |
|---|---|
| make_counter 这次调用结束了吗? | 已经返回。 |
| 它建立的 count 绑定还被保留吗? | 仍由返回函数需要的 closure cell 保留。 |
| 相关对象还有引用吗? | 有。counter 引用函数,函数的闭包结构引用 cell,cell 保存当前绑定。 |
因此不能把“函数返回”概括成“所有局部对象立即销毁”。除了闭包,返回出去的对象、其他引用、调试器或 traceback 也可能影响对象的存活。这里理解为“所需绑定得到保留”已经足够,不必假定闭包总是保存整个外层栈帧。
10 · 同一个函数,为什么能同时保留 n = 2、1、0?
这正是本次讨论中“输出复现了,但原理还不清楚”的关键例子。先看完整代码:
def countdown(n):
print("enter", n)
if n > 0:
countdown(n - 1)
print("leave", n)
countdown(2)
输出:
enter 2
enter 1
enter 0
leave 0
leave 1
leave 2
函数代码只有一份,但函数每调用一次,都会建立本次调用的参数和局部绑定。为了追踪,我们给三次调用取教学用的标签 A、B、C;它们都是同一个 countdown 函数的调用。
进入下一层之前,先计算实参
A 中 n 是 2。执行 countdown(n - 1) 时,先用 A 的 n 计算出整数 1,再把 1 传给新调用 B 的参数 n。n - 1 是求值表达式,不是 n = n - 1;A 的 n 没有被改成 1。B 之后以同样方式计算 0 并调用 C。
| 最深处仍未结束的调用 | 本次调用的局部 n | 当前状态 |
|---|---|---|
| C:countdown(0) | 0 | 条件为假,接下来执行 print("leave", n)。 |
| B:countdown(1) | 1 | 等待第 4 行中的 C 返回。 |
| A:countdown(2) | 2 | 等待第 4 行中的 B 返回。 |
在这个单线程的普通函数例子中,“三个调用尚未结束”不等于它们同时执行。当前执行的是最内层,外面的两次调用暂时等待。
拖动看看:哪一层正在执行,哪一层在等待?
同一份代码,三次独立调用
最深一层最深层快照:C 的 n = 0;暂停中的 A、B 仍分别保留自己的 n = 2 和 n = 1。
用滑块或按钮逐步查看;选择「从头看」可从调用 A 开始。
正在执行哪一行?
def countdown(n): print("enter", n) if n > 0: countdown(n - 1) print("leave", n)
高亮标出当前调用接下来执行的语句。
尚未结束的调用 · 栈顶在上
| 调用 | 局部 n | 状态 |
|---|---|---|
| C | 0 | 当前调用 · 下一句第 5 行 |
| B | 1 | 暂停在第 4 行 · 等待 C |
| A | 2 | 暂停在第 4 行 · 等待 B |
三个调用均已返回,程序继续在模块中执行。
A、B、C 是 countdown(2)、countdown(1)、countdown(0) 这三次调用的标签。
C 判断 0 > 0 为 False,跳过递归调用,继续第 5 行。
print 的累计输出
enter 2
enter 1
enter 0
调用的返回记录
(尚无调用返回)
这里单独记录返回值;这些 None 没有被 print 打印。
返回时,继续的是各自尚未完成的代码
- A:绑定 n = 2,打印 enter 2,然后调用 B。
- B:绑定 n = 1,打印 enter 1,然后调用 C。
- C:绑定 n = 0,打印 enter 0。条件
n > 0为假,只跳过缩进中的递归语句;函数没有因此提前退出。 - C:继续执行 if 后面的 print,打印 leave 0。函数体到达末尾,隐式返回 None。
- B:C 的调用表达式完成,B 继续下一行,用自己的 n = 1 打印 leave 1,随后返回 None。
- A:B 返回后,A 用自己的 n = 2 打印 leave 2,随后返回 None。
Namespace 和 frame 分别描述什么?
本次调用的局部命名空间描述“名字绑定到什么对象”。栈帧(frame)还涉及正在执行的代码、执行位置等运行状态,让一次调用知道返回后从哪里继续。用表格列出这些信息是理解模型,不等于每个 Python 实现都必须复制一份普通字典。
还有两个容易混淆的细节:第一,print 打印的六行文字不是 countdown 的返回值;子调用在本例中返回 None,调用语句没有把它保存下来。第二,A、B、C 的调用链没有给 countdown 增加 E。每个 n 属于各次调用自己的 L;函数体中再次使用裸名 countdown,在本例里解析到模块 G 中的函数。
与上一节的闭包相比:递归示例的外层调用仍在等待,尚未结束;计数器示例的 make_counter 调用已经结束,所需外层绑定仍由闭包保留。这是两种不同的存活情形。
继续阅读:递归返回后,如何完成当前赋值或表达式。依据:每次函数调用的局部命名空间、frame 对象、return 语句。
11 · obj.name、globals() 与 locals():哪些地方不能只套 LEGB?
先找对象,再访问它的属性
在 results.clear() 中,先解析裸名 results;得到列表后,访问它的 clear 属性并调用方法。在 runner.timeout 中,先解析 runner,再访问对象属性。属性名 timeout 不会再作为裸名,重新按 L、E、G、B 查一遍。
timeout = 30
class Runner:
timeout = 10
def __init__(self):
self.timeout = 5
def show(self):
print(timeout)
print(self.timeout)
print(Runner.timeout)
runner = Runner()
runner.show()
输出:
30
5
10
show 内的裸名 timeout 使用 G 中的 30;self.timeout 取得实例上的 5;Runner.timeout 取得类上的 10。普通方法的 E 不会自动包含类体中的 timeout。本例只展示普通属性;更一般的属性访问还涉及类的继承关系、描述符等规则,不能完整化约为一次 __dict__ 查询。
查看名字绑定,用这些接口
| 接口 | 主要用途 | 注意点 |
|---|---|---|
globals() | 取得当前代码关联的模块全局命名空间字典。 | 在函数内调用时,归属仍跟函数的定义环境有关。 |
locals() | 查看当前局部绑定。 | 函数中的返回结果与写回行为有版本和上下文差异。 |
vars(obj) | 返回对象的 __dict__ 属性。 | 不是所有对象都有 __dict__;类的 __dict__ 通常呈现为只读映射视图。 |
function.__globals__ | 直接查看函数关联的全局字典。 | 与函数自定义属性使用的 __dict__ 不同。 |
function.__closure__ | 查看捕获的外层绑定所对应的 cells。 | 没有闭包时可以是 None;全局名字不会因此出现在 cells 中。 |
timeout = 30
def inspect_call(retries):
case_id = "case-001"
current = locals()
print(current["retries"])
print(current["case_id"])
print(globals()["timeout"])
inspect_call(2)
输出:
2
case-001
30
版本边界:为什么不建议靠修改 locals() 来改局部变量?
在 Python 3.13 起明确的规则中,普通函数等优化作用域里的 locals() 返回一份当前绑定的字典快照。向这份字典重新赋值,不会把函数的局部变量重新绑定;每次调用都返回新的独立字典快照,之后的局部名重绑也不会更新此前的快照。
快照仍保存对原对象的引用。例如其中某项指向列表,调用列表的 append 仍可能修改同一个列表对象。这再次体现了“改字典里的绑定”和“改绑定指向的对象”之间的区别。模块和类体等上下文有各自的规定,不宜把函数规则扩展为所有 locals() 调用。
本文的运行环境为 CPython 3.12.14,这段 3.13+ 规则依据官方文档与 PEP 667 核对,没有把 3.12 上的实验冒充为 3.13+ 的执行证据。日常代码请用明确的赋值、参数、返回值和适用的 global / nonlocal 表达意图。
LEGB 是理解普通裸名解析的实用入口。类体、推导式、注解作用域以及自定义 exec / eval 环境还有特定规则;看到这些结构时,需要查对应规则,不能根据缩进或单一口诀推导所有情况。
12 · 把今天的追问变成复盘题
建议先遮住答案,用“名字、对象、绑定、当前调用”这些词解释一遍,再运行相应代码。下面是复习练习,网站不会据此自动把概念标记为已复盘。
1. Namespace 只属于函数吗?最小的拥有者是什么?
不只属于函数。模块、函数每次调用的局部环境、类以及一些对象的属性都有相关命名空间。不存在统一的“最小拥有者”;是否建立命名空间和作用域,要看具体 Python 结构与对象模型,不能只看缩进。普通 if / for 不建立新的局部作用域。
2. timeout 在最外面,为什么是 G,不能叫 E?E 只能有一层吗?
如果 timeout 定义在模块顶层,它属于 G。E 指词法上包围当前函数的函数环境;可以没有,也可以有多层,按由近到远的关系解析。模块属于另一种环境,不因为“在外面”就算 E。
3. reset() 中只写 results.clear(),为什么可以修改全局列表?
没有局部同名绑定,且词法外层函数中没有 results 绑定时,results 可以解析到 G。clear() 修改找到的列表对象,没有重新绑定裸名 results,所以这里不需要 global。若最近的 E 已有 results,则一般会先使用那个绑定。
4. 改成 results = [],到底在哪一步产生局部绑定?
普通函数体内没有 global / nonlocal 时,这个赋值使 results 被确定为局部名。在调用执行到该语句时,建立本次调用的 results → 新空列表绑定;外部同名绑定保持原状。def 阶段不会预先为未来所有调用执行这次赋值。
5. E 和 G 同时有 results,global / nonlocal 分别选谁?
global 指向函数关联的模块 G;nonlocal 指向最近一个含有该名字绑定定义的外层函数 E。nonlocal 找不到对应外层绑定时会产生 SyntaxError,不能回退到 G。
6. a 和 b 指向同一个列表。a.clear() 与 a = [] 对 b 有什么不同?
a.clear() 清空原列表,所以 b 观察到的同一列表也为空。a = [] 只把 a 重新绑定到新列表;b 仍指向原列表。不同名字可以有独立绑定,也可以引用同一个对象。
7. main.py 有 timeout = 99,为什么导入的 get_timeout() 仍返回 10?
get_timeout 在 settings.py 中定义,__globals__ 关联 settings 的全局字典,导入和调用不会替换这份关联。之后重新绑定 settings.timeout = 20,函数会读到 20;这也说明全局值没有被冻结。
8. 为什么先 print(timeout),后 timeout = 10,会触发 UnboundLocalError?
没有 global / nonlocal 声明时,同一普通函数块中的赋值使 timeout 被确定为局部名。执行第一次读取时它尚未绑定,不会回退借用 G 中的 timeout。UnboundLocalError 是 NameError 的子类。
9. make_counter 已经返回,counter 为什么还能持续加一?
返回的是捕获外层 count 绑定的函数对象;需要的绑定由 closure cell 保留。每次 counter() 使用同一份捕获状态,nonlocal 允许把该绑定重新指向更新后的整数。再次 make_counter() 会产生另一份状态。
10. countdown(0) 会把 countdown(2) 的 n 改成 0 吗?
不会。A、B、C 是同一函数的三次独立调用,各有 n = 2、1、0 的参数绑定。n - 1 只求出下一次调用的实参。C、B、A 依次结束,外层继续用自己的 n。这个运行时调用链不属于 E。
读到陌生代码时,可以按这个顺序检查
- 先圈出名字绑定操作:参数、普通赋值、def、class、import 等。
- 检查当前函数里有没有针对该名字的 global / nonlocal 声明。
- 把 L、词法外层 E、定义模块 G、B 分开;确认有没有遮蔽或未绑定的局部名。
- 再判断当前语句是在改名字绑定,还是在访问、修改某个对象的属性或内容。
- 出现递归或闭包时,进一步问:这是哪一次调用的绑定?这次调用是否已结束?还有谁引用它?
13 · 来源、整理范围与验证方式
本文按两组可检索到的 Namespace 相关讨论整合:当前会话中的命名空间、E/G、多层嵌套、results 的清空与重新赋值、global / nonlocal、闭包与 countdown 调用;另一会话中的 __globals__、跨模块调用以及 NameError / UnboundLocalError。说明、对照表、复盘题和交互演示按学习路径重新编写,未把其他无关话题混入这篇笔记。
语言规则依据 Python 3.14 官方文档核对;文中原创完整代码在 CPython 3.12.14 中分别执行,检查实际输出。两文件导入实验在各自的临时目录和新进程中运行。递归例子额外用运行跟踪核对最深处三次调用的参数与等待位置,以及 C、B、A 的返回顺序。版本相关的 locals() 说明单独标明为 3.13+ 文档结论。
- Python Tutorial · Scopes and Namespaces:命名空间、作用域与函数调用。
- Execution model · Naming and binding:绑定、名字解析、局部名分类与特殊作用域。
- Simple statements:赋值、global、nonlocal 与 return。
- Data model · User-defined functions:__globals__、__closure__、函数属性。
- Data model · Frame objects:代码、局部环境和执行位置。
- Modules 与 More on Lists:导入绑定及 clear 等原地修改操作。
- Built-in Exceptions:NameError 与 UnboundLocalError。
- Built-in Functions 与 PEP 667:globals、locals、vars 及版本边界。
阅读这篇文章表示可以继续复习这些问题;是否已经复盘,由你在学习地图中主动标记。