Python 装饰器:是什么、如何工作,以及如何编写自己的装饰器
装饰器是接收一个函数并返回新函数(通常是包装器)的函数,它在调用前后添加行为,例如日志、计时、缓存或权限检查。写在 def 上方的 @decorator 等价于 func = decorator(func);为了让包装器保留原函数的名字和文档字符串,使用 functools.wraps。
Python 中的装饰器是一个函数:它接收另一个函数,并返回一个新的函数,通常是对原函数的包装。借助它,可以在不修改函数代码的情况下为其添加日志、计时、缓存、重试等行为。装饰器写成 @名字,放在函数声明的上方,在 Flask、FastAPI、pytest 以及标准库中随处可见。
装饰器通俗地讲是什么
可以把它想象成礼物和包装纸。礼物(函数)保持不变,外面多了一层包装,它会先于礼物起作用。装饰器就是这样一层包装:调用函数时,先执行包装器的代码,包装器再调用原函数,并且可以对结果做些处理。
之所以能这样做,是因为在 Python 中函数是普通对象:可以把它们传给其他函数,从函数中返回,也可以在函数内部定义。装饰器正是建立在这三个特性之上。
装饰器如何工作:@ 只是语法糖
来看一个最简单的装饰器,它在调用前后打印消息:
def log_calls(func):
def wrapper():
print(f"正在调用 {func.__name__}")
result = func()
print(f"{func.__name__} 执行完毕")
return result
return wrapper
@log_calls
def hello():
print("你好!")
hello()
正在调用 hello
你好!
hello 执行完毕
写在 def 上方的 @log_calls 与在声明之后写下面这行完全等价:
def hello():
print("你好!")
hello = log_calls(hello)
现在名字 hello 指向的是 wrapper,原函数则保存在闭包中——wrapper 记住了外层函数中的变量 func。需要注意一个细节:装饰器只在 def 执行时应用一次,也就是模块导入时;而包装器会在每次调用时运行。
闭包与装饰器
闭包是一个内部函数,即使外层函数已经结束,它仍然记得外层函数的变量。没有闭包,装饰器就不可能实现:正是通过闭包,wrapper 才能访问 func。闭包中也可以保存状态,例如调用次数计数器:
def count_calls(func):
calls = 0
def wrapper(*args, **kwargs):
nonlocal calls
calls += 1
print(f"{func.__name__}: 第 {calls} 次调用")
return func(*args, **kwargs)
return wrapper
如何编写 Python 装饰器:通用模板
前面第一个例子有两个缺点:包装器不接收参数,而且会“吞掉”原函数的名字。下面是一个可靠的模板,编写任何装饰器都可以从它开始:
import functools
def my_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
# 调用之前的代码
result = func(*args, **kwargs)
# 调用之后的代码
return result
return wrapper
模板的三条规则:
*args, **kwargs— 包装器接收任意参数,并原样传递下去。return result— 别忘了返回结果,否则被装饰的函数总会返回None。@functools.wraps(func)— 把原函数的元数据复制到包装器上。
下面是一个测量执行时间的装饰器示例:
import functools
import time
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__} 用时 {elapsed:.3f} 秒")
return result
return wrapper
@timer
def slow_sum(n):
return sum(range(n))
slow_sum(10_000_000) # slow_sum 用时 0.142 秒
为什么需要 functools.wraps
如果不使用 wraps,被装饰函数的名字、文档字符串等属性都会被替换,外部看到的是 wrapper:
def plain(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@plain
def add(a, b):
"""计算两个数的和。"""
return a + b
print(add.__name__, add.__doc__) # wrapper None
使用 @functools.wraps(func) 之后,包装器会获得原函数的 __name__、__doc__、__module__、__qualname__ 和 __annotations__,同时 __wrapped__ 属性中保存着原函数的引用。这对调试和 traceback、help()、按名字注册函数的框架,以及显示真实签名的 inspect.signature 都很重要。
带参数的装饰器
有时装饰器需要配置,例如 @retry(times=3) 或 @app.route("/")。这时需要再增加一层嵌套:外层函数接收参数,并返回真正的装饰器——这种结构称为装饰器工厂。
import functools
def repeat(times):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = None
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(times=3)
def ping():
print("ping")
ping() # ping, ping, ping — 三行输出
调用顺序是:repeat(times=3) 返回 decorator,它再应用到 ping 上并返回 wrapper。换句话说,@repeat(times=3) 等价于 ping = repeat(times=3)(ping)。
一个实用的例子是出错后重试:
import functools
import time
def retry(times=3, delay=1.0, exceptions=(Exception,)):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except exceptions:
if attempt == times:
raise
time.sleep(delay)
return wrapper
return decorator
@retry(times=5, delay=0.5, exceptions=(ConnectionError,))
def fetch_rate():
...
在同一个函数上使用多个装饰器
装饰器可以叠加使用。它们从下往上应用:离 def 最近的先被应用。
@timer
@log_calls
def report():
...
# 等价于:
report = timer(log_calls(report))
调用时,最外层(timer)先执行,因此它也会计入 log_calls 的运行时间。顺序很重要:例如,一个装饰器检查访问权限,另一个缓存结果,二者的先后会影响结果。
Python 的标准及内置装饰器
语言和标准库中已经包含许多实用的装饰器:
| 装饰器 | 作用 |
|---|---|
@property | 把方法变成只读属性;配合 @x.setter 可以变成受控的可写属性 |
@staticmethod | 没有 self 的类方法,本质上是类命名空间中的普通函数 |
@classmethod | 方法接收类 cls 而不是实例——常用于编写替代构造函数 |
@functools.cache、@functools.lru_cache | 按参数缓存结果 |
@functools.wraps | 把函数的元数据复制到包装器上 |
@dataclasses.dataclass | 为类生成 __init__、__repr__、__eq__ |
@contextlib.contextmanager | 把生成器变成可用于 with 的上下文管理器 |
缓存:functools.cache 与 lru_cache
缓存装饰器会记住每组参数对应的结果,再次调用时直接返回,而不再计算。经典例子是递归计算斐波那契数,不加缓存时耗时呈指数增长:
import functools
@functools.cache
def fib(n):
return n if n < 2 else fib(n - 1) + fib(n - 2)
print(fib(100)) # 354224848179261915075
functools.cache(自 Python 3.9 起可用)保存所有结果,没有上限。@functools.lru_cache(maxsize=128) 只保留最近使用的若干条记录,并淘汰旧的,对长期运行的服务更安全。统计信息用 fib.cache_info() 查看,用 fib.cache_clear() 清空。这类函数的参数必须是可哈希的:传入列表会得到 TypeError: unhashable type: 'list'。而且只应缓存纯函数——即相同参数总是返回相同结果、没有副作用的函数。
property 装饰器的作用
@property 让你可以像访问属性一样调用方法(不加括号),并在赋值时进行控制:
class Account:
def __init__(self, balance):
self._balance = balance
@property
def balance(self):
return self._balance
@balance.setter
def balance(self, value):
if value < 0:
raise ValueError("余额不能为负数")
self._balance = value
acc = Account(100)
print(acc.balance) # 100
acc.balance = -5 # ValueError
staticmethod 与 classmethod
class Money:
def __init__(self, kopecks):
self.kopecks = kopecks
@classmethod
def from_rubles(cls, rubles):
return cls(round(rubles * 100))
@staticmethod
def is_valid(value):
return value >= 0
m = Money.from_rubles(12.5)
print(m.kopecks, Money.is_valid(-1)) # 1250 False
正是 @classmethod 让方法可以作用于类本身,而不是实例:from_rubles 也能正确地创建子类的对象。
类装饰器与用类实现的装饰器
装饰器也可以应用于类:它接收一个类,并返回一个类(同一个或修改过的)。@dataclass 就是这样工作的。下面是一个注册类的简单例子:
registry = {}
def register(cls):
registry[cls.__name__] = cls
return cls
@register
class CsvExporter:
pass
print(registry) # {'CsvExporter': <class '__main__.CsvExporter'>}
反过来,也可以用类充当装饰器。任何带有 __call__ 方法的对象都可以,状态很适合保存在实例属性中:
import functools
class CountCalls:
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
self.calls = 0
def __call__(self, *args, **kwargs):
self.calls += 1
return self.func(*args, **kwargs)
@CountCalls
def tick():
pass
tick(); tick()
print(tick.calls) # 2
异步装饰器
对于 async def 函数,包装器本身也必须是协程,并且用 await 等待结果,否则返回的只是一个尚未执行的协程对象:
import functools
def log_async(func):
@functools.wraps(func)
async def wrapper(*args, **kwargs):
print(f"开始 {func.__name__}")
result = await func(*args, **kwargs)
print(f"完成 {func.__name__}")
return result
return wrapper
装饰器有什么用
- 横切关注点 — 日志、指标、计时,无需把代码复制到每个函数中。
- 缓存 — 用
@functools.cache处理昂贵的纯计算。 - 检查 — 访问权限、参数验证、Web 处理函数中的鉴权。
- 注册 — Flask 和 FastAPI 中的路由(
@app.get("/")),pytest 中的 fixture。 - 可靠性 — 重试、超时、错误处理。
主要优点是职责分离:函数专注于自己的任务,辅助行为只需一行代码即可添加,也同样容易移除。
常见错误
- 包装器没有返回结果 — 被装饰的函数返回
None。 - 没有
*args, **kwargs— 带参数调用时出现TypeError。 - 忘记
wraps—__name__和文档字符串错误,日志难以理解。 - 工厂缺少括号 — 写成
@repeat而不是@repeat(3),函数会被当作times参数传入。 - 装饰器主体中有繁重的代码 — 它在导入模块时执行,而不是在调用时执行。
小结
Python 装饰器是高阶函数:接收一个函数,返回包装器;@ 只是赋值语句的简写。编写时从包含 *args, **kwargs、return result 和 functools.wraps 的模板开始;需要配置时,增加一层工厂函数。装饰器所依赖的函数、闭包和注解,在课程中都有实践讲解。
常见问题
能否取消函数上的装饰器?
如果装饰器使用了 functools.wraps,原函数可以通过 __wrapped__ 属性访问:func.__wrapped__(...) 会直接调用未包装的函数。否则只能从代码中删除带 @ 的那一行。
装饰器的代码什么时候执行?
装饰器函数本身在声明被装饰的函数时执行一次,通常是在导入模块期间。wrapper 内部的代码则在每次调用被装饰的函数时执行。
装饰器可以用于类的方法吗?
可以。普通的 *args, **kwargs 装饰器同样适用于方法,self 只是 args 的第一个元素。
装饰器和继承有什么区别?
继承通过类的层次结构改变行为;装饰器从外部包装具体的函数或类,不需要修改代码,并且可以方便地与其他装饰器组合。