讲个真事儿,上个月我写一个内部工具,需要给十几个函数统一加日志。
我心想,用装饰器呗,优雅又省事。于是写了个带日志级别的装饰器,打算让每个函数自己指定日志等级。
代码是这样的:
def log(level):
def decorator(func):
def wrapper(*args, **kwargs):
print(f"[{level}] 调用 {func.__name__}")
return func(*args, **kwargs)
return wrapper
return decorator
@log("INFO")
def get_user():
return {"name": "张三"}
@log("DEBUG")
def calculate():
return 42
运行没问题,完美。我心里还美滋滋的。
后来有个函数不需要指定级别,我就想:那简单,把括号去掉用默认值不就完了?于是改成了这样:
@log
def test_func():
return "hello"
结果,程序直接报错,说log函数缺少参数。我盯着屏幕看了十分钟,就多了对括号的事,怎么差别这么大?
那一刻我才真正搞明白,装饰器加不加括号,背后是两种完全不同的逻辑。
先搞明白装饰器本质是什么
在聊括号之前,咱们先把装饰器的本质搞清楚。一句话总结:
装饰器就是一个函数,它接收一个函数作为参数,返回一个新的函数。
语法糖@decorator等价于:
@decorator
def func():
pass
# 等价于
def func():
pass
func = decorator(func)
看到了吗?@decorator就是**把func传进decorator()这个函数里,然后把返回值重新赋值给func**。
关键点:装饰器在定义函数的时候就已经执行了,不是调用的时候才执行。
加括号 vs 不加括号,到底差在哪儿
这是最核心的问题。用一句话说清楚:
- **
@log**——把log本身当作装饰器,直接传入被装饰的函数 @log()——先调用log(),把它的返回值当作装饰器
你看,加括号是先执行函数调用,把结果当作装饰器。不加括号是直接把函数本身当作装饰器。
这两个返回的东西完全不一样:
log是一个函数,它接收一个函数作为参数log()是调用了log,它返回的是另一个函数
形状一样,但一个是原函数,一个是返回值。
场景一:无参装饰器——千万别加括号
最常见的装饰器写法:
def timer(func):
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
print(f"耗时: {time.time() - start}")
return result
return wrapper
@timer # ✅ 正确,不加括号
def slow_function():
time.sleep(1)
如果你手贱写成@timer():
@timer() # ❌ 报错
def slow_function():
time.sleep(1)
为什么报错?因为timer()先被执行,返回的是wrapper这个函数。然后Python把slow_function传给了wrapper。但wrapper接收的参数格式是(*args, **kwargs),它不接收一个函数作为参数,所以报错。
这就好比你本来应该把门牌号告诉快递员,结果你把门牌号告诉了物业,物业又说"我不收快递",快递就送不到了。
场景二:带参装饰器——必须加括号
这就是我踩坑的场景。当装饰器需要参数时,你必须多包一层:
def log(level):
def decorator(func):
def wrapper(*args, **kwargs):
print(f"[{level}] 调用 {func.__name__}")
return func(*args, **kwargs)
return wrapper
return decorator
# 使用
@log("INFO") # ✅ 加括号,传参数
def get_user():
return {"name": "张三"}
执行顺序是这样的:
- Python先执行
log("INFO"),返回decorator函数 - 然后把
get_user传给decorator,返回wrapper wrapper替换原来的get_user
所以带参装饰器其实是个"装饰器工厂"——你调用它传入参数,它生产出一个真正的装饰器。
场景三:最让人懵的——带默认参数的装饰器
有时候你想让装饰器既支持无参调用,又支持带参调用,比如:
@log
def func1():
pass
@log("INFO")
def func2():
pass
这能做到吗?可以,但写法很巧妙,需要判断第一个参数是不是一个可调用的函数:
def log(arg):
if callable(arg):
# 没加括号,arg就是被装饰的函数
def wrapper(*args, **kwargs):
print(f"调用 {arg.__name__}")
return arg(*args, **kwargs)
return wrapper
else:
# 加了括号,arg是传入的级别参数
def decorator(func):
def wrapper(*args, **kwargs):
print(f"[{arg}] 调用 {func.__name__}")
return func(*args, **kwargs)
return wrapper
return decorator
这样两种写法都能工作。callable()判断参数是不是函数。但说实话,这种写法对新手不太友好,容易造成混乱。大多数实际项目中,要么只支持无参,要么只支持带参,很少混用。
一个小实验:看看执行顺序
为了让理解更直观,做个小实验:
def outer(msg):
print(f"1. outer 被调用,参数: {msg}")
def decorator(func):
print(f"2. decorator 被调用,函数: {func.__name__}")
def wrapper(*args, **kwargs):
print(f"3. wrapper 被调用,执行 {func.__name__}")
return func(*args, **kwargs)
return wrapper
return decorator
print("开始定义函数...")
@outer("测试")
def test():
print("4. 函数主体执行")
print("定义完成,现在调用函数...")
test()
猜猜打印顺序:
开始定义函数...
1. outer 被调用,参数: 测试
2. decorator 被调用,函数: test
定义完成,现在调用函数...
3. wrapper 被调用,执行 test
4. 函数主体执行
看到了吗?装饰器在函数定义时就执行了,而不是在调用时。
outer在test定义的那一刻就运行了,返回decorator,decorator也立刻执行,返回wrapper替换test。等你真正调用test()时,执行的是wrapper里的逻辑。
常见错误TOP3
错误1:忘记return内层函数
def decorator(func):
func() # ❌ 没返回wrapper
# 忘记return了
@decorator
def test():
print("hello")
# test变成了None,调用报错
错误2:带参装饰器少了一层
def log(level):
def wrapper(func):
print(f"[{level}] {func.__name__}") # 只有两层
return func
@log("INFO") # 报错,log("INFO")返回的是wrapper
def test():
pass
# wrapper接收func,但log("INFO")返回的wrapper被当作装饰器
# 然后Python把test传给wrapper,wrapper返回了func
# 看起来没问题?但wrapper的参数是func,一旦test被调用,实际上调用的是原始test,没被包装
错误3:被装饰函数有参数时wrapper忘记传参
def timer(func):
def wrapper(): # ❌ 没接收参数
start = time.time()
func() # ❌ 没传参
print(time.time() - start)
@timer
def add(a, b):
return a + b
add(1, 2) # 报错,wrapper不接收参数
正确写法:
def timer(func):
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
print(time.time() - start)
return result
return wrapper
实战:一个完整的带参装饰器
给你看一个我项目中真实在用的日志装饰器,带参数控制日志级别和是否记录返回值:
import time
from functools import wraps
def log(level="INFO", record_return=False):
def decorator(func):
@wraps(func) # 保留原函数的元信息
def wrapper(*args, **kwargs):
print(f"[{level}] 执行 {func.__name__}")
start = time.time()
result = func(*args, **kwargs)
cost = time.time() - start
if record_return:
print(f"[{level}] {func.__name__} 返回值: {result}")
print(f"[{level}] {func.__name__} 耗时: {cost:.3f}s")
return result
return wrapper
return decorator
# 使用
@log("DEBUG", record_return=True)
def calculate(a, b):
return a + b
calculate(3, 5)
# 输出:
# [DEBUG] 执行 calculate
# [DEBUG] calculate 返回值: 8
# [DEBUG] calculate 耗时: 0.000s
注意到@wraps(func)了吗?这是个好习惯,它能保留原函数的__name__、__doc__等属性。不然被装饰后,函数名就变成了wrapper,调试时很麻烦。
总结:一句话记住
| 写法 | 含义 | 适用场景 |
@decorator |
func = decorator(func) |
无参装饰器 |
@decorator() |
func = decorator()(func) |
带参装饰器 |
@decorator("arg") |
func = decorator("arg")(func) |
带参装饰器传参 |
加括号,就是在调用。调用完的结果,才拿去当装饰器。
不加括号,就是把函数本身当装饰器。
就这么简单。记住这条,你就能避开我踩过的那个坑。
最后送你两句话:
无参装饰器不加括号,带参装饰器必须加括号。
如果你不确定,就看看这个装饰器定义的时候需不需要传参数。
那次之后,我每次写带参装饰器都会停顿一下,确认括号的位置对不对。这个习惯帮我省了不少事。
希望你看了这篇文章,能在写装饰器的时候多留个心眼。括号虽小,搞错了,程序可一点都不跟你客气。