闭包的本质:Python 如何捕获自由变量
文章目录一、从一个计数器开始二、LEGB 规则名字查找的顺序三、global 关键字打破 E 层四、nonlocal 关键字访问 E 层变量五、闭包的完整执行流程六、闭包变量的生命周期七、闭包的典型应用场景场景一函数工厂最常见用法场景二带记忆的递归函数场景三装饰器闭包的直接应用八、闭包的常见错误迟绑定九、__closure__ 与自由变量的深度解析十、知识点总结一、从一个计数器开始学作用域的时候通常会遇到这样的代码defmake_counter():count0defcounter():count1# 这里会报错returncountreturncounter cmake_counter()print(c())# UnboundLocalError: local variable count referenced before assignment这不是 bug——这是 Python 作用域规则在说话。报错的原因和count 1这行代码里发生了什么有关也在某种程度上揭示了闭包的本质。要彻底理解这段代码得先搞清楚 Python 的作用域规则以及闭包是怎么工作的。二、LEGB 规则名字查找的顺序Python 查找变量名时按 LEGB 的顺序逐层搜索LLocal当前函数内部定义的变量EEnclosing外层嵌套函数中的变量GGlobal模块级全局变量BBuilt-inPython 内置名字比如len、print用代码验证这个顺序xglobal# G 层defouter():xenclosing# E 层definner():xlocal# L 层print(x)# - local找到了就停inner()outer()每层可以定义和上层同名的变量彼此互不干扰。Python 之所以这样做是因为名字查找发生在运行时而非编译时——解释器执行到哪一行才去相应的作用域里找变量。LEGB 规则可视化B 层内置作用域Python 预定义L 层本地作用域当前函数内部E 层嵌套作用域外层函数作用域G 层全局作用域模块级别用 import/global 访问找不到时找不到时找不到时len, print, 自定义全局变量外层函数的局部变量当前函数的局部变量print, len, range, ...查找时从内向外逐层搜索找到即停。三、global关键字打破 E 层回到开头的计数器报错。count 1等价于countcount1Python 看到这行代码时发现等号左边有count就认为count应该是当前作用域的局部变量。但count在outer()的作用域里E 层不在counter()的作用域里L 层——Python 拒绝在 E 层创建同名 L 层变量所以报了UnboundLocalError。解决方式一把count提升到全局作用域count0defmake_counter():globalcount# 声明接下来访问全局的 countcount1returncountprint(make_counter())# 1print(make_counter())# 2但global有个严重问题它让count变成模块级全局变量。多个make_counter()实例会共享同一个count完全破坏了隔离性。四、nonlocal关键字访问 E 层变量nonlocal是global的近亲但作用域不同。它允许在 L 层函数中修改E 层嵌套外层的变量defmake_counter():count0defcounter():nonlocalcount# 声明接下来对 count 的赋值操作作用于外层的 countcount1returncountreturncounter cmake_counter()print(c())# 1print(c())# 2print(c())# 3nonlocal不会在 L 层创建新变量也不涉及 G 层——它直接作用于最近一层外层函数的变量。globalvsnonlocal的区别关键字作用层行为global模块级 G 层读写全局变量多个函数共享nonlocal嵌套外层 E 层读写外层函数变量多个闭包独立五、闭包的完整执行流程理解nonlocal后再看闭包的完整执行流程。回到最初报错的代码但这次不加任何关键字defmake_counter():count0# - E 层变量defcounter():print(count 当前值:,count)# 读 E 层变量 - 没问题returncount# 读 E 层变量 - 没问题returncounter读操作不加nonlocal不会报错——Python 允许读取外层变量。只有写操作count something或count something才会触发UnboundLocalError因为等号左边让 Python 认为这是一个新的 L 层变量。当 Python 看到nonlocal count时在编译期生成字节码时就已经把这件事记下来了importdisdefmake_counter():count0defcounter():nonlocalcount count1returncountreturncounter# counter 函数的字节码dis.dis(counter:make_counter())关键字节码5 2 LOAD_GLOBAL 0 (count) 4 LOAD_CONST 1 (1) 6 BINARY_OP 0 () 8 STORE_FAST 0 (count) 10 LOAD_FAST 0 (count) 12 RETURN_VALUELOAD_GLOBAL 0 (count)是nonlocal的实现方式——如果不用nonlocal这行会变成LOAD_FAST读 L 层变量然后STORE_FAST会触发UnboundLocalError。六、闭包变量的生命周期闭包有一个经常被忽视的特性闭包变量的生命周期和闭包本身一样长。defmake_multiplier(factor):# factor 绑定在 make_multiplier 的局部作用域里defmultiply(value):returnvalue*factorreturnmultiply doublermake_multiplier(2)# make_multiplier() 已经执行完毕退出了# 但 doubler 仍然持有 factor2print(doubler(5))# 10print(doubler(100))# 200factor原本在make_multiplier()的局部作用域里。函数退出后局部变量通常应该被销毁——但doubler还在使用它所以 Python 的垃圾回收机制检测到factor仍有外部引用就把它保留下来通过 cell 对象包装后存入doubler.__closure__。这就是为什么doubler.__closure__[0].cell_contents能读取到2——cell 对象就是闭包变量在内存中的载体。doubler.__closure__(cell at 0x...:intobjectat 0x...,)doubler.__closure__[0].cell_contents2一个更复杂的例子验证多个闭包共享同一个外层变量defprocessor(initial0):totalinitialdefadd(x):nonlocaltotal totalxreturntotaldefsubtract(x):nonlocaltotal total-xreturntotalreturnadd,subtract add,subtractprocessor(100)print(add(30))# 130total 100 30print(subtract(20))# 110total 130 - 20print(add(10))# 120total 110 10add和subtract指向同一个 cell 对象——修改total对两个函数都生效。这是闭包的共享状态特性常用于事件处理器、回调函数等场景。七、闭包的典型应用场景场景一函数工厂最常见用法根据不同参数生成专用函数defpower_factory(exp):defpower(base):returnbase**expreturnpower squarepower_factory(2)cubepower_factory(3)print(square(5))# 25print(cube(5))# 125exp被捕获在闭包里square和cube各有独立的exp值互不干扰。相比写死参数函数工厂更灵活避免了为每种指数写专门的函数。场景二带记忆的递归函数defmemoized_fibonacci():cache{}# E 层变量deffib(n):ifnincache:returncache[n]ifn1:resultnelse:resultfib(n-1)fib(n-2)cache[n]resultreturnresultreturnfib fibmemoized_fibonacci()print(fib(100))# 354224848179261915075print(fib(200))# 280571172992510140037611908417314019cache字典在闭包里持久化每次递归调用都能访问同一个缓存——避免了普通递归中子问题被重复计算的问题。场景三装饰器闭包的直接应用装饰器本质上就是闭包importfunctoolsimporttimedeftiming_decorator(fn):functools.wraps(fn)defwrapper(*args,**kwargs):# fn 和 elapsed_time 都是闭包变量starttime.perf_counter()resultfn(*args,**kwargs)elapsedtime.perf_counter()-startprint(f{fn.__name__}耗时{elapsed:.4f}s)returnresultreturnwrapperwrapper捕获了fn被装饰的函数和elapsed_time计时变量两个自由变量。timing_decorator返回的wrapper闭包里装着被装饰函数的引用调用时真正执行的是wrapper而非原函数。八、闭包的常见错误迟绑定这是闭包里最隐蔽的错误。当闭包在循环中创建时所有闭包实例捕获的是同一个变量而变量的值以闭包被调用时的值为准——而不是创建时的值defcreate_multipliers():multipliers[]foriinrange(5):multipliers.append(lambdax:x*i)# i 是自由变量returnmultipliers fnscreate_multipliers()# 全部返回 4*416而不是 0*4, 1*4, 2*4, 3*4, 4*4print([fn(4)forfninfns])# [16, 16, 16, 16, 16]循环结束时i 4所有闭包引用的是同一个i所以调用时都得到4 * 4 16。解决方式用默认参数在闭包创建时立即捕获当前值defcreate_multipliers_fixed():multipliers[]foriinrange(5):multipliers.append(lambdax,ii:x*i)# ii 把当前值绑定为默认值returnmultipliers fnscreate_multipliers_fixed()print([fn(4)forfninfns])# [0, 4, 8, 12, 16]lambda x, ii: ...里ii的右边i是自由变量在定义时取值为当前的循环变量左边i是默认参数绑定到 lambda 的 L 层作用域。每次循环迭代时i的当前值被拍进默认参数之后循环继续i变化也不影响已经绑定好的默认参数。用functools.partial也能解决importfunctoolsdefcreate_multipliers_partial():multipliers[]foriinrange(5):multipliers.append(functools.partial(lambdax,i:x*i,ii))returnmultipliers九、__closure__与自由变量的深度解析可以用__code__.co_freevars直接看到函数捕获了哪些自由变量defouter(x):definner(y):# z 从更外层捕获defdeeper(z):returnxyzreturndeeperreturninner# 查看各层函数的自由变量outer_fnouter(10)inner_fnouter_fn(20)deeper_fninner_fn(30)outer_fn.__code__.co_freevars(x,)inner_fn.__code__.co_freevars(x,y)deeper_fn.__code__.co_freevars(x,y,z)# __closure__ 的顺序和 co_freevars 一一对应deeper_fn.__closure__(cell at...:intobjectat...,cell at...:intobjectat...,cell at...:intobjectat...)deeper_fn.__closure__[0].cell_contents,\ deeper_fn.__closure__[1].cell_contents,\ deeper_fn.__closure__[2].cell_contents(10,20,30)co_freevars是字节码层面的元信息告诉解释器哪些名字是自由变量__closure__是这些自由变量对应的 cell 对象序列两者顺序一致。十、知识点总结LEGB 作用域规则L: 本地作用域E: 嵌套外层作用域G: 模块全局作用域B: 内置作用域nonlocal 关键字修改 E 层变量global 关键字修改 G 层变量闭包函数 捕获的环境__closure__存储自由变量的 cell 元组__code__.co_freevars自由变量名字列表迟绑定陷阱循环中创建闭包时用默认参数捕获闭包变量生命周期 闭包生命周期函数工厂记忆化递归装饰器如果觉得有帮助欢迎收藏、关注本专栏。