news 2026/10/22 9:14:29

从作用域到闭包:Python嵌套函数、状态记忆与装饰器底层原理

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从作用域到闭包:Python嵌套函数、状态记忆与装饰器底层原理

我最初学闭包的时候,最大的困惑不是"闭包怎么写",而是"为什么这玩意儿会存在"。你好端端地把一个函数返回出去,内部函数里引用了外部函数的变量,结果外部函数已经执行完了,那个变量居然还在。这个反直觉的行为,才是闭包真正让人头大的地方。

写Python写了几年之后,我回头看这段经历,发现闭包就像一把精度极高的瑞士军刀:它藏在语言里,平时你不见得会刻意去想它,可一旦你需要保存状态、延后执行、生成带记忆的函数,它就成了最顺手的那把刀。这篇文章是我给刚入门Python的读者写的第三篇,专门把闭包拆开揉碎。我会从"它为什么会出现"讲起,再到核心机制,然后是两个躲不掉的应用场景和好几个我实际踩过的坑,最后聊聊闭包和装饰器之间的血缘关系。如果你正准备用Python做数据分析之外的正经工程开发,或者想彻底看懂别人的项目里那些"函数里面套函数"的写法,这篇应该能帮你省不少时间。

1. 从"函数返回函数"开始讲起

1.1 为什么会有嵌套函数

在Python里,你可以在函数内部再定义一个函数,这在语法上没有任何特殊之处:

def outer(): def inner(): print("I am inner") inner() outer()

这种写法最初给人的感觉是:这不就是把代码拆碎一点吗?我直接在outer里写print("I am inner")不是更省事?单纯看这个例子,确实是这样。嵌套函数真正的价值,不在于它简化了当前这一层函数,而在于它可以让"内部函数"访问外部函数里定义的变量:

def outer(): message = "hello from outer" def inner(): print(message) inner() outer() # 输出 hello from outer

到这里,它仍然只是一个作用域访问的问题。inner能访问message,是因为当Python执行到inner()时,它会在当前作用域里面找message,找不到,就会沿着外层作用域一路找上去,最终在outer的局部变量里找到。

真正让闭包成为一个独立概念的转折点,是把inner这个函数对象作为返回值交出去:

def outer(): message = "hello from outer" def inner(): print(message) return inner func = outer() func() # 输出 hello from outer

问题来了:outer已经执行完了,按照常规理解,它的局部变量message应该随着栈帧一起没了。但func()调用的时候,还能准确打印出message的值。这说明message并没有真正消失,它被某种东西"包"住了。这个"包",就是我们说的闭包。

1.2 "栈帧消失"这个直觉错在哪里

很多人觉得Python是解释型语言,运行时那一套跟C这种编译型语言应该不一样。但实际上,Python的函数调用同样会创建栈帧,一个函数执行完成后,它的栈帧确实会被销毁。那message为什么还在?

因为Python在把inner返回出去的时候,不是返回一个孤零零的函数代码,而是返回了"函数对象 + 它引用的外部变量的上下文"打包组合体。技术上说,inner有一个__closure__属性,指向一个元组,元组的每个元素都是"单元格对象"(cell),这些单元格里装着外部变量的引用。函数代码本身是死的,单元格才是让函数"记住"外面世界的活机制。

你可以这样理解:闭包像是一台自带干粮的无人机。函数代码是无人机本体,__closure__是它携带的补给箱,外部变量就是补给箱里的物资。就算起飞平台(外层函数)已经被拆掉,无人机仍然可以凭借补给箱里的物资继续执行任务。

提示:闭包的必要条件是三个——嵌套函数、内部函数引用了外部变量、外部函数把内部函数返回出去(或者以其它方式让内部函数逃出外部函数的作用域)。三个条件缺一个,严格意义上都不算闭包。

2. 闭包捕获的是变量,不是捕获值

2.1 一个重要且容易踩坑的事实

这节课一开始我学到的闭包定义是"内部函数可以访问外部函数的变量",但还有一个关键点,很多人学到后面才发现:闭包捕获的是变量本身,而不是变量那一刻对应的值。

看这段代码:

def outer(): count = 0 def inner(): count = count + 1 # 这里会报错! return count return inner func = outer() func()

如果你试过这段代码,会发现它直接报UnboundLocalError。原因并不是闭包"不起作用",而是inner里面写了count = ...,Python编译器把count当成inner的局部变量了。既然它是局部变量,你在它被赋值之前就读取,就会报"在赋值前被引用的局部变量"。

正确的写法是用nonlocal声明:

def outer(): count = 0 def inner(): nonlocal count count += 1 return count return inner func = outer() print(func()) # 1 print(func()) # 2 print(func()) # 3

看到没有,这个计数器func是带记忆的。每次调用它,它都在上一次的基础上加一。如果count是从函数外面传进来的一个不可变对象,比如整数,那么每次+= 1都会产生一个新的整数对象,我们必须通过nonlocal告诉Python:这个count不是inner自己的局部变量,请修改外层outer作用域里的那个count。

很多第一次接触闭包的人在这里懵了,为什么前面message那个例子可以直接读不用nonlocal,而这里改一个数字却要nonlocal?因为"读"和"写"在作用域分析规则里是完全不同的两件事。只要对某个名字有赋值操作,Python就默认它是当前函数作用域的局部变量;读取则可以去外层找。nonlocal的本质就是打破这个默认,明确指定"请把它当成外层函数作用域里的变量来处理"。

2.2 用__closure__把闭包的秘密挖出来

我建议每个学闭包的人都手动检查一下__closure__,这比看十篇文章都管用:

def outer(): x = 10 y = [1, 2, 3] def inner(): return x + y[0] return inner func = outer() print(func.__closure__) # (<cell at 0x...: int object at 0x...>, <cell at 0x...: list object at 0x...>)

__closure__里有几个单元格,就说明inner捕获了几个外部变量。注意,它是元组,不是字典,也就意味着没有变量名信息,只有引用。如果你想知道对应的名字,可以用inner.__code__.co_freevars,两者按位置一一对应:

print(func.__code__.co_freevars) # ('x', 'y')

这里有个很实用的排查技巧:假如你在调试一个闭包,发现返回的函数行为不正常,先打印func.__closure__看看它到底捕获了什么。如果闭包单元格里放的是某个可变对象,那么函数之外任何对这个可变对象的修改,闭包内部都会"看得到";如果你希望它只看得到一个固定的值,就得在创建闭包的时候把值拷贝一份再存进去。

3. 闭包的三个典型应用场景

3.1 场景一:带内部状态的计数器

闭包最常见的入门案例就是计数器。比起定义一个带self.count的类,闭包写法更短,尤其适合只需要一个简单状态的场景:

def make_counter(start=0): count = start def counter(): nonlocal count count += 1 return count return counter c1 = make_counter(10) c2 = make_counter(100) print(c1()) # 11 print(c1()) # 12 print(c2()) # 101 print(c1()) # 13

关键是c1和c2之间互相独立。它们虽然由同一个make_counter创建,但每次调用make_counter都会创建一份全新的count变量和一组全新的闭包单元格。这非常像"迷你对象"——闭包的私有状态,就是面向对象里的"实例属性",只不过它不需要你去定义一个类。

如果你觉得自己不用写计数器,这个例子好像用不上。但实际上,很多场景都可以抽象成"需要一个带记忆的函数":比如生成自增ID、统计某个函数被调用了多少次、记录上一次调用的参数用于比较等等。

3.2 场景二:回调函数与延迟计算

闭包的第二个大用途是延迟执行。你不在外层函数返回的那一刻计算结果,而是把内部函数交给一个执行者,让执行者在合适的时间去调用它。

举个例子,你在做一个简单的定时注册系统,想把事件和对应的处理函数绑定在一起:

def bind_event(name, event_id): def handler(): print(f"处理事件 {name},编号 {event_id}") return handler on_click = bind_event("点击按钮", 1001) on_keypress = bind_event("按下键盘", 1002) # 一段时间之后,事件真的触发了 on_click() on_keypress()

这里的name和event_id就是被闭包捕获的上下文。你不用创建一大堆包含字段的对象,闭包自己就把"该带的信息"带在身上了。尤其是当你需要把函数作为参数传给某个框架(比如定时任务库、信号处理库、GUI框架),闭包写起回调来非常顺畅。

延迟计算还有一层含义:真正耗时的操作可以在闭包内部做,外层只是把环境和参数准备好。例如:

def make_url_builder(base): def build(path): return f"{base}/{path}" return build api = make_url_builder("https://api.example.com") print(api("/users")) # https://api.example.com/users print(api("/orders/1")) # https://api.example.com/orders/1

base在函数创建的时候就已经固定,后续所有调用都不用再重复传这个参数。这种用闭包"预先注入参数"的模式,很多框架底层都在用。

4. 闭包最经典的雷区:循环变量与延迟绑定

4.1lambda在列表里集体翻车的原因

这是闭包教学里重量级的坑。先看这段代码:

funcs = [] for i in range(3): funcs.append(lambda: i) for f in funcs: print(f())

很多初学者以为输出是0 1 2,实际输出却是2 2 2。为什么?三个lambda没有自己的局部变量i,它们的闭包捕获的是外层for循环里的同一个i。for循环结束后,i停在最后一个值2,三个lambda看到的都是2。

这完美印证了上面说的:闭包捕获的是变量,不是值。这三个lambda并不是在每次append的时候把当时i的值"快照"下来,而是共享了同一个单元格。

那怎么解决?一个办法是立刻用默认参数绑定值:

funcs = [] for i in range(3): funcs.append(lambda i=i: i) for f in funcs: print(f())

这里的技巧是:lambda i=i: i把i作为默认参数。默认参数在函数定义的时候就会被求值并保存,所以相当于在append的那一刻,把当前i的值存进了lambda自己的参数里,彻底切断了对外层i的引用。另一种方案是把循环体放到一个辅助函数里:

def make_lambda(i): return lambda: i funcs = [make_lambda(i) for i in range(3)] for f in funcs: print(f())

每次调用make_lambda(i)都会创建一个新的作用域和新的单元格,因此三个lambda捕获的是三个不同的i,输出就正确了。我个人更推荐第二种,因为它更明确,而且不需要记忆"默认参数求值时机"这个冷知识。

注意:只要你在循环里创建了闭包,并且闭包使用了循环变量,就必须意识到延迟绑定的问题。不要拿"现在代码碰巧不会错"来麻痹自己,列表推导式、线程池任务提交、回调注册这种场景,都很容易中招。

4.2 闭包和全局变量之间的微妙差异

很多人觉得闭包保存状态不就是高级一点的全局变量吗?这个理解大方向不错,但有一个本质区别。

全局变量是全程序共享的,你在一处改了它,另一处读它的函数立刻受影响。闭包则不同,它把状态锁在"创建它的那次函数调用"里。同一个make_counter创建的两个计数器互不干扰,这就是它比全局变量强的地方。

如果你在多个地方都用了同一个全局变量来保存状态,后期调试的时候,你根本不知道是哪个调用点改了它。而闭包的状态封装在一个函数里,外部访问不到,只能通过它暴露的接口(也就是返回的闭包函数)去操作,可控性高得多。这也是"用闭包代替全局变量"这条工程建议背后的真正逻辑。

我再补充一个实际碰到的场景。我以前做一个图像处理的小工具,里面有个模块需要用到一个不断更新的缓存字典。第一版用的是模块级全局字典,后来发现两个功能模块在互相覆盖缓存,排查了很久。改成闭包之后:

def make_cache(): cache = {} def get_or_compute(key, compute): if key not in cache: cache[key] = compute() return cache[key] return get_or_compute

每个模块各自make_cache()一次,拿到的是完全独立的缓存。问题立刻消失,而且代码也更干净了。这类经验说明,闭包不仅仅是语法装点,它在真实工程里直接就是"让步全局变量"的解法。

5. 闭包背后的"紫色":装饰器原理

5.1 装饰器为什么离不开闭包

如果你看过装饰器的源码,会发现它几乎就是闭包的标准模板:

def my_decorator(func): def wrapper(*args, **kwargs): print("before") result = func(*args, **kwargs) print("after") return result return wrapper @my_decorator def hello(): return "hello"

这里的wrapper不是一个普通的函数吗?它外层有my_decorator,内部引用了func这个外部参数,并且my_decorator把wrapper作为返回值。完整的闭包三要素全齐了。

装饰器的本质就是对函数对象的再加工:你把旧函数传给装饰器,装饰器返回一个新函数,新函数内部悄悄调用了旧函数,还夹带了额外逻辑。夹带的额外逻辑能拿到旧函数这个引用,靠的正是闭包。没有闭包,装饰器得换一种非常别扭的方式来实现,比如给函数对象挂属性,但那就远远不如现在这么优雅了。

5.2 带参数的装饰器是闭包套闭包

真正的老手会用"带参数的装饰器",也就是在装饰器外面再套一层函数,这其实就是闭包分层:

def repeat(times): def decorator(func): def wrapper(*args, **kwargs): for _ in range(times): func(*args, **kwargs) return wrapper return decorator @repeat(3) def say_hello(): print("hi")

repeat(3)先执行,返回decorator;decorator捕获了times=3;decorator(func)执行后返回wrapper;wrapper捕获了func。三层函数,两层闭包,一层套一层。理解单一的闭包之后,看这种代码就就不再困惑了,无非就是每个函数层都有自己的一份外部变量需要保存。

实操中,如果你需要写一个装饰器去缓存函数的计算结果,或者记录函数的执行时间,你会发现写出来的结构基本都是上面这个模板。闭包是装饰器"背后的人",而装饰器是所有人每天都会用到的东西,比如staticmethod、property、各种框架里的路由装饰器,底层全是这套机制。

6. 闭包配套工具与避坑笔记

6.1 用functools.wraps保住原函数的名字

写装饰器时,我们直接返回wrapper的话,原函数的名字、文档字符串都会被覆盖。这在调试的时候非常烦人:

import functools def my_decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapper

functools.wraps的内部实现也用到了闭包:它把原函数的一些元信息(__name__、__doc__等)复制到wrapper上。虽然这不是闭包本身的必备知识点,但只要你实际写装饰器,几乎必然会碰到,顺手带上这个习惯能省很多排查时间。

6.2 闭包会不会导致内存泄漏

这个问题我在实际项目中真的纠结过。闭包因为持有外部变量的引用,如果这个闭包被长期保存,就有可能导致一些大对象无法被回收。

举一个例子:

def outer(data): # data 是一个很大的列表 def inner(): return len(data) return inner big_data = list(range(10**7)) f = outer(big_data)

只要f还活着,big_data就永远不会被回收,哪怕你实际上只在闭包里用到了len(data)。这个问题在开发长时间运行的Web服务时尤其需要注意:如果你往某个全局列表里塞进大量闭包,每个闭包又都绑定了大对象,内存就会悄悄上涨。

解决方案也不是很复杂。如果你只需要长度,就不要捕获整个列表,可以在外层函数里先算出长度,让闭包只捕获一个整数:

def outer(data): length = len(data) def inner(): return length return inner

说到底,闭包捕获的引用只要不再需要,最好的做法是让闭包本身及时失活。判断"这个闭包会不会活很久"是写程序时值得多看一眼的地方。

6.3 个人使用闭包的经验总结

学闭包学到后面,我最大的体会是:它能让你写出又短又封闭的代码,但代价是理解门槛。一个小建议:如果闭包超过三层嵌套,或者一个闭包里同时捕获了四五个外部变量,可读性就会明显下降。这时候我更倾向于定义一个类,把状态和操作都显式写在属性里。

闭包最适合的场景,是那些"只有一个方法的状态对象":计数器、缓存器、事件回调、单参数预绑定。一旦逻辑复杂,需要多个方法协作,类的表达能力就超过了闭包。

我建议你亲手做一组实验练一练:先写普通函数,再用闭包改写统计功能;创建一个循环往列表里塞lambda,观察延迟绑定现象,然后尝试修复它;再写一个计时装饰器,用functools.wraps和不用它分别跑一次,观察函数名的差异。做完这三组实验,闭包对你来说就不再是个概念,而是一种随时可以调用的肌肉记忆。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 9:13:28

IWR1843+DCA1000毫米波雷达:从原始ADC数据到人体动作感知

简介&#xff1a;这是一套围绕德州仪器IWR1843DCA1000毫米波雷达的完整项目研究包&#xff0c;面向毫米波雷达初学者及中高级开发者&#xff0c;覆盖数据采集、调频连续波-多输入多输出仿真、点云人体追踪、热力图定位、手势识别与生命体征检测六大应用场景&#xff0c;适合课题…

作者头像 李华
网站建设 2026/10/22 9:12:27

改进QPSO-SVM的时序预测实战:原理拆解与Matlab调参实现

做时序预测的这些年&#xff0c;我花在SVM参数调优上的时间&#xff0c;比选模型本身还要多。基于改进量子粒子群优化算法的QPSO-SVM&#xff0c;正是来解决这个问题的&#xff1a;让量子粒子群替代人工网格搜索&#xff0c;自动找到合适的C、epsilon和核宽度。这篇文章把这套思…

作者头像 李华
网站建设 2026/10/22 9:12:13

RAG基础实操指南:从概念到落地,拆解检索增强生成链路

早几年做大模型应用&#xff0c;最尴尬的一件事不是模型答错&#xff0c;而是模型太会“一本正经地胡说八道”。问一个内部知识库的问题&#xff0c;只要语料里没有&#xff0c;它就能现场编一个答案给你&#xff0c;还编得逻辑完备、细节丰富。所以当基于RAG基础技术的问答方案…

作者头像 李华
网站建设 2026/10/22 9:11:57

Qt与OpenCV实战:从零手写图片效果编辑器

简介&#xff1a;基于Qt交互与OpenCV打造的图片效果编辑器完整项目包&#xff0c;面向毕业设计、课程设计、工程实训、期末大作业及学科竞赛等场景&#xff0c;适合需要快速复现或借鉴完整图像处理界面的学习者。压缩包共收录59个文件&#xff0c;包含若干cpp与h源文件可查看核…

作者头像 李华
网站建设 2026/10/22 9:11:54

用IDEA调试Ghidra脚本:从环境配置到断点命中的完整实践

搞逆向分析的朋友迟早会走到这一步&#xff1a;手头有几个二进制文件需要批量分析&#xff0c;或者想让脚本自动完成重复性工作&#xff0c;于是开始接触Ghidra的脚本机制。但Ghidra自带的脚本编辑体验实在不怎么样&#xff0c;写长了看不清、报错全靠猜&#xff0c;更别说打断…

作者头像 李华
网站建设 2026/10/22 9:11:41

find、read、数组、seq实战:从文件查找到自动化脚本开发

1. 为什么我把find称为"星级命令"&#xff1a;先理清它到底解决什么问题很多初学者学Linux命令&#xff0c;习惯按字母顺序一天背两三个&#xff0c;背到find常常一头雾水——参数一大堆&#xff0c;写出来的命令像天书&#xff0c;复制别人的又是"找不到文件&q…

作者头像 李华