
1. 写了两三年 Python 还没吃透装饰器这篇从零给你讲明白先说说我自己的判断最让 Python 新人头疼的几个知识点里Decorator装饰器一定能排进前三。很多人背了一堆语法规则知道符号加一个函数名能装饰另一个函数可真到了自己写代码的时候遇到需要给函数加日志“需要统计函数运行耗时”这种需求还是会不由自主地往每个函数里复制粘贴一样的代码。等别人用装饰器一写三五行搞定又觉得高端得不行。这玩意儿真的没那么玄乎。用一个生活化的比喻来开个头装饰器就像给函数套了一个信封信封外面的地址、贴的邮票、写的备注都是额外加上的功能但信封里面的信——也就是你原来那个函数的逻辑——一个字都不用动。用专业一点的话说装饰器是一种高阶函数它接收一个函数作为参数返回一个新的函数从而在不修改原函数代码的前提下给它增加额外的能力。这篇文章适合谁适合已经会写 Python 基础语法、能看懂def和return但一看到符号就犯怵的读者。我会顺着一条线来讲先讲 Python 的函数到底是个什么东西再讲闭包这个心脏然后手写几个真实项目里高频使用的装饰器最后把我这几年踩过的坑一个一个摆出来。你放心这篇文章读完你不仅能看懂别人的装饰器还能自己写。2. 装饰器到底在解决什么问题2.1 函数也是对象名字只是标签在动手写装饰器之前必须先改变一个认知Python 里的函数不是一段只能调用的死代码它是一个实实在在的对象可以被赋值、被传参、被返回。你平时写的这个def greet(name): return fHello, {name}本质上是在做两件事先在内存里创建了一个函数对象然后把名字greet绑定到这个对象上。所以你完全能这样写hello greet # 把 greet 指向的函数对象再贴一个名字 print(hello(张三)) # Hello, 张三 print(greet(李四)) # Hello, 李四hello和greet指向的是同一个函数对象只是名字不同。既然函数就是个对象那它就能像整数、字符串一样被传来传去——把函数当作参数传给另一个函数这是装饰器最底层的地基。很多人刚开始不理解我直接调用不就行了干嘛要把函数像个皮球一样传来传去 因为你一旦能把函数传出去就能在函数外面包裹一层逻辑在调用它之前或之后做额外的事。这就是装饰器的雏形。2.2 先别用 手动包一层试试假设你现在接手了一个老项目里面有很多类似这样的函数def get_user(): # 假装去数据库查用户 return {name: 张三, age: 30} def get_order(): # 假装去数据库查订单 return {order_id: 10086}产品经理说“所有查数据的函数调用的时候给我打一条日志不然出了问题都不知道谁调的。” 你第一反应可能是打开文件挨个函数里插入打印语句。函数少还好说函数一多改完这个漏那个而且污染了原有函数的逻辑。换一种思路我写一个包装工函数它接收一个原始函数返回一个新函数这个新函数在调用原始函数之前先打印日志def log_wrapper(func): def inner(*args, **kwargs): print(fCalling {func.__name__}) result func(*args, **kwargs) print(fFinished {func.__name__}) return result return inner get_user log_wrapper(get_user) get_user()注意这几行的关键点log_wrapper接收get_user这个函数对象然后在内部定义了一个新函数innerinner里面先打印日志再调用原来的get_user最后把结果原样返回。log_wrapper的返回值是inner这个新函数。最后一行get_user log_wrapper(get_user)意思是把原来的名字get_user重新绑定到inner上。从此以后所有调用get_user()的地方都不用改但它们执行的其实是inner里那套逻辑——日志打印了原函数也执行了。这就叫不修改原函数代码却增强了它的功能。如果你有十个函数要加日志只需要挨个执行一句xxx log_wrapper(xxx)就够了。2.3 符号只是语法糖每次都要写get_user log_wrapper(get_user)也挺烦的于是 Python 提供了一种更优雅的写法log_wrapper def get_user(): return {name: 张三, age: 30}只要把log_wrapper放在函数定义的正上方Python 会在函数定义完成后自动执行get_user log_wrapper(get_user)这一步。符号本身不包含任何新的魔法它就是一层语法糖把传函数进去再赋值回来这个操作包装成了更易读的形式。理解了这一点你看任何装饰器代码心里都会有点底了不管装饰器内部写得多花哨最终都是把一个函数交给另一个函数换回一个增强版的新函数。3. 闭包装饰器的心脏3.1 函数里再定义函数为什么能在外层拿到内层函数想看明白装饰器的代码你绕不开闭包Closure这个概念。别被这个名字吓到我用一句话给你讲明白如果在一个函数内部定义了另一个函数并且内部函数用到了外部函数的变量那么这个内部函数连同它用到的外部变量一起被称为闭包。看一个最经典的例子def outer(x): def inner(y): return x y return inner add_5 outer(5) print(add_5(3)) # 8这里比较反直觉的点在于outer(5)执行完了按理说变量x的生命周期已经结束了可是add_5(3)居然还能准确地知道x是 5。原因在于inner这个函数被返回的时候Python 把x5这个环境一起打包带走了于是inner里随时能用x。这个函数 被捕获的外部变量的包裹体就是闭包。装饰器的 inner 函数为什么能访问外层函数的func参数原理一模一样。func作为log_wrapper的参数被inner捕获进了自己的环境里。等到外面调用inner的时候func早就通过闭包机制被保存得妥妥的。闭包就是装饰器能正常工作的心脏没有闭包装饰器就无从谈起。3.2 闭包里的变量是引用而不是快照这里有一个特别容易踩的坑闭包捕获的是变量本身而不是变量在某一时刻的值。换句话说内部函数用到的外部变量是一个活生生的引用不是一份拷贝。举个例子def counters(): result [] for i in range(3): def f(): return i result.append(f) return result for f in counters(): print(f()) # 猜猜输出什么很多人猜是0 1 2实际输出是2 2 2。为什么因为三个f捕获的都是同一个变量i循环结束后i停在了2于是三个函数拿到的都是2。这就是著名的闭包延迟绑定问题。想拿到0 1 2你得让每个f捕获一个独立的变量比如这样def f(ii): return i把当前值作为默认参数相当于给每个函数拍了一张快照。这个坑在装饰器里同样存在后文我会专门展开这里先留个印象。3.3 动手写装饰器需要的最小闭包骨架看完上面这些你其实已经能自己拼出一个最小可用的装饰器骨架了def my_decorator(func): def wrapper(*args, **kwargs): # 调用前可以加逻辑 result func(*args, **kwargs) # 调用后可以加逻辑 return result return wrapper这个骨架里有两个细节你要记牢。第一wrapper的参数必须写成(*args, **kwargs)这样才能接受任何函数传进来的任意参数不然装饰器就只能用在特定签名的函数上。第二wrapper里一定要return result把原函数的返回值原样递出去不然外面拿到的就是None。我见过太多新手写的装饰器逻辑写对了就是忘了返回原函数的结果导致被装饰的函数全都返回None排查半天。这俩细节记牢了你的装饰器就活了一半。4. 从零手写第一个装饰器一个能通用的计时器4.1 先解决每个函数都要统计耗时的重复劳动先看一个现实中经常出现的需求你要对一批函数做性能分析想知道每个函数大概跑了多久。最粗暴的办法是在每个函数里写start time.time()然后在结尾写print(time.time() - start)。十来二十个函数改下来你就想骂人了。用装饰器解决这个问题非常干净import time import functools def timer(func): functools.wraps(func) def wrapper(*args, **kwargs): start time.perf_counter() result func(*args, **kwargs) elapsed time.perf_counter() - start print(f{func.__name__} took {elapsed:.4f} seconds) return result return wrapper timer def compute_sum(n): return sum(range(n)) compute_sum(1000000)运行结果类似compute_sum took 0.0312 seconds这里我要特别解释一下time.perf_counter()而不是time.time()的原因。time.time()返回的是墙钟时间它会受系统时间调整、NTP 校准影响精度也不够time.perf_counter()是专门用来测短时间间隔的计数器精度高、不受系统时间跳变影响。做性能分析时这是最靠谱的选择。参数选择是有讲究的不是哪个顺手用哪个。4.2 为什么一定要加 functools.wraps代码里我用了functools.wraps(func)这行字看起来像是装饰器的装饰器很多人会问不加行不行不加其实也能跑但会有两个问题。第一个问题compute_sum.__name__会变成wrapper而不是compute_sum。这意味着你调试的时候看到的函数名全是wrapper日志里分不清谁是谁有些框架还会根据函数名做 URL 路由映射名字一变路由就乱了。第二个问题函数的文档字符串、参数签名等元信息也会丢失。用help(compute_sum)看的时候你看到的是wrapper的 docstring而不是compute_sum原本的说明。对内部工具、第三方库的兼容性影响很大。functools.wraps的原理其实不神秘它就是把这个函数的一部分属性比如__name__、__doc__、__module__、__qualname__等从原函数复制到 wrapper 上让包装后的函数看起来还是原来的函数。我给的框架是自己写装饰器默认就带上functools.wraps(func)它是装饰器专业度的分水岭。4.3 给装饰器本身传参数秒表变定时器前面的timer装饰器写死了只打印秒数。如果我想让它支持不同的输出单位比如有的函数我想看毫秒有的想直接看分钟怎么办思路是再在外面包一层函数先把配置参数传进去返回真正的装饰器def timer_with_unit(units): units {s: 1, ms: 1000, us: 1000000} factor units[unit] def decorator(func): functools.wraps(func) def wrapper(*args, **kwargs): start time.perf_counter() result func(*args, **kwargs) elapsed (time.perf_counter() - start) * factor print(f{func.__name__} took {elapsed:.2f} {unit}) return result return wrapper return decorator timer_with_unit(ms) def compute_sum(n): return sum(range(n))注意这里一共出现了三层函数很多人就是在这三层里迷路的。我给你梳理一下最外层timer_with_unit(ms)一执行就得到了一个真正的装饰器decoratordecorator再把compute_sum传进去得到wrapper以后每次调用compute_sum()实际执行的是wrapper()。你可以这样理解当装饰器本身需要参数时就把原本的装饰器再退一层用外层函数来接收参数。这个模式在代码里非常常见写多了你甚至会觉得它是万能的——配置驱动装饰器行为基本都靠这层。5. 装饰器的高阶玩法从单层到多层从函数到类5.1 多层装饰器的执行顺序不能想当然三个装饰器叠在一起会怎样比如deco1 deco2 def hello(): pass顺序是先执行离函数最近的那个也就是先deco2(hello)得到结果后再交给deco1。自下而上装饰自上而下执行。我建议你把装饰和执行分开记装饰阶段deco1(deco2(hello))先里后外执行阶段真正调用hello()时先执行外层deco1的 wrapper再执行deco2的 wrapper最后才是原始函数。这个顺序在日志、权限校验叠加时特别重要。比如你有两个装饰器login_required和logging。如果你是先验证登录再打日志那用户未登录时就不会留下日志如果是先打日志再验证登录那未授权请求也会留下记录。这就是安全审计和日志监控的区别你得按业务要求排好顺序不能随手叠。5.2 用类写装饰器有状态装饰器的典型场景函数式写法能搞定大部分场景但有一类需求更适合用类来写装饰器需要在多次调用之间保存状态。比如我想统计一个函数被调用了几次用函数闭包也能写但用类写会更直观class CallCounter: def __init__(self, func): functools.wraps(func)(self) self.func func self.count 0 def __call__(self, *args, **kwargs): self.count 1 print(fcall count: {self.count}) return self.func(*args, **kwargs) CallCounter def do_something(): pass这里有两行值得讲。第一行functools.wraps(func)(self)看起来有点绕其实它就是前面说的wraps机制的类版本——wraps(func)返回一个装饰器把func的元信息复制到self这个对象上这样实例的__name__也是原函数名。第二行__call__让实例变得可调用这样do_something()实际上就是调用CallCounter实例的__call__方法。类装饰器还有一个额外的好处你可以在类里加方法给装饰器扩展能力。比如加个reset()清零计数加个report()输出统计结果这些都是函数式写法也能做但类写法更顺手的地方。5.3 模拟一个登录校验装饰器讲清楚业务怎么做讲了很多理论来一个真正的业务案例。Web 服务里最常见的需求是某些接口必须登录才能访问。很多框架自带这个能力但你想理解原理自己手写一个简化版非常划算import functools def login_required(func): functools.wraps(func) def wrapper(*args, **kwargs): user get_current_user() if user is None: raise PermissionError(请先登录) return func(useruser, *args, **kwargs) return wrapper login_required def get_profile(userNone): return {name: user[name]}这里面有几个真实项目里会遇到的细节。第一get_current_user()可能从请求上下文、session 或者 token 里解析用户这部分被封装在函数内部装饰器不用关心来源只管有没有。第二login_required校验完之后把user以关键字参数的形式传给原函数这样原函数就不用再去查一遍用户了等于装饰器顺带做了注入依赖的活。如果再进一步很多接口不止要求登录还要求用户有特定权限。这时候你就能把权限码作为参数传进去做一个带参数的装饰器def require_permission(permission_code): def decorator(func): functools.wraps(func) def wrapper(*args, **kwargs): user get_current_user() if user is None: raise PermissionError(请先登录) if permission_code not in user.get(permissions, []): raise PermissionError(f缺少权限: {permission_code}) return func(*args, **kwargs) return wrapper return decorator require_permission(order:delete) def delete_order(order_id): pass这种装饰器工厂 业务校验的模式在大型项目里非常普遍。只要你理解了前面那三层函数结构写这种代码就是套模板的事。5.4 利用装饰器实现失败重试解决一个真实痛点我要分享的另一个高频场景是重试机制。调用外部接口、数据库操作、读取网络资源经常碰到偶发性的 timeout 或者连接重置直接报错太可惜了重试一次往往就成功了。有人会写for i in range(3): try: ... except: continue每个调用点都写一遍非常丑。用装饰器写一个可配置的重试机制import time import functools def retry(times3, delay0.5, exceptions(Exception,)): def decorator(func): functools.wraps(func) def wrapper(*args, **kwargs): for attempt in range(times): try: return func(*args, **kwargs) except exceptions as e: if attempt times - 1: raise time.sleep(delay) print(f第 {attempt 1} 次失败重试中...) return wrapper return decorator retry(times5, delay1, exceptions(ConnectionError, TimeoutError)) def fetch_data(url): pass注意exceptions(Exception,)这个参数我建议你在真实项目里别默认捕获所有异常。重试只应该处理值得重试的暂时性错误像ValueError、TypeError这种业务错误重试十次也一样报错还白白浪费等待时间。所以这里的默认参数是Exception是为了演示实战中请按需收窄。6. 把装饰器用进真实项目缓存、队列与协程的结合6.1 手写一个记忆化缓存装饰器性能优化里有个很重要的技巧叫记忆化Memoization如果一个函数的输入不变输出大概率也不变那就把第一次计算的结果缓存起来下次直接用。Python 里其实有现成的functools.lru_cache但我建议你至少自己写一遍能极大加深对装饰器的理解def memoize(func): cache {} functools.wraps(func) def wrapper(*args): if args not in cache: cache[args] func(*args) return cache[args] return wrapper memoize def fib(n): if n 2: return n return fib(n - 1) fib(n - 2)这里的关键在于cache {}定义在装饰器内部、wrapper 外部。因为闭包的存在每次调用 wrapper 都能访问同一个cache字典函数调用之间的状态就这样被保存下来了。这段代码跑一下计算fib(50)的速度会快得惊人而不用装饰器的版本可能早就卡住了。实际项目里我建议直接用functools.lru_cache(maxsize128)它比你手写的版本多了缓存数量限制、线程安全等考虑。但我依然坚持让新人手写一遍因为只有手写一遍你才能真正理解闭包保存状态到底是怎么回事。6.2 结合 queue 做一个异步任务提交装饰器很多同学学了 queue 之后只知道在线程间传递数据其实 queue 和装饰器结合起来也非常实用。比如你想把某个函数改成丢进队列异步执行立刻返回一个任务编号而不是同步等它执行完import queue import uuid import threading task_queue queue.Queue() results {} def async_task(func): functools.wraps(func) def wrapper(*args, **kwargs): task_id str(uuid.uuid4()) task_queue.put((task_id, func, args, kwargs)) return task_id return wrapper def worker(): while True: task_id, func, args, kwargs task_queue.get() try: results[task_id] func(*args, **kwargs) except Exception as e: results[task_id] e finally: task_queue.task_done() threading.Thread(targetworker, daemonTrue).start() async_task def heavy_calculation(x, y): return x * y task_id heavy_calculation(3, 4) print(task_id) # 立刻输出一个任务编号这个例子里装饰器的作用非常直观它把一个普通的同步调用包装成了提交任务到队列的操作。任务在后台线程执行结果放在results字典里调用方拿着task_id稍后去取。这在构建简单的异步任务系统、或者做爬虫任务调度时特别实用。这里体现的是一个很通用的思想装饰器可以把函数的调用方式和执行方式解耦。原函数只负责计算装饰器负责决定是立即执行、延迟执行、还是放到别的线程里执行。解耦之后同一个函数在不同环境下的调用策略就可以很灵活地换。6.3 装饰器与协程异步函数的装饰器要怎么写现在 Python 的 asyncio 用得越来越多很多同学会问我拿装饰器去装饰 async 函数为什么不行 不是不行而是要注意如果你给一个async def函数套一个普通装饰器返回的wrapper是普通函数它执行完不会返回协程对象整个异步链路就断了。正确的做法是让 wrapper 返回一个协程也就是在 wrapper 内部去调用原函数并把返回值协程对象原样返回。如果装饰器自己也要做异步操作那 wrapper 本身也得是async defdef async_logging(func): functools.wraps(func) async def wrapper(*args, **kwargs): print(fasync call {func.__name__}) result await func(*args, **kwargs) print(fasync done {func.__name__}) return result return wrapper async_logging async def fetch_page(): return html判断一个装饰器适不适合异步函数核心就一句wrapper 是不是 async def以及返回的是不是 awaitable。除非你明确知道自己在做什么否则不要把.result()、run_until_complete这类阻塞操作写进装饰器里那会让异步变成同步性能大打折扣。7. 常见问题与排查技巧实录7.1 闭包延迟绑定循环里创建装饰器全是同一个结果前文讲了延迟绑定问题在装饰器场景里同样会发生。最常见的翻车现场是批量注册路由或者批量生成接口函数def bad_decorators(): funcs [] for i in range(5): def inner(): return i funcs.append(inner) return funcs结果五个函数返回的都是4。原因前面说过inner捕获的是变量i的引用循环结束时i已经是4了。解决办法是绑定当前值def inner(ii): return i。排查技巧看到好几个函数返回的值都是循环最后一次的值第一反应就应该是延迟绑定。改默认参数法是最直观的修复尤其是在装饰器工厂内部生成多个 wrapper 时一定要留个心眼。7.2 装饰器“弄丢”了函数签名和文档排查半天才知道前面的functools.wraps部分聊过元信息丢失但漏了一个比较隐蔽的副作用参数签名丢失。有些代码是拿inspect.signature(func)来做参数校验或者自动生成接口文档的一旦函数被装饰器包过校验就可能失效。比如def add(a, b0): 两数相加 pass some_decorator def add(a, b0): pass import inspect print(inspect.signature(add)) # 没有 wraps 时是 (*args, **kwargs)如果你依赖参数签名做自动校验丢失签名就是 bug。排查技巧只要发现inspect.signature的结果和实际函数不符优先检查装饰器是否用了functools.wraps。这也是我前文不厌其烦强调 wraps 的根本原因——它不是可选项是必须项。7.3 多层装饰器顺序搞反权限校验失效我前文提到自下而上装饰自上而下执行这个顺序如果不理解会出现一种隐蔽的 bug你以为先校验了权限实际是先打印了日志才校验。比如logger login_required def delete(): pass执行顺序是先记录日志再校验登录。这意味着未登录用户的请求也会被写进日志甚至日志中包含敏感信息如 URL、参数。在安全审计场景下这是违规的。排查技巧当你需要必须通过的关卡在最前面时把最严格的装饰器放在最上面让它在执行链的最外层把门。7.4 装饰器装饰递归函数只对第一次调用生效这个问题堪称经典。你有一个递归函数timer def fact(n): if n 1: return 1 return n * fact(n - 1)你以为每次递归都计时实际上timer只装饰了最外层的fact。因为函数体里的fact这个名字已经被重新绑定成了wrapper但fact(n - 1)这个调用在wrapper内部它也指向wrapper——所以理论上每次递归都进wrapper但打印次数却可能出乎意料。真正的问题在于wrapper的返回值经过timer被包装统计出来的总耗时是包含所有递归的可如果你想要每次递归的详细耗时这份装饰器的逻辑就要改。更隐蔽的坑是用装饰器做缓存优化递归斐波那契如果不注意 wraps递归内部调用的是 wrapper缓存反而可能失效。在实际操作里要检查装饰器对递归的副作用可以临时打印fact.__name__和调用次数。如果确认是递归场景且需要逐层统计建议把装饰器逻辑写进递归函数内部或者用一个辅助函数来区分入口调用和递归调用。8. 一点使用心得送给你这几个建议带新人的时候我最常被问的一句话是“装饰器这么绕我到底什么时候该用” 我的建议很简单当你在代码里发现同一段前置逻辑或后置逻辑在多个函数里重复出现时就优先考虑装饰器。日志、鉴权、计时、重试、缓存、限流、事务开关都是装饰器的舒适区。反过来如果一个函数只有一次性需求硬套装饰器反而画蛇添足。根据我个人的实操经验新手从看得懂到写得顺最有效的路径就是把我上面写的计时器、重试、缓存这三个装饰器关闭网页自己默写一遍。能默写出来说明闭包、wraps、参数传递这些点你已经真正吃透了。再往后你去看 Django、Flask 这些框架源码里的装饰器会发现思路完全一致只是业务逻辑更复杂而已。最后分享一个小技巧如果你发现自己总是忘了加functools.wraps可以把这个当成代码习惯就像写函数一定会写return一样。时间长了这个习惯会帮你省下大量排查签名导致 bug 的时间。装饰器是 Python 里少有的会了就很爽不会就很痛的语法值得你花一个下午把它彻底拿下。