Python 装饰器,别再只会加个 @log 了(彻底搞懂原理 + 应用)
装饰器可以说是 Python 最让人“又爱又恨”的语法糖之一。
刚开始我们觉得它只是个语法糖,可一旦深入,就发现它其实可以做很多:权限验证、日志记录、缓存、参数校验、接口拦截、自动重试……几乎所有能想到的“函数增强”都可以用装饰器实现。
但问题也随之而来:
- 装饰器到底是怎么“包裹函数”的?
- 为什么装饰器函数要写三层?
@wraps
到底有什么用?- 装饰器能不能传参数?
- 多个装饰器嵌套,顺序怎么搞清楚?
这篇文章,让我们不依赖死记硬背,把装饰器的原理和实际用法讲清楚讲透。
装饰器是啥?
装饰器本质上是一个“接收函数,返回新函数”的函数。
说白了就是:它接收一个函数,然后生成一个“升级版”的函数,把你想加的功能(比如打印日志)包在原函数前后。
最小例子:
def outer(func): |
执行 say_hello()
,你会看到:
before |
这就完成了“在不修改原函数的前提下,扩展了功能”。
注意:@outer
实际上就是把 say_hello = outer(say_hello)
。
为什么装饰器要嵌套这么多层?
你可能看到过这种结构:
def decorator(func): |
为什么这么绕?因为你需要:
decorator
用来接收原函数;wrapper
是新生成的函数,包裹逻辑写在这里;*args, **kwargs
保证你可以装饰任意函数;return wrapper
是把包装好的新函数交出去。
换句话说,装饰器的“嵌套”是为了兼容性 + 可扩展性。
加个日志:第一个实战装饰器
我们从最经典的“打印日志”开始:
from functools import wraps |
调用 say_hello("Tom")
输出:
调用函数 say_hello |
注意两点:
@wraps(func)
这一行不是装饰器的核心逻辑,但是装饰器开发的必备礼仪。- 它保留原函数的函数名、注释、签名信息;
- 不然
say_hello.__name__
会变成wrapper
,会对调试和文档工具产生困扰。
*args, **kwargs
是必须的,这是为了适配任意参数的函数。
装饰器能传参数吗?
很多人会误以为装饰器不能传参。其实可以,而且还很常用,比如:
|
实现思路是:再套一层函数,把参数先收下,再生成装饰器。
def retry(times): # 外层函数:接收 retry 的参数 |
这个装饰器实现了一个“异常捕获 + 自动重试”的机制,结构上是三层嵌套(参数函数 → 装饰器函数 → wrapper 函数),核心逻辑是在 wrapper 中对原函数执行进行 try/except 控制,并根据传入参数
times
来决定重试次数。
调用流程:
@retry(3)
→ 先执行retry(3)
返回decorator
- 然后
decorator(func)
才包住你要增强的函数
看起来复杂,但逻辑很顺:你先把“设定参数”的工作做掉,再交出一个真正的装饰器。
多个装饰器嵌套,顺序到底谁先谁后?
先上代码:
def A(func): |
输出:
A: before <-- 最外层的 wrapper |
顺序解释:
你可以理解为
run = A(B(run))
,即 B 先包,A 后包;B(run)
:先执行 B,把run
包一层,变成B_wrapper
A(...)
:再用 A 把B_wrapper
再包一层,变成最终的A_wrapper
- 得到的最终
run
是这样的嵌套结构:run = A_wrapper(B_wrapper(run_body))
执行时是“外层先进,然后一层层剥开”。
类装饰器:当你需要保存状态
普通装饰器只能作用一次,如果你想记录这个函数被调用了几次,怎么办?可以用类来封装状态。
class CountCalls: |
调用 greet()
两次,你会看到:
第 1 次调用 |
类装饰器的核心是实现 __call__
,它允许“实例像函数一样被调用”。
下面拆解一下这个类装饰器的工作方式:
构造方法:__init__
当你写下:
|
其实就等价于:
greet = CountCalls(greet) |
也就是说:
- Python 会把
greet
函数传给CountCalls.__init__()
的func
参数; - 然后返回的是
CountCalls
的实例; - 此后,
greet()
实际上是调用这个实例对象。
调用方法:__call__
Python 的一个语法特性是:如果一个类实现了 __call__()
方法,那么它的实例就可以像函数一样被调用。
所以当写下:
greet() |
Python实际执行的是:
greet.__call__() |
也就是:
self.count += 1 |
类装饰器适用场景
相比函数装饰器,类装饰器的优势在于:
- 可以保存状态(如调用次数、时间、历史数据)
- 可以更容易扩展(比如记录时间戳、日志等)
- 面向对象风格,代码更清晰,适合复杂逻辑
在 Flask 中一定见过它
Flask 的路由就是一个装饰器:
|
你也可以自己写一个装饰器,比如对接口做 token 校验:
def require_token(func): |
装饰器的本质就是在原函数执行前,拦截请求、检查权限、决定放行与否。
它的作用是在执行真正的业务函数前,先检查请求头中的 token 是否合法,如果不合法,就直接返回 401。
@require_token
加到 Flask 路由函数上,表示:客户端访问 /secure
路由时,Flask 会先执行 require_token
装饰器中的逻辑,再决定是否调用 secure_data
函数。
可能踩过的坑
1. 忘记 return 原函数结果
def wrong(func): |
如果原函数有返回值,直接被吞了,调用者拿不到结果。
2. 不加 @wraps,函数名变了
错误示例:
def no_wraps(func): |
问题分析:
- Python 函数有元信息,比如
__name__
、__doc__
、__annotations__
- 如果你不加
@wraps(func)
,被装饰函数的__name__
会变成"wrapper"
,不再是原来的函数名。
后果:
|
这会影响:
- 日志打印
- 调试断点
- 文档生成工具(如 Sphinx)
- Flask/FastAPI 等框架的自动路由注册
正确做法:
from functools import wraps |
3. 参数漏写,装饰器不能泛用
错误示例:
def rigid(func): |
问题分析:
- 这种写法只适用于两个参数的函数;
- 一旦装饰的是三个参数的函数或无参函数,就报错了。
后果:
|
正确做法:
永远使用:
def wrapper(*args, **kwargs): |
必须用 *args, **kwargs
保证适配任何函数。
总结
错误 | 后果 | 正确写法 |
---|---|---|
忘记 return | 返回值丢失 | return func(...) |
忘记 @wraps | 名字、注释等信息丢失 | @wraps(func) 来自 functools 模块 |
没写 *args, **kwargs | 只能装饰特定参数函数 | 用 def wrapper(*args, **kwargs) 通用封装 |
总结:装饰器是代码复用的艺术
在实际开发中,你一定遇到过这样的情况:
- 某段逻辑在多个函数中反复出现,比如权限校验、日志打印;
- 每次都 copy paste 一遍,不仅麻烦,还容易出错;
- 你想要一种更优雅的方式,在不改动函数本身的前提下加上这些逻辑。
装饰器,正是为此而生。
它的本质其实很简单:
接收一个函数,返回一个“增强版”的函数。
一旦你理解了这一点,你就可以:
- 把重复代码封装成可复用的“外挂”;
- 像堆积木一样给函数加功能;
- 写出更模块化、更可维护、更 Pythonic 的代码。
装饰器不只是语法糖,更是一种编程思维。掌握它,是走向成熟 Python 开发者的重要一步。
练习题
写一个 @timeit
装饰器,打印函数执行耗时
import time |
运行结果:
执行完了 |
写一个 @only_once
装饰器,保证函数只执行一次
应用场景:
- 初始化操作
- 单例加载
- 模块只需启动一次的逻辑
代码实现:
from functools import wraps |
运行结果:
初始化配置 |
写一个 @check_admin
装饰器,只有 admin 用户能访问
我们先假设有一个方法可以拿到“当前用户”的信息,比如:
# 模拟当前登录用户(通常来自 session 或 token) |
装饰器代码实现:
from functools import wraps |
运行结果:
用户 tom 没有权限访问 delete_user |
写一个装饰器支持 @logger(level="info")
的形式
目标写法:
|
输出:
[INFO] 调用函数 say_hi |
实现代码:
from functools import wraps |
使用示例:
|
输出:
[WARNING] 调用函数 delete_data |
可选优化:
使用 logging
模块替代 print
:
import logging |
代码解释:
引入并配置 logging 模块
import logging |
- 引入标准库
logging
,用于比print
更强大、更灵活的日志打印。 basicConfig(level=...)
设置最低日志输出级别(低于该等级的日志不会打印)。logging.basicConfig(level=logging.INFO)
只会显示INFO
及以上等级的日志,低于INFO
的不会显示。
- 默认等级是
WARNING
,你设置为INFO
是为了让logging.info(...)
能打印出来。
Python 日志等级
等级名称 | 对应常量 | 用途描述 |
---|---|---|
CRITICAL |
50 |
非常严重错误,程序可能崩溃 |
ERROR |
40 |
一般错误,程序可以继续运行 |
WARNING |
30 |
警告(默认等级),可能出问题 |
INFO |
20 |
一般信息,如状态变化、请求等 |
DEBUG |
10 |
详细调试信息 |
NOTSET |
0 |
最低等级,几乎不单独使用 |
写一个 @cache(seconds=5)
装饰器,缓存函数返回结果一段时间
目标用法:
在一定时间内(比如 5 秒)缓存函数的返回值,在缓存有效期内多次调用不再重新执行函数。
|
第一次调用时执行函数,5 秒内再次调用直接返回缓存结果,不再打印“函数被执行了”。
实现代码:
import time |
测试代码:
|
输出示例:
函数真正执行了 |
说明:
- 缓存使用了字典
cache_data
保持函数结果和时间戳。 - 每次调用时,先检查当前时间与上次调用时间的差值。
- 如果在
seconds
范围内,就直接返回缓存值;否则更新缓存。
支持“不同参数组合分别缓存”的版本
每组参数组合都有独立的缓存副本
缓存对每组参数分别生效,超时后自动失效
代码实现:
import time |
示例使用:
|
输出结果:
计算: 1 + 2 |
关键点解释:
- 参数缓存 key 用
(args, tuple(sorted(kwargs.items())))
构造,确保:- 可哈希(作为字典 key)
- 相同参数顺序不同也能识别一致(kwargs 排序)
- 每组参数对应一个
(result, timestamp)
缓存值 - 缓存时效性通过
time.time()
判断 - 相当于简化版带超时的 LRU 缓存。