我感觉的闭包
刚学 Python 的时候,跟着课程走,感觉 Python 就那样也不难啊,直到我碰到了闭包。闭包是我感觉从学 Python 开始,用的时间最长的一小节课程 ,44分钟的课程反反复复看了好几遍。
后来真的弄明白了,才发现它朴素得有点无聊——无非是"函数记住了自己出生时的环境"。顿时感觉OMG,自己在干嘛,废了废了,浪费了大把时间在纠结一个非常简单的东西,这篇就按我自己的理解顺序捋一遍。
一、先看一段经典的代码示例
defouter():msg="hello"definner():print(msg)returninner f=outer()f()# hello如果你已经习惯 Python,可能一扫就过去了。但稍微停一下想想:
outer()已经执行完了,msg是它的局部变量。按正常理解,函数返回时msg这个局部变量应该跟着栈帧一起被回收。那f()打印出来的"hello"是哪来的?
答案就是:inner把这个变量"拎"出来了,带在自己身上。
这是闭包的起点。一句话概况:
闭包 = 内层函数 + 它记住的那部分外层环境。
二、它是怎么记住的
Python 里函数是一等公民,它可以是对象,这一点很多人知道。但其实这个对象上还挂着一个属性,专门放"外面的变量":
defouter():msg="hello"definner():print(msg)returninner f=outer()print(f.__closure__)# (<cell at 0x000001F2E4C1B180: str object at 0x000001F2E4C0A8B0>,)print(f.__closure__[0].cell_contents)# hello那个cell你可以理解成一个小盒子。变量msg没被销毁,而是被装进盒子,跟着inner函数对象一起走了。
我第一次看到这个的时候还挺意外:原来变量不是消失了,是搬家了。
所以f()每次执行,去自己的盒子里取msg,自然拿得到。
顺带一提,不是外层所有变量都会被装盒,只有真正被内层用到的才会。这个下面还会提。
换句话说: 就可以想象一个背包客带着有用的家当离家旅游,回来发现家没了,但是,不是有句话叫做:“人在家就在”,背包客(内层函数)和 家当(变量)在一起又是一个新家。
三、构成闭包的条件
按教科书说法是三条,我按自己好记的顺序排了:
- 函数里面定义了函数(嵌套)
- 内层函数用到了外层的变量
- 外层函数返回的是内层函数
第 2 条是关键。如果inner里没提msg,那就没有闭包,__closure__会是None。
第 3 条其实是说:内层函数得"活下来",跑到外面去用。如果inner()在外层函数里就调用完了,那也没什么好记的。
四、为什么要有这东西
因为有些状态,你不想为它专门写个类。
比如我有个需求:做个计数器,每次调用加一,而且不能让别人从外面改这个数。
用函数写是长这样:
defcounter():n=0defstep():n+=1returnnreturnstep c=counter()print(c())# ?print(c())# ?先别急着往下看,猜猜输出是什么。
实际上它会直接报错:
UnboundLocalError: cannot access local variable 'n' where it is not associated with a value原因是n += 1等价于n = n + 1。只要函数体里出现了对n的赋值,Python 在编译期就把n认定为step的局部变量,而不是外层那个。于是第一件事就是读一个还没被赋值的局部变量,报错。
怎么修?告诉 Python:“我要改的是外层的那个 n。”
这个时候就要用到nonlocal这个关键字了
defcounter():n=0defstep():nonlocaln n+=1returnnreturnstep c=counter()print(c())# 1print(c())# 2print(c())# 3nonlocal就是干这个的。
有了nonlocal修饰就可以放心的更改外层函数的变量了,提到nonlocal就很容易让人想起另外一个关键字global,而且global跟nonlocal有着相同的作用,但是作用范围不一样:
nonlocal:找外层函数里的变量,逐层向外,不涉及全局global:直接指向(全局)变量
一个常见错误是在模块级函数里用nonlocal,会直接报SyntaxError,因为没有外层函数可找。
五、经典陷阱:循环里的闭包
funcs=[]foriinrange(3):funcs.append(lambda:i)forfinfuncs:print(f())这真是一道很经典的示例,不妨猜猜看它的输出是什么?
如果你觉得是0 1 2,那你跟我第一次见的时候一样天真。那是一道选择题,看一眼我直接选择了0 1 2这个答案。实际是:
2 2 2为什么?
因为闭包捕获的是变量本身,不是它当时的值。三个 lambda 用的是同一个i(也就是同一个 cell),循环结束时i停在 2,所以三个函数一起打印 2。
想固化当时的值,就得在创建函数的时候把这个值"复制"一份进去。最常见的是用默认参数:
funcs=[]foriinrange(3):funcs.append(lambdai=i:i)# 注意这里的 i=iforfinfuncs:print(f())# 0 1 2默认参数在函数定义时求值,所以每次循环都存了一份当下的i。
六、闭包和类,该用哪个
这是绕不过去的问题。实话说,绝大多数情况下,闭包能做的类也能做,反过来也成立。
# 闭包版defcounter():n=0defstep():nonlocaln n+=1returnnreturnstep# 类版classCounter:def__init__(self):self.n=0defstep(self):self.n+=1returnself.n我的实际取舍是看状态量和调试需求:
- 状态就一两个、逻辑也就一小段,用闭包,短
- 状态多了、方法多了、需要
print出来看内部、需要序列化、需要被别人继承或者扩展——老老实实写类 - 闭包的状态藏在
__closure__里,调试器默认不给你展开,出问题时你会在print里来回猜。类就不一样,obj.n一眼就看到了
所以网上那些"闭包性能更好、应该替代类"的说法,我听一半,有的时候一味的节省时间未见得是什么好事
七、几个零碎的注意点
闭包捕获的是变量,不是值。前面循环那个坑就是这条的直接后果。
闭包会延长变量的生命周期。被装进 cell 的东西,只要函数对象还在,就不会被回收。如果外层闭的是个大列表、大图片、数据库连接,那它就跟着一直活着。
只有被用到的变量才进闭包。想看具体哪些,可以:
defouter():a=1b=2definner():returna# 只用了 areturninner f=outer()print(f.__code__.co_freevars)# ('a',)print(f.__closure__)# 只有一个 cellb没被引用,就不会被记住。
查看是否真的形成了闭包,最直接的就是f.__closure__,None说明没有。
八、收个尾
闭包这东西,说白了就一句:函数带着它记得的环境到处跑。
真正需要记住的坑也就两个:
- 想改外层变量,得写
nonlocal - 循环里创建函数,小心变量是共享的
剩下的都算自己的手感,多多练习几次自然就熟了。
如果你看到这里还有一点懵,我建议就手敲几遍,让AI生成也罢,自己找题也罢,一定不要看答案或者让AI直接给出代码,自己敲一遍,报错再看正确的写法,这样反复来上一两遍,比看十篇解释都管用。
学习嘛,就是这么个过程。