闭包与作用域

一句话概述

闭包(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) 返回 wrapperwrapper 引用了外部变量 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.SGDAdam 等)内部就是用类似闭包的机制维护每个参数的动量、二阶矩等状态。理解这个机制对调试优化器行为、实现自定义优化器非常有帮助。

概念关系图谱

概念核心含义与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(形成闭包)。