闭包到底"闭"住了什么?一文讲透 LEGB 规则与循环里的闭包陷阱

「Python 进阶之路」系列 Day04

写在前面

函数执行完毕之后,它内部的局部变量不是应该被销毁吗?可现实是,你经常能看到这样的代码:一个函数返回了另一个函数,外层函数明明早就执行完了,返回的那个内层函数却还能一直访问、甚至修改外层函数的变量,就像外层函数从没消失过一样。

这就是闭包 。今天这篇从 Python 变量查找的底层规则 LEGB 讲起,把闭包的原理讲透,再顺带拆解一个几乎人人都踩过的经典坑------循环里创建的多个闭包,为什么最后拿到的全是同一个值


一、是什么:LEGB 规则与闭包

变量查找顺序:LEGB

Python 查找一个变量名时,会按固定顺序在四层作用域里逐层往外找,找到第一个匹配的就停止:

flowchart LR A[Local<br/>局部作用域] --> B[Enclosing<br/>闭包外层作用域] B --> C[Global<br/>全局作用域] C --> D[Built-in<br/>内置作用域]
python 复制代码
x = "global x"

def outer():
    x = "enclosing x"
    def inner():
        x = "local x"
        print(x)
    inner()
    print(x)

outer()
print(x)
# local x
# enclosing x
# global x

如果局部、闭包、全局作用域里都没有这个名字,Python 最后会去内置作用域找:

python 复制代码
def show_len():
    print(len)   # 局部/闭包/全局都没有 len,最终在内置作用域里找到

show_len()   # <built-in function len>

什么是闭包

闭包(Closure):一个函数如果引用了外层函数的变量,并且这个函数本身被返回或者传递到了外层函数作用域之外,那么这个内层函数加上它记住的外层变量,就构成了一个闭包。

python 复制代码
def make_counter():
    count = 0
    def counter():
        nonlocal count
        count += 1
        return count
    return counter

c1 = make_counter()
c2 = make_counter()
print(c1(), c1(), c1())   # 1 2 3
print(c2())                # 1 ------ c2 是独立的闭包,有自己独立的 count,不受 c1 影响

make_counter 明明已经执行完毕返回了,但它返回的 counter 函数依然能访问、修改 count------闭包的本质就是让一个函数"随身携带"它被创建时所在的外层作用域,而不是任由这个作用域在外层函数返回后被销毁


二、为什么:内层函数默认只能读,改要靠 nonlocal

内层函数默认只能读取外层变量,但如果在内层函数里直接对这个名字赋值,Python 会把它当成一个全新的局部变量,而不是修改外层那一个:

python 复制代码
def make_counter_bad():
    count = 0
    def counter():
        count += 1   # 报错!
        return count
    return counter

make_counter_bad()()
# UnboundLocalError: cannot access local variable 'count' where it is not associated with a value

为什么会这样 :Python 在编译函数体时,只要函数内部任意位置出现了对某个名字的赋值语句(count += 1 等价于 count = count + 1,本质是一次赋值),就会把这个名字标记为当前函数的局部变量------这个判定发生在编译期,和赋值语句实际执行到没到没关系。所以 count += 1 这一行,右边想读取 count 的值时,Python 已经认定这是个局部变量了,而这个局部变量此刻还没被赋值过,读取自然报错。

要修改外层(非全局)作用域的变量,必须显式声明 nonlocal,告诉 Python "这个名字用外层作用域的,不要新建局部变量":

python 复制代码
def make_counter():
    count = 0
    def counter():
        nonlocal count
        count += 1
        return count
    return counter

(如果要修改的是模块级别的全局变量,对应的关键字是 global,原理完全一样。)


三、怎么用:循环里创建闭包的经典坑

python 复制代码
funcs = []
for i in range(3):
    def f():
        return i
    funcs.append(f)

print([f() for f in funcs])
# [2, 2, 2]  ← 而不是期望中的 [0, 1, 2]!

真相:闭包捕获的是变量本身,不是当时的值

闭包捕获的是外层变量本身(准确说是变量所在的那个"格子"/cell),不是闭包创建那一刻变量的值。 for 循环里的 i 全程只有一个变量,三次循环只是不断把这同一个变量重新赋值成 0、1、2,循环结束后它的值定格在 2。三个 f 函数的闭包引用的都是这同一个 i,等到真正调用 f() 去读值时,循环早就跑完了,读到的自然是最终值 2。

这种行为叫延迟绑定(Late Binding) :闭包里的自由变量,是在闭包被调用 的那一刻才去查找当前值的,而不是在闭包被定义的那一刻就把值"拍板定案"。

修复方式一:用默认参数,在定义时就把值"拍照"固定住

python 复制代码
funcs2 = []
for i in range(3):
    def f(i=i):     # 默认参数在 def 语句执行时求值一次
        return i
    funcs2.append(f)

print([f() for f in funcs2])   # [0, 1, 2]

这里用到的正是 Day01 讲过的知识点:默认参数在 def 执行的那一刻就被求值并绑定 ,所以 i=i 会在每次循环时把当时的 i 值存成默认参数,不会等到调用时才去查外层变量------同一个语言机制,Day01 里是坑,这里反而成了修复坑的工具。

修复方式二:用工厂函数,让每次调用都产生独立的 enclosing 作用域

python 复制代码
def make_f(i):
    def f():
        return i
    return f

funcs3 = [make_f(i) for i in range(3)]
print([f() for f in funcs3])   # [0, 1, 2]

make_f(i) 每次调用都会创建一个全新、独立的函数作用域,i 是这个作用域自己的参数,不再是循环共享的那个变量,自然不会被后续循环覆盖。

lambda 表达式会踩一模一样的坑,修复方式也完全一样:

python 复制代码
funcs4 = [lambda: i for i in range(3)]
print([f() for f in funcs4])           # [2, 2, 2] ← 同样的坑

funcs5 = [lambda i=i: i for i in range(3)]
print([f() for f in funcs5])            # [0, 1, 2] ← 同样的默认参数修复法

眼见为实:用 closure 看闭包到底存了什么

python 复制代码
def make_adder(x):
    def adder(y):
        return x + y
    return adder

add5 = make_adder(5)
print(add5(3))                              # 8
print(add5.__closure__)                      # (<cell at 0x...: int object at 0x...>,)
print(add5.__closure__[0].cell_contents)      # 5 ------ 闭包 cell 里真正存的值

每个闭包函数都有一个 __closure__ 属性,是一个 cell 对象组成的元组,cell_contents 就是闭包捕获到的外层变量当前的值------这也是理解"闭包捕获的是变量本身、而不是某一刻的值"最直观的证据。


四、面试追问

Q1:LEGB 规则是什么?

Python 查找一个变量名的顺序:Local(当前函数局部作用域)→ Enclosing(外层闭包函数作用域)→ Global(模块全局作用域)→ Built-in(Python 内置作用域),从内到外逐层查找,找到第一个匹配的就停止,都找不到才会抛 NameError

Q2:什么是闭包?

一个内层函数引用了外层函数的变量,并且这个内层函数本身被返回或者传递到了外层函数作用域之外,此时内层函数连同它引用的外层变量,就构成了一个闭包------闭包让函数能"带着"创建时所在的作用域一起被使用,即使外层函数已经执行结束。

Q3:为什么内层函数直接对外层变量赋值会报 UnboundLocalError?

Python 在编译函数体时,只要函数内部出现了对某个名字的赋值语句,就会把这个名字判定为当前函数的局部变量,这个判定是编译期静态完成的。如果没有用 nonlocal/global 声明,那么这个名字在整个函数体内都被当成局部变量处理,赋值语句之前对它的读取,会因为这个"局部变量"还没被真正赋值而报错。

Q4:为什么循环里创建的多个闭包,调用后拿到的都是同一个值?

因为闭包捕获的是外层变量本身,不是闭包定义那一刻的值,这种行为叫延迟绑定------闭包只有在被调用时才会去查找自由变量的当前值。循环变量在整个循环过程中始终是同一个变量,只是被反复重新赋值,循环结束后它的值固定在最后一次的结果上,所有闭包调用时读到的都是这个最终值。

Q5:怎么修复循环闭包的坑?

两种常见思路:一是利用默认参数在 def 执行时就求值一次的特性,把 def f(i=i) 写成默认参数,相当于在定义时就把当前的 i 值"拍照"固定下来;二是用一个工厂函数包一层(def make_f(i): ...),让每次调用都产生一个独立的 enclosing 作用域,内层函数引用的是这个独立作用域里的参数,不再和循环变量共享同一个格子。


下一篇预告

Day05 讲装饰器从入门到精通------@property@staticmethod@classmethod 这几个最常见的装饰器到底在干什么,以及自己动手写一个带参数的装饰器需要注意什么。

相关推荐
techdashen2 小时前
Go设计取舍之六: sync.Mutex正常模式与饥饿模式
开发语言·后端·golang
Lumi_Peak2 小时前
Claude思考了42秒,我的代码质量直接提升了一个档次
python·claude
Zane19942 小时前
CAS 与原子类:Java 如何实现无锁编程
java·后端
颜进强2 小时前
前端看后端 13:什么是 Cookie 和 Session?
前端·后端
gitboyzcf2 小时前
mpegts.js解决Chrome无法播放7568×142分辨率报错 DOMException问题
前端·后端
m沐沐2 小时前
【机器学习】DBSCAN聚类算法——原理、参数调优与实战
人工智能·python·深度学习·算法·机器学习·聚类·dbscan
程序员-Benothing3 小时前
Java ForkJoinPool 详解:从分治思想到高性能并行计算
java·开发语言·后端·面试·职场和发展
梅孔立3 小时前
推荐一个 Python 开源项目:AI 模板填充 + Markdown 转 Word,面向 Aspose 模板引擎的效率神器
人工智能·python·开源
码农小韩3 小时前
AIAgent应用开发——大模型理论基础与应用(七)
python·学习·ai·大模型·agent