
很多刚接触Python的同学都会在“闭包”这个词上卡一阵子。函数我会写列表推导式我也看得懂可一到闭包教材上的例子要么是好几层嵌套要么直接甩一串装饰器代码过来看两遍还是不知道这东西到底在干嘛。我在带新人和写项目时被问过很多次类似的问题所以这篇打算换个讲法不谈高深理论而是从“闭包到底帮我们解决了什么问题”入手一步步把它的原理、写法、坑和应用场景全部过一遍适合刚学完函数、准备进阶的Python初学者。后文的代码我都用Python 3.10在本地跑过都是可直接复制的完整示例。你可以边看边在自己电脑上敲一遍尤其是最后那几个坑光看不算亲手踩一遍才能记住。1. 先搞明白闭包到底解决了什么问题1.1 从一个真实的入门困惑开始我们先放下“闭包”这个词想一个非常具体的场景写一个计数器函数每次调用它返回一个新的数字第一次返回1第二次返回2第三次返回3以此类推。最朴素的想法是用全局变量count 0 def next_number(): global count count 1 return count print(next_number()) # 1 print(next_number()) # 2 print(next_number()) # 3功能上没问题但用起来有几个隐患。我最常见到的情况是项目里函数一多全局变量很容易被别的地方误改。比如某天你为了调试在某个函数里写了一句count 100运行结果立刻变得诡异排查半天才发现是全局变量被覆盖了。而且count作为全局变量会一直留在模块的命名空间里如果这只是某个模块内部的临时逻辑完全没有必要把它暴露到全局。还有一种写法是把状态挂在函数本身的属性上def next_number(): next_number.count 1 return next_number.count next_number.count 0 print(next_number()) # 1 print(next_number()) # 2这个也能跑但每次都要记得先初始化函数属性写起来也别扭。最关键的是这两种方案解决的都是“如何保存状态”这一个问题却没有解决“状态与调用逻辑如何绑定在一起”的封装问题。1.2 为什么需要“带记忆”的函数回到需求本身我们需要的是一个函数它既有调用逻辑又能在多次调用之间保存自己的状态。换句话说这个函数要“带记忆”。类也能做到这一点用实例属性存状态方法和状态天然绑定在一起。这个思路没有错但有时候我们只是需要一个简单的、轻量的“带状态函数”没必要为了一个计数器专门定义一个类。Python提供了一条更轻的路径——闭包。闭包可以在不引入类、不引入全局变量的情况下让一个内部函数“记住”它外层的环境信息。我习惯用一个类比来解释闭包就像随身带了一本小本子。外层函数是发本子的人内层函数是带着本子出去跑业务的人。本子在哪状态就在哪。别人拿不到你的本子只有你本人能翻看和记录这就是闭包比全局变量优雅的地方。1.3 不引入新语法闭包靠的是“函数嵌套”Python里函数是一个“一等对象”意味着函数可以像整数、字符串一样被赋值给变量、放进列表、当作参数传入其他函数也可以作为返回值返回。闭包就是基于两个最简单的特性函数可以嵌套定义函数可以当作返回值返回。先看一个最简洁的例子def outer(x): def inner(y): return x y return inner add_ten outer(10) print(add_ten(1)) # 11注意最后一行写法return inner返回的是inner这个函数本身不是inner()的调用结果。初学者最容易在这里迷糊——inner没加括号代表“把函数交出去”inner()加了括号代表“立刻执行这个函数”。闭包需要的是前者先把函数交出去以后你拿着这个函数想什么时候调就什么时候调。outer(10)执行完之后从常规理解来看参数x10的生命周期应该结束了。但add_ten(1)却依然知道x是10这说明x并没有被销毁而是被inner通过某种机制保存了下来。这就是闭包的核心现象。2. 闭包的工作机制函数、作用域与自由变量2.1 闭包的定义与三个必备条件看完上面的例子我们可以给闭包下一个相对严谨的定义闭包是指内层函数引用了外层函数的局部变量并且外层函数把这个内层函数作为返回值返回那么这个内层函数连同它引用的外层变量一起整体就称为闭包。满足闭包需要三个条件缺一不可存在嵌套函数即函数内部还定义了一个函数。内层函数引用了外层函数的局部变量。外层函数把内层函数作为返回值返回。下面这段代码我把三个条件都标出来了def outer(x): # 外层函数 def inner(y): # 条件1嵌套函数 return x y # 条件2引用外层变量 x return inner # 条件3返回内层函数如果内层函数没有引用外层变量即使有嵌套和返回也不算闭包。比如def outer(): def inner(): return 42 return inner这个inner没有引用outer里的任何东西只是恰好定义在外层函数里而已。它返回后和普通函数没有任何区别不涉及“记忆环境”的机制。2.2 自由变量为什么没有被销毁这是初学者最大的疑问outer(10)调用结束后参数x10不是应该被回收吗为什么add_ten还能拿到它答案是Python在创建inner函数的时候发现它引用了外层函数的变量x于是把这个变量封装成一个“cell”对象保存到了inner函数的__closure__属性里。当外层函数调用结束正常的局部变量确实会被回收但被封装成cell的变量不会被回收因为它还被inner引用着。我再打一个比方你在一家公司临时加班电脑是公司分配的下班本来要收回。但公司发现你天天带回家继续干活就给你办了外带手续把这台电脑登记到你的名下。闭包里的自由变量就是这台“办了外带手续的电脑”它的使用期限和持有它的人绑定了。所以闭包其实可以理解为“函数 被捕获的环境变量”的组合。函数本身没有变变的是它多带了一个环境背包。2.3 用closure偷看闭包内部理论说再多不如实际看一眼。Python提供了__closure__属性专门保存闭包捕获的外层变量def outer(x): def inner(y): return x y return inner add_ten outer(10) print(add_ten.__closure__) # (cell at 0x000001F23A8B3A90: int object at 0x000001F23A83F430,) print(add_ten.__closure__[0].cell_contents) # 10__closure__是一个元组每一个元素对应一个被捕获的外层变量。cell_contents就是当前这个变量的值。如果某个嵌套函数没有捕获任何外层变量它的__closure__就是None这也给了我们一个快速判断“到底算不算闭包”的方法。我自己调试闭包相关代码时这个技巧非常常用。曾经遇到过一个很隐蔽的bug闭包函数每次返回的结果都不对但又看不出逻辑错误。后来用cell_contents逐个查看捕获变量的当前值才发现是外层变量在某次循环中被意外修改了。这种“偷看”内部状态的能力比在代码里到处加print要高效得多。2.4 LEGB 查找规则与闭包的关系要彻底理解闭包还得提一下Python的变量查找规则也就是常说的LEGB规则。这个缩写代表四层作用域LLocal当前函数内的局部变量。EEnclosing外层函数里的局部变量。GGlobal模块全局变量。BBuilt-inPython内置的变量比如len、print。内层函数查找一个变量时会严格按这个顺序一层层往外找。闭包就是触发了第二层“Enclosing”——内层函数在自己的局部命名空间里找不到某个变量于是跑到外层函数的作用域里找找到了就把它“记住”。明白了LEGB规则就很容易理解后面会遇到的nonlocal关键字。如果不做任何声明内层函数里给一个变量赋值这个变量会被当成内层函数的局部变量Python不会自动帮你修改外层变量。因为在它看来“赋值”就相当于在本地创建了新变量。如果这个新变量和外层变量重名Python会优先把它视作局部变量从而抛出UnboundLocalError。nonlocal的作用就是告诉Python“这个变量不是本地的去外层函数作用域里找。”3. 手写一个闭包从最简单到生产可用3.1 案例一计数器回到开头的计数器需求用闭包实现def make_counter(): count 0 def increment(): nonlocal count count 1 return count return increment c1 make_counter() print(c1()) # 1 print(c1()) # 2 c2 make_counter() print(c2()) # 1注意nonlocal count这一行它是整个计数器能跑起来的关键。count 1是对变量count做赋值操作如果没有nonlocal声明解释器会认为count是increment内部的局部变量于是报UnboundLocalError: local variable count referenced before assignment。加上nonlocal之后Python才会去外层函数make_counter的作用域里找到count并修改它。这个例子还体现了一个重要特性每次调用make_counter()都会生成一个完全独立的闭包。c1和c2互不干扰各自维护自己的count。这比全局变量的方案强在状态隔离也比类的方案轻在不需要定义额外类型。3.2 案例二延迟计算与函数模板闭包另一个典型的应用场景是“延迟计算”。意思是我先配置好一个函数不急着执行等真正需要的时候再调用。def make_power(n): def power(x): return x ** n return power square make_power(2) cube make_power(3) print(square(4)) # 16 print(cube(2)) # 8这里make_power(2)不会立刻计算任何幂运算它只是生成了一个“记住了n2”的函数。等到你调用square(4)时才真正执行计算。可以把这种写法理解为生成“函数模板”你定义好规则参数留到以后填。这种模式在实际开发里很常见。比如数据分析时你想对不同的列应用不同的归一化逻辑可以先通过闭包生成多个预处理函数再统一塞进一个流程里。又比如写GUI或游戏的时候你想给不同的按钮绑定不同的点击行为但每个行为的参数不同闭包可以提前把参数“焊死”在函数里避免写一堆lambda表达式还绕不清楚。3.3 案例三用闭包封装对象属性闭包还能实现类似“私有属性”的效果。下面这个例子模拟一个小游戏里的角色对象hp是角色血量外部只能通过我提供的函数来修改def create_player(name, hp100): def hurt(damage): nonlocal hp hp - damage return hp def heal(amount): nonlocal hp hp amount return hp def get_info(): return {name: name, hp: hp} return {hurt: hurt, heal: heal, get_info: get_info} p create_player(阿勇) print(p[get_info]()) # {name: 阿勇, hp: 100} p[hurt](30) print(p[get_info]()) # {name: 阿勇, hp: 70} p[heal](10) print(p[get_info]()) # {name: 阿勇, hp: 80}从外部来看hp这个变量无法被直接访问只能通过hurt、heal、get_info这三个函数间接操作。这就实现了一个轻量的“对象”状态被包裹在闭包里行为由返回的函数提供。那什么时候用闭包什么时候用类呢我的经验是如果只是封装一两个状态、三四个方法不需要继承、多态闭包方案更直白代码量也少。但一旦逻辑复杂涉及多个方法之间的互相配合需要用继承来扩展或者想清晰地区分类型那还是用类更合适。闭包不是类的替代品而是对于“小场景”更快捷的工具。3.4 案例四装饰器的底层就是闭包可以说不理解闭包就理解不了装饰器。实际上装饰器就是闭包最经典的应用。下面是一个最简单的装饰器def my_logger(func): def wrapper(*args, **kwargs): print(fcall {func.__name__}) return func(*args, **kwargs) return wrapper my_logger def add(a, b): return a b print(add(1, 2)) # 输出 # call add # 3这个程序运行后其实经历了这些步骤my_logger等价于add my_logger(add)。也就是说原来的add函数被传进了my_logger变成wrapper闭包捕获的外层变量然后新的add被替换成了wrapper函数。之后你再调用add(1, 2)实际上调用的是wrapper(1, 2)wrapper帮忙先打印日志再调用真正被记住的原函数。我见过不少初学者能写出装饰器的代码但说不出为什么能这样“包一层”。其实就是因为闭包让wrapper记住了外层传入的func。把闭包学透了装饰器的原理就变得非常朴素。4. 新手最容易踩的坑变量绑定、nonlocal 与循环陷阱4.1 坑一循环变量捕获这个坑几乎人人都会踩一次。需求很常见我想生成三个函数第一个返回0第二个返回1第三个返回2。先看错误写法funcs [] for i in range(3): def f(): return i funcs.append(f) for f in funcs: print(f()) # 实际输出 # 2 # 2 # 2你可能会想循环执行三次每次i分别是0、1、2为什么最终三个函数都返回2原因在于闭包捕获的是变量i本身而不是i在那个时刻的值。循环结束后i的最终值是2所以三个闭包函数再去取i拿到的都是2。它们的__closure__指向的是同一个cell对象里面装的都是同一个i。解决办法有两种。第一种是给函数设置默认参数把当前值“钉死”funcs [] for i in range(3): def f(xi): return x funcs.append(f) for f in funcs: print(f()) # 0 # 1 # 2默认参数是在函数定义时求值的所以xi会把当时的i值复制一份保存为默认值。这相当于“拍照留念”。第二种办法是用工厂函数再包一层def make_func(x): def f(): return x return f funcs [] for i in range(3): funcs.append(make_func(i)) for f in funcs: print(f()) # 0 # 1 # 2这里make_func(i)每次调用都会创建一个新的局部变量x这个x被内层函数捕获。由于每次调用make_func都重新创建了作用域x的值不会被后面的循环污染。4.2 坑二nonlocal 与可变对象有些初学者发现用nonlocal写计数器会报错其实是忘了加nonlocal就想出一个绕路的办法用列表装整数然后原地修改列表里的元素。def make_counter_list(): count [0] def increment(): count[0] 1 return count[0] return increment c make_counter_list() print(c()) # 1 print(c()) # 2这个写法确实能跑因为它没有给count这个变量名重新赋值而是修改了count[0]属于“原地修改可变对象”不需要nonlocal声明。但我不推荐用这个办法去“躲避”nonlocal。对于计数器这种简单场景count [0]的可读性太差别人看你代码还得想一下为什么要套一层列表。更好的建议是老老实实写nonlocal count它才是Python为闭包修改外层变量提供的标准方式。另外要注意nonlocal只能用来声明“外层函数作用域”里的变量不能声明全局变量声明全局变量要用global。两者的分工是global告诉Python去全局作用域找nonlocal告诉Python去外层函数作用域找。4.3 坑三闭包持有大对象导致内存回收不了这个坑在长期运行的服务里尤其要注意。闭包会让外层函数的局部变量一直存活直到闭包本身被垃圾回收。如果外层函数里有大对象而闭包被长期持有内存就会持续占用。def load_big_data(): data [i for i in range(10 ** 7)] # 假设这是一个巨大的数据 def get_len(): return len(data) return get_len f load_big_data()在这个例子里data是那个巨大的列表。按理说load_big_data()返回后data应该被回收。但因为get_len这个闭包捕获了它data就会一直留在内存里哪怕你以后根本不需要这个列表了。如果在一个常驻进程里反复创建这种闭包内存占用会缓慢上涨排查起来还不太容易发现。解决思路有三个第一确定闭包不再需要时用del f删除闭包引用第二设计外层函数时尽量只捕获必要的小对象不要把整个大对象塞进闭包第三如果确实需要大数据处理考虑换用类并在必要时显式清理data属性。闭包虽然方便但“随手捕获”的习惯在资源敏感的场景里可能带来隐患。4.4 常见问题速查表我把新手学闭包最常见的几个问题整理成一张表方便你遇到问题时快速定位。现象原因解决办法内层函数修改外层变量时报UnboundLocalError没有声明变量来自外层在变量赋值语句前加nonlocal循环里创建的闭包最终都返回同一个值闭包捕获的是变量本身循环结束后变量停在最后一个值用默认参数def f(xi):或工厂函数包一层闭包函数返回的值一直不变修改外层变量时没有加nonlocal导致修改的是内层新变量检查并补上nonlocal程序内存持续增长长期运行的脚本越来越慢闭包长期持有外层大对象导致对象无法被回收用完闭包后del掉或尽量不捕获大对象调用闭包时感觉外层变量“丢了”内层函数定义时引用的变量名和外层不一致查找走了全局或内置作用域检查LEGB查找顺序确认变量在外层函数中有定义5. 闭包在真实项目里的高价值场景5.1 装饰器Web框架和日志组件的常客装饰器是闭包最广为人知的应用在FastAPI、Flask、Django这类Web框架里路由注册、权限校验、参数校验大量依赖装饰器。理解了闭包你看框架源码时就不会被一层层符号吓到。本质上每个装饰器都是一个“接收函数、返回新函数”的闭包。如果你需要带参数的装饰器比如“日志级别可配置”还需要在装饰器外面再包一层函数。举个例子def log_with(level): def decorator(func): def wrapper(*args, **kwargs): print(f[{level}] call {func.__name__}) return func(*args, **kwargs) return wrapper return decorator log_with(INFO) def add(a, b): return a b add(1, 2) # 输出 # [INFO] call add这里最外层log_with(INFO)返回的是decoratordecorator又返回了wrapper闭包。三层函数嵌套其实就是三层闭包叠加。初学者看到这种代码容易晕但只要一层层拆开看每一层都在干什么就会非常清晰。5.2 缓存与记忆化空间换时间闭包很适合实现“记忆化”缓存也就是把函数的计算结果保存下来下次遇到相同参数时直接返回缓存值不再重复计算。一个经典的例子是斐波那契数列def memoize(func): cache {} def wrapper(n): if n not in cache: cache[n] func(n) return cache[n] return wrapper memoize def fib(n): if n 2: return n return fib(n - 1) fib(n - 2) print(fib(30))fib(30)如果用暴力递归需要重复计算海量的子问题很可能跑得很慢。用上memoize之后cache被wrapper闭包捕获已经算过的结果都存在字典里计算fib(30)瞬间完成。Python官方库里的functools.lru_cache就是类似思路的工程级实现。你自己写一个简易版比直接调库更能体会闭包在状态保存上的威力。5.3 回调函数与API封装把配置“焊死”在函数里闭包另一个常见用途是为不同场景快速生成专属函数。比如你提供一个数据接口服务不同的用户有各自的token你想给每个用户生成一个带认证信息的请求函数def make_api_client(base_url, token): def request(path, methodGET): return f{method} {base_url}{path} with token {token} return request user_a make_api_client(https://api.example.com, token-a) user_b make_api_client(https://api.example.com, token-b) print(user_a(/user/info)) # GET https://api.example.com/user/info with token token-a print(user_b(/user/info)) # GET https://api.example.com/user/info with token token-bbase_url和token被闭包“焊死”在request函数里主流程只需要关心path和method就够了。在爬虫、自动化脚本、数据分析预处理中这种“配置前置调用简化”的写法很实用。5.4 闭包不是Python独有的跨语言看代码更顺闭包并不是Python独有的特性JavaScript、Go、Java通过Lambda和匿名内部类等语言里也有类似概念。不同语言写法不同但思想是一致的函数可以携带它定义时的环境变量。你在Python里理解了闭包再看JavaScript的function返回值、Go语言的函数字面量就会有“似曾相识”的感觉。顺带提醒一句在数学和泛函分析里也有“闭包”这个词指的是点集拓扑里的一个概念和编程里的闭包完全是两码事。平时搜资料时如果看到把“闭包”和“稠密”放在一起讲那是数学内容不是Python的内容别搞混了。如果让我给初学者一个最实在的建议学闭包不要死记定义拿计数器、缓存、装饰器这三个例子各敲三遍直到能不看代码自己写出来。我自己带新人的时候发现大部分卡在闭包上的人其实卡在“函数竟然还能返回函数”这个思维转换上。一旦你接受了函数是一等对象这件事闭包就是水到渠成的自然结果。最后再分享一个小技巧调试闭包时多利用closure和cell_contents查看外层变量的当前值比靠猜和到处print快得多。写代码时也尽量保持闭包捕获的变量少而小这样状态清晰内存也更安全。