## 引言
这是第四章「函数」的第二篇。第一篇你学会了如何定义函数、传递参数和返回值——那是「函数怎么写」的问题。但从这篇开始,我们要解决一个更根本的问题:**函数里的变量到底从哪来,又到哪里去?**
你一定遇到过这样的困惑:明明在函数外面定义了变量 `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()` 函数直接查看当前作用域的命名空间内容:
```python
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()` 返回的是真实的全局命名空间字典,对其修改会立即生效:
```python
x = 1
def modify_global():
globals()['x'] = 99
modify_global()
print(x) # 99
```
虽然 `globals()` 可以修改全局变量,但这是一种极不推荐的 hack 手法。正确的方式是使用 `global` 关键字声明意图。
### LEGB 规则:名字查找的完整路径
当一个名字在代码中被引用时,Python 按照 **LEGB 规则**依次查找:
1. **L(Local)**:当前函数(或 lambda)的局部命名空间。
2. **E(Enclosing)**:外层函数的局部命名空间(仅当存在嵌套函数时)。
3. **G(Global)**:当前模块的全局命名空间。
4. **B(Built-in)**:Python 内置命名空间。
查找过程是**从小到大逐层向外**的,一旦在某一层找到匹配的名字,就停止查找并使用该绑定;如果四层都找不到,抛出 `NameError`。
```python
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 规则的自然结果:赋值总是写入当前最内层的命名空间。
```python
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` 关键字声明「我要修改全局变量」:
```python
count = 0
def increment():
global count
count = count + 1
increment()
increment()
print(count) # 2
```
`global` 的含义是告诉 Python:在这个函数体内,所有对这个名字的赋值都作用于全局命名空间,查找时也优先在局部作用域之外寻找。
### nonlocal:修改变量,但不突破到全局
`nonlocal` 是 Python 3 引入的关键字,用于在嵌套函数中修改外层函数(而非全局)的变量。它的查找规则是:从当前函数的外层开始,沿 Enclosing 链向上查找第一个匹配的变量绑定,然后修改它。
```python
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`。
```python
def demo():
x = 1
def inner():
nonlocal x # 正确:x 在 outer 中存在
x = 2
inner()
print(x) # 2
demo()
```
如果 `nonlocal` 引用的变量在 Enclosing 链上不存在,Python 会在**编译阶段**直接报 `SyntaxError`,即使函数从未被调用也会出错:
```python
# 这段代码无法通过编译(取消注释会报错)
# def bad_demo():
# x = 1
# def inner():
# nonlocal y # SyntaxError!y 在 Enclosing 链上不存在
# y = 2
# inner()
```
### 闭包:函数携带环境的记忆
闭包(Closure)是指一个函数对象**记住了它定义时所处环境中的变量**。即使外层函数已经返回,内层函数仍然可以通过 Enclosing 作用域访问那些变量。
闭包的形成有三个必要条件:
1. 存在嵌套函数(一个函数定义在另一个函数内部)。
2. 内层函数引用了外层函数的变量(Enclosing 作用域中的变量)。
3. 外层函数返回了内层函数。
```python
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__` 属性查看函数是否形成了闭包,以及它捕获了哪些变量:
```python
print(double.__closure__) # (<cell at 0x...: int object at 0x...>,)
print(double.__closure__[0].cell_contents) # 2
```
`__closure__` 是元组,每个元素是一个 cell 对象,保存着被捕获的变量的引用。如果函数没有形成闭包,`__closure__` 为 `None`。
## 实现
下面通过三个递进的示例,展示作用域和闭包在实际编程中的应用。
### 示例一:日志计时器(闭包的实际用途)
一个常见的工程需求是测量函数执行时间。我们用一个闭包来实现一个通用的计时装饰器的雏形:
```python
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` 执行完毕后仍然可用。
### 示例二:数据隔离的私有状态
闭包可以用来创建具有私有状态的对象,而不必使用类:
```python
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` 属性那么规范,但在轻量级场景中非常实用。
### 示例三:全局配置读取器
```python
# 模拟一个配置文件
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 闭包中最经典、也最容易让人困惑的陷阱。闭包捕获的是**变量的引用**,而不是变量在捕获时刻的值。当闭包被调用时,才会去查找那个变量的当前值。
```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。
修复方法一:用默认参数捕获当前值(默认参数在定义时求值):
```python
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`:
```python
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] 正确
```
修复方法三:使用生成器表达式(隐式创建新的作用域):
```python
fs = (lambda i=i: i for i in range(3))
print([f() for f in fs]) # [0, 1, 2] 正确
```
第三种方法之所以有效,是因为生成器表达式(以及列表推导式在 Python 3 中)会创建新的局部作用域,每次迭代都有独立的 `i` 绑定。
### 陷阱二:在循环中定义闭包并立即调用
```python
# 看起来应该打印 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)` 的最后一个值)。修复方式同样是默认参数捕获:
```python
for i in range(5):
def inner(x=i):
return x
print(inner())
```
### 陷阱三:global 与 nonlocal 混用的误解
```python
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 链上**最近的**那个绑定,不会影响更外层的同名变量,更不会触及全局变量。这种精确的作用域控制使得闭包中的状态管理变得可靠。
### 陷阱四:在类定义中使用闭包变量
```python
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`)和解包技术,这是函数接口设计的最后一块拼图。
## 练习与思考题
1. 阅读以下代码,预测输出,然后用 Python 实际运行验证:
```python
x = 10
def f():
print(x)
x = 20
f()
```
为什么会报错?如何在 `f` 内部正确访问外部的 `x`?
2. 编写一个函数 `create_accumulator()`,返回一个函数,每次调用该函数时累加传入的值并返回当前总和。例如:`acc = create_accumulator()`,`acc(10)` 返回 10,`acc(5)` 返回 15,`acc(-3)` 返回 12。
3. 分析下面代码的输出,并解释原因:
```python
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`。
4. 思考题:Python 为什么采用 LEGB 查找顺序而不是更直观的「从外到内」?从性能和语言设计角度分析这种选择的意义。