Python 装饰器:给函数穿上"功能外套"
引言:咖啡加料的智慧
想象你开了一家咖啡店。菜单上有美式、拿铁、卡布奇诺……有一天,顾客提出新需求:"我想要每杯咖啡都加一份奶泡。"
你有两种选择:
- 方案一:改造每台咖啡机——把美式机、拿铁机的内部结构全拆开,加入奶泡功能。费时费力,万一以后不加奶泡了还得改回来;
- 方案二:做一个"奶泡机外挂"——任何咖啡做好后,从这外挂过一遍,就自动加上奶泡。咖啡机本身不用动,想加就加,想撤就撤。
Python 的**装饰器(Decorator)**就是方案二——在不修改函数本身的前提下,给函数动态增加功能。它是 Python 中最优雅、最常用的语法特性之一,Web 框架、日志系统、权限校验……到处都有它的身影。
一、热身:理解装饰器的三个前置知识
装饰器是建立在前面学过的知识之上的,先快速回顾三块"积木"。
1.1 函数也是对象,可以赋值给变量
def now():
print('2024-6-1')
f = now # 把函数赋值给变量 f(注意:没有括号!)
f() # 通过变量调用函数
# 2024-6-1关键点:f = now 没有调用函数,只是让 f 也指向同一个函数对象。就像一个人有中文名也有英文名,叫哪个名字找到的都是同一个人。
1.2 函数有 __name__ 属性
每个函数对象都自带 __name__ 属性(前后各两个下划线),记录着函数的名字:
>>> now.__name__
'now'
>>> f.__name__
'now'这个属性看起来不起眼,后面会看到它引发的一个重要问题。
1.3 高阶函数:可以接收函数、返回函数
上一篇我们学过:函数可以作为参数传入,也可以作为返回值返回。装饰器正是**"接收函数 + 返回函数"**的组合应用。
二、装饰器到底解决什么问题?
假设我们有一个函数:
def now():
print('2024-6-1')现在想给它加一个功能:每次调用时自动打印一行日志"call now():"。
最笨的办法是直接改函数:
def now():
print('call now():') # 侵入式修改
print('2024-6-1')这样做的麻烦:
- 修改了原函数,违背"尽量不动已有代码"的原则;
- 如果有 100 个函数都要加日志,就得改 100 次,代码里全是重复;
- 万一以后不想要日志了,还得再改 100 次改回来。
装饰器的思路:写一个"加日志的包装器",把 now() 塞进去,拿出来一个"带日志功能的 now()"。原函数一行不动。
生活化理解:就像给手机贴膜——手机(原函数)不用拆开改装,贴上一层膜(装饰器)就多了防刮功能,不想要了撕掉就行。
三、第一个装饰器:打印日志
3.1 完整代码
def log(func):
def wrapper(*args, **kw):
print('call %s():' % func.__name__)
return func(*args, **kw)
return wrapper3.2 逐行拆解
def log(func): # 第一层:接收"被装饰的函数"
def wrapper(*args, **kw): # 第二层:定义"包装后的新函数"
print('call %s():' % func.__name__) # 新增的功能:打印日志
return func(*args, **kw) # 调用原函数,并把结果返回
return wrapper # 把新函数返回出去三层结构各管一件事:
| 层级 | 角色 | 职责 |
|---|---|---|
log | 装饰器本体 | 接收原函数,返回新函数 |
wrapper | 新函数 | 先加功能,再调用原函数 |
func | 原函数 | 被"记住"在闭包里,随时可用 |
wrapper 的参数写成 (*args, **kw),意思是"任何参数我都收"——这样无论原函数是无参、两个参还是带关键字参数,wrapper 都能原样转发给它。
3.3 使用装饰器:@ 语法糖
@log
def now():
print('2024-6-1')调用:
>>> now()
call now():
2024-6-1一行日志自动打印,然后执行原函数。now() 的定义一行没改!
3.4 @ 背后的真相
@log 只是一个语法糖(写法上的便利),它完全等价于:
def now():
print('2024-6-1')
now = log(now) # @log 的本质就是这行代码理解这行等价代码是理解装饰器的关键:
log(now)被调用,返回wrapper函数;now这个名字重新指向wrapper;- 以后调用
now(),实际执行的是wrapper()——它先打印日志,再去调用被"记住"的原函数。
注意:原来的 now 函数并没有消失,它还活在内存里,被 wrapper 通过闭包引用着。只是 now 这个"门牌号"现在指向了新房子。
3.5 用图解梳理执行流程
定义阶段(@log 生效时):
now ──► log(now) ──► 返回 wrapper
now 重新指向 wrapper(wrapper 内部记住了原 now)
调用阶段(now() 时):
now() 实际上是 wrapper()
├── print('call now():') ← 新增功能
└── return 原now() ← 原功能
└── print('2024-6-1')四、进阶:带参数的装饰器
4.1 需求升级
上面的 log 只能打印固定的日志格式。如果我们想自定义日志文本,比如:
@log('execute')
def now():
print('2024-6-1')期望输出:
execute now():
2024-6-1这时装饰器本身也要接收参数 'execute',结构就要再套一层。
4.2 三层嵌套的装饰器
def log(text): # 第一层:接收"装饰器的参数"
def decorator(func): # 第二层:接收"被装饰的函数"
def wrapper(*args, **kw): # 第三层:包装后的新函数
print('%s %s():' % (text, func.__name__))
return func(*args, **kw)
return wrapper
return decorator4.3 为什么需要三层?
对比两种装饰器的等价代码就明白了:
# 无参数装饰器(两层)
now = log(now)
# 带参数装饰器(三层)
now = log('execute')(now)带参数的版本执行分两步:
log('execute')先执行,返回decorator函数(此时'execute'被记住);decorator(now)再执行,返回wrapper(此时原函数也被记住)。
生活化理解:
- 两层装饰器像"标准款贴膜"——拿来就贴;
- 三层装饰器像"定制款贴膜"——先告诉店家你要什么图案(
log('execute')),店家做好贴膜(decorator),再贴到你的手机上(decorator(now))。
4.4 记忆口诀
装饰器带参数,就多套一层函数。 最外层收参数,中间层收函数,最里层干活。
五、重要补丁:functools.wraps
5.1 一个隐藏的问题
装饰后的函数,__name__ 变了:
>>> @log
... def now():
... print('2024-6-1')
>>> now.__name__
'wrapper' # 原本是 'now',现在变成了 'wrapper'!为什么?因为 now 现在指向的是 wrapper 函数,它的名字当然叫 'wrapper'。
这有什么问题?依赖函数名和函数签名的代码会出错。比如:
- 某些框架靠函数名自动生成文档;
- Flask 用函数名作为 URL 路由的默认端点名;
- 调试工具、单元测试框架会显示错误的函数名。
5.2 解决方案:@functools.wraps
Python 内置的 functools.wraps 可以把原函数的 __name__、__doc__ 等属性复制到 wrapper 上:
import functools
def log(func):
@functools.wraps(func) # 关键:加上这一行
def wrapper(*args, **kw):
print('call %s():' % func.__name__)
return func(*args, **kw)
return wrapper现在:
>>> now.__name__
'now' # 正常了!带参数的装饰器同理,加在 wrapper 前面:
import functools
def log(text):
def decorator(func):
@functools.wraps(func) # 同样加在这里
def wrapper(*args, **kw):
print('%s %s():' % (text, func.__name__))
return func(*args, **kw)
return wrapper
return decorator生活化理解:贴膜之后,手机的"身份证"(__name__)显示成了贴膜的品牌。functools.wraps 就像贴膜时把手机的身份证信息也印到膜上——外面看起来还是原来那台手机。
5.3 一条铁律
写装饰器,永远记得加
@functools.wraps(func)。
即使你暂时用不到 __name__,加上它也没有任何坏处,却能避免未来莫名其妙的 bug。这是专业代码和业余代码的分水岭之一。
六、实战:常用装饰器案例
6.1 计时装饰器:测量函数执行时间
import time
import functools
def metric(func):
@functools.wraps(func)
def wrapper(*args, **kw):
start = time.time() # 记录开始时间
result = func(*args, **kw) # 执行原函数
end = time.time() # 记录结束时间
print('%s 执行耗时 %.2f 秒' % (func.__name__, end - start))
return result
return wrapper
@metric
def slow_task():
time.sleep(1.5)
return "任务完成"
slow_task()
# slow_task 执行耗时 1.50 秒应用场景:性能调优时,给可疑的函数加上 @metric,立刻知道谁拖慢了程序。
6.2 权限校验装饰器
import functools
current_user = {"name": "小明", "is_admin": False}
def require_admin(func):
@functools.wraps(func)
def wrapper(*args, **kw):
if not current_user.get("is_admin"):
print("权限不足,操作被拒绝!")
return None
return func(*args, **kw)
return wrapper
@require_admin
def delete_database():
print("数据库已删除!")
delete_database() # 权限不足,操作被拒绝!应用场景:Web 开发中,给"删除""修改配置"等敏感接口统一加上权限检查,不用在每个函数里重复写判断。
6.3 重试装饰器(带参数)
网络请求失败时自动重试,重试次数可配置:
import time
import functools
def retry(times):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kw):
for attempt in range(times):
try:
return func(*args, **kw)
except Exception as e:
print('第 %d 次尝试失败:%s' % (attempt + 1, e))
if attempt < times - 1:
time.sleep(1) # 等 1 秒再重试
print('重试 %d 次后仍然失败' % times)
return None
return wrapper
return decorator
@retry(3)
def fetch_data():
import random
if random.random() < 0.7: # 模拟 70% 概率失败
raise ConnectionError("网络超时")
return "数据获取成功"
fetch_data()这是三层装饰器的真实应用:retry(3) 先把"重试 3 次"这个配置记住,再包装函数。
6.4 缓存装饰器:避免重复计算
import functools
def cache(func):
stored = {}
@functools.wraps(func)
def wrapper(n):
if n not in stored:
stored[n] = func(n) # 没算过才算,算过直接取
return stored[n]
return wrapper
@cache
def fib(n):
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
print(fib(30)) # 没有缓存要等很久,有缓存瞬间出结果提示:Python 内置了现成的缓存装饰器
functools.lru_cache,实际项目中直接用它即可,这里手写是为了演示原理。
6.5 一个函数叠多个装饰器
装饰器可以叠加使用,执行顺序是"从下往上包装,从上往下执行":
@log # 后包装(在外面)
@metric # 先包装(在里面)
def task():
print("干活")
# 等价于:task = log(metric(task))
task()
# call task(): ← log 的日志先打印(外层)
# 干活
# task 执行耗时 0.00 秒 ← metric 的统计后打印(内层)生活化理解:像穿衣服——先穿内衣(@metric),再穿外套(@log)。出门时先看到外套。
七、进阶话题
7.1 终极挑战:写一个"两种用法都支持"的装饰器
有时我们希望同一个装饰器既支持无参写法,又支持带参写法:
@log
def f1(): pass
@log('execute')
def f2(): pass思路:检查第一个参数是不是函数。如果是,说明是 @log 直接装饰;否则说明是 @log('...'),先返回真正的装饰器。
import functools
def log(arg):
# 情况一:arg 是函数,说明用的是 @log
if callable(arg):
func = arg
@functools.wraps(func)
def wrapper(*args, **kw):
print('call %s():' % func.__name__)
return func(*args, **kw)
return wrapper
# 情况二:arg 是文本,说明用的是 @log('...')
else:
text = arg
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kw):
print('%s %s():' % (text, func.__name__))
return func(*args, **kw)
return wrapper
return decorator
@log
def f1():
print('f1')
@log('execute')
def f2():
print('f2')
f1() # call f1(): 然后 f1
f2() # execute f2(): 然后 f2这种写法在开源库中很常见(比如 click、django 的某些装饰器),看懂它说明你已经真正理解了装饰器的嵌套结构。
7.2 类也可以做装饰器
除了函数,类也能实现装饰器(通过 __call__ 方法):
import functools
class Log:
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
def __call__(self, *args, **kw):
print('call %s():' % self.func.__name__)
return self.func(*args, **kw)
@Log
def now():
print('2024-6-1')
now()
# call now():
# 2024-6-1什么时候用类装饰器? 当装饰器需要维护复杂状态时(比如统计调用次数、累计耗时),类天然有属性,比函数的闭包变量更清爽。
7.3 装饰器在真实世界中的应用
你日常用的框架里全是装饰器:
# Flask Web 框架:注册路由
@app.route('/home')
def home():
return '欢迎回家'
# Django:要求登录
@login_required
def profile(request):
...
# pytest:标记跳过某个测试
@pytest.mark.skip(reason="还没修好")
def test_feature():
...
# Python 内置:静态方法、属性
class Circle:
@property
def area(self):
return 3.14 * self.r ** 2理解了装饰器,这些框架的"魔法"就都不再神秘。
八、常见误区与避坑指南
8.1 误区一:装饰时把函数"调用"了
# ❌ 错误:now() 是调用函数,传给 log 的是返回值 None
now = log(now())
# ✅ 正确:传函数本身,不加括号
now = log(now)记忆:装饰器要的是"菜谱"(函数本身),不是"做好的菜"(执行结果)。
8.2 误区二:wrapper 里忘记 return 原函数的结果
# ❌ 错误:原函数的返回值被吞掉了
def log(func):
def wrapper(*args, **kw):
print('call %s():' % func.__name__)
func(*args, **kw) # 没有 return!
return wrapper
@log
def add(a, b):
return a + b
print(add(1, 2)) # call add(): 然后打印 None —— 3 不见了!修正:return func(*args, **kw),一个字母都不能少。这是个极其隐蔽的 bug——函数照常执行,但返回值悄悄变成了 None。
8.3 误区三:忘记 @functools.wraps,导致函数"身份丢失"
如第五节所述,不加 functools.wraps,被装饰函数的 __name__、__doc__ 都会变成 wrapper 的。依赖这些属性的框架(Flask、Sphinx 文档工具等)会出问题。
铁律再强调一遍:wrapper 定义前,永远先写 @functools.wraps(func)。
8.4 误区四:装饰器叠放顺序搞反
@require_admin # 外层:先检查权限
@metric # 内层:先被包装
def delete_data():
...上面是先查权限再计时;如果把两个装饰器调换顺序,就变成"先开始计时,再查权限"——即使权限不足被拒绝,也会被计时统计进去。
原则:谁写在上面,谁就是"最外层包装",谁先执行。
8.5 误区五:在装饰器里做了耗时操作,拖慢导入速度
装饰器在模块导入时就会执行(@log 那一刻 log(func) 就被调用了)。如果在装饰器外层写耗时代码:
def log(func):
time.sleep(10) # ❌ 灾难:每次 import 都卡 10 秒
def wrapper(*args, **kw):
...
return wrapper原则:装饰器外层只做"准备工作"(定义 wrapper),所有真正的逻辑都放在 wrapper 内部——它只在函数被调用时才执行。
8.6 误区六:滥用装饰器,逻辑藏得太深
装饰器虽好,但它把逻辑"藏"在了函数定义之外。如果一个函数叠了五六个装饰器,读代码的人要逐个拆开才能搞清执行顺序,反而降低可读性。
原则:装饰器用于横切的通用功能(日志、权限、计时、缓存),业务核心逻辑还是应该写在函数体内。
九、实战练习
练习 1:计时装饰器
设计一个装饰器 @metric,能作用于任何函数,并打印该函数的执行时间(毫秒)。
import time, functools
# 你的代码:def metric(fn): ...
@metric
def fast(x, y):
time.sleep(0.0012)
return x + y
@metric
def slow(x, y, z):
time.sleep(0.1234)
return x * y * z
f = fast(11, 22)
s = slow(11, 22, 33)
print(f, s) # 应该输出 33 和 7986参考答案
import time, functools
def metric(fn):
@functools.wraps(fn)
def wrapper(*args, **kw):
start = time.time()
result = fn(*args, **kw)
end = time.time()
print('%s 执行了 %.2f ms' % (fn.__name__, (end - start) * 1000))
return result
return wrapper易错点:别忘了 return result,否则函数返回值会丢失。
练习 2:前后日志
编写一个装饰器,在函数调用前后分别打印 'begin call' 和 'end call':
@before_after
def task():
print("执行中...")
# 期望输出:
# begin call
# 执行中...
# end call参考答案
import functools
def before_after(func):
@functools.wraps(func)
def wrapper(*args, **kw):
print('begin call')
result = func(*args, **kw)
print('end call')
return result
return wrapper练习 3:调用次数统计
写一个装饰器 @count_calls,统计函数被调用了多少次,每次调用时打印"第 N 次调用"。
参考答案
import functools
def count_calls(func):
@functools.wraps(func)
def wrapper(*args, **kw):
wrapper.count += 1 # 用函数属性存计数
print('第 %d 次调用 %s' % (wrapper.count, func.__name__))
return func(*args, **kw)
wrapper.count = 0 # 初始化计数器
return wrapper
@count_calls
def hello():
print("你好")
hello() # 第 1 次调用 hello
hello() # 第 2 次调用 hello技巧:函数也是对象,可以挂属性(wrapper.count),适合存这种轻量状态。状态复杂时建议改用类装饰器。
十、小结
- 装饰器的本质:一个"接收函数、返回函数"的高阶函数,在不修改原函数代码的前提下动态增强功能;
@语法糖:@log等价于now = log(now),函数名重新指向包装后的新函数;- 带参数的装饰器要三层嵌套:最外层收参数,中间层收函数,最里层干活;
- 永远加
@functools.wraps(func):保留原函数的__name__等元信息,避免框架和调试工具出问题; - wrapper 里记得
return func(\*args, **kw):漏掉return会悄悄吞掉原函数的返回值; - 多个装饰器叠加:从下往上包装,从上往下执行;
- 装饰器适合横切关注点:日志、计时、权限、缓存、重试……这些"每个函数都要,但和业务无关"的功能。
装饰器是 Python 函数式编程的集大成者——它用到了函数即对象、高阶函数、闭包等几乎所有前置知识。掌握了装饰器,你就拿到了阅读 Flask、Django 等框架源码的钥匙,也拥有了用几行代码给整个项目"加buff"的能力。