ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python装饰器从入门到工程实践:函数对象、闭包与functools.wraps

Python装饰器从入门到工程实践:函数对象、闭包与functools.wraps 装饰器这东西很多 Python 初学者第一眼看到就懵了——something挂在函数上面像魔法一样改变了函数的行为。我刚学的时候也翻了不少文章看完还是一头雾水为什么装饰器要套那么多层函数functools.wraps到底有什么用什么时候该自己写一个装饰器后来写了几年代码踩了不少坑才逐渐明白装饰器其实就是 Python 藏在语法糖底下的函数复用机制。它解决的是一类非常具体的痛点你有一段逻辑比如打日志、算耗时、做鉴权需要加在很多函数上但你不想在每个函数里都复制粘贴同一段代码。装饰器把这些横切逻辑抽出来让业务函数保持干净只关心自己的事。这篇我会从最底层的函数对象说起一直讲到带参数装饰器、类装饰器、以及实际项目里手写的重试、缓存、鉴权装饰器。每个代码示例都可以直接跑每一步我都会解释为什么这么写。同时也整理了我在真实项目里踩过的坑——函数元信息丢失、装饰器顺序搞反、包装函数忘了返回这些问题如果你提前知道能省下好几个小时的调试时间。想一口气搞清楚装饰器的朋友这篇应该能帮到你。1. 理解装饰器的核心函数是一等公民1.1 函数可以像变量一样传递是装饰器的地基在 Python 里函数不只是一个定义在文件顶部的代码块它本质上是一个对象。你可以把它赋值给变量放进列表作为参数传给另一个函数甚至作为返回值从函数里拿出来。这个特性就是俗称的函数是一等公民。很多教程轻描淡写地提了一句Python 函数是对象然后就直接上装饰器代码导致初学者在后面看得云里雾里。其实装饰器所有的套路都建立在这个特性之上。我打个比方你把函数想象成一个工具——比如一把螺丝刀。装饰器就是给螺丝刀装上电动手柄的过程。你拿到一辆车原函数把它交给改装车间装饰器改装车间围着它加工一圈还给你一辆带电动功能的螺丝刀包装后的函数。因为函数在 Python 里就是一个可以传递、可以加工的对象所以这个过程才能成立。看一个最简单的例子def say_hello(): print(你好世界) # 函数可以赋值给变量 greet say_hello greet() # 输出你好世界 # 函数可以作为参数传递 def call_func(func): func() call_func(say_hello) # 输出你好世界这里greet和say_hello指向同一个函数对象调用哪一个都是执行同一个函数。同理把函数传给另一个函数那这个函数内部就能决定什么时候调用、怎么调用、调用前后做什么。这就是装饰器在底层做的事——接收一个函数返回一个加工过的新函数。理解了这一点后面看装饰器代码就不会再觉得它是什么魔法了。1.2 闭包让内层函数记住外层环境有了函数作为对象这个地基还不够装饰器还需要另一个关键机制闭包。闭包说白了就是一个内层函数它引用了外层函数里的变量即使外层函数已经执行完毕这个变量依然被保存下来内层函数随时能用。来看这个例子def outer(prefix): def inner(name): print(f{prefix}, {name}) return inner hello outer(早上好) hello(小明) # 输出早上好, 小明outer(早上好)执行完按道理prefix这个局部变量应该没了。但因为inner引用了它Python 会把prefix包装进inner的闭包环境里。后面调用hello(小明)inner还能读到prefix的值。你可以这样理解inner是一个背着背包的函数背包里装着prefix。不管走到哪里只要调用inner它都能从背包里掏出prefix来用。这个背包就是闭包环境。装饰器本质上就是一个利用闭包机制的函数外层函数接收被装饰的函数作为参数内层函数负责在被装饰函数的前后加入额外逻辑然后外层函数把内层函数返回出去。整个装饰器就是闭包 函数对象两个概念组合出来的产品。2. 从零手写一个装饰器不吃透语法糖后面全是坑2.1 最原始的写法先理解装饰器的加工过程很多教程一上来就写xxx但第一次学装饰器的人根本不知道做了什么。我们先不看语法糖用最原始的写法来实现一个打印日志的装饰器。def log_decorator(func): def wrapper(*args, **kwargs): print(f调用函数: {func.__name__}) result func(*args, **kwargs) print(f函数结束: {func.__name__}) return result return wrapper def add(a, b): return a b # 手动给函数加上日志功能 logged_add log_decorator(add) print(logged_add(3, 5))运行结果调用函数: add 函数结束: add 8这里log_decorator做了一件事把add函数包装成了另一个函数logged_add。这个新函数在调用原始add前后执行额外的打印逻辑。注意wrapper里这行result func(*args, **kwargs)是关键。有人刚开始写装饰器忘了接收*args和**kwargs或者忘了return result结果被装饰的函数要么传不了参要么返回值为None。因为wrapper是新的函数你不主动调用原函数、不主动返回原函数的结果外面拿到就是None。所以说装饰器最简单的模型是外层接收函数内层扩展逻辑最后返回内层函数。把这个模型刻进脑子里后面所有的变体都是在这个模型上加点东西。2.2 语法糖写一次处处复用理解了原始写法再看语法糖就是顺理成章的事def log_decorator(func): def wrapper(*args, **kwargs): print(f调用函数: {func.__name__}) return func(*args, **kwargs) return wrapper log_decorator def add(a, b): return a b print(add(3, 5))这里的log_decorator等价于上面手动写的add log_decorator(add)。Python 在定义完add函数后自动把它传给log_decorator再用返回的wrapper替换掉原来的名字。语法糖的好处是装饰逻辑写在函数定义上方一眼可见不会在文件底下出现一大串手动包装代码。而且在多个模块里复用同一个装饰器非常方便加一个就行了不需要额外写赋值语句。如果你后面看到某种被装饰函数调用方式变了的情况比如add从直接调用变成了调用 wrapper不要奇怪这正是装饰器的工作方式。add这个名字指向的不再是原始函数而是包装后的函数。2.3 functools.wraps这行代码不改排查能排查到怀疑人生上面我写的装饰器都有一个小毛病被装饰之后函数的身份信息丢了。def log_decorator(func): def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapper log_decorator def add(a, b): return a b print(add.__name__) # 输出wrapperadd.__name__变成了wrapper而不是add。这影响的不只是打印好看不好看。很多框架和工具依赖函数名做路由映射、日志记录、序列化比如 Flask 的endpoint默认取函数名pytest 的测试用例也是根据函数名定位。如果你装饰了接口函数忘了加functools.wraps轻则日志里全是wrapper重则框架直接报错或者行为异常。解决办法是在wrapper上面加一行wraps(func)from functools import wraps def log_decorator(func): wraps(func) def wrapper(*args, **kwargs): print(f调用函数: {func.__name__}) return func(*args, **kwargs) return wrapperfunctools.wraps做的事是把原始函数的__name__、__doc__、__module__、__qualname__等一系列元信息复制到wrapper上。它还会设置__wrapped__属性指向原始函数方便调试工具循着__wrapped__找到最底层的原始函数。我个人的习惯是每写一个装饰器第一行先加wraps(func)。这不是可选项是一个专业 Python 开发者的基本素养。踩过一次框架路由出问题的坑之后你就再也不会忘。3. 进阶带参数的装饰器与装饰器叠加3.1 带参数的装饰器为什么多包了一层有时候光给函数加一个装饰器还不够你还想给装饰器本身传参数。比如写一个日志装饰器想指定日志级别写一个重试装饰器想指定重试次数。如果直接写log_decorator(levelINFO) def add(a, b): return a b那log_decorator接收到的不是add函数而是字符串INFO。这时需要再加一层外层函数用来接收参数中间层接收函数最内层包装函数from functools import wraps def log_decorator(levelINFO): def decorator(func): wraps(func) def wrapper(*args, **kwargs): print(f[{level}] 调用 {func.__name__}) return func(*args, **kwargs) return wrapper return decorator log_decorator(levelWARNING) def add(a, b): return a b add(1, 2) # 输出[WARNING] 调用 add这里有三层函数每一层的职责很清晰最外层log_decorator(levelWARNING)接收装饰器的参数返回一个真正的装饰器中间层decorator(func)接收被装饰的函数最内层wrapper(*args, **kwargs)接收被装饰函数的参数执行包装逻辑这三种参数混在一起最容易搞混。我的记忆方法是越外层的函数接收的信息越宏观——装饰器参数最先被确定越内层的函数接收的信息越微观——真实调用时的参数最后才出现。3.2 多个装饰器叠加执行顺序真的重要实际项目里一个函数常常被多个装饰器包裹比如timer log def download_data(): ...这时候很多人容易搞混执行顺序。记住两句话装饰顺序是从下往上的download_data先被log装饰然后把结果交给timer再装饰一次。调用顺序是从上往下的最终调用download_data()时先执行timer的逻辑再执行log的逻辑最后才是原始函数本体。我写过一段验证代码打印每层执行顺序def first(func): def wrapper(*args, **kwargs): print(first: before) return func(*args, **kwargs) return wrapper def second(func): def wrapper(*args, **kwargs): print(second: before) return func(*args, **kwargs) return wrapper first second def work(): print(work) work()输出first: before second: before work这说明work先被second包装再交给first包装所以调用时first的before最先打印。这个顺序在叠加鉴权、日志、缓存、重试这类装饰器时非常重要。比如你现在有一个参数校验和一个缓存装饰器它们的顺序决定了是先校验还是先查缓存——业务场景不同合理顺序完全不同。我的建议是把改变函数行为的放在上面增强函数能力的放在下面。具体而言cache一般在最外层因为它要先判断能不能命中缓存而validate应该在缓存之前或之后取决于你的业务。可以先把代码写出来然后用上面的打印 before的方法实测一遍确认顺序符合预期。3.3 类装饰器当装饰器需要记住状态时基于闭包的装饰器是无状态的每次调用wrapper都是独立的。如果你需要一个装饰器能够记住调用了多少次、累计多长时间等信息可以考虑用类来实现。from functools import wraps class CountCalls: def __init__(self, func): wraps(func)(self) self.func func self.count 0 def __call__(self, *args, **kwargs): self.count 1 print(f{self.func.__name__} 已被调用 {self.count} 次) return self.func(*args, **kwargs) CountCalls def hello(): print(hello) hello() hello() hello()输出hello 已被调用 1 次 hello hello 已被调用 2 次 hello hello 已被调用 3 次 hello类装饰器的思路是把被装饰的函数存到实例的func属性里__call__方法就是每次调用时被执行的逻辑。因为实例在多次调用之间是同一个对象所以它的属性就能持续记录状态。有一点要特别注意这里用了wraps(func)(self)而不是像函数装饰器那样wraps(func)加在wrapper上。因为类本身不是函数需要通过调用wraps(func)然后把self传进去把函数元信息复制到实例上。这个写法比较绕我第一次用类装饰器时也卡了一会儿。类装饰器适合的场景包括要统计函数调用次数、要记录函数最近一次执行的参数、要维护一个跨调用的缓存状态等。如果只是简单的打日志这种无状态逻辑函数装饰器就够了没必要用类。注意类装饰器里self.count是挂在实例上的而实例是整个程序生命周期里唯一的。如果那个函数在多线程里被并发调用self.count的增减不是线程安全的。需要计数准确的话要加锁或者改用threading.Lock保护。我遇到过的线上事故里就有同事用类装饰器统计 QPS并发一高数字就漂最后排查了半天发现是计数丢失。4. 内置装饰器与典型应用场景写自定义装饰器之前先把 Python 自带的那几个高频装饰器用熟。它们更成熟、性能更好也覆盖了不少常见需求。4.1 property让方法伪装成属性property在很多代码里都会出现。它的核心作用是把方法调用伪装成属性访问。class User: def __init__(self, first_name, last_name): self.first_name first_name self.last_name last_name property def full_name(self): return f{self.first_name} {self.last_name}直接user.full_name就能拿到拼接后的名字不需要user.full_name()。关键是它比普通属性多了控制权——你可以在full_name的 getter 里做格式化、校验、缓存拆分等逻辑。如果以后想改成中文环境下返回姓名英文环境下返回名姓改一个地方就够了调用的位置完全不用动。4.2 lru_cache一行代码让递归快上几个数量级functools.lru_cache是一个经典到不能再经典的缓存装饰器。它会把函数的参数和返回值缓存起来下次调用相同参数时直接返回缓存结果。斐波那契数列是最常用的例子from functools import lru_cache lru_cache(maxsize128) def fib(n): if n 2: return n return fib(n - 1) fib(n - 2) print(fib(100))没有lru_cache时fib(100)的递归调用次数是指数级的跑完不知道得等到什么时候加了缓存后每个n只计算一次瞬间出结果。使用它的几个要点maxsize指定缓存上限设成默认值 128 即可。如果函数参数空间特别大可以调大一点缓存基于参数值所以参数必须是可哈希的。如果传了 list 这种不可哈希类型会直接抛TypeError被缓存的是纯函数的结果如果函数内部依赖全局状态、外部 IO 或随机数结果就不该缓存4.3 contextmanager用生成器语法简化上下文管理我们平时用with open(file) as f打开文件。自己写一个支持with的上下文管理器标准做法是定义一个类实现__enter__和__exit__方法写起来比较重。contextlib.contextmanager可以让你用生成器语法更轻量地实现同样的事from contextlib import contextmanager contextmanager def a_text_file(path): f open(path, w) try: yield f finally: f.close() with a_text_file(test.txt) as f: f.write(hello)yield之前的部分相当于__enter__yield之后的部分相当于__exit__。如果yield的语句之间抛了异常finally同样会执行保证资源释放。这个装饰器在写测试代码、临时修改环境变量、临时切换当前目录时特别实用。我曾经用它写过一个小工具在测试用例里临时把数据库连接串指向测试库用例结束后自动恢复原值代码非常干净。4.4 实际项目里我常用的三个自定义装饰器除了内置装饰器实际项目中我写过的、一直在维护的三个自定义装饰器可以给你参考。第一个是重试装饰器处理网络抖动临时失败from functools import wraps import time import logging logger logging.getLogger(__name__) def retry(max_attempts3, delay1): def decorator(func): wraps(func) def wrapper(*args, **kwargs): for attempt in range(max_attempts): try: return func(*args, **kwargs) except Exception as e: if attempt max_attempts - 1: raise logger.warning(f{func.__name__} 第 {attempt 1} 次失败: {e}) time.sleep(delay) return wrapper return decorator retry(max_attempts3, delay0.5) def fetch_data(): ...第二个是参数校验装饰器。处理接口层非空字符串一类的基础校验逻辑放在每个接口入口处代码非常简洁def require_non_empty(*field_names): def decorator(func): wraps(func) def wrapper(*args, **kwargs): for name in field_names: value kwargs.get(name) if value is None or str(value).strip() : raise ValueError(f参数 {name} 不能为空) return func(*args, **kwargs) return wrapper return decorator require_non_empty(username, email) def register(username, email, password): ...第三个是慢调用告警装饰器。用于监控耗时超过阈值的方法方便主动发现性能问题def slow_func_warning(timeout1.0): def decorator(func): wraps(func) def wrapper(*args, **kwargs): start time.perf_counter() result func(*args, **kwargs) cost time.perf_counter() - start if cost timeout: logger.warning(f{func.__name__} 耗时 {cost:.2f}s 超过阈值 {timeout}s) return result return wrapper return decorator这类装饰器写好后放到项目的common/decorators.py模块里全局复用。不需要每个接口复制粘贴同样的time.perf_counter()逻辑这就是装饰器在工程上最大的价值。5. 常见问题与排查技巧实录做博客和技术支持这么久我见过不少人在装饰器上栽跟头。下面这些问题是我总结出来出现频率最高的建议存下来。5.1 被装饰函数凭空消失了忘记 return 的连锁反应新手写装饰器最容易漏的就是return func(*args, **kwargs)。漏掉的后果是用户调用被装饰函数时函数体确实执行了但返回的是None。这在写业务逻辑、接口调用时非常隐蔽因为你不一定立刻发现返回值丢了。我当时排查过一个案例同事写了一个缓存装饰器缓存里存的是原函数返回值但包装函数忘了 return结果所有调用都返回None然后缓存里存了一堆None。排查了很久才发现是包装函数没有返回原函数结果。排查心得如果你发现某个被装饰函数的返回值不对劲先把装饰器内部的return检查一遍再看有没有因为遗漏return导致返回值被吞掉。5.2 函数元信息丢失导致框架路由失效前面提到过不加wraps(func)的话函数名字会变成wrapper。这在 Flask、DRFDjango REST Framework这类框架里会产生连锁问题。比如 Flask 视图函数默认使用函数名作为endpoint多个视图如果都叫wrapper就会出现endpoint冲突请求无法路由到正确视图。排查建议如果遇到路由相关错误先看视图函数的__name__是不是正常的。在视图函数里打印一下app.view_functions看看 endpoint 映射里的名字是什么。如果全是wrapper恭喜你找到病根了。5.3 装饰器叠加顺序导致逻辑诡异前面说过装饰器的执行顺序是从上到下装饰从下到上执行。但实际项目里叠加顺序经常是想当然的。举个典型的错误案例require_login require_permission(admin) def admin_setting(): ...如果require_login只负责校验是否登录require_permission(admin)只负责校验权限。按从下到上执行的顺序先执行require_permission(admin)再执行require_login。如果是未登录用户第一次校验权限时拿不到用户身份就直接抛异常了。而未登录和无权限应该返回不同的响应。正确写法应该是把require_login放在下面让它先执行。排查心得叠加装饰器之前先在纸上画清楚哪个信息是哪个装饰器先需要的。依赖关系决定了顺序不清楚的时候就打印日志观察调用顺序是否符合预期。5.4 类装饰器在多线程环境下状态错乱类装饰器维护实例状态是优点但同时也带来线程安全问题。之前提过self.count这类属性没有加锁多线程并发时更新会丢数据。不仅在类装饰器里函数装饰器如果用了nonlocal修改变量、或者用全局变量做缓存也有同样问题。解决方案如果状态只有读没有写问题不大如果状态有写操作可以考虑threading.Lock或者把状态写入一个专门的线程安全存储里。另外需要想清楚这个状态到底需不需要跨线程共享——如果只是纵向统计单次请求内部的调用次数用contextvars更合适。5.5 装饰器改函数签名导致 IDE、文档、测试工具困惑装饰器如果不注意会改变被装饰函数的__signature__导致 IDE 提示错误、文档生成工具显示异常、pytest 的参数化也无法正常工作。functools.wraps会自动把__wrapped__关联到原始函数很多现代工具如inspect.signature会循着__wrapped__找到真实签名。但如果你的装饰器确实改变了函数签名比如添加了参数要确保签名对象正确处理。我遇到过一个 Case项目用inspect.signature自动生成 API 文档某个接口装饰器没有 wraps导致生成的文档里所有接口参数都变成了*args, **kwargs完全没法用。加上wraps后自动解决。6. 项目实践中我总结的几条心得6.1 什么该用装饰器什么不该用装饰器适合处理横切关注点——日志、鉴权、缓存、重试、限流、监控这些逻辑跟具体业务无关却散落在各个函数里最适合封装成装饰器。不适合用装饰器的场景也很明显过度包装会牺牲可读性。如果一个函数上面叠了七八个下面的业务逻辑埋得太深别人看代码会一头雾水。装饰器也不是万能的如果逻辑本身就很个性化、只在一个函数里用到一次直接写在函数内部更直白硬套装饰器反而制造理解负担。我给自己定的原则是装饰器只做无状态或仅有轻状态的通用增强一旦发现这个装饰器开始依赖函数内部的具体参数结构、或者需要跟别的装饰器密切配合才能跑通就说明抽象层级出了问题不如直接写显式代码。6.2 从需求反推装饰器设计的思路有人问我什么时候才应该自己写装饰器而不是抄一个我的建议是从需求出发先罗列现在重复出现的问题。举个例子。你的项目里多个接口需要打印入参和出参还要统计耗时又要在出错时重试三次。这三个需求每次都是复制粘贴一大段代码。这时候就该考虑三个独立的装饰器log_io、timer、retry。然后你可以按需组合log_io timer retry(max_attempts3) def call_payment_api(order_id): ...注意这里我把三个装饰器拆成三个独立的小装饰器而不是写一个超级装饰器同时做三件事。独立装饰器更灵活可组合、可单独复用、可单独测试。如果写成一个上帝装饰器参数会变得非常多代码内部逻辑交叠出了问题很难定位。这是我的核心设计原则每个装饰器只做一件小事。6.3 给新手的三个练习建议如果只看文章不动手装饰器永远学不会。我给新手的建议是从简单到复杂练这三个练习写一个只允许数字参数的校验装饰器接收int/float类型的参数才执行函数否则抛出类型错误。写一个打印函数调用完整信息的装饰器打印函数名、参数元组、关键字参数字典、返回值。用类写一个装饰器工厂让函数通过retry(attempts3)这种形式指定重试次数。完成这三个练习后装饰器的核心用法你就基本掌握了。第三个练习相对难一些我当时的做法是先把函数装饰器版写出来再逐步改成类版一边改一边观察每一层函数执行时的差异。6.4 给已有函数加装饰器的一个小技巧有时候你不想改动已有的函数定义但又想临时给它加上装饰逻辑。这时候不用改原函数直接用语法糖之外的方式即可original_func some_function some_function log_decorator(original_func)这在调试线上问题时特别好用——不用改业务代码在入口文件里临时给某个函数挂上日志装饰器观察一下输出完事之后再删掉。我经常用这个方式排查某个函数被谁调用了这类问题。6.5 性能与调试装饰器不是免费的每加一层装饰器本质上就是多了一次函数调用。如果一个函数本身执行很快微秒级别而它被几十个装饰器包裹或者在一个大循环里被反复调用十万次那装饰器本身的调用开销就不能忽略。我在性能优化时遇到过这种情况某段代码在热路径里调用了被三个装饰器包裹的函数profile 之后发现装饰器的开销占了将近 15%。这种场景下可以考虑在关键热路径上改用直接写逻辑或手动精简装饰器层数。调试时还有一个技巧多个装饰器叠加后单步调试经常弄不清现在在哪一层。可以用fn.__wrapped__一路回溯到原始函数在 IDE 的 Watch 面板里查看fn.__wrapped__.__wrapped__就能看到完整的装饰器链。我在 PyCharm 里排查多层装饰器时这个字段帮了我大忙。回到开头那句话——装饰器不是魔法它就是函数对象 闭包的组合用法。理解这个本质后再复杂的装饰器也不会让你打怵。我个人这些年的切身体会是装饰器最好的使用方式是把入参打印、鉴权、重试、缓存这类横切逻辑沉淀成团队公共库的通用能力让业务代码保持简洁、让新人能快速看懂。最忌讳的是在业务代码里随手写出逻辑纠缠的超级装饰器那只会成为维护的负担。如果你刚开始学装饰器建议不要只看不练。拿我今天写的这几个例子——日志装饰器、带参数的retry、类装饰器CountCalls——自己动手跑一遍再动手改成你真实项目里需要的版本。踩过一两次坑之后你会发现装饰器反而成了你最趁手的工具之一。
返回列表