1. 从函数是一等公民说起
Python里流传着一句话:“万物皆对象”。但很多初学者学到函数的时候,只是把函数当作“一段可以重复调用的代码”,很少意识到函数本身也是一个对象,可以被赋值给变量、塞进列表里、当作参数传给另一个函数、甚至作为返回值丢出来。这个认知不扭转过来,闭包和装饰器就会看得云里雾里。
1.1 函数也是“数据”
我们先做一个最简单的实验:
def greet(name): return f"Hello, {name}" # 把函数赋值给另一个变量 say_hi = greet print(say_hi("小明")) # 输出: Hello, 小明 print(greet.__name__) # 输出: greet当你写下say_hi = greet时,并没有执行greet,只是把函数对象本身贴上了新标签。这就像你把一张菜谱复印了一份,菜谱还是那张菜谱,只是多了一个存放位置。既然函数是对象,它就能被当作参数传递:
def run_twice(func, arg): result1 = func(arg) result2 = func(arg) return result1, result2 print(run_twice(greet, "小红"))这种“把函数传来传去”的玩法,在函数式编程里叫高阶函数。Python 内置的map、filter、sorted的 key 参数,本质上都是这个套路。理解了这个前提,再看闭包就不会觉得它是从天而降的黑魔法,它只是“函数作为返回值”的自然延伸。
1.2 LEGB 规则:闭包的地基
要搞懂闭包,必须先搞清楚 Python 查找变量时的顺序。Python 内部遵循一套 LEGB 规则:
- L(Local):当前函数内部的局部作用域
- E(Enclosing):外层函数的局部作用域
- G(Global):模块的全局作用域
- B(Built-in):Python 内置作用域
举个例子:
x = 100 # G 全局 def outer(): y = 50 # E 外层函数的局部 def inner(): z = 10 # L 内层函数的局部 print(x, y, z) inner() outer() # 输出: 100 50 10inner内部访问y时,自己的局部作用域里没有,就去外层函数outer的作用域里找。这种“往外层找”的机制,就是闭包能够工作的前提。记住一个关键点:作用域是静态的,由函数定义的位置决定,而不是调用时的位置决定。inner在outer内部定义,所以它能访问outer的变量,哪怕outer已经执行结束了,inner依然记得这些变量。
1.3 延迟计算:函数只有在调用时才执行
函数对象和函数执行是两回事。定义函数不会执行函数体,只有加上了()才会真正运行。这个看似废话的常识,其实是理解装饰器“定义时注册、调用时生效”的关键。
def outer(): print("outer 执行了") def inner(): print("inner 执行了") return inner result = outer() # 立刻输出: outer 执行了 print("中间做一些别的事") result() # 此刻才输出: inner 执行了注意到没有,outer执行时,inner只是被创建并返回,完全没有执行。等到你真正调用result()时,inner的函数体才跑起来。这种“先打包、后使用”的思路,正是装饰器给函数“套壳”的底层逻辑。如果你在练习闭包时发现“怎么什么都没输出”,先检查一下是不是漏了括号,这个低级错误我见过不少人踩。
2. 闭包:函数带着“行李箱”
闭包这个词听起来玄乎,英文叫 closure,直译是“封闭”。它干的活其实很朴素:内层函数引用了外层函数的变量,然后外层函数把这个内层函数返回出去。返回出去的内层函数,随身携带了外层函数当时的变量环境,就像出门旅行带了一个行李箱,里面装着需要的东西。
2.1 动手写第一个闭包
def make_multiplier(factor): def multiplier(value): return value * factor return multiplier double = make_multiplier(2) triple = make_multiplier(3) print(double(10)) # 20 print(triple(10)) # 30make_multiplier(2)执行完后,按理说局部变量factor=2应该被回收,但double函数仍然记得factor的值。原因很简单:multiplier的代码里使用了factor,Python 会把multiplier连同它引用的外部变量一起打包。这个“包”就是闭包。
用生活类比来解释——make_multiplier像一个模具工厂,你给工厂一个模具(factor=2),工厂给你一个工人(double),这个工人永远按“乘 2”的工艺干活。每个工人只听自己模具的话。
2.2 闭包的三个必要条件
对照刚才的例子,一个标准的闭包必须同时满足三条:
- 存在嵌套函数(外层函数套内层函数)
- 内层函数引用了外层函数的变量
- 外层函数将内层函数作为返回值返回
三条缺一不可。如果内层函数不引用外层变量,那它只是普通函数,不构成闭包;如果外层函数不返回内层函数,那变量环境无法被“带出去”,闭包也无从谈起。
判断一个函数是不是闭包,有一个技巧:打印内层函数的__closure__属性。它是非None的元组时,说明这是闭包:
print(double.__closure__) # 输出: (<cell at 0x...: int object at 0x...>,)2.3 闭包的核心用途:保存状态而不污染全局
闭包最常见的用途就是“记住状态”。你可以用全局变量来模拟计数器,但全局变量容易被别处意外修改,而且命名空间很容易撞车。用闭包可以把状态封装在函数内部:
def create_counter(): count = 0 def increment(): count += 1 # 这里会报错吗? return count return increment上面的代码写出来直接运行会报UnboundLocalError。原因在于count += 1的左侧是赋值操作,Python 会把count视为increment内部的局部变量,于是它就不再向外层查找了。解决办法是显式声明nonlocal count:
def create_counter(): count = 0 def increment(): nonlocal count count += 1 return count return increment c1 = create_counter() print(c1()) # 1 print(c1()) # 2 c2 = create_counter() print(c2()) # 1,彼此独立nonlocal是闭包里修改外部变量的关键。它告诉 Python:“别把count当成当前函数局部变量,去外层作用域找,并且允许我修改它。” 这点和全局变量用的global类似,但作用范围限定在外层函数,而不是模块全局。
2.4 闭包的替代方案与取舍
有人可能会说,计数器用类不就行了?的确可以:
class Counter: def __init__(self): self.count = 0 def increment(self): self.count += 1 return self.count类和闭包都能实现状态保存。区别在于:类更重,有完整的命名空间和方法定义;闭包更轻,只暴露一个可调用对象,外部无法访问内部状态。如果你只需要“一个带记忆的函数”,闭包更简洁;如果你需要多个方法协同管理状态,类更合适。
我个人在实际项目里的经验是:闭包适合做“一次性定制函数”,比如你想给某个特定场景生成一个专属处理函数;类适合做“需要长期维护状态、且状态逻辑较复杂”的场景。两者不是替代关系,而是搭配使用。
3. 装饰器:闭包的应用典范
装饰器本质上就是一个闭包——外层函数接收一个函数,内层函数对接收的函数做增强,最后把增强后的函数返回。它解决的是一个极其普遍的痛点:多个函数需要共享相同的逻辑,但你不想在每个函数里复制粘贴同一段代码。
3.1 痛点场景:到处都是重复代码
假设你在开发一个 Flask Web 项目,很多视图函数都需要先判断用户是否登录:
def view_dashboard(): if not check_login(): return "请先登录" return "仪表盘页面" def view_profile(): if not check_login(): return "请先登录" return "个人资料页面" def view_settings(): if not check_login(): return "请先登录" return "设置页面"三个函数里重复了登录判断的代码。一旦判断逻辑要改(比如从函数调用改成读请求头),你要修改三个地方。如果视图函数有三十个,那就是三十处修改。这种时候,装饰器就是正解。
3.2 手工“套壳”:不写语法糖的装饰器
先把公共逻辑抽出来:
def require_login(func): def wrapper(*args, **kwargs): if not check_login(): return "请先登录" return func(*args, **kwargs) return wrapper然后使用的时候,不急着上@语法,而是手动套壳:
def view_dashboard(): return "仪表盘页面" view_dashboard = require_login(view_dashboard)核心逻辑就在wrapper里:先做登录校验,校验通过才调用原始函数,并原样返回结果。*args, **kwargs是必须的,因为你不知道被装饰的函数到底接收什么参数,用这两个通配符可以“原封不动”地传递任意参数。这是装饰器里最常用的写法。
3.3 语法糖 @ 的本质
手动套壳写多了很啰嗦,而且很容易忘了某几个函数要套壳。Python 提供了@语法糖:
@require_login def view_dashboard(): return "仪表盘页面"@require_login在定义阶段就执行了,它等价于view_dashboard = require_login(view_dashboard)。理解这一点非常重要:装饰器不是在函数调用的时候才生效,而是在模块加载、函数定义完成的那一刻就完成了“包装”。你可以用一个简单的print验证:
def decorator(func): print("装饰器执行了") def wrapper(*args, **kwargs): print("wrapper 执行了") return func(*args, **kwargs) return wrapper @decorator def add(a, b): return a + b # 运行上面代码,立刻输出: 装饰器执行了 # 但 add(1, 2) 被调用时,才输出: wrapper 执行了实际项目里这带来一个常见问题:如果你写了装饰器内部的初始化逻辑(比如建立数据库连接池),它在 import 模块时就执行了,而不是在第一个请求进来时。理解了执行时机,就能避免很多诡异的 Bug。
3.4 functools.wraps:别丢掉函数的名字
直接写装饰器有个副作用:被装饰后的函数,它的元信息会变成wrapper的。这会带来两个实际问题:一是add.__name__变成wrapper,很多日志系统靠函数名定位问题时会一片混乱;二是某些框架(比如 Django、Flask)依赖函数名做路由映射,名字变了路由就乱了。
解决办法是使用标准库的functools.wraps:
from functools import wraps def require_login(func): @wraps(func) def wrapper(*args, **kwargs): if not check_login(): return "请先登录" return func(*args, **kwargs) return wrapper@wraps(func)会把func的__name__、__doc__、__module__、__dict__等属性拷贝到wrapper上。它自己也是一个装饰器,服务于装饰器内部的 wrapper。我的建议是:你在自己写每一个装饰器时,都要加@wraps,没有例外。这不是可选项,而是必备习惯,否则排查问题时会付出额外时间。
4. 带参数装饰器和类装饰器
上面的require_login不需要额外参数。但很多场景下,装饰器本身需要接收配置。比如你希望限流装饰器能指定“每秒最多调用几次”,或者日志装饰器能指定日志级别。这时候就需要给装饰器再加一层。
4.1 三层嵌套:装饰器的“套娃”
带参数的装饰器,结构上多了一层:
from functools import wraps def throttle(max_per_second): def decorator(func): @wraps(func) def wrapper(*args, **kwargs): # 假设这里有频率控制逻辑 print(f"限流: 每秒最多 {max_per_second} 次") return func(*args, **kwargs) return wrapper return decorator @throttle(max_per_second=5) def fetch_data(): return "数据内容"这个三层结构怎么理解?@throttle(max_per_second=5)先执行throttle(5),拿到真正的装饰器decorator,然后用它去包装fetch_data。换句话说,throttle是一个“装饰器工厂” —— 你给它配置参数,它生产出一个装饰器。
实际的限流逻辑可以用time模块配合一个闭包变量实现,比如记录上次调用时间,如果间距小于设定阈值就抛异常或等待。三层嵌套看着复杂,但你只需要记住:最外层接收参数,中间层接收函数,最内层接收函数调用的参数。
4.2 用类写装饰器:面向对象的方案
有些团队更习惯用类来管理状态,装饰器同样可以用类实现。核心原理是让类实例成为可调用对象(实现__call__方法):
import time from functools import wraps class Timer: def __init__(self, label="耗时"): self.label = label self.total_time = 0 self.call_count = 0 def __call__(self, func): @wraps(func) def wrapper(*args, **kwargs): start = time.perf_counter() result = func(*args, **kwargs) elapsed = time.perf_counter() - start self.total_time += elapsed self.call_count += 1 print(f"{self.label}: 本次 {elapsed:.4f}s, 累计 {self.total_time:.4f}s, 调用 {self.call_count} 次") return result return wrapper timer = Timer("API请求") @timer def query_user(user_id): time.sleep(0.01) return {"id": user_id, "name": "张三"} query_user(1) query_user(2)类装饰器的好处在于:你可以在类属性里保存“累计状态”,比如总共耗时多少、调用了几次。这在统计接口性能时特别实用。函数式闭包虽然也可以保存状态,但用类来表达“有状态的对象”更直观,也方便后续扩展reset()之类的方法。
4.3 多层装饰器的执行顺序
一个函数上可以叠加多个装饰器:
@require_login @timer def view_profile(): return "个人资料"执行顺序遵循“就近原则”:离函数最近的装饰器先执行。也就是说,timer先包装原始函数,require_login再包装timer返回的 wrapper。调用时从外到内执行:先检查登录,再计时,最后执行原始函数。
如果你把顺序反过来写:
@timer @require_login def view_profile(): return "个人资料"那就是先计时,再检查登录。这看起来差不多,但计时里包含了登录检查的时间,统计口径就变了。实际项目里写多层装饰器时,一定要想清楚每一层包裹的意义。我的建议是:把“基础设施类”装饰器(如日志、计时)放在最里层,把“业务逻辑类”装饰器(如权限校验)放在最外层,这样调用顺序最符合认知:先过业务关卡,再记录基础指标。
5. 实战:手写三个能直接用的装饰器
前面讲了原理和语法,现在把它真正落到实际场景里。我挑三个在业务开发中出现频率最高的装饰器场景,每个都给出可以直接抄进项目的代码。
5.1 登录权限校验:Web 接口的第一道关卡
from functools import wraps def login_required(func): @wraps(func) def wrapper(*args, **kwargs): # 伪代码:从请求上下文中获取用户 user = getattr(args[0], "user", None) if args else None if user is None: raise PermissionError("请先登录") return func(*args, **kwargs) return wrapper @login_required def get_account(user=None): return f"用户 {user['name']} 的账户信息" # 正常调用 get_account({"name": "李四"}) # 未登录调用,抛出 PermissionError这里有个细节:wrapper里的getattr(args[0], "user", None)假设第一个参数是请求对象或用户对象,但实际项目里这个逻辑可能因框架而异。在 Django 里通常是request.user,在 Flask 里可以用g.user,在 FastAPI 里可以直接依赖注入。但装饰器的写法完全一致:先取用户,取不到就拦截,取到了再放行。这就是把公共逻辑从业务函数中分离出来的意义。
5.2 重试机制:处理网络抖动和临时故障
调用第三方接口时,偶尔会碰到网络超时或返回 5xx 错误。直接让用户看到报错体验很差,合理的做法是自动重试几次:
import time from functools import wraps def retry(max_attempts=3, delay=0.5, exceptions=(Exception,)): def decorator(func): @wraps(func) def wrapper(*args, **kwargs): for attempt in range(1, max_attempts + 1): try: return func(*args, **kwargs) except exceptions as e: if attempt == max_attempts: raise print(f"第 {attempt} 次调用失败: {e}, {delay}s 后重试") time.sleep(delay) return None return wrapper return decorator @retry(max_attempts=3, delay=0.2, exceptions=(TimeoutError, ConnectionError)) def fetch_remote_data(): # 模拟一个偶尔失败的接口 import random if random.random() < 0.5: raise TimeoutError("接口超时") return "远程数据"这个装饰器的要点是exceptions参数。默认捕获所有Exception太宽泛了,可能把代码本身的 Bug 也吞掉重试,掩盖了真正的问题。最佳实践是只捕获你预期的临时性异常,比如超时、连接重置、服务暂时不可用。重试次数和延迟时间也应该可配置,不要写死。
5.3 性能监控:统计函数耗时
这个我在日常工作中用得最多。定位线上慢接口,或者优化算法时,都需要快速知道每个函数的耗时:
import time import logging from functools import wraps logger = logging.getLogger(__name__) def log_time(level="INFO"): def decorator(func): @wraps(func) def wrapper(*args, **kwargs): start = time.perf_counter() result = func(*args, **kwargs) elapsed = time.perf_counter() - start logger.log(level, f"函数 {func.__name__} 耗时 {elapsed:.3f}s") return result return wrapper return decorator @log_time(level="INFO") def heavy_computation(): time.sleep(0.1) return 42注意我用的是time.perf_counter()而不是time.time()。time.time()返回的是墙上时钟时间,可能被系统时间调整(比如 NTP 同步)影响,测出来的耗时不一定可靠。perf_counter()专门用于测量短时间间隔,精度和稳定性都好很多。这个细节在性能调优场景下很关键,也是很多教程里没提过的坑。
6. 常见问题与排查技巧实录
学闭包和装饰器,光看教程觉得自己懂了,一写代码就报错,这太正常了。我把自己在学习和带新人时反复撞见的几个坑整理成速查表,每个都配上排查思路。
6.1 修改外层变量时提示 UnboundLocalError
现象:内层函数里执行count += 1,直接报UnboundLocalError: local variable 'count' referenced before assignment。
原因:+=在左侧,Python 把它当成内层函数的局部变量声明,于是不再向外层查找。
解法:在闭包内层函数的第一行加nonlocal count。如果是修改全局变量,则用global。注意nonlocal只能嵌套在函数内部使用,类方法里不行,模块顶层也不行。
排查心得:初学者最容易搞混的是“读”和“写”。只读取外层变量不需要任何声明,一旦有赋值操作就需要nonlocal。我在新人的代码里看到这个问题,第一反应就是让他找出哪个变量在闭包里被赋值了。
6.2 闭包变量延迟绑定的陷阱
现象:写循环生成闭包,结果所有函数输出的都是同一个值。
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]原因:f里面引用的i是循环变量,它最终停在2。闭包记住的是变量本身,不是变量当时的“快照”。
解法:用默认参数把当前值“钉”进去:
for i in range(3): def f(i=i): return i funcs.append(f)这样i作为默认参数在函数定义时就被求值并固定了。这个坑在写回调函数、事件处理器时特别常见,比如tkinter按钮循环绑定时,几乎所有初学者都会踩到。
6.3 装饰器之后函数元信息丢失
现象:使用inspect.signature检查被装饰函数时,看到的参数变成了*args, **kwargs,或者日志里函数的__name__显示为wrapper。
原因:装饰器返回的确实是wrapper,原始函数的信息没有被带过来。
解法:在wrapper上使用@wraps(func)。如果@wraps还不能满足需求,比如你想让被装饰函数的函数签名也保真,可以使用functools.wraps加inspect.signature配合,但绝大多数场景下@wraps就够了。
排查心得:实际排查时,可以先打印func.__name__,如果显示wrapper,十有八九是漏了@wraps。这个 bug 非常隐蔽,因为代码能正常跑,只有当依赖函数元信息(自动化文档、路由注册、代理工具)时才会炸出来。
6.4 多层装饰器顺序搞反导致统计错乱
现象:想记录“登录后接口的耗时”,结果耗时里包含了登录检查的时间,数值明显偏大。
原因:装饰器的叠加顺序决定了调用链的执行顺序。贴近函数的装饰器先包装、但后执行,外层装饰器先执行。
解法:想清楚每一层的职责。登录校验是业务拦截,应该在最外层;性能统计是底层基础能力,应该在最内层。
# 正确: 先登录校验,后计时 @login_required @log_time def view_dashboard(): return "面板" # 错误: 计时包含登录校验的时间 @log_time @login_required def view_dashboard(): return "面板"排查心得:遇到多层装饰器不确定顺序时,可以用一个最笨也最有效的办法:在每个装饰器的wrapper里加一行print,看调用时要经过哪些层、先后顺序如何,跑一次就明白了。定位完再删掉调试代码。
6.5 装饰器参数传错类型
现象:写了带参数的装饰器之后,忘了加参数,直接写@retry而不是@retry(max_attempts=3),然后报TypeError: decorator() missing 1 required positional argument或者行为诡异。
原因:@retry会把被装饰函数当作参数传给retry,但你的retry期待的却是max_attempts,参数错位了。
解法:要么统一约定:带参数的装饰器在使用时必须加括号;要么把装饰器写成兼容模式,检测第一个参数是否是函数。我个人推荐前者,约定清晰、代码可读性高。
排查心得:这个坑最容易出现在团队协作中,因为某个装饰器一开始没有参数,后来加了参数,旧代码没跟着改。我会在项目里做一个约定:凡是带参数的装饰器,函数名用动词短语,比如with_retry、with_logging,看起来就像“使用重试”和“使用日志”,大家一眼就能看出需要加参数。
7. 我在实际项目里的体会
闭包和装饰器这套东西,刚学的时候会觉得绕,但只要跨过那个门槛,写起来会非常上瘾。它们最直接的价值不是炫技,而是把一个横切逻辑从业务代码里“抽”出来,让每个函数只关心自己的本职。
我记忆很深的一次实战,是接手一个遗留的 Flask 项目。里面十几个视图函数都手工写了登录判断、日志记录、异常捕获,代码散落得到处都是,改一个逻辑要在十几个文件里同步改。我花了一个下午把公共逻辑抽成三个装饰器,然后逐个往视图函数上挂,改完删掉了将近 300 行重复代码。效果立竿见影,后来同事再新增接口,只需要在函数上加一行@login_required,谁看了都愿意用。
最后再分享一个小技巧:学装饰器时不要死记硬背模板,先理解“函数是对象、闭包是打包环境、装饰器是套壳”这三个层次,然后从手写一个最简单的不带参数的装饰器开始,慢慢加wraps、加参数、改写成类。每加一层复杂度,就亲手跑一遍验证。这样练上四五个例子,闭包和装饰器就彻底变成你自己的东西了。后面你再去看 Flask 的@app.route、Django 的@login_required,甚至 Boost 这种大型库里的装饰器,都不会再觉得是黑魔法,反而能一眼看穿它们各自做了什么。