作用域(scope) 决定一段代码可以直接使用哪些名称, 命名空间(namespace) 则保存名称到对象的映射。
函数中只要出现对某个名称的绑定操作,编译器通常就把它在整个函数体中视为局部名称;因此,赋值前读取同名全局变量会抛出 UnboundLocalError。
先确认名称的所有者,再选择参数与返回值、nonlocal 或 global;不要为了消除报错就机械添加 global。
是什么,为什么存在
作用域(scope) 描述名称在源码中的可见范围, 命名空间(namespace) 保存名称与对象之间的绑定。两者回答不同问题:作用域回答「这里能直接写哪个名称」,命名空间回答「这个名称当前指向哪个对象」。把它们都叫作「变量存放位置」,很容易把查找规则与对象生命周期混为一谈。
Python 中的名称不是装对象的盒子。rate = 0.2 会在当前命名空间中建立名称 rate 到浮点对象的 名称绑定(name binding) ,后续赋值可以让同一名称指向另一个对象。多个名称也可以指向同一个对象,所以名称的作用域与对象是否仍然存活不是一回事。
作用域让模块和函数可以复用常见名称,而不必给每个临时值发明全局唯一的名称。函数调用会获得自己的局部绑定,嵌套函数可以读取词法外层绑定,模块则提供可共享的全局绑定。内置名称是最后一层回退,因此 len 和 ValueError 无需导入即可使用。
你会在函数参数、赋值、导入、循环目标、模式匹配和异常处理器中遇到名称绑定。重构时把一条赋值移进函数、在循环中生成回调,或者把模块常量改成局部配置,都可能改变名称解析。理解作用域后,这些行为可以从源码推导,不需要靠试错猜测。
工作原理
Python 按词法嵌套解析普通名称,也就是查看函数定义在源码中的位置,而不是查看运行时由谁调用它。常用的 LEGB 是一条实用查找路径:Local、Enclosing、Global、Built-in。它适合解释函数中的读取,但类代码块、推导式和注解作用域有额外规则,不能只靠四个字母概括。
| 层级 | 中文 | 绑定来自哪里 | 未找到时 |
|---|---|---|---|
| L | 局部作用域 | 当前函数的形参和绑定操作 | 继续查看外层作用域 |
| E | 外层作用域 | 词法上包围当前函数的函数 | 逐层向外查找 |
| G | 全局作用域 | 定义当前函数的模块 | 继续查看内置命名空间 |
| B | 内置作用域 | builtins 模块 | 抛出 NameError |
查找名称之前,编译器会扫描整个函数代码块,判断哪些名称属于局部、自由或全局名称。函数内任何绑定操作通常都会让目标成为该代码块的局部名称,即使赋值写在名称读取之后,或者位于永远不会执行的分支中。因此,UnboundLocalError 的根因往往不在报错行,而在同一函数的另一条赋值语句。
绑定操作不只有 =。形参、def、class、import、for 的目标、with ... as、except ... as、捕获模式和赋值表达式都会绑定名称;del 虽然执行的是解绑,也会影响编译期分类。属性赋值 order.total = 10 与下标赋值 items[0] = 10 修改对象,不会在当前作用域绑定 order 或 items。
普通读取总是选择当前环境中最近的可见绑定。局部名称可以遮蔽同名外层、全局或内置名称,但不会删除那些外层绑定。离开局部代码块后,外层名称仍保持原来的绑定。
global 与 nonlocal
global name 告诉编译器:当前代码块中对 name 的使用和赋值都指向模块全局命名空间。它不表示「在所有模块中全局」,也不创建跨进程或跨线程的共享存储。若名称尚不存在,后续赋值会在当前函数所属模块中创建它。
nonlocal name 指向最近一个已经绑定该名称的外层函数作用域。它不能指向模块作用域,也不能凭空创建外层绑定;找不到目标时,代码会在编译阶段抛出 SyntaxError。内层函数只读取自由变量时无需声明,只有重新绑定外层名称时才需要 nonlocal。
| 需求 | 推荐写法 | 原因 |
|---|---|---|
| 读取模块配置 | 直接读取名称 | 读取不需要 global |
| 修改调用方提供的数据 | 参数加返回值 | 所有权与数据流可见 |
| 更新某次外层函数调用的状态 | nonlocal | 状态留在该词法外层作用域 |
| 更新真正属于模块的状态 | global | 明确修改模块命名空间 |
大多数业务函数更适合接收参数并返回新值,因为调用关系直接暴露了状态流向。global 和 nonlocal 不是禁用功能,但它们应匹配真实的状态所有者。若一个修复扩大了状态的生命周期或共享范围,它通常引入了比原报错更难发现的问题。
特殊边界
类体执行时会创建类命名空间,完成后该命名空间成为类的属性字典。类体中绑定的普通名称不会成为方法的外层函数绑定,所以方法应写 self.rate、type(self).rate 或 Pricing.rate,而不是直接写 rate。类体内的未绑定普通名称会转向全局命名空间查找,这又与函数局部名称的行为不同。
列表、集合和字典推导式不会把迭代变量泄漏到包含它们的作用域。生成器表达式也有自己的执行作用域。它们仍能读取外层可见名称,但在类体内不能把普通类属性当成词法外层变量;Python 3.14 的注解作用域是这一规则的特例。
示例
下面三个示例依次展示 LEGB 读取、编译期分类与命名空间检查。输出来自本地 python3 实际运行;示例只使用 Python 3.12 与目标版本 Python 3.14 共有的语义。
跟随 LEGB 查找名称
内层函数 render() 同时读取四层名称。每个名称都只在一个层级绑定,因此输出能直接显示查找结果。
tax_rate = 0.20
def print_report():
department = "returns"
def render(order_ids):
heading = "pending"
print("local:", heading)
print("enclosing:", department)
print("global:", tax_rate)
print("built-in:", len(order_ids))
render([101, 102, 103])
print_report()local: pending
enclosing: returns
global: 0.2
built-in: 3查找是按名称分别进行的,不是先为函数选择一个统一命名空间。heading 在当前局部作用域找到,department 来自词法外层函数,tax_rate 来自模块,len 最后在内置命名空间找到。若更近的作用域绑定同名名称,更外层的绑定就会被遮蔽。
函数从哪里被调用不会改变这条路径。即使另一个函数先绑定 department = "support" 再调用 render(),render() 仍读取定义它的 print_report() 那次调用中的绑定。Python 使用词法作用域,而不是动态作用域。
对比局部、global 与 nonlocal
broken_price() 故意在读取 discount 后赋值。异常被捕获后程序继续运行,从而把三种重新绑定方式放在一份可执行输出中比较。
discount = 10
calls = 0
def broken_price():
try:
print(discount)
except UnboundLocalError as error:
print(type(error).__name__)
discount = 20
return discount
def record_call():
global calls
calls += 1
def make_budget(limit):
remaining = limit
def spend(amount):
nonlocal remaining
remaining -= amount
return remaining
return spend
print("local:", broken_price())
record_call()
print("global:", calls)
spend = make_budget(50)
print("nonlocal:", spend(8), spend(7))UnboundLocalError
local: 20
global: 1
nonlocal: 42 35broken_price() 中的赋值让 discount 在整个函数体中成为局部名称,所以更早的 print(discount) 不会回退到全局值 10。异常处理结束后,局部绑定被设为 20。该调用没有改变模块中的 discount。
record_call() 明确更新模块绑定,spend() 则更新某次 make_budget() 调用拥有的绑定。再次调用 make_budget(50) 会得到独立的 remaining。若状态不应跨调用共享,参数与返回值通常比 global 更合适。
读取命名空间视图
globals() 与 locals() 让你检查当前命名空间。真实命名空间包含许多实现和运行环境名称,所以示例只选择确定的键,不直接打印整个字典。
REGION = "eu"
class Shipping:
unit = "kg"
visible_names = sorted(
name for name in locals() if not name.startswith("__")
)
def summarize(order_id):
subtotal = 25
names = locals()
print("function:", sorted(names))
print("values:", names["order_id"], names["subtotal"])
print("module:", globals()["REGION"])
print("class:", Shipping.visible_names)
summarize("A-17")module: eu
class: ['unit']
function: ['order_id', 'subtotal']
values: A-17 25类体执行 locals() 时,映射会成为交给元类构造器的类命名空间;因此 unit 最终可通过 Shipping.unit 访问。函数中的映射包含当前已绑定的形参与局部名称。模块顶层的 locals() 与 globals() 指向同一个命名空间,但函数优化作用域中的行为不同。
Python 3.14 规定,在函数、生成器和协程等优化作用域中,每次 locals() 返回当前绑定的新字典。修改该字典不会写回真实局部变量,后续局部赋值也不会改动之前取得的字典。把 locals() 当作调试快照可以,把它当作赋值接口不行。
陷阱
在赋值前读取同名全局名称
修复方法: 先确定状态归谁所有。优先把值作为参数传入并返回更新值;确实属于模块或外层函数时,分别使用 global 或 nonlocal,并把声明放在首次使用之前。
用 global 掩盖所有权错误
修复方法: 写出状态的生命周期和共享边界。请求状态用参数、返回值或实例保存;只有进程内模块确实拥有该值时才使用 global,并测试两个调用方交错执行。
遮蔽内置名称
修复方法: 使用 records、order_id 和 total 等领域名称。诊断时检查当前局部与全局绑定;不要靠 builtins.list 长期绕过含糊命名。
把类属性当作方法的外层局部名称
修复方法: 实例策略写成 self.rate,类级策略写成 type(self).rate 或明确类名。这样继承和属性覆盖也能按照接口表达,而不是依赖偶然存在的全局名称。
通过 locals() 修改函数变量
修复方法: 动态字段应放进显式字典、数据类或普通对象。locals() 适合诊断和向明确接受映射的 API 提供数据,不是函数局部变量的写入接口。
把作用域当成对象生命周期
修复方法: 分开追踪「哪个作用域可见这个名称」与「哪些引用让对象保持可达」。文件、锁和事务使用上下文管理器显式释放,不要把正确性押在名称离开作用域后的回收时机上。
编译器看到的绑定
局部名称的分类发生在函数执行之前。编译器为每个代码块建立符号表,记录名称是否为形参、局部、自由、非局部或全局名称。这个静态分类解释了为什么运行时无法根据某个分支是否执行,再临时决定读取全局名称。
内层函数使用、但没有在自身代码块中绑定的名称是 自由变量(free variable) 。提供该绑定的词法外层函数是 外层作用域(enclosing scope) 。闭包让这些绑定在外层调用返回后仍可用,但闭包的共享与延迟绑定细节属于单独主题。
标准库 symtable 可以读取编译器的符号表,而不执行被分析的源码。下面的程序把一段源码编译为符号表,并检查最内层函数中的四个名称。
import symtable
SOURCE = """
fee = 2
def make_total(tax):
def total(amount):
subtotal = amount * (1 + tax)
return subtotal + fee
return total
"""
module = symtable.symtable(SOURCE, "billing.py", "exec")
factory = module.lookup("make_total").get_namespace()
total = factory.lookup("total").get_namespace()
checks = (
("parameter", lambda symbol: symbol.is_parameter()),
("local", lambda symbol: symbol.is_local()),
("free", lambda symbol: symbol.is_free()),
("global", lambda symbol: symbol.is_global()),
)
for name in sorted(total.get_identifiers()):
symbol = total.lookup(name)
roles = [label for label, check in checks if check(symbol)]
print(f"{name}: {','.join(roles)}")amount: parameter,local
fee: global
subtotal: local
tax: free形参 amount 同时是局部名称,subtotal 由赋值建立局部绑定,tax 由外层函数提供,fee 则解析到模块。这里的 global 分类表示隐式全局读取;源码不需要写 global fee,因为函数没有给 fee 赋值。
符号表适合静态检查和开发工具,但业务代码通常不应靠它决定运行路径。若接口只有在分析字节码或符号表后才看得懂,状态所有权已经过于隐蔽。优先用函数签名、对象属性和返回值表达数据流。
从错误类型定位查找阶段
名称相关异常指向不同的失败阶段。先辨认异常类型,再查看绑定表,通常比立即添加声明更有效。异常信息中的名称只是起点,还要检查整个代码块中的绑定操作。
| 现象 | 表示什么 | 先检查哪里 |
|---|---|---|
NameError | 可见作用域中没有找到名称 | 拼写、导入和执行顺序 |
UnboundLocalError | 当前函数已把名称归类为局部,但读取时尚未绑定 | 函数内所有赋值与绑定目标 |
SyntaxError 指向 nonlocal | 没有可供声明指向的外层函数绑定 | 外层函数是否真的绑定该名称 |
AttributeError | 名称查找已得到对象,但对象没有该属性 | 实例、类及描述符链 |
AttributeError 不是 LEGB 查找失败。表达式 order.total 会先按作用域解析 order,再对所得对象执行属性查找;这两步应分别诊断。给模块增加名为 total 的全局变量不会修复缺失的对象属性。
异常类型也可能被自定义代码改变,例如 __getattr__() 可以控制缺失属性的处理。即便如此,先分开名称解析与属性访问仍然有效。不要因为报错文本中出现同一个单词,就假定它们使用同一套查找规则。
不创建局部作用域的语句
Python 的缩进块不等于新作用域。if、for、while、with、try 和 match 不会像函数那样创建普通局部作用域,所以在这些语句中绑定的名称通常在同一包含代码块的后续位置仍可见。是否执行过绑定仍由控制流决定,未执行的分支不会凭空产生运行时绑定。
异常处理器是一个容易漏掉的细节。except Error as error 会绑定 error,但处理器结束时 Python 会清除该目标,以断开异常、回溯和执行帧之间的引用环。因此,不要指望在 except 之后继续读取该名称。
结构化模式匹配的捕获模式也会绑定名称。不同分支若让后续代码看到不一致的绑定集合,读者很难证明哪些名称一定存在。更稳妥的写法是在每个成功分支中构造同一种结果,再让后续代码只读取那个结果名称。
类、推导式与注解作用域
类体本身是可执行代码,查找普通名称时可以读取全局与内置名称,也可以读取类体中更早绑定的名称。类创建完成后,类命名空间中的条目成为属性。方法函数却不把这个类命名空间当作普通词法外层作用域,这是类属性必须通过属性访问表达的根本原因。
推导式隔离迭代变量,避免 Python 2 风格的名称泄漏。不过,在类体中执行的推导式也不能直接读取普通类局部名称,因为推导式的隐含作用域没有把类命名空间纳入普通外层函数链。把所需值放在模块、在类创建后计算,或改写为明确的构造步骤,通常更清楚。
Python 3.14 中,函数注解、变量注解、类型参数列表和 type 语句会使用注解作用域。注解作用域大体类似函数作用域,却可以访问紧邻的类命名空间;多数注解作用域还会延迟求值。这是现代类型语法的专门规则,不应推广成「类方法可以直接读取类属性」。
注解作用域中的类型参数不能由内层作用域用 nonlocal 重新绑定,表达式也限制 yield、yield from、await 和 :=。维护元编程或类型检查工具时,应按 Python 3.14 的执行模型处理这些名称。普通业务函数若没有使用现代类型语法,无需为这套特殊规则增加控制流。
globals() 与 locals() 的契约
globals() 返回实现当前函数的模块命名空间字典。函数被导入后从另一个模块调用,globals() 仍指向定义该函数的模块,而不是调用模块。直接修改该字典会修改模块状态,效果与 global 绑定同属一个共享边界,因此只应在明确需要命名空间操作的工具中使用。
locals() 的可修改性取决于作用域。模块、类以及给 exec() 或 eval() 显式提供的非优化命名空间会暴露真实映射;Python 3.14 的函数、生成器和协程等优化作用域则返回独立快照。读取快照适合日志与诊断,但其中的对象引用仍指向原对象,修改可变对象本身仍可能产生副作用。
exec() 和 eval() 接受全局与局部命名空间映射,但动态执行还有安全与可维护性问题。不要为了绕过普通作用域规则而拼接源码;结构化数据应放在字典,对象行为应通过函数或类表达。处理不受信任输入时,eval() 与 exec() 都不是解析器。
名称、引用与生命周期
作用域只规定名称在哪里可见,不保证对象何时回收。函数返回后,其普通局部名称不再可由调用方直接访问,但返回值、闭包、容器或全局注册表可能继续引用那些对象。引用关系决定对象可达性,具体回收时机还取决于 Python 实现与引用环。
重新绑定与修改对象也必须分开。items = [] 让名称指向新列表,其他名称仍可引用旧列表;items.append(value) 则修改所有引用方看到的同一个列表。global 与 nonlocal 控制前一种名称重新绑定,不是对象方法调用的权限声明。
调试状态错误时,可以画两张小表:一张记录每个名称由哪个代码块绑定,另一张记录每个对象被哪些名称或容器引用。第一张解释 NameError、UnboundLocalError 与遮蔽,第二张解释共享修改和对象存活时间。把这两个问题分开,通常比打印整个命名空间更快找到根因。
延伸阅读
5个问题 · 2 道输出预测题 · 1 道找错题