我还记得第一次在代码里看到login_required这种写法时盯着那一行呆了好几秒——Python 里什么时候多了个 语法后来才知道那叫装饰器。而真正让我理解装饰器为什么是这样写的反而是在搞明白闭包之后。闭包和装饰器几乎是 Python 函数式编程里最核心的一对组合拳也是从会用 Python走向理解 Python的一道分水岭。这篇文章就是我在实战中反复琢磨后的总结适合正在学 Python、已经能把函数和类写熟、但看到闭包和装饰器就发怵的读者。我会从函数是对象这件事讲起把闭包的内存与作用域机制拆开再手把手把装饰器的几种写法、实战场景和常见坑都过一遍保证读完之后你能自己写出带参数装饰器、类装饰器并且敢在自己的项目里真的用上它。1. 函数就是对象理解闭包前必须打通的一环很多人学装饰器学不下去不是因为装饰器本身难而是前面的地基松了。装饰器建立在两个前提上函数是对象函数可以嵌套定义。这两个前提都来自 Python 的函数式编程基因也是闭包能成立的根本原因。1.1 Python 函数的一等公民身份在 Python 里函数和整数、字符串、列表没有本质区别它们都是对象。什么意思你可以把函数赋值给变量、塞进列表、当作参数传给另一个函数、再当作返回值从函数里吐出来。这在一部分语言里是不可想象的但在 Python 里这就是日常。def greet(name): return f你好{name} say_hi greet # 函数赋值给变量不加括号 print(say_hi(张三)) # 输出你好张三 funcs [greet, len, str] for f in funcs: print(f, type(f)) # 函数就是对象可以放进容器注意我在赋值say_hi greet的时候没有写括号。写greet是引用函数对象本身写greet(张三)才是调用它并拿返回值。这个区别极其重要因为装饰器的本质就是把一个函数对象拿过来加工一下再丢一个新的函数对象回去。1.2 嵌套函数与外层变量的捕捉函数可以定义在函数内部这种嵌套函数在 Python 里非常自然。但嵌套本身不稀奇稀奇的是内层函数可以引用外层函数的局部变量。看这个例子def outer(): message 我是外层变量 def inner(): print(message) return inner func outer() func() # 输出我是外层变量inner定义在outer里面它用了outer的局部变量message。按照很多人的直觉outer()执行完后message这个局部变量就应该被回收了那之后func()凭什么还能打印出它这就是闭包在起作用Python 在创建inner这个函数对象时会把message所在的单元格cell一并保存下来于是inner被返回之后即使outer已经结束message依然活着。用生活里的例子类比闭包就像给一个人配了一把钥匙。这个人在outer这栋楼里出生的离开这栋楼时带走了其中某个保险柜的钥匙之后不管走到哪他随时能打开那个保险柜哪怕这栋楼已经拆了。1.3 用 cell 对象看闭包到底包住了什么有些文章把闭包讲得很玄其实你完全可以用内省的方式亲眼看看它包住了什么。每个闭包函数都有一个__closure__属性里面就是一个个 cell 对象def outer(x): def inner(): return x 1 return inner f outer(10) print(f.__closure__) # (cell at 0x...: int object at 0x...,) print(f.__closure__[0].cell_contents) # 10我第一次看到__closure__的时候闭包这个概念才真正落地所谓闭包不是一个模糊的函数记住了外部变量这种说法而是函数对象身上实实在在挂着一个叫__closure__的元组里面每个 cell 守住一个外层变量。这也能解释为什么闭包的开销比普通函数稍大一丁点——它多维护了这些引用。注意__closure__只在函数确实捕获了外层变量时才存在。如果内层函数没有引用任何外层变量访问__closure__会得到空元组或 None。这个细节在调试时很管用能帮你判断一个函数到底是不是真正的闭包。2. 闭包的本质与边界从外层变量到 nonlocal理解了函数是对象、嵌套函数能捕获外层变量闭包的壳就清楚了。但真正写代码时你会发现闭包有一个特别容易中招的坎内层函数想修改外层变量。这块踩坑的人十个里有八个值得单独拆开讲。2.1 闭包是怎么保存外部变量的先明确一点闭包捕获的是变量本身不是变量的值那一刻的快照。也就是说外层变量后面怎么变内层再调用时会看到最新的值。这个特性有时候是好事有时候是坑后面第 6 节我会专门说一个由此引出的经典陷阱这里先看一个正常例子def make_counter(): count 0 def counter(): nonlocal count count 1 return count return counter c1 make_counter() print(c1()) # 1 print(c1()) # 2 print(c1()) # 3 c2 make_counter() print(c2()) # 1新的闭包有自己独立的 countc1和c2各自守着不同的countcell互不干扰。这也是工厂函数最典型的用法用一个闭包把状态封装在一个函数对象里对外只暴露调用接口不暴露内部变量。读到这里你可能发现这不就是最简版的类吗确实。很多场景里闭包和单方法类可以互相替代但闭包写起来更轻、更符合函数式风格。2.2 闭包中修改外层变量为什么必须 nonlocal如果你在inner里直接写count 1而不声明nonlocalPython 立刻报UnboundLocalError。这个报错第一次遇到时非常反直觉外层明明有count为什么加一都不行原因在于 Python 对赋值有一套变量绑定规则函数体内只要出现对某个名字的赋值Python 在编译阶段就把这个名字判定为这个函数的局部变量。于是count 1被拆解成读局部变量 count写回局部变量 count而局部变量 count 又没有定义自然报错。nonlocal count的作用就是明确告诉 Python这个count不是本函数的局部变量请去外层函数的变量作用域里找。def outer(): total 0 def inner(): nonlocal total total 1 # 没有 nonlocal 会 UnboundLocalError return total return inner这个规则在 Python 里同样适用于类属性、模块变量等场景吗不恰恰是闭包这个场景最容易踩。模块级变量用global局部嵌套用nonlocal两个关键字别记混。我见过有人把nonlocal写到模块级函数里直接 SyntaxError其实就是没理解nonlocal 只针对嵌套函数的外层作用域。2.3 闭包在真正业务里能干什么初学者容易觉得闭包是个练习题概念日常用不上。真不是。闭包最典型的应用是配置数据与行为绑定。举个例子我在某跨平台系统项目里写过一个给报表导出的工具里面有个按不同平台导出不同格式的需求def make_exporter(platform): header PLATFORM:{0}.format(platform) def export_row(data): # 这里既用到 header又用到 platform return header | |.join(str(v) for v in data) return export_row wechat_exporter make_exporter(wechat) web_exporter make_exporter(web) print(wechat_exporter([a, 1])) print(web_exporter([a, 1]))make_exporter每次调用都生成一个专属的export_row函数platform和header被锁在各自的闭包里。如果不用闭包你要么给export_row每次传一堆参数要么定义一个类然后管理实例。闭包让数据和逻辑打包在一起代码更内聚。这种模式在注册回调、构建模板函数、实现简单的策略模式时随处可见这就是函数式编程的味道。3. 装饰器写法演进从手动包装到 语法糖闭包讲透了装饰器的原理就有了。一句话概括装饰器装饰器是一个接受函数作为参数、并返回一个新函数的函数。新函数通常会在原函数前后插入额外逻辑再调用原函数。听起来复杂但它是从很朴素的手动包装一步步演化来的。3.1 最朴素的包装函数写法假设你有一个函数希望每次执行前打印一行日志不改原函数的前提下最笨的办法就是手动包一层def say_hello(): print(hello) def with_logging(func): def wrapper(*args, **kwargs): print(执行前进入函数) result func(*args, **kwargs) print(执行后函数结束) return result return wrapper say_hello with_logging(say_hello) say_hello()这里发生了三件事say_hello被传入with_loggingwith_logging返回了一个新函数wrapperwrapper被重新赋给变量名say_hello。所以之后你调用say_hello()其实是在调用wrapper()原来的say_hello函数还藏在wrapper的闭包里等着被调用。这就是装饰器的雏形。*args, **kwargs不是装饰器专用的但它很重要wrapper必须能接收任意参数才能转发给任何目标函数。3.2 用 wraps 保住函数身份上面的手动包装有个副作用say_hello.__name__变成了wrapper而不是原来的say_hello。这在调试、文档生成、测试时都会带来麻烦。解决办法是 Python 标准库提供的functools.wrapsfrom functools import wraps def with_logging(func): wraps(func) def wrapper(*args, **kwargs): print(执行前进入函数) return func(*args, **kwargs) return wrapper with_logging def say_hello(): print(hello) print(say_hello.__name__) # 输出say_hellowraps(func)本身也是一个装饰器它会帮我们把原函数的__name__、__doc__、__module__、__dict__等元信息复制到wrapper上。**我现在写任何装饰器都会第一时间加wraps(func)不加这个的装饰器一律视为半成品。**这不是洁癖我在某模拟项目中排查过一个根据函数名生成统计报表的问题因为装饰器没加 wraps所有被装饰函数的名字全变成了wrapper统计维度直接乱掉。3.3 语法糖如何工作手动say_hello with_logging(say_hello)写多了读写都别扭。Python 用语法把这一行搬到了函数定义上方with_logging def say_hello(): print(hello)这两段代码完全等价。with_logging不是魔法只是一个把下面的函数传给 with_logging再把结果重新绑定为同名变量的语法糖。理解这一点非常重要因为一旦你需要在装饰器后面接一长串逻辑比如带参数装饰器、装饰器叠加只要脑子里能翻译回函数传进去、结果赋回来这个朴素模型就不会被绕晕。装饰器的真正威力在于正交分离:业务函数只关心自己的核心逻辑横切关注点日志、鉴权、耗时、缓存统统交给装饰器。全栈项目里最常见的login_required、app.route、staticmethod本质都是这个套路。4. 装饰器要升级了带参数装饰器与类装饰器基础装饰器够用吗够用但遇到同一个装饰器、不同函数想要不同行为的场景就捉襟见肘了。比如我想写一个日志装饰器有的函数要打 DEBUG 级日志有的要打 INFO 级。此时就得让装饰器自己能接收参数。4.1 三层嵌套的带参数装饰器带参数装饰器的经典结构是三层函数嵌套很多初学者看到三层就吓退了。别怕逐层拆开就一句话最外层接收装饰器参数中间层接收被装饰函数最内层包装实际逻辑。from functools import wraps def log_level(level): def decorator(func): wraps(func) def wrapper(*args, **kwargs): print([{0}] 调用 {1}.format(level, func.__name__)) return func(*args, **kwargs) return wrapper return decorator log_level(INFO) def save_order(): print(保存订单) log_level(DEBUG) def clean_cache(): print(清理缓存) save_order() clean_cache()为什么一定要三层因为log_level(INFO)这个写法在 Python 里其实分两步先调用log_level(INFO)得到decorator再用decorator装饰save_order。所以最外层必须先接收INFO并返回一个装饰器。你在实际项目里想写一个需要配置的装饰器时可以直接套这个三层模板把中间层的名字改成decorator最外层参数改成你的配置项基本不会出错。经验带参数装饰器写多了最外层的函数名容易起得像业务函数名建议统一命名成decorator和wrapper保持约定俗成隔几个月再回来看也不费劲。4.2 类装饰器与call除了函数式写法装饰器也可以是类。类装饰器利用 Python 的__call__让实例变得可调用import time from functools import wraps class Timer: def __init__(self, func): self.func func self.total_time 0 self.call_count 0 def __call__(self, *args, **kwargs): start time.perf_counter() result self.func(*args, **kwargs) elapsed time.perf_counter() - start self.total_time elapsed self.call_count 1 print(执行耗时 {0:.4f}s累计调用 {1} 次.format(elapsed, self.call_count)) return result Timer def heavy_work(): time.sleep(0.1) heavy_work() heavy_work()类装饰器的优势是状态可以被显式保存累计耗时、调用次数、最近一次执行时间都可以作为实例属性维护比在闭包里用nonlocal写起来更直观。我在给某图像处理 Demo 做性能统计时就用过这种写法把每次调用的时间戳存在实例里最后还能导出一份统计表比单纯打印日志好用得多。类装饰器的缺点则是可读性稍微差一点别人看到Timer时如果不看类定义不一定立刻反应出来它是装饰器。团队协作时优先函数式装饰器个人工具类场景用类装饰器非常香。5. 全栈实战里最常用的装饰器场景鉴权、日志、缓存、重试前面都在讲装饰器怎么写这一节讲真实项目里我几乎天天碰到的四种场景。这些场景不是炫技是装饰器最能发挥价值的落地点。5.1 登录鉴权最简单的横切逻辑Web 项目里几乎每个接口都要判断当前用户是否已登录、是否有权限。如果每个视图函数开头都写一遍判断代码代码会重复到崩溃。装饰器把鉴权逻辑抽出来from functools import wraps def login_required(func): wraps(func) def wrapper(*args, **kwargs): user get_current_user() # 实际项目中从 session/token 解析用户 if user is None: raise PermissionError(请先登录) return func(*args, **kwargs) return wrapper login_required def profile(): return 这是个人中心profile函数内部完全不需要知道用户怎么登录、怎么判断这套逻辑它只关心登录后的业务。哪天鉴权从 session 改成 JWT只需要改装饰器内部所有接口自动生效。我维护某跨平台系统后台时鉴权逻辑从 Cookie 会话迁移到 Token 认证只改了一个装饰器文件十几个接口函数一行没动。5.2 耗时统计与日志观察性从哪来线上排查问题时最缺什么Log。给关键函数统一加性能和入参日志是提升可观测性最便宜的方式import time from functools import wraps def timed(func): wraps(func) def wrapper(*args, **kwargs): start time.perf_counter() result func(*args, **kwargs) elapsed time.perf_counter() - start if elapsed 0.5: print(慢调用警告{0} 耗时 {1:.2f}s.format(func.__name__, elapsed)) return result return wrapper timed def sync_data(): time.sleep(0.6) # 模拟慢操作注意我在装饰器里加了一个超过 0.5 秒就警告的逻辑。这不是多余的动作而是在真实业务里日志如果每一行都打反而会淹没关键信息带着阈值告警的日志才是排查问题的金子。装饰器的好处是可叠加、可配置这个timed你可以随时加在任意一个可疑的函数的头上不用改任何业务代码。5.3 结果缓存为重复计算省时间有些函数入参相同、结果固定反复调用完全是在浪费资源。装饰器可以做一层透明缓存from functools import wraps def cached(func): cache {} wraps(func) def wrapper(*args, **kwargs): key (args, tuple(sorted(kwargs.items()))) if key not in cache: cache[key] func(*args, **kwargs) return cache[key] return wrapper cached def compute_report(date_str, metric): # 假设这里是耗时很长的报表计算 return report-{0}-{1}.format(date_str, metric) print(compute_report(2024-01-01, revenue)) print(compute_report(2024-01-01, revenue)) # 第二次直接命中缓存这个缓存装饰器有几个隐患实战中要留意参数里有不可哈希对象比如 list时直接放进元组会报错缓存无限增长长时间运行的进程迟早内存爆掉。我在某数据处理任务里用它缓存数据库查询结果加了最多缓存 1000 条超出按 FIFO 清理的逻辑。装饰器不是越简单越好而是越贴合场景越好。5.4 统一异常处理与重试外部接口、网络请求、数据库连接总会有偶发性失败。重试装饰器能把失败重试这个横切逻辑集中管理from functools import wraps import random import time def retry(max_attempts3, delay0.1): def decorator(func): wraps(func) def wrapper(*args, **kwargs): last_exc None for attempt in range(max_attempts): try: return func(*args, **kwargs) except Exception as exc: print(第 {0} 次调用 {1} 失败{2}.format(attempt 1, func.__name__, exc)) last_exc exc time.sleep(delay) raise last_exc return wrapper return decorator retry(max_attempts3, delay0.05) def flaky_request(): if random.random() 0.6: raise ConnectionError(模拟网络闪断) return 请求成功这个retry是我在真实项目中用的最勤的装饰器之一。注意两点一是要记录最后一次异常并重新抛出否则调用方无法感知最终失败二是重试要有间隔裸奔式的重试在故障时会把下游打挂。装饰器把重试策略和业务函数彻底解耦业务代码里没有任何 try/except逻辑清爽很多。6. 装饰器叠加顺序与几个易踩的边角坑会写了坑也来了。装饰器用多了之后出现问题往往不是在写装饰器本身而是装饰器之间、装饰器与函数定义之间的微妙关系。这一节我把自己踩过的坑和排查思路完整摆出来。6.1 叠加装饰器的执行顺序比你想的更重要多个装饰器叠在一个函数上执行顺序不是从上往下而是从下往上包装从上往下执行。看这段代码from functools import wraps def outer_decorator(func): wraps(func) def wrapper(*args, **kwargs): print(外层装饰器 - 执行前) result func(*args, **kwargs) print(外层装饰器 - 执行后) return result return wrapper def inner_decorator(func): wraps(func) def wrapper(*args, **kwargs): print(内层装饰器 - 执行前) result func(*args, **kwargs) print(内层装饰器 - 执行后) return result return wrapper outer_decorator inner_decorator def work(): print(业务逻辑) work()输出顺序是外层装饰器 - 执行前内层装饰器 - 执行前业务逻辑内层装饰器 - 执行后外层装饰器 - 执行后。翻译一下最靠近函数的装饰器先包装最靠近函数的那层逻辑先执行整体像洋葱一样一层层往里剥再一层层往外出。这个洋葱模型在 Web 中间件里到处都是。实战中我建议功能独立的装饰器叠加顺序无所谓有依赖关系的比如先鉴权再记日志要把鉴权放下面更靠近函数确保日志能记录到已经登录后的调用。6.2 闭包变量的延迟绑定一个让所有列表函数踩坑的经典问题这是 Python 闭包最著名的陷阱之一。看这个例子你的第一反应输出是什么def create_funcs(): funcs [] for i in range(3): def f(): return i funcs.append(f) return funcs for f in create_funcs(): print(f())输出是 2、2、2不是 0、1、2。原因就是我在第 2 节强调的闭包捕获的是变量本身不是值。循环结束后i最终停在了 2三个闭包看到的都是同一个i的当前值。解决办法是让默认参数在定义函数时就把当前值绑定进去def create_funcs(): funcs [] for i in range(3): def f(ii): # 默认参数在函数定义时求值完成快照 return i funcs.append(f) return funcs我第一次遇到这个坑是在生成一组延迟回调的时候当时所有回调打印出来的都是循环变量的最终值排查了很久才反应过来。要避免这个坑写循环内嵌套函数时先想清楚这个变量我到底是要闭包捕获(动态)还是要立刻快照(静态)。6.3 装饰器装饰递归函数日志会翻倍的隐性问题给递归函数加日志装饰器会有一个隐蔽的坑因为装饰器把函数名绑定到了wrapper上递归函数内部的调用会再走一次wrapper导致日志数量呈指数级膨胀。看这个例子timed def factorial(n): if n 1: return 1 return n * factorial(n - 1) factorial(4)你以为只打印一次耗时日志实际上递归每调用一次都会经过装饰器日志会打多条耗时统计也叠加反复。原因在于factorial这个名字在递归体内查找时找到的已经是装饰后的wrapper。解决方式有两种一是把递归的实现放到内部函数里装饰只加在外层对外入口上二是递归时直接引用原始函数对象可以通过闭包把原始函数保存下来。实战中我推荐第一种代码更直观def factorial(n): def _fact(n): if n 1: return 1 return n * _fact(n - 1) return _fact(n)把递归函数包一层装饰器只装饰对外的factorial内部递归走_fact日志正好只打印一次。这类装饰器和递归的组合问题光靠想很难想到踩过一次就会长记性。7. 我调试装饰器的小习惯让它可见最后分享一个实用性极强的个人习惯。装饰器最大的问题是不透明你调用一个被装饰的函数中间发生了什么它自己不会告诉你。我排查装饰器相关 bug 时从来不在脑子里推演而是第一时间在装饰器内部加两行临时的打印把进没进 wrapper、参数是什么、原函数叫什么全打出来def debug_decorator(func): wraps(func) def wrapper(*args, **kwargs): print(进入装饰器正在调用 {0}, args{1}, kwargs{2}.format(func.__name__, args, kwargs)) result func(*args, **kwargs) print(退出装饰器结果{0}.format(result)) return result return wrapper加上之后跑一次装饰器到底有没有生效、参数怎么传的、返回值是什么一眼就清楚。排查完再把这层调试装饰器删掉或者用一个环境变量开关控制。别看这个办法简单它帮我省下的时间远比写装饰器的时间多。闭包和装饰器刚上手时觉得绕其实拆到底都是函数是对象 嵌套函数捕获外层变量这两件事的组合。把这一节的例子动手敲一遍再回到自己的项目里找个重复代码的场景试着抽一个装饰器你就能真正体会到这套函数式工具顺手的那一面。