
1. 从一次代码重构说起为什么你需要理解装饰器两年前我在一个数据清洗项目里接了这样一个需求十几个函数都要打印执行耗时、记录调用参数、在异常时重试两次。第一版我老老实实复制粘贴每个函数里加三遍同样的逻辑。跑起来倒是没问题可过了两周需求方说要给这些函数加缓存我又逐个函数改了一遍。等到某天凌晨改到第五个函数的时候我突然意识到这绝对不是正确的工作方式——重复代码在失控而装饰器恰好就是解决这类问题的标准答案。装饰器Decorator在Python里不是一个新概念Python 2.4就引入了但它直到今天依然是很多人的认知盲区。每次你在Flask里写app.route(/login)、在Django里写login_required、在pytest里写pytest.fixture你其实都在用装饰器。只是大多数时候我们是用它的人而不是写它的人。这篇文章适合三类读者一是写了一些Python但一直没用过装饰器的同学二是会用但不太清楚内部原理、遇到带参数装饰器就发怵的人三是想系统梳理一遍好写进自己代码库的工程师。我会从最基础的语法讲起一路走到带参数的装饰器、类装饰器、内置装饰器最后聊聊我在实际项目里踩过的坑和总结的排查方法。文章里的所有示例代码都是可以直接跑的建议你边看边在本地环境里验证。说到底装饰器背后的思想非常简单函数在Python里是一等对象它跟整数、字符串、列表一样可以被赋值、被当作参数传递、被当作返回值返回。装饰器就是利用这个特性在不修改原函数代码的前提下给函数附加新的行为。这个“不修改原函数代码”是核心中的核心它意味着你可以保持业务函数干净把横切关注点日志、鉴权、缓存、重试、限流全部收敛到装饰器里。下面我们一步步把这块硬骨头啃下来。2. 装饰器的工作原理与最小实现2.1 函数是一等对象装饰器的地基要理解装饰器必须先接受一个事实函数名是一个变量它指向一个函数对象。你写def greet(): return hello本质上是在做一次赋值操作greet这个名字绑定到了一个函数对象上。所以你可以f greet然后调用f()可以把greet塞进列表、字典可以把它传给另一个函数。我经常用一个生活化的类比来讲这层关系函数是一个人函数名是这个人的名字。装饰器做的事情是在保留名字不变的前提下给这个人换了一套装备或加了一层身份。外面的人还是叫这个名字但实际上干活的已经是增强过的版本了。看一下最基础的一个例子def my_decorator(func): def wrapper(): print(调用函数之前) func() print(调用函数之后) return wrapper my_decorator def say_hello(): print(hello!)执行say_hello()输出是调用函数之前 hello! 调用函数之后my_decorator这个语法糖等价于先定义函数say_hello然后执行say_hello my_decorator(say_hello)。注意这个过程发生在模块加载阶段也就是函数定义被解释执行的那一瞬间而不是在你调用say_hello()的时候。明白这个时机很重要很多人调试装饰器时看半天没发现问题就是因为没意识到装饰器在导入模块时就执行了。2.2 语法糖背后的等价逻辑理解语法糖背后的等价逻辑是彻底掌握装饰器的分水岭。我建议你亲自在交互环境里跑一遍这个对照# 不用 写法 def say_hello(): print(hello!) say_hello my_decorator(say_hello)你会发现这个写法跟上面的装饰器版本完全等效。符号只是一个快捷方式它让代码更简洁、可读性更强但并没有引入任何新的运行机制。理解了这个等价关系之后很多困惑会迎刃而解。比如为什么装饰器函数必须接收一个函数作为参数因为赋值语句say_hello my_decorator(say_hello)里say_hello在等号右边就是一个函数对象。再比如为什么装饰器内部总要定义一个wrapper函数因为它必须返回一个新的函数对象给say_hello这个名字而wrapper就是那个新函数。有个细节值得注意装饰器在模块加载时执行而不是在函数调用时执行。如果你在装饰器里加一行print你会看到它只打印一次而不是每次调用都打印。这个特性有时可以用来做注册表比如框架里常见的register装饰器就是在导入模块时把函数注册到一个全局列表里压根不需要调用函数本身。提示理解“装饰器在导入时执行”这个时机能帮你解释很多诡异现象。比如两个模块互相导入时装饰器可能抛异常比如装饰器内部如果访问了尚不存在的全局变量会在导入阶段直接报错。遇到这类问题先去检查装饰器那层逻辑而不是业务函数内部。2.3 一个可以直接抄作业的计时装饰器理论说了这么多我们先写一个最简单的实用装饰器统计函数执行耗时。这个装饰器我在性能排查时几乎天天用代码量不大但非常实用。import time import functools def timer(func): functools.wraps(func) def wrapper(*args, **kwargs): start time.perf_counter() result func(*args, **kwargs) end time.perf_counter() print(f{func.__name__} 耗时 {(end - start) * 1000:.2f} ms) return result return wrapper timer def compute(): time.sleep(0.1) return 42 print(compute())到这里我们已经实现了一个可以实际使用的装饰器。但如果你照着这个写法去装饰更多函数很快会发现一个让很多人头疼的问题函数的元信息丢了。3. functools.wraps 与函数元信息的保持3.1 装饰之后你的函数还是它自己吗跑一下这段对比代码你会看到问题的严重性def my_decorator(func): def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapper my_decorator def greet(): 返回问候语 return hello print(greet.__name__) # 输出 wrapper print(greet.__doc__) # 输出 None函数名从greet变成了wrapper文档字符串也丢了。这在调试时会带来很大的迷惑性日志里打印的函数名全是wrapperIDE的自动补全和文档提示也全部失效。更麻烦的是有些框架依赖__name__做路由映射函数名一变路由就乱了。这时候就轮到functools.wraps出场了。它的作用是把原函数的__name__、__doc__、__module__、__qualname__、__annotations__等属性复制到wrapper上同时更新wrapper的__dict__。注意它本身也是一个装饰器只是专门用来修复元信息的。3.2 wraps 的内部原理简介functools.wraps的实现其实不神秘。它内部用了一个WRAPPER_ASSIGNMENTS元组里面装着要复制的属性名还有一个WRAPPER_UPDATES元组里面装着要更新的__dict__键。核心代码翻译过来就是遍历这些属性名用getattr(func, attr)拿到原函数的值再setattr(wrapper, attr, ...)赋到包装函数上。但有个细节很多资料不会告诉你functools.wraps并没有把__wrapped__之外的特殊属性全部复制。比如__code__、__defaults__这类跟函数调用行为直接相关的属性它不管。这意味着如果你依赖于反射机制去检查一个被装饰函数的参数签名光靠wraps是不够的。好在Python 3.4以后有了inspect.signature它通过__wrapped__属性可以穿透包装层拿到原函数的签名。这里还牵扯到一个最佳实践写装饰器时请务必将wrapper上的__wrapped__属性处理好。functools.wraps已经帮你做了它会设置wrapper.__wrapped__ func。这个属性让inspect模块能够追踪到原函数也让很多调试工具能显示真正的函数名。3.3 一手经验日志装饰器的正确写法下面这个日志装饰器是我在项目里沉淀下来的版本兼顾了元信息保留、参数打印、返回值截断、异常记录import functools import logging logger logging.getLogger(__name__) def log_call(levellogging.INFO): def decorator(func): functools.wraps(func) def wrapper(*args, **kwargs): args_repr [repr(a) for a in args] kwargs_repr [f{k}{v!r} for k, v in kwargs.items()] signature , .join(args_repr kwargs_repr) logger.log(level, f调用 {func.__name__}({signature})) try: result func(*args, **kwargs) logger.log(level, f{func.__name__} 返回 {result!r}) return result except Exception as e: logger.exception(f{func.__name__} 抛异常: {e}) raise return wrapper return decorator有几点是我实际用出来的经验参数和返回值都用repr()而不是str()这样能区分字符串和数字能看到列表内容排查数据问题的时候友好很多。返回值如果特别大打印之前可以截断一下避免日志被刷爆。我在一个数据管道项目里就遇到过函数返回整个DataFrame然后日志文件每秒钟涨几十MB的惨案。捕获异常后必须raise否则会吞掉异常改变原函数的行为语义。这一点很多人容易忽略装饰器最忌讳的就是悄悄改变函数的返回值或异常行为。4. 带参数的装饰器与多层嵌套的拆解4.1 为什么需要带参数的装饰器先想一个场景你要给不同接口设置不同的重试次数。有的接口偶尔超时重试3次就够了有的接口依赖外部服务可能要重试5次。如果装饰器不接受参数你只能写多个几乎一样的装饰器或者把重试次数写死。带参数的装饰器就是为了让装饰器本身可配置化。带参数装饰器的使用形式是这样的retry(max_attempts3, delay0.5) def fetch_data(): ...注意这里retry(max_attempts3, delay0.5)后面紧跟的不是函数而是一次函数调用。换句话说retry(max_attempts3, delay0.5)先执行返回一个真正的装饰器然后这个装饰器再作用于fetch_data。4.2 三层嵌套的写法与执行顺序带参数的装饰器在代码结构上要比基础版多一层。最外层函数接收装饰器的参数中间层接收被装饰的函数内层接收原函数的调用参数。整个写下来是这个样子import functools import time def retry(max_attempts3, delay0.5): def decorator(func): functools.wraps(func) def wrapper(*args, **kwargs): for attempt in range(1, max_attempts 1): try: return func(*args, **kwargs) except Exception as e: if attempt max_attempts: raise print(f第 {attempt} 次失败: {e}{delay} 秒后重试) time.sleep(delay) return wrapper return decorator执行时机这块要捋清楚一整个链条模块导入时先执行retry(max_attempts3, delay0.5)得到decorator。然后把原函数对象传给decorator得到wrapper。fetch_data这个名字被重新绑定到wrapper上。这个链条里每一层都有自己独立的命名空间参数通过闭包机制在各层之间传递。最外层的max_attempts和delay被中间层的decorator捕获中间层接收的func被内层wrapper捕获内层wrapper才是真正在每次调用时运行的那个函数。4.3 一个参数的两种用法兼容设计有没有可能让retry和retry(3)都工作即不传参数时用默认值传一个位置参数时把它当作max_attempts这是可以做到的只需要判断第一个参数到底是函数还是普通参数import functools def retry(funcNone, *, max_attempts3, delay0.5): if func is None: return lambda f: retry(f, max_attemptsmax_attempts, delaydelay) functools.wraps(func) def wrapper(*args, **kwargs): for attempt in range(1, max_attempts 1): try: return func(*args, **kwargs) except Exception as e: if attempt max_attempts: raise time.sleep(delay) return wrapper return wrapper我实际用下来觉得这种兼容写法有利有弊。好处是调用方不需要记“到底要不要加括号”坏处是逻辑复杂了一点点而且在IDE的类型提示上会变得不友好。如果只是给自己项目用我更推荐直接统一成带括号的写法retry()反正多写一对括号成本很低。但如果你在维护一个开源的公共库兼容写法是值得考虑的。4.4 三层嵌套的调试经验三层嵌套的函数在报错时看堆栈特别痛苦因为你可能会看到retry.locals.decorator.locals.wrapper这种长串名字。我给出的建议是每一层都用有意义的函数名比如decorator和wrapper这种约定俗成的名字比inner、outer这种模糊命名可读性好。想快速验证参数到底传没传到最内层可以在wrapper里临时加一行print(debug_args)确认后删掉即可。使用functools.wraps后堆栈信息里很多工具会自动跳过包装层直接显示原函数的函数名和文件行号排查问题会舒服很多。5. 类装饰器与类作为装饰器5.1 用类实现装饰器call的妙用用函数写装饰器是主流但用类也能实现同样效果而且当你需要维护状态时类实现往往更清晰。关键就在于__call__方法——一个实例如果定义了__call__它就可以像函数一样被调用。import functools class CountCalls: def __init__(self, func): functools.update_wrapper(self, func) self.func func self.call_count 0 def __call__(self, *args, **kwargs): self.call_count 1 print(f{self.func.__name__} 已被调用 {self.call_count} 次) return self.func(*args, **kwargs)这里有个容易忽略的细节为了保留原函数的元信息我对slef实例使用了functools.update_wrapper(self, func)。因为类实例不是一个函数wraps里的WRAPPER_ASSIGNMENTS照样能给它赋属性它就像一个可调用对象一样兼容了这套机制。类装饰器的优势在于状态管理。函数式写法里也有闭包可以存计数变量但当状态一多比如调用次数、最近一次调用的时间、平均耗时、调用参数历史闭包的代码就会变得比较绕。类实现天然有实例属性状态管理就是普通的属性读写逻辑上清晰很多。5.2 类装饰器的典型场景单例与缓存单例模式是类装饰器最经典的用武之地。用装饰器实现单例远比继承父类实现单例要灵活因为它不限制被装饰类的能力import functools def singleton(cls): instances {} functools.wraps(cls) def get_instance(*args, **kwargs): if cls not in instances: instances[cls] cls(*args, **kwargs) return instances[cls] return get_instance singleton class Database: def __init__(self): print(初始化数据库连接)上面是函数式写法。如果换成类装饰器可以做得更直接把类实例直接存到instance属性上。class Singleton: def __init__(self, cls): self._cls cls self._instance None functools.update_wrapper(self, cls) def __call__(self, *args, **kwargs): if self._instance is None: self._instance self._cls(*args, **kwargs) return self._instance很多新手在使用单例装饰器装饰一个类时有个误解以为后续Database()返回的还是Database类。实际上返回的是Singleton实例只是它长得像类、用起来也像类而已。这个差异在某些场景下会暴露出来比如isinstance(obj, Database)的检查会失败因为此时Database已经指向Singleton实例而不是原来的类了。如果你在动态检查类型的地方用了单例装饰器务必记得这一点。5.3 何时选择函数式、何时选择类我个人的选型标准是这样的装饰逻辑简单、不需要维护状态优先用函数式代码量少可读性好。需要维护多个状态或者状态之间有复杂的交互用类式。装饰器本身是可配置的且配置项较多建议外层用函数工厂、内部用类或者函数都可以关键是状态归属清晰。类装饰器还有一个隐藏优势它可以定义方法。比如你在__call__之外定义了一个reset()方法调用方可以通过decorator_obj.reset()来重置状态。这在函数式写法里就得额外返回一个带方法的对象写法反而更绕。6. 内置装饰器与常用装饰器盘点6.1 property、classmethod、staticmethod 的实际语义property应该是除了app.route之外最常用的内置装饰器了。它的底层机制是把一个方法转换为一个描述符从而让你可以用属性访问的方式来调用方法。这不仅是语法糖还带来了真实的约束能力。class Temperature: def __init__(self, celsius): self._celsius celsius property def fahrenheit(self): return self._celsius * 9 / 5 32 fahrenheit.setter def fahrenheit(self, value): self._celsius (value - 32) * 5 / 9给外部使用者的体验是temp.fahrenheit既能读又能写但他们看不到背后的方法调用。这就是接口与实现分离的典型案例你随时可以改内部存储结构但只要保持属性接口不变调用方代码就不用动。classmethod和staticmethod的区别同样是高频考点。简单来说classmethod的第一个参数是类本身cls它既能被实例调用也能被类直接调用通常用于定义工厂方法比如从配置字典里创建对象。staticmethod不接收任何隐式参数完全就是一个普通函数只是放在类的命名空间里通常用于放那些跟类有关联但又不依赖类和实例的工具函数。判定方法很简单看函数体内到底需不需要访问cls或self不需要就用staticmethod只访问cls就用classmethod。6.2 functools.lru_cache 的用法与原理functools.lru_cache大概是Python标准库里最实用的装饰器之一。原理是用一个字典缓存函数的参数与返回值的映射遇到相同参数直接返回缓存结果不再执行函数体。import functools functools.lru_cache(maxsize128) def fibonacci(n): if n 2: return n return fibonacci(n - 1) fibonacci(n - 2) print(fibonacci(50))maxsize是缓存条目上限超过上限后会按LRU策略淘汰最久未用的条目。这个装饰器使用起来非常轻松但它有几个坑需要注意函数的参数必须是可哈希的。如果你传一个列表进去当场报TypeError: unhashable type: list。这要求你的函数参数得用元组而不是列表或者把列表转成元组再传。被缓存函数的返回值会被永久保存。如果返回值是可变对象外部修改这个对象会导致缓存数据被污染因为下次返回的是同一个对象引用。解决办法是返回不可变类型或者在返回值上做防御性拷贝。带lru_cache的函数不适合有副作用的用法。如果函数内打印、写文件、发网络请求那缓存生效后这些副作用都只执行一次可能跟你预期不一致。6.3 dataclasses.dataclass 与装饰器的关系dataclass在Python 3.7引入它算是一种特殊的类装饰器会自动根据类注解生成__init__、__repr__、__eq__等方法。这里它跟普通装饰器的区别在于它接收的不是函数而是类并且会修改类的属性给类添加方法。from dataclasses import dataclass dataclass class Point: x: float y: float p1 Point(1.0, 2.0) p2 Point(1.0, 2.0) print(p1 p2) # True如果没有dataclass这个比较结果是False从装饰器的角度去理解dataclass你会发现装饰器的适用范围比“函数包装”要大得多它还可以做代码生成。只要传入的是一个可调用对象装饰器就能工作并不限于函数或类本身。这也解释了为什么你可以用装饰器去包装生成器函数、异步函数、甚至任何带有__call__的对象。6.4 自定义装饰器的几个应用模板除了标准库和框架自带的装饰器在实际业务中我更常用下面几个模板每个都以代码片段的形式沉淀在个人代码库里限流器模板控制单位时间内调用次数import functools import time def rate_limit(max_calls10, period60.0): def decorator(func): call_times [] functools.wraps(func) def wrapper(*args, **kwargs): now time.monotonic() call_times[:] [t for t in call_times if now - t period] if len(call_times) max_calls: raise RuntimeError(调用次数超过限制) call_times.append(now) return func(*args, **kwargs) return wrapper return decorator权限校验模板函数级访问控制def require_role(role): def decorator(func): functools.wraps(func) def wrapper(*args, **kwargs): current_user kwargs.get(user) if current_user is None or current_user.role ! role: raise PermissionError(f需要 {role} 角色) return func(*args, **kwargs) return wrapper return decorator异步函数装饰器模板对齐了async def函数的情况def async_timer(func): functools.wraps(func) async def wrapper(*args, **kwargs): start time.perf_counter() result await func(*args, **kwargs) elapsed time.perf_counter() - start print(fasync {func.__name__} 耗时 {elapsed:.4f}s) return result return wrapper注意异步函数的装饰器在定义wrapper时也要是async def并且在内部用await去调用原函数。如果你把同步装饰器用在异步函数上返回的是一个普通函数它不会自动变成协程调用方await它的时候会直接报错。7. 实际项目中的装饰器编排技巧7.1 多个装饰器的叠加顺序写a、b、c三个装饰器叠在一起很多人以为执行顺序是从上往下。实际规则是装饰器的应用是从下往上但函数调用时的执行顺序是从上往下。def outer_decorator(func): functools.wraps(func) def wrapper(*args, **kwargs): print(外层装饰器 - 调用前) result func(*args, **kwargs) print(外层装饰器 - 调用后) return result return wrapper def inner_decorator(func): functools.wraps(func) def wrapper(*args, **kwargs): print(内层装饰器 - 调用前) result func(*args, **kwargs) print(内层装饰器 - 调用后) return result return wrapper outer_decorator inner_decorator def task(): print(执行任务)执行task()的输出顺序是外层装饰器 - 调用前 内层装饰器 - 调用前 执行任务 内层装饰器 - 调用后 外层装饰器 - 调用后等效展开就是task outer_decorator(inner_decorator(task))。先执行inner_decorator(task)得到一个新的函数然后outer_decorator再包装这个新函数所以最外层的装饰器是最后包装上去的但在调用时它是最先执行的。7.2 装饰器顺序的典型坑登录、权限、缓存这个顺序不是单纯为了炫技它在实际项目中会影响正确性。常见的最佳实践顺序是先做轻量级的前置判断再做重量级的操作最后才是业务逻辑。我有一个具体例子。接口层需要做三件事登录校验、权限校验、结果缓存。如果顺序写反了先缓存后鉴权会有一个严重的安全隐患未登录用户请求一个被缓存的结果时如果命中了缓存就直接返回数据了根本没走到鉴权那层。我亲眼见过一个内部系统因为这个顺序问题把本应鉴权的数据暴露给了匿名请求。正确的顺序应该是cache_result() require_permission(admin) login_required def get_sensitive_data(user): ...展开后调用顺序是先login_required确认登录再require_permission校验权限最后才查缓存。这样即使缓存命中也经过了完整的鉴权链路。7.3 偏函数与装饰器结合使用functools.partial本身跟装饰器没有直接关系但在实际项目里它们经常配合出现。比如你想让一个带参数的装饰器适用于某个固定参数的场景可以用偏函数绑定默认值from functools import partial retry_5_times partial(retry, max_attempts5, delay1.0) retry_5_times def call_external_api(): ...偏函数的好处是生成了一个语义明确的新装饰器调用处读代码时一眼就能懂“这个接口会重试5次”。比直接写retry(max_attempts5, delay1.0)的意图更集中也更方便复用。7.4 装饰器与依赖注入的简单实现装饰器不仅可以给函数加逻辑还能做依赖管理。我曾在服务端项目里用装饰器实现了轻量级的依赖注入核心思路是用一个全局注册表来存储依赖关系_registry {} def inject(*deps): def decorator(func): functools.wraps(func) def wrapper(*args, **kwargs): resolved [ _registry[dep] for dep in deps ] return func(*(resolved list(args)), **kwargs) return wrapper return decorator这种写法虽然远不如fastapi的Depends那么完善但对于一些小工具、脚本项目已经完全够用而且代码量极少维护成本低。不过要注意全局注册表在测试时会带来状态污染问题建议在测试环境的setup里清理_registry。8. 常见问题与排查技巧实录8.1 函数签名被破坏被装饰后的函数如果用help()查看签名会显示成wrapper(*args, **kwargs)而不是原函数的真实签名。根源是__name__、__doc__等元信息被覆盖了。解决方案很简单给wrapper加上functools.wraps(func)。如果加了还不行检查一下你是否在自定义装饰器里手动覆盖了__name__之类的属性或者检查函数是否被多个装饰器叠加多次包装。对于需要精确展示参数签名的情况inspect.signature可以穿透__wrapped__拿到原函数签名这也是解决之道。8.2 装饰器参数写错导致的不执行retry和retry()两个写法的区别对新手来说是重灾区。写retry时retry接收的是函数本身写retry()时先执行retry()得到真正的装饰器然后这个装饰器再接收函数。如果把两者混用最常见的报错是TypeError: retry() missing 1 required positional argument或者int object is not callable。排查思路是打印每个层次的类型。比如在retry的第一行加print(type(func))看传进来的是函数还是别的。一旦传进来的是函数对象说明你用了retry而不是retry()此时如果retry内部把第一个参数当成了max_attempts数字在用必然报错。8.3 装饰器内异常被吞掉最危险的问题不是报错而是装饰器悄无声息地改变了函数的行为。很多新手会在wrapper里这样写try: result func(*args, **kwargs) except Exception: pass return result异常被pass吞掉后调用方拿到的要么是未定义的变量报错要么是None返回值。正确的处理方式是用logger.exception记录异常堆栈然后raise原样抛出。记住一个原则装饰器只能增加行为不能改变原有函数的成功/失败语义除非你这个装饰器的初衷就是做重试和降级。8.4 缓存装饰器的序列化问题lru_cache要求函数参数可哈希。当你的参数是列表时可以先把列表转成元组lru_cache装饰的函数内部再转换。如果参数是字典可以转成排序后的元组对。参数是自定义类对象时如果你的类没有实现__hash__默认就会用对象身份来哈希这样相同内容的两个不同实例不会被缓存命中。排序项其实很容易被忽略frozenset(d.items())可以用于字典但注意必须在调用func前完成转换。如果你在装饰器内部转换参数原函数的签名没有变化缓存对调用方是透明的这是更好的封装方式。8.5 类中方法使用装饰器的特殊问题当你装饰一个类的实例方法时有一个大坑装饰器拿到的func是绑定后的方法还是普通函数这取决于装饰器的写法。看这段代码def method_decorator(func): functools.wraps(func) def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapper class Foo: method_decorator def bar(self): passbar在被装饰时func是一个普通函数self不是自动绑定的。装饰器执行期间bar方法还没有绑定到实例上。所以你在wrapper里用args[0]才能拿到self。如果装饰器需要访问实例的属性和方法要么通过args[0]要么改为类装饰器的形式。对于classmethod和staticmethod装饰顺序也要注意。比如classmethod my_decorator和my_decorator classmethod效果不同。前者先转成类方法再装饰func接收的第一个参数是cls后者先装饰原函数再转成类方法func的参数顺序就跟普通函数一致了。常规经验是如果你要在类方法上叠自定义装饰器把自定义装饰器放在classmethod的上方这样wrapper接收的cls才能正常传递。8.6 排查工具使用 inspect 模块排查装饰器问题时inspect模块是我的首选工具import inspect def wrapper(*args, **kwargs): return func(*args, **kwargs) print(inspect.signature(func)) print(inspect.unwrap(wrapper)) # 返回被包装的原函数inspect.signature(func)在内部会寻找__wrapped__属性直到找到最终的原函数所以它能给出真实的参数签名而不是*args, **kwargs。在调试多层装饰器时这个功能格外有用能迅速剥开包装层看到原始函数。9. 装饰器的常见误区与工程化建议9.1 装饰器不是AOP的全部提到装饰器很多人会联想到面向切面编程AOP。装饰器确实可以实现类似AOP的部分功能比如日志、权限、缓存但跟Java的AspectJ等完整AOP框架相比装饰器有几个明显差别它作用于函数级而非类级它是显式叠加而非隐式织入它能控制的切点类型有限对属性的拦截、对类构造过程的拦截就非常别扭。所以我的建议是在函数级做横切逻辑优先用装饰器涉及到类的拦截、动态代理、全局级切面就要考虑使用元类、描述符或其他方案。选了合适的工具事半功倍工具选型错了后面维护成本指数级上升。9.2 装饰器与性能引入的开销装饰器多了一层函数调用自然带来微小的性能开销。对于绝大多数业务场景这层开销可以忽略不计。但在热路径上比如每秒钟调用上万次的函数你就要注意了。我实测过一个纯计算函数加了一层最简单的无逻辑装饰器后调用时间增加了大约一微秒左右。对于IO密集型任务这个开销完全无所谓对于高频的纯CPU计算一微秒乘以百万次就是一秒级别的影响。若不幸遇到这个情况考虑是否能把装饰器的逻辑内联进业务函数或者改用类装饰器减少闭包层级。9.3 装饰器的可读性与维护性装饰器用多了代码会变得非常“魔法”。堆了一大堆符号后新人看代码时往往一头雾水。我的工程化建议是每个自定义装饰器都要写清晰的文档字符串说明它的行为、参数含义、潜在副作用。装饰器逻辑保持单一职责一个装饰器只做一件事。装饰器跟业务函数放在不同模块里业务模块只负责import不负责实现装饰器细节。如果装饰器带有较复杂的状态或配置用类来组织别硬塞闭包。9.4 装饰器与测试的配合测试装饰器时本质上有两个层面要测装饰器本身对被装饰函数的影响以及被装饰函数仍然保留原功能。我推荐的测试方案是准备一个简单的桩函数比如返回固定值的函数然后在这个桩函数上应用装饰器做断言def test_timer_decorator(): timer def fake(): return 42 assert fake() 42测试状态型装饰器比如计数器、缓存需要确保测试用例之间是隔离的。最简单的办法是每次测试里重新定义一个新的被装饰函数因为装饰器里的闭包状态是随函数定义产生的新函数就有新的状态。9.5 装饰器与类型标注的兼容性类型标注在装饰器场景下容易踩坑。mypy和pyright对装饰器的推断支持已经不错但前提是你正确使用了functools.wraps并且最好用ParamSpec和TypeVar来标注泛型装饰器from typing import Callable, ParamSpec, TypeVar P ParamSpec(P) T TypeVar(T) def timer(func: Callable[P, T]) - Callable[P, T]: functools.wraps(func) def wrapper(*args: P.args, **kwargs: P.kwargs) - T: ... return func(*args, **kwargs) return wrapperParamSpec是Python 3.10开始引入的它允许你在类型层面完整保留原函数的参数签名。如果你的项目还在用Python 3.8或3.9typing_extensions里也提供了ParamSpec的向后移植版本。用了这套标注之后IDE提示的质量会大幅提升调试的时候能看清参数名和类型。提示类型标注里最容易被忽略的是Callable[P, T]中P和T要放在装饰器的内层函数上还是外层函数上。当装饰器本身有参数时P和T要绑定到真正的decorator层而外层工厂函数跟P、T无关。写错位置会让类型检查器给出完全错误的推断结果。10. 几个亲手验证过的装饰器组合场景最后分享三个我实际在项目里组合使用的场景。这些场景覆盖了数据采集、Web服务和异步任务三种典型环境组合的方法和踩坑经验都有实战参考价值。第一个场景是数据采集脚本的性能监控。我需要把每个采集步骤的耗时、成功率、数据量统计出来。我写了一个装饰器内部分别做了计时、异常捕获、结果统计同时用类来保存每次采集的详情列表class Monitor: def __init__(self, func): functools.update_wrapper(self, func) self.func func self.records [] def __call__(self, *args, **kwargs): start time.perf_counter() try: result self.func(*args, **kwargs) status ok except Exception as e: result None status ferror: {e} elapsed time.perf_counter() - start self.records.append({ time: time.strftime(%Y-%m-%d %H:%M:%S), elapsed: elapsed, status: status, }) return result统计结果不用等脚本跑完随时可以拿monitor.records查看。这个类装饰器把状态管理得很干净后续加导出功能也容易。第二个场景是Flask服务里对多个接口做统一的参数校验与错误包装。因为接口数量多每个都写一遍try结构太啰嗦我写了一个带参数的错误包装装饰器def api_wrapper(schema): def decorator(func): functools.wraps(func) def wrapper(*args, **kwargs): try: request_data kwargs.get(data) or {} validated schema(**request_data) kwargs[data] validated return {code: 0, data: func(*args, **kwargs)} except ValidationError as e: return {code: 400, message: str(e)} except Exception as e: return {code: 500, message: str(e)} return wrapper return decorator这里我强调的是“错误包装”职责的单一性装饰器不判断业务逻辑只负责校验和返回统一的结构。业务函数保持干净只管算数据。第三个场景是异步任务队列里的幂等控制。任务重复执行有时会导致数据重复写入我封装了一个简单、可复用的幂等装饰器以任务名称和参数哈希作为去重键def idempotent(redis_client): def decorator(func): functools.wraps(func) def wrapper(*args, **kwargs): key fidempotent:{func.__name__}:{hash(str(args) str(kwargs))} if redis_client.get(key): return None result func(*args, **kwargs) redis_client.set(key, 1, ex3600) return result return wrapper return decorator这里有一个需要留意的点hash(str(args))在不同进程里对同一个字符串可能不同如果要在分布式环境里保证幂等不建议直接用Python内建的hash()。更稳妥的做法是用hashlib.sha256来计算参数摘要保证跨进程一致性。这三个场景分别体现了类装饰器状态管理、带参装饰器统一包装、装饰器与外部依赖组合三种典型模式。读完最后一个示例你现在应该对装饰器有了比较完整的认知从函数一等对象这层地基到语法糖后面的等价赋值逻辑再到wraps修复元信息、带参装饰器三层嵌套、类装饰器状态管理、内置装饰器的应用以及实战中的顺序编排和调试排查。在我日常工作中装饰器用得最频繁的那一刻往往是代码里开始出现重复的“打开日志、记录时间、捕捉异常、关闭连接”四件套的时候。判断标准很朴素同一段横切逻辑出现第三遍就该动手抽一个装饰器了。这个原则帮我省下了大量时间也让业务代码保持了难得的干净。