「Python 进阶之路」系列 Day04
写在前面
函数执行完毕之后,它内部的局部变量不是应该被销毁吗?可现实是,你经常能看到这样的代码:一个函数返回了另一个函数,外层函数明明早就执行完了,返回的那个内层函数却还能一直访问、甚至修改外层函数的变量,就像外层函数从没消失过一样。
这就是闭包 。今天这篇从 Python 变量查找的底层规则 LEGB 讲起,把闭包的原理讲透,再顺带拆解一个几乎人人都踩过的经典坑------循环里创建的多个闭包,为什么最后拿到的全是同一个值。
一、是什么:LEGB 规则与闭包
变量查找顺序:LEGB
Python 查找一个变量名时,会按固定顺序在四层作用域里逐层往外找,找到第一个匹配的就停止:
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 这几个最常见的装饰器到底在干什么,以及自己动手写一个带参数的装饰器需要注意什么。