闭包与作用域
一句话概述
闭包(Closure)是函数式编程中的核心概念——当一个嵌套的内部函数引用了外部函数的变量,并且外部函数已经返回了这个内部函数,这个内部函数就形成了一个闭包。闭包"记住"了它诞生时的环境(外部函数的局部变量),即使外部函数已经执行完毕,这些变量的值仍然可以被内部函数访问和修改(配合 nonlocal)。闭包是实现装饰器、工厂函数、回调函数的底层机制。
💡 核心要点:①闭包 = 内部函数 + 它引用的外部变量(自由变量)——形成封闭的作用域 ②形成闭包的三个条件:嵌套函数、内部引用外部变量、外部返回内部函数 ③闭包中的自由变量存储在__closure__属性中,是 cell 对象的元组 ④nonlocal关键字让内部函数可以修改(而不仅是读取)外部变量 ⑤闭包是装饰器的底层基础——装饰器的状态保存依赖闭包
教学与演示
一、闭包是什么——函数"记住"了它的出生地
是什么:闭包(Closure)是一个函数对象,它"记住"了定义时所在作用域中的变量值——即使那个作用域已经不存在了。技术上:当一个嵌套的内部函数引用了外部函数的局部变量,并且外部函数的返回值是这个内部函数本身——这个内部函数 + 它捕获的外部变量就组成了闭包。被引用的外部变量称为"自由变量"(Free Variable),存储在函数的 __closure__ 属性中。
大白话 闭包就像一个孩子——他离开家(外部函数执行完毕)去外地工作(被返回到别处调用),但他永远记得家里的电话号码(捕获的外部变量)。别人可以打电话给他(调用闭包函数),他通过记忆中的号码(__closure__)联系家里。即使"家"已经不存在了(外部函数栈帧已销毁),孩子的记忆还在(闭包保留了变量值的副本)。
为什么:闭包解决了"如何在函数之间共享状态而不使用全局变量"的问题。全局变量让所有人能访问——太不安全;函数参数传递——对回调函数不友好。闭包提供了一种优雅的中间方案:状态是私有的(只有内部函数能访问),但生命周期是持久的(超出外部函数的执行期)。闭包是实现装饰器、工厂模式、回调注册的底层基石。
怎么做:
import numpy as np
# ====== 1. 闭包的形成——三步走 ======
def outer(x):
"""外部函数——x 是外部函数的局部变量"""
def inner(y):
"""内部函数——引用了外部的 x"""
return x + y # x 是自由变量(来自外部作用域)
return inner # 返回内部函数
print("=== 闭包基本示例 ===")
add_10 = outer(10) # 创建闭包——x=10 被"记住"了
print(f"add_10(5) = {add_10(5)}") # 15
print(f"add_10(20) = {add_10(20)}") # 30
add_100 = outer(100) # 另一个闭包——独立捕获 x=100
print(f"add_100(5) = {add_100(5)}") # 105
print(f"add_10(5) = {add_10(5)}") # 15 —— 互不影响!
# ====== 2. 查看闭包的"记忆"——__closure__ 属性 ======
print(f"\n=== 查看闭包的自由变量 ===")
print(f"add_10 的 __closure__: {add_10.__closure__}")
print(f"自由变量的值: {add_10.__closure__[0].cell_contents}") # 10
print(f"自由变量名: {add_10.__code__.co_freevars}") # ('x',)
# ====== 3. 闭包的三个条件检查 ======
def check_closure(func):
"""检查一个函数是否是闭包"""
is_closure = func.__closure__ is not None
if is_closure:
free_names = func.__code__.co_freevars
print(f"✅ 是闭包:捕获了 {len(free_names)} 个自由变量: {free_names}")
else:
print(f"❌ 不是闭包")
def not_a_closure(a):
"""这个函数不是闭包——没有引用外部变量"""
return a * 2
check_closure(add_10) # ✅ 是闭包
check_closure(not_a_closure) # ❌ 不是闭包
# ====== 4. AI 场景:创建带配置的数据预处理器 ======
def make_normalizer(mean, std):
"""闭包工厂——创建带固定均值和标准差的归一化器"""
def normalize(data):
return (data - mean) / (std + 1e-8) # mean 和 std 来自外部!
return normalize
print(f"\n=== 数据预处理器闭包 ===")
train_mean = np.array([100.0, 50.0, 25.0])
train_std = np.array([20.0, 10.0, 5.0])
normalizer = make_normalizer(train_mean, train_std)
test_data = np.array([120.0, 55.0, 30.0])
normalized = normalizer(test_data)
print(f"原始数据: {test_data}")
print(f"归一化后: {np.round(normalized, 4)}")
print(f"验证: (120-100)/20={1.0}, (55-50)/10={0.5}, (30-25)/5={1.0}")
# 两个归一化器完全独立
image_normalizer = make_normalizer(
mean=np.array([0.485, 0.456, 0.406]), # ImageNet RGB 均值
std=np.array([0.229, 0.224, 0.225]) # ImageNet RGB 标准差
)
img_pixel = np.array([0.5, 0.4, 0.3])
print(f"\n图像像素: {img_pixel}")
print(f"ImageNet归一化: {np.round(image_normalizer(img_pixel), 4)}")
print(f"Z-score归一化: {np.round(normalizer(img_pixel), 4)}")什么用:在 AI 中,闭包最常见的应用是"预配置"——把训练阶段的统计信息(均值、标准差、词表映射、类别映射)冻结在闭包中,推理时直接使用。PyTorch 的 transforms.Normalize(mean, std) 内部就是用类似闭包的机制保存归一化参数。Scikit-learn 的 Pipeline 中,每个 Transformer 的 transform 方法某种程度上也是闭包行为的体现。
二、用闭包实现状态保持——比全局变量更优雅
是什么:闭包不仅能"记住"值,配合 nonlocal 关键字还能修改外部变量。这让闭包可以作为一个"有记忆的函数"——每次调用都能读写私有状态。每个闭包实例有自己独立的、私有的状态。
大白话 闭包就像一个上了锁的储蓄罐——你每次投币(调用函数),储蓄罐里的钱就多一点(闭包内的计数器增加)。别人看不到储蓄罐里有多少钱(私有状态),只能通过"投币"这个操作来增加。如果你想要一个独立的储蓄罐,只需要再买一个(再调用一次外部函数)。
为什么:在很多场景中,你需要一个函数"记住上一轮的状态"——比如学习率调度器需要记住当前的 step 和 best_loss。用类可以实现,但闭包更轻量——不需要写 __init__、不需要管理 self 属性。
怎么做:
import numpy as np
# ====== 1. 用 nonlocal 修改闭包中的变量 ======
def make_counter(initial=0, step=1):
"""创建计数器闭包"""
count = initial
def increment():
nonlocal count # 声明要修改外部的 count!
count += step
return count
def get_value():
return count
return increment, get_value
print("=== 闭包状态管理 ===")
inc, get = make_counter(initial=0, step=2)
print(f"初始值: {get()}") # 0
print(f"inc: {inc()} → {inc()} → {inc()}") # 2, 4, 6
print(f"当前值: {get()}") # 6
# 再创建一个独立的计数器
inc2, get2 = make_counter(initial=100)
print(f"\n第二个计数器: {get2()}") # 100
print(f"inc2: {inc2()}, 第一个: {get()}") # 101, 6 —— 互不影响!
# ====== 2. AI 场景:指数移动平均(EMA)跟踪器 ======
def make_ema_tracker(alpha=0.9):
"""创建 EMA 跟踪器闭包——常用于平滑训练损失曲线"""
ema_value = None
step_count = 0
def update(new_value):
nonlocal ema_value, step_count
step_count += 1
if ema_value is None:
ema_value = new_value
else:
ema_value = alpha * ema_value + (1 - alpha) * new_value
return ema_value
def get_ema():
return ema_value
return update, get_ema
print(f"\n=== EMA 跟踪器 ===")
loss_ema_fast, get_fast = make_ema_tracker(alpha=0.7)
loss_ema_slow, get_slow = make_ema_tracker(alpha=0.95)
np.random.seed(42)
epoch_losses = [1.0 + np.random.normal(0, 0.3) for _ in range(10)]
epoch_losses[5] = 0.3 # 模拟异常低损失
print(f"{'Epoch':<8}{'原始Loss':<12}{'EMA(快)':<12}{'EMA(慢)':<12}")
for i, loss in enumerate(epoch_losses):
fast = loss_ema_fast(loss)
slow = loss_ema_slow(loss)
print(f"{i+1:<8}{loss:<12.4f}{fast:<12.4f}{slow:<12.4f}")
print(f"分析:alpha=0.7 对异常值响应快,alpha=0.95 更平滑但滞后")
# ====== 3. 早停检查器 ======
def make_early_stopping(patience=5, min_delta=0.001):
"""早停检查器闭包——记住最优损失"""
best_loss = float("inf")
wait_count = 0
def should_stop(current_loss):
nonlocal best_loss, wait_count
if current_loss < best_loss - min_delta:
best_loss = current_loss
wait_count = 0
return False
else:
wait_count += 1
return wait_count >= patience
return should_stop
print(f"\n=== 早停检查器 ===")
early_stop = make_early_stopping(patience=3, min_delta=0.01)
val_losses = [0.85, 0.72, 0.70, 0.69, 0.71, 0.70, 0.70, 0.72]
for epoch, loss in enumerate(val_losses):
stop = early_stop(loss)
status = "🛑 早停!" if stop else "✅ 继续"
print(f" Epoch {epoch+1}: val_loss={loss:.2f} → {status}")
if stop:
break什么用:在 AI 训练中,闭包是实现训练状态管理的轻量级方案。EMA 跟踪器用于在 TensorBoard 中绘制平滑损失曲线;早停检查器记录最优验证指标并自动停止过拟合的训练;梯度累积器在分布式训练中管理小 batch 的梯度。
三、闭包与作用域的深度理解——自由变量的绑定时机
是什么:闭包捕获的是变量本身(变量的引用),而不是变量的值。这意味着如果在闭包调用之前外部变量被修改了,闭包看到的是最新值——这就是"延迟绑定"(Late Binding)。这个特性在循环中创建闭包时要特别小心。
大白话 闭包不是拍一张照片存起来——它是装了一个"实时监控摄像头"。外部变量变化了,闭包看到的就是变化后的值。这通常是好事(计数器闭包就需要这个特性),但在循环中可能踩坑:你在 for 循环里创建了 5 个闭包,它们都"监控"同一个变量i,等循环结束i=4,5 个闭包看到的都是 4。
为什么:理解"捕获变量引用而非值"是正确使用闭包的关键。如果你想要闭包记住循环中每个 i 的值,需要在创建闭包时"冻结"这个值。
怎么做:
import numpy as np
# ====== 1. 延迟绑定演示——经典闭包陷阱 ======
print("=== 闭包陷阱:延迟绑定 ===")
# ❌ 错误:所有闭包引用同一个循环变量 i
closures_wrong = []
for i in range(5):
def wrong_func():
return i
closures_wrong.append(wrong_func)
print("错误版本(所有闭包看到最后的 i):")
for f in closures_wrong:
print(f" f() = {f()}", end=" | ") # 全部输出 4!
# ✅ 正确方法一:用默认参数"冻结"值
closures_right = []
for i in range(5):
def right_func(val=i): # val 是默认参数,在定义时求值!
return val
closures_right.append(right_func)
print("\n\n正确版本一(默认参数冻结):")
for f in closures_right:
print(f" f() = {f()}", end=" | ") # 0 1 2 3 4
# ✅ 正确方法二:用外层函数创建独立作用域
def make_func(n):
def inner():
return n
return inner
closures_right2 = [make_func(i) for i in range(5)]
print("\n\n正确版本二(工厂函数):")
for f in closures_right2:
print(f" f() = {f()}", end=" | ")
# ====== 2. 为什么会有这个问题?——查看 __closure__ ======
print(f"\n\n=== 检查闭包的 __closure__ ===")
closures = []
for i in range(3):
closures.append(lambda: i)
for idx, f in enumerate(closures):
cell_value = f.__closure__[0].cell_contents
print(f" closure[{idx}]: cell 内容={cell_value}, cell id={id(f.__closure__[0])}")
print("注意:所有闭包的 cell id 相同——它们是同一个变量!")什么用:理解闭包的延迟绑定特性对避免 AI 代码中的 bug 至关重要。在超参数搜索中动态创建多个训练函数时,如果不注意闭包的变量引用,可能导致所有训练函数使用同一组超参数。
四、闭包与装饰器的关系——装饰器的底层基础
是什么:装饰器本质上就是闭包的一个特殊应用。当你写 @timer def func(): pass 时,timer(func) 返回 wrapper,wrapper 引用了外部变量 func——这就是一个闭包。装饰器能保留状态(如调用次数、缓存字典)完全依赖闭包机制。
大白话 如果装饰器是装修好的精装房,闭包就是盖房子的砖头。@decorator 这个门牌号看起来很高级,但推开门的结构就是闭包。理解了闭包,装饰器就从"魔法"变成了"工程"。
为什么:很多学习者在理解装饰器时感到困惑,核心原因是没理解闭包。如果把装饰器拆开来看——它就是一个接受函数的闭包工厂。理解了这一点,带参数装饰器的三层嵌套、类装饰器的 __call__ 替代——全部都变得透明了。
怎么做:
import numpy as np
import time
from functools import wraps
# ====== 1. 装饰器本质 = 闭包的应用 ======
def timer_closure(func):
"""这就是一个普通的闭包工厂——func 是自由变量"""
@wraps(func)
def wrapper(*args, **kwargs):
"""wrapper 是内部函数——它引用了外部的 func"""
start = time.time()
result = func(*args, **kwargs) # func 来自外部作用域——自由变量!
elapsed = time.time() - start
print(f" {func.__name__}: {elapsed:.4f}s")
return result
return wrapper
@timer_closure
def compute_sin(angles):
"""计算角度的正弦值"""
time.sleep(0.02)
return np.sin(angles)
print("=== 装饰器 = 闭包应用 ===")
angles = np.linspace(0, 2 * np.pi, 5)
result = compute_sin(angles)
# 验证 wrapper 确实是闭包
print(f"\n验证闭包结构:")
print(f" 自由变量: {compute_sin.__code__.co_freevars}") # ('func',)
print(f" 闭包 cell 存在: {compute_sin.__closure__ is not None}")
# ====== 2. 闭包在 AI 训练中的角色 ======
def create_training_step(lr=0.01, momentum=0.9):
"""创建训练步骤闭包——模拟 SGD with Momentum 优化器"""
velocity = 0.0
step_count = 0
def step(gradient, current_param):
"""执行一步参数更新"""
nonlocal velocity, step_count
step_count += 1
velocity = momentum * velocity - lr * gradient
new_param = current_param + velocity
return new_param
def get_state():
return {"velocity": velocity, "step": step_count}
return step, get_state
print(f"\n=== 优化器状态管理(闭包模拟 SGD with Momentum) ===")
opt_layer1, state1 = create_training_step(lr=0.1, momentum=0.9)
opt_layer2, state2 = create_training_step(lr=0.01, momentum=0.5)
param1 = np.array(5.0)
param2 = np.array(3.0)
gradients = [
(np.array(2.0), np.array(0.5)),
(np.array(1.5), np.array(0.3)),
(np.array(1.0), np.array(0.2)),
]
print(f"初始参数: layer1={param1[0]:.2f}, layer2={param2[0]:.2f}")
for i, (grad1, grad2) in enumerate(gradients):
param1 = opt_layer1(grad1, param1)
param2 = opt_layer2(grad2, param2)
s1, s2 = state1(), state2()
print(f" Step {i+1}: layer1={param1[0]:.4f}, layer2={param2[0]:.4f}")
print(f"\n两个优化器状态完全独立——互不干扰!")什么用:理解装饰器的闭包本质让你能更自信地编写自定义装饰器。PyTorch 的优化器(torch.optim.SGD、Adam 等)内部就是用类似闭包的机制维护每个参数的动量、二阶矩等状态。理解这个机制对调试优化器行为、实现自定义优化器非常有帮助。
概念关系图谱
| 概念 | 核心含义 | 与AI的关系 | 关联概念 |
|---|---|---|---|
| 闭包 | 内部函数 + 捕获的外部变量 | 归一化参数冻结、优化器状态管理 | 自由变量、nonlocal |
| 自由变量 | 内部函数引用的外部非全局变量 | 闭包捕获的配置参数 | closure、cell |
| nonlocal | 允许内部函数修改外部变量 | EMA 跟踪器、早停计数器 | global、闭包 |
| closure | 存储闭包自由变量的 cell 元组 | 调试闭包、理解内存管理 | cell_contents、co_freevars |
| 延迟绑定 | 闭包捕获变量引用而非值 | 循环创建闭包时的陷阱 | 默认参数冻结、工厂函数 |
| 装饰器 | 闭包的特殊应用 | 计时、缓存、重试、限流 | @语法、wrapper |
| 工厂函数 | 返回闭包的外部函数 | 创建配置化的数据预处理器 | 闭包、参数化 |
| 作用域 | 变量可见的范围 | 理解训练循环中变量的生命周期 | LEGB、闭包捕获 |
重点答疑
Q1: 闭包和匿名函数(lambda)是什么关系?
它们是不同的概念,但常常一起使用。lambda 是创建匿名函数的语法,它本身不一定是闭包——如果 lambda 没有引用外部变量,它就不是闭包。闭包是描述函数是否捕获外部变量的概念——无论是 def 定义的函数还是 lambda,只要引用了外部自由变量就是闭包。
Q2: 闭包中的变量什么时候被销毁?
闭包中捕获的变量在闭包函数本身被销毁时才被回收。当没有任何引用指向闭包函数时,Python 的垃圾回收器会回收闭包函数及其 __closure__。如果闭包函数被保存到了全局变量或数据结构中,它和捕获的变量会一直存在直到程序结束或显式删除。
Q3: 为什么 nonlocal 不能用于模块级变量,但 global 可以?
nonlocal 的设计目的是"找最近的外层非全局作用域"。如果允许 nonlocal 用于模块级,它就和 global 没有区别了——模块级已经是全局作用域。nonlocal 用于嵌套函数中的外层函数变量,global 用于模块级变量——两个关键字各司其职。
Q4: 如何判断一个函数是不是闭包?
最准确的方法:检查 func.__closure__ is not None。注意:如果内部函数没有引用任何外部变量,即使它是嵌套定义的,__closure__ 也是 None——它就不是闭包。可以用 func.__code__.co_freevars 查看自由变量名。
章节单词汇总
| 英文 | 音标 | 术语/释义 |
|---|---|---|
| closure | /ˈkloʊʒər/ | 闭包;内部函数捕获外部变量的机制 |
| free variable | /friː ˈveriəbəl/ | 自由变量;闭包中引用的外部非全局变量 |
| enclosing | /ɪnˈkloʊzɪŋ/ | 外层的;嵌套函数中包围内层的作用域 |
| cell | /sel/ | 单元;Python 内部存储闭包变量的对象 |
| late binding | /leɪt ˈbaɪndɪŋ/ | 延迟绑定;闭包在调用时才查找变量值 |
| factory | /ˈfæktəri/ | 工厂;返回闭包的外部函数(工厂函数) |
| stateful | /ˈsteɪtfəl/ | 有状态的;闭包可以作为有状态的函数 |
| decay | /dɪˈkeɪ/ | 衰减;EMA 中的衰减因子(alpha 参数) |
面试练习
Q1 [单选] 形成闭包需要满足哪三个条件?
- A. 函数嵌套、内部函数有参数、外部函数有返回值
- B. 函数嵌套、内部函数引用外部变量、外部函数返回内部函数
- C. 函数嵌套、使用 lambda、使用 global
- D. 函数嵌套、使用 nonlocal、使用 @ 语法
解答:B 正确。闭包的三个条件:(1) 嵌套函数定义 (2) 内部函数引用了外部函数的局部变量 (3) 外部函数返回内部函数。有了这三个条件,内部函数就形成了一个闭包。
Q2 [单选] 以下代码输出什么?def outer(): x=5; def inner(): return x; return inner; f=outer(); print(f())
- A. 报错
- B. None
- C. 5
- D. 0
解答:C 正确。outer()返回inner函数,inner闭包捕获了x=5。调用f()返回 5。
Q3 [多选] 关于 __closure__ 属性,以下正确的是?
- A. 闭包函数的
__closure__不为 None - B.
__closure__是 cell 对象的元组 - C. 所有嵌套定义的函数都有
__closure__ - D.
cell.cell_contents可以查看闭包捕获的变量值
解答:A、B、D 正确。C 错误——如果嵌套定义的内部函数没有引用任何外部变量,它的__closure__就是None,它不是闭包。
Q4 [单选] 循环创建的函数列表:funcs = []; for i in range(3): funcs.append(lambda: i); print([f() for f in funcs])
- A.
[0, 1, 2] - B.
[2, 2, 2] - C.
[0, 0, 0] - D. 报错
解答:B 正确。闭包捕获的是变量i的引用,循环结束时i=2,所有闭包看到的都是 2。修复:lambda val=i: val。
Q5 [多选] 如何在循环中创建闭包,每个闭包捕获不同的值?
- A. 使用默认参数:
lambda x=i: x - B. 使用工厂函数:
(lambda n: lambda: n)(i) - C. 使用
def make_func(n): def inner(): return n; return inner - D. 在 lambda 中使用
global i
解答:A、B、C 正确。三种方法都能创建独立的作用域来"冻结"循环变量的当前值。D 错误。
Q6 [单选] nonlocal 关键字的作用是?
- A. 声明一个全局变量
- B. 让内部函数可以读取外部变量
- C. 让内部函数可以修改外部函数的局部变量
- D. 声明一个模块级变量
解答:C 正确。nonlocal 专门用于在嵌套函数的内部函数中修改外层函数的局部变量。
Q7 [单选] 闭包和全局变量相比的主要优势是?
- A. 执行速度更快
- B. 不需要导入模块
- C. 状态私有 + 每个实例独立 + 生命周期可控
- D. 可以存储更多数据
解答:C 正确。闭包的核心优势:(1) 私有——外部无法直接修改 (2) 隔离——每次调用创建独立状态 (3) 持久——生命周期和闭包函数绑定。
Q8 [多选] 以下哪些场景适合使用闭包而非类?
- A. 统计收集器(add/mean/std/count 几个简单操作)
- B. 配置化的数据预处理器(只需记住 mean 和 std)
- C. 需要继承和多态的复杂对象层次结构
- D. EMA 平滑跟踪器(单一职责的状态管理)
解答:A、B、D 正确——单一职责、简单状态管理用闭包更简洁。C 错误——需要继承和多态时用类。
Q9 [单选] 闭包中的变量什么时候会被 Python 垃圾回收?
- A. 外部函数执行完毕后立即回收
- B. 当没有任何引用指向闭包函数时
- C. 闭包函数被调用后
- D. 永远不会回收
解答:B 正确。闭包变量存储在 __closure__ 中,只要闭包函数还有引用存在,闭包变量就存活。
Q10 [单选] 以下哪个是装饰器和闭包的关系?
- A. 装饰器就是闭包,两者完全相同
- B. 装饰器和闭包没有关系
- C. 装饰器本质上是闭包的一种特殊应用
- D. 闭包是装饰器的一种
解答:C 正确。装饰器是一个闭包工厂——外层函数接收 func,返回包装函数 wrapper,wrapper 引用了外部的 func(形成闭包)。