
1. 从函数是一等公民说起Python里流传着一句话“万物皆对象”。但很多初学者学到函数的时候只是把函数当作“一段可以重复调用的代码”很少意识到函数本身也是一个对象可以被赋值给变量、塞进列表里、当作参数传给另一个函数、甚至作为返回值丢出来。这个认知不扭转过来闭包和装饰器就会看得云里雾里。1.1 函数也是“数据”我们先做一个最简单的实验def greet(name): return fHello, {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 规则LLocal当前函数内部的局部作用域EEnclosing外层函数的局部作用域GGlobal模块的全局作用域BBuilt-inPython 内置作用域举个例子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)执行完后按理说局部变量factor2应该被回收但double函数仍然记得factor的值。原因很简单multiplier的代码里使用了factorPython 会把multiplier连同它引用的外部变量一起打包。这个“包”就是闭包。用生活类比来解释——make_multiplier像一个模具工厂你给工厂一个模具factor2工厂给你一个工人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 countdef 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.wrapsfrom functools import wraps def require_login(func): wraps(func) def wrapper(*args, **kwargs): if not check_login(): return 请先登录 return func(*args, **kwargs) return wrapperwraps(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_second5) def fetch_data(): return 数据内容这个三层结构怎么理解throttle(max_per_second5)先执行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(userNone): 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_attempts3, delay0.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_attempts3, delay0.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(levelINFO): 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(levelINFO) 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(ii): 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_attempts3)然后报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 这种大型库里的装饰器都不会再觉得是黑魔法反而能一眼看穿它们各自做了什么。