本文目录
看到 @timer 贴在 def fetch(): 上面,容易以为 Python 在函数体里「插入」了一段计时逻辑。实际上解释器只做一件事:先定义函数,再用装饰器的返回值替换掉原来的名字。没有魔法插入,没有改字节码,只有名字重新绑定——和写 fetch = timer(fetch) 一模一样。
装饰器在标准库和第三方库里无处不在:@property 把方法变成描述符,@staticmethod 改调用约定,Flask 的 @app.route("/") 把函数登记进路由表。把 @ 还原成等价的后置调用,读源码、写包装器、排查「为什么 __name__ 变了」都会稳很多。
装饰器解决的是横切关注点:计时、鉴权、缓存、重试、注册——同一段逻辑要套在很多函数或类上,又不想在每个函数体里复制粘贴。弄懂「替换」语义后,你可以自己实现轻量版 lru_cache 或路由表,而不必死记每个框架的魔法。
语法糖:@ 等价于后置调用
装饰器本质上是高阶函数:输入一个 callable,输出一个 callable(或任意对象,但常见是函数)。@ 只是让这一步写在定义旁边,读起来更紧凑。
def shout(func):
def wrapper(*args, **kwargs):
print(">>>")
result = func(*args, **kwargs)
print("<<<")
return result
return wrapper
@shout
def greet(name: str) -> str:
return f"Hello, {name}"
# 与下面完全等价:
# def greet(name: str) -> str:
# return f"Hello, {name}"
# greet = shout(greet)展开对照:
| 写法 | 实际步骤 |
|---|---|
@shout + def greet | 1. 创建函数对象,临时绑到 greet;2. greet = shout(greet) |
无 @ | 手动写第 2 步 |
装饰器必须是可调用对象(函数、带 __call__ 的类实例等);它接收被装饰的可调用对象,返回新的可调用对象(通常包装原函数,闭包持有原函数引用)。
运行:
print(greet("Ada"))
# >>>
# Hello, Ada
# <<<注意:装饰在定义时执行一次,不是每次调用 greet 时都执行 shout。若装饰器里有注册逻辑,副作用发生在 import/定义阶段。这也是「import 某模块就自动注册路由/信号处理器」的实现基础——模块被加载,函数被定义,装饰器立刻跑完注册。
functools.wraps:别让包装函数「丢身份」
裸写 wrapper 会把 __name__、__doc__、__module__、__qualname__ 等元数据弄丢。调试器、文档生成、help() 都会显示 wrapper 的名字,栈追踪也不直观。
def bare(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@bare
def add(a: int, b: int) -> int:
"""两数之和。"""
return a + b
print(add.__name__) # wrapper
print(add.__doc__) # Nonefunctools.wraps 把原函数的元数据复制到 wrapper 上——写装饰器时的常规写法,相当于「语法糖之上的语法糖」:
from functools import wraps
def logged(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"call {func.__name__}")
return func(*args, **kwargs)
return wrapper
@logged
def mul(a: int, b: int) -> int:
"""乘法。"""
return a * b
print(mul.__name__) # mul
print(mul.__doc__) # 乘法。
print(mul(3, 4)) # call mul → 12若还要保留函数签名(用于 inspect),可叠加 functools.wraps 与 inspect.signature 或第三方 decorator 包;标准库场景 wraps 通常够用。
带参数的装饰器:多一层工厂
@retry(times=3) 这种形式,Python 会先求值 retry(times=3),再把结果当作装饰器去装饰函数。因此需要三层嵌套:外层接收装饰器参数,中层接收被装饰函数,内层才是实际调用。
from functools import wraps
def repeat(times: int):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
last: object = None
for _ in range(times):
last = func(*args, **kwargs)
return last
return wrapper
return decorator
@repeat(times=2)
def ping() -> str:
print("ping")
return "pong"
# 等价于:ping = repeat(times=2)(ping)
ping()
# ping
# ping展开顺序:repeat(2) 先返回 decorator;decorator(ping) 再返回 wrapper;最终名字 ping 指向 wrapper。写错缩进层级(少一层 def)是初学者最常踩的坑:若 @repeat(2) 后面直接跟函数,说明 repeat(2) 必须返回一个「单参数 callable」。
可选参数装饰器可以用 *args, **kwargs 或「无参时直接当 decorator 用」的分支实现,但核心模型不变——多一层工厂。
装饰类、叠放与 import 时机
类也可以被装饰——@dataclass 接收类对象,返回增强后的类。原理仍是「类语句执行完,名字绑到装饰器返回值」。
装饰器可以叠放:
@outer
@inner
def f(): ...等价于 f = outer(inner(f)),从下往上应用:inner 先包 f,outer 再包结果。顺序影响行为(例如外层先记日志还是内层先校验权限)。
因为装饰在定义时运行,模块被 import 时就会执行。重量级初始化(连数据库、读大文件)放在装饰器里要慎重,必要时改成懒执行——第一次调用再注册或再加载配置,避免 import 副作用拖慢启动。
无参装饰器与带参装饰器的区别,纯粹是「装饰器本身要不要先吃参数」。写 @cache 时,cache 直接是 (func) -> wrapper;写 @cache(maxsize=128) 时,cache(maxsize=128) 必须先返回真正的 decorator。IDE 里把 @ 行折叠成赋值语句,是验证理解的好办法。
和元类的边界
上一篇的 metaclass 在 class 语句执行过程中改造「如何造类」;装饰器在类或函数对象已经创建之后包装它。轻量横切逻辑(日志、缓存、权限检查、路由注册)优先装饰器;要改类成员表、阻止某些属性进类、统一 metaclass 行为时,才轮到 metaclass。
类装饰器与 metaclass 偶尔重叠(都能在类定义时改类),但装饰器不介入 namespace 构建阶段,通常更好懂、更好测。选哪个可以问:我需要在类体还没执行完时就改 namespace 吗?需要 → metaclass;类已经造好,只想包一层或改 __annotations__ → 装饰器。
用类实现装饰器
函数不够用时,可以用类的 __call__ 当装饰器——尤其需要跨调用保存状态时:
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.count}")
return self.func(*args, **kwargs)
@CountCalls
def work() -> None:
print("done")
work()
work()
# #1 done
# #2 done这里 @CountCalls 等价于 work = CountCalls(work):装饰器本身是可调用类,实例持有原函数和计数器。带参类装饰器则在 __init__ 里收参数,在 __call__ 里收被装饰对象——模式与三层函数相同。
保留签名与 typing
类型检查器看的是最外层 callable 的签名。若 wrapper 写成 (*args, **kwargs),mypy/pyright 会丢失参数信息。Python 3.10+ 可用 ParamSpec 转发:
from collections.abc import Callable
from functools import wraps
from typing import ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def trace(func: Callable[P, R]) -> Callable[P, R]:
@wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print("enter", func.__name__)
return func(*args, **kwargs)
return wrapper运行时行为不变,静态分析能跟上。工程里公开 API 的装饰器值得多花这一行。
写装饰器时还有一个细节:wrapper 若需转发 *args, **kwargs,要保证原函数接受的参数都能透传;若 wrapper 签名写死,会挡住仅关键字参数或 **kwargs 调用。*args, **kwargs 是最稳妥的默认,配合 wraps 与 ParamSpec 兼顾运行时与类型检查。
常见坑
| 现象 | 原因 |
|---|---|
help(f) 显示 wrapper | 忘了 @wraps |
| 带参装饰器报「callable expected」 | 少包一层,把 repeat(times=2) 的返回值当函数体写了 |
| 装饰后类型检查对不上 | 静态类型看的是 wrapper 签名,需 ParamSpec / TypeVar 标注 |
| 循环 import 时注册表是空的 | 装饰器在 import 时跑,依赖模块尚未加载完 |
异步代码里也有 @asyncio.coroutine 时代遗留模式;现代写法用 async def 本身。若写 async 装饰器,wrapper 需是 async def,并在内部 await func(...)——同步装饰器包 async 函数仍可行,但 wrapper 返回 coroutine,调用方要记得 await。
标准库里的装饰器长什么样
functools.lru_cache 是带参装饰器:@lru_cache(maxsize=128) 先调 lru_cache(maxsize=128) 得到 decorator,再装饰目标函数。源码里能看到典型的三层结构与 wraps 用法——读标准库实现是写装饰器的最佳习题。
contextlib.contextmanager 把生成器变成上下文管理器,也是装饰器:装饰后的函数在 with 语句里执行。不同装饰器返回类型可以不是函数(如 property 返回描述符),但「定义时替换名字」这一点不变。
写自己的装饰器时,先在纸上展开成 f = deco(f) 再还原 @,能避免大多数结构错误。带参版本多写一行「工厂返回 decorator」, mentally 把两层括号 () 分开看。
团队规范可要求:对外暴露的装饰器必须带 @wraps,并在 docstring 里说明是否缓存、是否线程安全。读代码的人不必点开 wrapper 才知道语义。
和 property、staticmethod 的关系
@staticmethod、@classmethod 也是装饰器:它们接收函数对象,返回描述符(下一篇)。@property 把函数变成 property 实例。标准库大量用法都符合「定义完 → callable 包装 → 名字重绑」同一模型,只是返回值不一定是函数。
装饰器可以嵌套在类定义里装饰方法——class 体中的 @classmethod 同样是先 def 再替换。方法与函数用的是同一套装饰器机制,没有单独的「方法装饰器语法」。
测试装饰器时,用无 @ 的展开形式 wrapped = deco(fn) 断言行为,比 import 整模块更隔离。副作用(注册、计数)也应在测试里验证「定义时发生一次」,而不是每次调用重复注册。
装饰器与类型标注:@overload、某些框架 stub 也使用 @ 语法,但语义仍是替换——不要与 TypeScript 的装饰器实验特性混为一谈,Python 装饰器没有独立的 stage 概念。弄混时记住:Python 的 @ 永远在定义完成后立刻执行一次。
记住:@decorator 不是注解处理器,只是替换——把原名字重新绑到 decorator(原对象) 的返回值;wraps 保留可追溯的元数据;带参装饰器多包一层工厂函数。把这三点内化,装饰器就从「符号魔法」变回普通的函数调用链。