引言
这是第四章「函数」的第二篇。第一篇你学会了如何定义函数、传递参数和返回值——那是「函数怎么写」的问题。但从这篇开始,我们要解决一个更根本的问题:函数里的变量到底从哪来,又到哪里去?
你一定遇到过这样的困惑:明明在函数外面定义了变量 x,函数内部却报 NameError;或者函数内部修改了变量,外部值却没有变化;又或者闭包的结果与预期完全不符,调试半天才发现是变量绑定延迟导致的。这些问题的根源都指向同一个概念——作用域(Scope)。
作用域是 Python 变量查找的路径规则,它决定了「一个名字在当前代码位置能看到哪些绑定」。理解作用域,是写出正确、可维护 Python 代码的必经之路。本篇将带你完整梳理 LEGB 规则,通过 globals() 和 locals() 看清变量的实际布局,用 nonlocal 解决嵌套函数修改变量的难题,并揭示闭包中最为隐蔽的一个陷阱——变量延迟绑定。
阅读前提:已掌握第一篇的函数定义与参数传递。本篇不涉及装饰器(第 5 篇)、lambda 进阶用法(第 4 篇)。
概念与原理
命名空间:名字的地图
在深入作用域之前,必须先理解**命名空间(Namespace)**这个底层概念。
命名空间是一个「名字到对象」的映射表,本质上是一个字典。每次你执行 x = 5,Python 就在某个命名空间里存入一条记录:{'x': <int对象5>}。Python 中主要有四种命名空间:
- 内置命名空间(Built-in):Python 启动时自动创建,包含
print、len、int、str等内置函数和类型。所有代码都能访问它们,无需导入。 - 全局命名空间(Global):当前模块(文件)的顶层命名空间。模块中所有在函数外部定义的变量都存放于此。
- 局部命名空间(Local):函数被调用时临时创建的命名空间,存放该函数的局部变量。函数返回后销毁。
- 嵌套函数的局部命名空间:当函数嵌套时,内层函数的局部命名空间独立于外层。
命名空间的生命周期各不相同:内置命名空间随 Python 解释器启动而产生,随退出而销毁;全局命名空间随模块加载而产生,随模块卸载而销毁;局部命名空间则随函数调用而产生,随函数返回而销毁。
你可以用 globals() 和 locals() 函数直接查看当前作用域的命名空间内容:
x = 10 # 写入全局命名空间
def sample():
y = 20 # 写入局部命名空间
print("globals keys:", list(globals().keys())[:5])
print("locals:", locals())
sample()
print("global x:", x) # 10,在全局命名空间中
# print("local y:", y) # NameError!y 只在 sample 的局部命名空间中存在
注意:locals() 返回的是字典的副本,对其修改不会真正影响局部变量;而 globals() 返回的是真实的全局命名空间字典,对其修改会立即生效:
x = 1
def modify_global():
globals()['x'] = 99
modify_global()
print(x) # 99
虽然 globals() 可以修改全局变量,但这是一种极不推荐的 hack 手法。正确的方式是使用 global 关键字声明意图。
LEGB 规则:名字查找的完整路径
当一个名字在代码中被引用时,Python 按照 LEGB 规则依次查找:
- L(Local):当前函数(或 lambda)的局部命名空间。
- E(Enclosing):外层函数的局部命名空间(仅当存在嵌套函数时)。
- G(Global):当前模块的全局命名空间。
- B(Built-in):Python 内置命名空间。
查找过程是从小到大逐层向外的,一旦在某一层找到匹配的名字,就停止查找并使用该绑定;如果四层都找不到,抛出 NameError。
x = "global" # G
def outer():
x = "enclosing" # E
def inner():
x = "local" # L
print(x) # 输出 local,L 层找到
inner()
print(x) # 输出 enclosing,L 层(outer 自身)找到
outer()
print(x) # 输出 global,L 层没有,G 层找到
当内层函数需要引用外层函数的变量时,E 层的查找变得至关重要。这也是闭包能够工作的基础。
修改全局变量:global 关键字
默认情况下,函数内部对变量的赋值操作会创建一个同名局部变量,而不是修改全局变量。这是 LEGB 规则的自然结果:赋值总是写入当前最内层的命名空间。
count = 0
def increment():
count = count + 1 # 读取 L/E/G/B,但赋值时创建局部 count
# 实际执行时:先读取 count(G 层找到,值为 0),再赋值给新局部变量
# 但是!count + 1 中的 count 在赋值前尚未在局部定义,会报 UnboundLocalError
increment()
上面的代码会报 UnboundLocalError: local variable 'count' referenced before assignment。原因是:Python 在编译阶段看到函数体内有 count = count + 1 这种赋值语句,就把 count 标记为局部变量;但执行时右侧的 count 还没被赋值,所以报错。
要用 global 关键字声明「我要修改全局变量」:
count = 0
def increment():
global count
count = count + 1
increment()
increment()
print(count) # 2
global 的含义是告诉 Python:在这个函数体内,所有对这个名字的赋值都作用于全局命名空间,查找时也优先在局部作用域之外寻找。
nonlocal:修改变量,但不突破到全局
nonlocal 是 Python 3 引入的关键字,用于在嵌套函数中修改外层函数(而非全局)的变量。它的查找规则是:从当前函数的外层开始,沿 Enclosing 链向上查找第一个匹配的变量绑定,然后修改它。
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
nonlocal 与 global 的关键区别:nonlocal 只能引用 Enclosing 链上的变量,不能引用全局变量;如果 Enclosing 链上找不到匹配变量,会报 SyntaxError。
def demo():
x = 1
def inner():
nonlocal x # 正确:x 在 outer 中存在
x = 2
inner()
print(x) # 2
demo()
如果 nonlocal 引用的变量在 Enclosing 链上不存在,Python 会在编译阶段直接报 SyntaxError,即使函数从未被调用也会出错:
# 这段代码无法通过编译(取消注释会报错)
# def bad_demo():
# x = 1
# def inner():
# nonlocal y # SyntaxError!y 在 Enclosing 链上不存在
# y = 2
# inner()
闭包:函数携带环境的记忆
闭包(Closure)是指一个函数对象记住了它定义时所处环境中的变量。即使外层函数已经返回,内层函数仍然可以通过 Enclosing 作用域访问那些变量。
闭包的形成有三个必要条件:
- 存在嵌套函数(一个函数定义在另一个函数内部)。
- 内层函数引用了外层函数的变量(Enclosing 作用域中的变量)。
- 外层函数返回了内层函数。
def make_multiplier(factor):
"""返回一个乘法器函数"""
def multiplier(x):
return x * factor # factor 来自 Enclosing 作用域
return multiplier
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(10)) # 20
print(triple(10)) # 30
double 和 triple 是两个独立的函数对象,但它们共享同一段代码(multiplier),只是 factor 这个变量的绑定不同。这就是闭包的强大之处——每个闭包都携带了自己的「环境快照」。
可以用 __closure__ 属性查看函数是否形成了闭包,以及它捕获了哪些变量:
print(double.__closure__) # (<cell at 0x...: int object at 0x...>,)
print(double.__closure__[0].cell_contents) # 2
__closure__ 是元组,每个元素是一个 cell 对象,保存着被捕获的变量的引用。如果函数没有形成闭包,__closure__ 为 None。
实现
下面通过三个递进的示例,展示作用域和闭包在实际编程中的应用。
示例一:日志计时器(闭包的实际用途)
一个常见的工程需求是测量函数执行时间。我们用一个闭包来实现一个通用的计时装饰器的雏形:
import time
def timer(label="函数"):
"""工厂函数:返回一个带计时功能的包装器"""
def wrapper(func):
def inner(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"[{label}] {func.__name__} 耗时 {elapsed:.6f} 秒")
return result
return inner
return wrapper
# 使用示例
@timer("计算")
def fibonacci(n):
if n <= 1:
return n
return fibonacci(n-1) + fibonacci(n-2)
result = fibonacci(20)
print(f"结果: {result}")
这里 timer 返回 wrapper,wrapper 返回 inner,形成了一个两层闭包。label 和 func 变量被 inner 捕获,即使在 timer 和 wrapper 执行完毕后仍然可用。
示例二:数据隔离的私有状态
闭包可以用来创建具有私有状态的对象,而不必使用类:
def create_bank_account(initial_balance=0):
"""创建一个银行账户,余额对外不可见"""
balance = initial_balance # 私有变量,在闭包内维护
def deposit(amount):
nonlocal balance
if amount <= 0:
raise ValueError("存款金额必须为正数")
balance += amount
return balance
def withdraw(amount):
nonlocal balance
if amount <= 0:
raise ValueError("取款金额必须为正数")
if amount > balance:
raise ValueError("余额不足")
balance -= amount
return balance
def get_balance():
return balance
return deposit, withdraw, get_balance
deposit, withdraw, get_balance = create_bank_account(1000)
print(get_balance()) # 1000
deposit(500)
print(get_balance()) # 1500
withdraw(200)
print(get_balance()) # 1300
# 无法直接访问 balance,只能通过接口操作
balance 变量在外部无法直接访问——这就是闭包实现的「信息隐藏」。虽然不如类的 __private 属性那么规范,但在轻量级场景中非常实用。
示例三:全局配置读取器
# 模拟一个配置文件
CONFIG = {
"api_host": "https://api.example.com",
"timeout": 30,
"retries": 3
}
def make_config_getter(key):
"""工厂:返回一个读取指定配置项的函数"""
def get():
if key not in CONFIG:
raise KeyError(f"配置项 '{key}' 不存在")
return CONFIG[key]
return get
get_host = make_config_getter("api_host")
get_timeout = make_config_getter("timeout")
print(get_host()) # https://api.example.com
print(get_timeout()) # 30
易错点与陷阱
陷阱一:闭包变量绑定的延迟问题(经典陷阱)
这是 Python 闭包中最经典、也最容易让人困惑的陷阱。闭包捕获的是变量的引用,而不是变量在捕获时刻的值。当闭包被调用时,才会去查找那个变量的当前值。
def make_functions():
funcs = []
for i in range(3):
funcs.append(lambda: i)
return funcs
fs = make_functions()
print([f() for f in fs]) # [2, 2, 2],不是预期的 [0, 1, 2]
原因分析:循环结束时 i 的值为 2。三个 lambda 都引用同一个变量 i(在 Enclosing 作用域中),而不是各自捕获 i 在当时的值。当 fs[0]() 被调用时,查找 i 得到的是最终值 2。同理 fs[1]() 和 fs[2]() 也查到 2。
修复方法一:用默认参数捕获当前值(默认参数在定义时求值):
def make_functions_fixed():
funcs = []
for i in range(3):
funcs.append(lambda x=i: x) # x=i 在定义时就求值
return funcs
fs = make_functions_fixed()
print([f() for f in fs]) # [0, 1, 2] 正确
修复方法二:使用 functools.partial:
from functools import partial
def make_functions_partial():
funcs = []
for i in range(3):
funcs.append(partial(lambda x: x, i))
return funcs
fs = make_functions_partial()
print([f() for f in fs]) # [0, 1, 2] 正确
修复方法三:使用生成器表达式(隐式创建新的作用域):
fs = (lambda i=i: i for i in range(3))
print([f() for f in fs]) # [0, 1, 2] 正确
第三种方法之所以有效,是因为生成器表达式(以及列表推导式在 Python 3 中)会创建新的局部作用域,每次迭代都有独立的 i 绑定。
陷阱二:在循环中定义闭包并立即调用
# 看起来应该打印 0,1,2,3,4,实际打印 5,5,5,5,5
for i in range(5):
def inner():
return i
print(inner())
每个 inner 都引用同一个 i,而循环执行完后 i 的值是 4(range(5) 的最后一个值)。修复方式同样是默认参数捕获:
for i in range(5):
def inner(x=i):
return x
print(inner())
陷阱三:global 与 nonlocal 混用的误解
x = 1
def outer():
x = 2
def middle():
x = 3
def inner():
nonlocal x # 修改 middle 中的 x,不是 outer 中的
x = 4
inner()
print("middle:", x) # 4
outer_local = x
middle()
print("outer:", x) # 2,outer 的 x 不受影响
outer()
print("global:", x) # 1
nonlocal 只影响 Enclosing 链上最近的那个绑定,不会影响更外层的同名变量,更不会触及全局变量。这种精确的作用域控制使得闭包中的状态管理变得可靠。
陷阱四:在类定义中使用闭包变量
def make_class():
secret = "hidden"
class MyClass:
def get_secret(self):
return secret # 引用 Enclosing 作用域
return MyClass
C = make_class()
print(C().get_secret()) # hidden
类体在定义时会创建一个局部的命名空间,但类体内部不能直接使用 nonlocal 来修改变量——类体不是函数体。如果需要修改 Enclosing 变量,必须在类的方法中使用 nonlocal 或 global。
小结
本篇深入讲解了 Python 的作用域机制和命名空间概念。我们从命名空间的四种类型出发,系统梳理了 LEGB 规则——名字查找的路径:先从当前函数局部(L)找,找不到再到外层函数(E),再找不到到全局(G),最后到内置(B)。通过 globals() 和 locals(),我们直接看到了变量的物理布局;通过 global 和 nonlocal,我们掌握了修改变量绑定的正确手段。最后,我们通过闭包的三个实际应用(计时器、私有状态、配置读取器)理解了闭包的工程价值,并用经典的「循环中定义闭包」陷阱揭示了变量延迟绑定的本质。
作用域是 Python 最核心的机制之一。理解它,你就能写出行为可预测的代码,避免那些「明明定义了却找不到」或「改了却没生效」的神秘 bug。下一篇我们将学习可变参数(*args、**kwargs)和解包技术,这是函数接口设计的最后一块拼图。
练习与思考题
-
阅读以下代码,预测输出,然后用 Python 实际运行验证:
x = 10 def f(): print(x) x = 20 f()为什么会报错?如何在
f内部正确访问外部的x? -
编写一个函数
create_accumulator(),返回一个函数,每次调用该函数时累加传入的值并返回当前总和。例如:acc = create_accumulator(),acc(10)返回 10,acc(5)返回 15,acc(-3)返回 12。 -
分析下面代码的输出,并解释原因:
def make_factories(): factories = [] for color in ["red", "green", "blue"]: factories.append(lambda: color) return factories f1, f2, f3 = make_factories() print(f1(), f2(), f3())用三种不同的方法修复它,使输出为
red green blue。 -
思考题:Python 为什么采用 LEGB 查找顺序而不是更直观的「从外到内」?从性能和语言设计角度分析这种选择的意义。