正在阅读Python Namespace 与 Scope:从名字绑定、LEGB 到闭包与递归栈帧
香港天气加载中…
QWeather ↗

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 与这个列表的关联在某个命名空间中,这个名字当前关联哪个对象。

这里的赋值会求出右侧表达式的结果,再把左侧名字绑定到那个对象。绑定发生在哪个命名空间,还要看这条语句处于模块代码、函数体还是其他执行环境;后面会逐步展开。

命名空间也管理函数名、类名和导入的模块名

“变量”容易让人只想到数字或列表,但名字可以绑定到许多种对象。函数和类本身也是对象,模块也能通过名字引用。下面四个名字都在模块代码执行时建立绑定:

Python
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 定义执行
mathmath 模块对象import 语句

函数调用时绑定参数,同样会建立名字绑定。例如某个函数定义为 def run_case(case_id): ...,调用时,参数名 case_id 会在该次调用的局部环境中绑定到实参提供的对象。名字的拼写相同,并不保证它们属于同一份绑定。

官方参考:Python 执行模型:名字绑定。

b = a 不会自动复制 a 指向的对象

Python
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 教程:作用域与命名空间。

函数对象的属性,与调用函数时的局部名字,分开理解

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 数据模型:用户定义的函数。

不同实例可以保存同名属性

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 语句不会仅凭缩进创建新作用域

Python
if True:
    note = "visible"

for step in range(2):
    last = step

print(note)
print(last)

输出:

visible
1

这些语句直接写在模块顶层,所以其中的赋值仍然建立模块中的名字绑定。如果把同样的语句放进函数体,赋值通常就属于那个函数的局部作用域。不能根据‘又缩进了一层’就断定新增了一个命名空间或作用域。

普通字典的键不会自动成为裸名字

Python
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

Python
def run_suite():
    retries = 2

    def run_case():
        print(retries)

    run_case()

run_suite()

输出:

2

在 run_suite() 的函数体中,retries 是这次调用的局部名字。从内部的 run_case() 看,它来自包围自己的函数作用域。名字没有因此被复制一份;变化的是我们站在哪段代码中观察它。

LEGB:普通函数中理解裸名字查找的顺序

字母英文在普通函数中对应什么
LLocal当前这次函数调用的局部绑定,包括参数和局部名字。
EEnclosing从定义位置向外,包围当前函数的外层函数作用域;可以没有,也可以有多层。
GGlobal当前函数所属的定义模块的全局环境。
BBuiltins对应执行环境中的内建名字,例如 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 只能有一层吗?”把定义位置完整摆出来,这个区别就清楚了。

Python
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 都绑定了同一个名字,优先使用更近的一层

Python
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() 中的 timeout10
只删去 run_suite() 中的 timeout = 10run_project() 中的 timeout20
再删去 run_project() 中的 timeout = 20模块中的 timeout30
在原版 run_case() 的 print 前增加 timeout = 5当前 run_case() 调用自己的局部绑定5

表中最后一行特意把赋值放在读取之前;如果放在读取之后,就会涉及“已被判定为局部名字,但尚未绑定”的错误,不能据此简单地说会先读到外层值。

官方参考:Python 执行模型:名字解析。

调用者的局部变量,不会自动成为被调用函数的 E

Python
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 等属性访问来理解。

官方参考:Python 执行模型:函数作用域与类定义的特殊规则。

05 · results.clear() 与 results = []:对象和绑定分别发生了什么

这组例子是今天讨论中的关键。日常说“修改变量”时,经常把两件事放在一起:修改某个名字指向的对象,以及让某个名字改为指向另一个对象。下面把每种情况分开运行。

第一种:在函数体中执行 results = []

Python
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()

Python
results = ["PASS"]

def reset():
    results.clear()

reset()
print(results)

输出:

[]

这个函数中没有给裸名字 results 赋值,也没有名为 results 的参数。在这个完整例子中,它没有自己的局部 results,也没有包含同名绑定的外层函数,于是使用模块 G 中的 results。

接下来还要完成属性访问和调用:

  1. 解析名字 results,得到模块绑定所指的列表。
  2. 在这个列表对象上解析属性 clear,得到相应方法。
  3. 调用该方法,删除列表中的全部元素。

因此,你问“它能生效,是不是因为根据 LEGB 的 G 找到了 results = ["PASS"]?”——在这个例子里,是的。更精确地说,找到了模块命名空间中 results 的绑定及其关联对象;之后 .clear() 的部分属于属性访问与方法调用。

整个过程没有把名字 results 重新绑定到另一个列表。原来的列表仍然是原来的对象,只是内容变空了。读取全局名字,以及通过该名字修改可变对象,都不因为“在函数内部发生”就自动需要 global。

官方参考:Python 教程:list.clear()。

第三种:用 global 改写模块中的名字绑定

Python
results = ["PASS"]

def reset():
    global results
    results = []

reset()
print(results)

输出:

[]

global results 告诉 Python:当前函数代码块中的这个名字按全局名字处理。随后执行 results = [] 时,赋值更新的是这个函数所属模块里的 results 绑定,让它关联一个新建的空列表。

这里的 global 是名字作用域声明。它的关键作用不是“只有写了它才允许读全局变量”,而是让这里的赋值作用到 G 中的名字绑定。

官方参考:Python 参考:global 语句。

输出都是 [],为什么仍然要区分?看另一个引用就知道了

假设还有 other = results。它建立第二个名字,让 other 也关联原来的列表。清空原对象与重新绑定 results,此时就能观察到不同结果。

展开运行:clear() 会让两个名字都观察到空列表
Python
results = ["PASS"]
other = results

def reset():
    results.clear()

reset()
print(results)
print(other)
print(results is other)

输出:

[]
[]
True

只有一个列表对象。清空它之后,两个名字仍然关联它,所以从两边看到的都是空列表。

展开运行:global 重新绑定后,other 仍关联原列表
Python
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 results
results = []
把模块中的 results 绑定到新空列表原列表内容保持不变;其他引用仍可访问它

这张表针对上面的完整例子。实际判断时,仍然先看当前代码块是否存在参数、赋值或声明,确定 results 的作用域,再判断后续操作修改对象还是更新绑定。

06 · global 对应 G,nonlocal 对应最近有绑定的 E

你接着问:“如果 E 和 G 中都有 results,使用 global 时会绑定哪一个?”答案是:赋值会更新 G,也就是当前函数所属模块中的绑定。同名的外层函数局部绑定不会被这个 global 声明选中。

这里也纠正一下当时顺手写出的字母:nonlocal 对应 E,global 对应 G。

两边都叫 results:global 更新模块中的那一个

Python
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"]:

Python
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 选择最近包含这个绑定的一层

Python
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 赋值,可以读取模块绑定并修改列表:

Python
results = ["PASS"]

def add_result():
    results.append("FAIL")

add_result()
print(results)

输出:

['PASS', 'FAIL']

把它改成 results += ["FAIL"],却会出现不同结果:

Python
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 换成调用者的模块。

在同一个空目录里建立下面两个文件。第一个文件定义函数:

settings.py
timeout = 10


def get_timeout():
    return timeout

第二个文件导入并调用这个函数:

main.py
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 mathmathmath 模块对象
import math as mmmath 模块对象
from math import sqrtsqrtmath 模块中的 sqrt 函数对象

from settings import timeout 也不会建立一个自动跟随 settings.timeout 重新绑定的“同步变量”。仍使用上面的 settings.py,把下面内容保存为另一个文件,在新进程中运行:

import_value.py
import settings
from settings import timeout

settings.timeout = 20

print(timeout)
print(settings.timeout)

输出:

10
20

导入时,本地模块的 timeout 也指向原来的整数 10;随后重新绑定 settings.timeout,不会重新绑定导入方的 timeout。如果导入的是同一个可变对象,对对象做原地修改则可能被两边同时看到,这仍然是 共享对象与独立绑定的区别。

依据:函数的 __globals__、Python 教程:模块与导入。

08 · 明明 G 中有 timeout,为什么仍然会报错?

LEGB 容易让人形成一个误解:运行到一行代码时,先随便找 L,找不到就一定继续找 E、G、B。普通函数中的名字,还会先按代码中的绑定操作确定其作用域类别。被确定为局部名后,读取时若尚未绑定,不会转而借用同名的全局值。

下面把另一会话中的两种报错放在同一个完整实验中:

Python
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

第一种:已经是局部名字,但读得太早

  1. 在 run_cases_1 的同一个函数体内,存在 timeout = 10,且没有 global / nonlocal 声明。
  2. 因此,这个函数体中的 timeout 被确定为局部名;赋值位于 print 后面也一样。
  3. 执行到 print(timeout) 时,本次调用的局部 timeout 尚未绑定对象。
  4. 于是产生 UnboundLocalError。模块里的 30 不会被拿来补位,后面的赋值也没有执行。

这条规则针对当前函数代码块。内层另一个函数中有同名赋值,并不等于外层当前函数也做了赋值。即使当前函数的某个赋值分支最终没有运行,静态局部名分类仍可能已经成立。

第二种:可访问的环境里找不到这个名字

run_cases_2 没有给 timeout_2 建立局部绑定,也没有包围它的函数环境。读取时从关联的模块全局环境、内置环境查找;这个独立实验中两处都没有 timeout_2,因此产生 NameError。

异常本例中的含义
UnboundLocalError知道它是本次函数调用的局部名,但此刻没有绑定的值。
NameError按照适用的名字解析规则,没有找到该名字。

UnboundLocalError 是 NameError 的子类。如果同一个 try 后同时列出两种 except,想分别处理它们,应先写更具体的 UnboundLocalError。实际异常消息的措辞会随 Python 版本变化,这里核验的是异常类型和触发原因。

修正代码时先确定意图:只读取全局值,就不要在该函数里又把同名名字当作局部赋值;确实要重新绑定模块中的名字,可以声明 global;若参数和返回值更适合这个接口,也可以显式传入、返回结果。

依据:名字解析与局部绑定规则、UnboundLocalError 的异常继承关系。

09 · 外层函数已经返回,闭包为什么还能使用 count?

把 nonlocal 放进一个计数器,就能把作用域规则和绑定的存活时间联系起来。这里 count 对 make_counter 来说是局部名字,对 increase 来说来自 E。

Python
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

先分清两次不同的函数调用

  1. make_counter() 开始执行,建立 count → 0 的绑定。
  2. 执行内层 def,创建 increase 函数对象。increase 的代码引用了外层 count。
  3. return increase 把函数对象交给调用者,此时没有执行 increase 的函数体。外层名字 counter 随后绑定到这个返回的函数对象。
  4. make_counter 这次调用已经结束。以后执行 counter(),才会进入 increase 的函数体。
  5. 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 也可能影响对象的存活。这里理解为“所需绑定得到保留”已经足够,不必假定闭包总是保存整个外层栈帧。

依据:__closure__ 与 cell、对象、引用与回收。

10 · 同一个函数,为什么能同时保留 n = 2、1、0?

这正是本次讨论中“输出复现了,但原理还不清楚”的关键例子。先看完整代码:

Python
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。

正在执行哪一行?

def countdown(n):    print("enter", n)    if n > 0:        countdown(n - 1)    print("leave", n)

高亮标出当前调用接下来执行的语句。

尚未结束的调用 · 栈顶在上

调用局部 n状态
C0当前调用 · 下一句第 5 行
B1暂停在第 4 行 · 等待 C
A2暂停在第 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 打印。

返回时,继续的是各自尚未完成的代码

  1. A:绑定 n = 2,打印 enter 2,然后调用 B。
  2. B:绑定 n = 1,打印 enter 1,然后调用 C。
  3. C:绑定 n = 0,打印 enter 0。条件 n > 0 为假,只跳过缩进中的递归语句;函数没有因此提前退出。
  4. C:继续执行 if 后面的 print,打印 leave 0。函数体到达末尾,隐式返回 None。
  5. B:C 的调用表达式完成,B 继续下一行,用自己的 n = 1 打印 leave 1,随后返回 None。
  6. 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 查一遍。

Python
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 中。
Python
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 环境还有特定规则;看到这些结构时,需要查对应规则,不能根据缩进或单一口诀推导所有情况。

依据:类体与名字解析边界、locals / globals / vars、PEP 667。

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。

读到陌生代码时,可以按这个顺序检查

  1. 先圈出名字绑定操作:参数、普通赋值、def、class、import 等。
  2. 检查当前函数里有没有针对该名字的 global / nonlocal 声明。
  3. 把 L、词法外层 E、定义模块 G、B 分开;确认有没有遮蔽或未绑定的局部名。
  4. 再判断当前语句是在改名字绑定,还是在访问、修改某个对象的属性或内容。
  5. 出现递归或闭包时,进一步问:这是哪一次调用的绑定?这次调用是否已结束?还有谁引用它?

在学习地图中找到这组概念 · 回看变量命名中的名字绑定 · 回看模块入口与导入

13 · 来源、整理范围与验证方式

本文按两组可检索到的 Namespace 相关讨论整合:当前会话中的命名空间、E/G、多层嵌套、results 的清空与重新赋值、global / nonlocal、闭包与 countdown 调用;另一会话中的 __globals__、跨模块调用以及 NameError / UnboundLocalError。说明、对照表、复盘题和交互演示按学习路径重新编写,未把其他无关话题混入这篇笔记。

语言规则依据 Python 3.14 官方文档核对;文中原创完整代码在 CPython 3.12.14 中分别执行,检查实际输出。两文件导入实验在各自的临时目录和新进程中运行。递归例子额外用运行跟踪核对最深处三次调用的参数与等待位置,以及 C、B、A 的返回顺序。版本相关的 locals() 说明单独标明为 3.13+ 文档结论。

  1. Python Tutorial · Scopes and Namespaces:命名空间、作用域与函数调用。
  2. Execution model · Naming and binding:绑定、名字解析、局部名分类与特殊作用域。
  3. Simple statements:赋值、global、nonlocal 与 return。
  4. Data model · User-defined functions:__globals__、__closure__、函数属性。
  5. Data model · Frame objects:代码、局部环境和执行位置。
  6. Modules 与 More on Lists:导入绑定及 clear 等原地修改操作。
  7. Built-in Exceptions:NameError 与 UnboundLocalError。
  8. Built-in Functions 与 PEP 667:globals、locals、vars 及版本边界。

阅读这篇文章表示可以继续复习这些问题;是否已经复盘,由你在学习地图中主动标记。

PythonNamespaceScopeLEGBglobal / nonlocal闭包递归

整理于 2026-10-19 · 合并两组 Namespace 相关讨论。语言规则参考 Python 3.14 官方文档,完整示例在 CPython 3.12.14 中核验;locals() 的 3.13+ 行为单独标明文档依据。

MORE IN DEVELOPMENT3.1 数据结构分类:逻辑关系、存储方式与 Python 容器