Python装饰器从基础到有参数
1. 概述
装饰器是Python中最“魔幻”的特性之一,但如果只是复制粘贴而不理解本质,也容易产生困惑。本文从函数是一等对象的概念出发,依次介绍基础装饰器、用functools.wraps保留元数据、接收参数的装饰器,以及基于类的装饰器。读完这篇文章后,你就能自己写日志记录、计时或重试装饰器,而不用担心“丢失原始函数名”这样的坑。
2. 函数是一等公民
学习装饰器之前,你需要理解一个事实:在Python中,函数也是对象。你可以把函数赋值给变量、作为参数传递,或者从函数中返回另一个函数。
def greet(name: str) -> str:
return f"Hello, {name}!"
say_hello = greet
print(say_hello("Alice"))
Hello, Alice!
say_hello只是指向同一个函数对象greet的另一个名字。正是这个特性,使得Python允许编写接收函数作为输入、返回函数作为输出的函数——这正是装饰器的基础。
3. 基础装饰器:函数包装函数
装饰器的本质很简单:它是一个函数,接收另一个函数,返回一个新函数来“包装”(wrap)原函数的行为。
def log_call(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__} with args={args}, kwargs={kwargs}")
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result}")
return result
return wrapper
def add(a: int, b: int) -> int:
return a + b
add = log_call(add)
print(add(2, 3))
Calling add with args=(2, 3), kwargs={}
add returned 5
5
log_call接收func(原函数add)并定义wrapper——这是一个内部函数,调用func但在前后添加日志。log_call(add)返回wrapper,我们把它重新赋值给add。从此以后,调用add(...)实际上是在调用wrapper(...)。
3.1. @语法——语法糖
手动编写func = decorator(func)太冗长了。Python提供了@decorator语法来完成同样的事,更简洁:
@log_call
def multiply(a: int, b: int) -> int:
return a * b
print(multiply(4, 5))
Calling multiply with args=(4, 5), kwargs={}
multiply returned 20
20
把@log_call放在def multiply上面,相当于执行multiply = log_call(multiply)。这只是语法糖——没有其他隐藏的魔法。
4. 元数据丢失问题和functools.wraps
当装饰器用wrapper“替换”了原函数,你会丢失原函数的__name__、__doc__等属性。
@log_call
def subtract(a: int, b: int) -> int:
"""两个数相减。"""
return a - b
print(subtract.__name__)
print(subtract.__doc__)
wrapper
None
这是调试或使用文档生成工具时常见的问题——你以为在调用subtract,但Python告诉你名字是wrapper。解决办法是使用functools.wraps:
import functools
def log_call(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@log_call
def subtract(a: int, b: int) -> int:
"""两个数相减。"""
return a - b
print(subtract.__name__)
print(subtract.__doc__)
subtract
两个数相减。
functools.wraps(func)是一个装饰器的装饰器:它把func的__name__、__doc__、__module__等属性复制到wrapper。
重要提示: 在实际装饰器中始终使用
functools.wraps。跳过它会导致调试困难,还会破坏内省(比如help()函数,或依赖函数名的框架)。
5. 接收参数的装饰器
很多时候你希望装饰器可以配置,比如@retry(times=3)。此时需要额外的一层函数:外层函数接收参数,返回真正的装饰器,装饰器再接收func。
import functools
import time
def retry(times: int = 3, delay: float = 1.0):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_exception = None
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except Exception as exc:
last_exception = exc
print(f"Attempt {attempt} failed: {exc}")
time.sleep(delay)
raise last_exception
return wrapper
return decorator
@retry(times=2, delay=0)
def call_payment_api(order_id: int) -> str:
raise ConnectionError(f"Timeout for order {order_id}")
try:
call_payment_api(101)
except ConnectionError as exc:
print(f"Giving up: {exc}")
Attempt 1 failed: Timeout for order 101
Attempt 2 failed: Timeout for order 101
Giving up: Timeout for order 101
需要记住的三层结构:
retry(times, delay)——工厂函数,接收配置参数,返回decorator。decorator(func)——真正的装饰器,接收原函数。wrapper(*args, **kwargs)——执行函数,在调用已装饰函数时运行。
@retry(times=2, delay=0)实际上先调用retry(times=2, delay=0)(返回decorator),然后再把decorator应用到call_payment_api。
5.1. 装饰器既支持有参数又支持无参数
有时你想让@retry既能用在没有参数的情况(@retry而不是@retry())。干净的做法是结合functools.partial和判断callable:
import functools
def retry(func=None, *, times: int = 3):
if func is None:
return functools.partial(retry, times=times)
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except Exception as exc:
print(f"Attempt {attempt} failed: {exc}")
raise RuntimeError("All attempts failed")
return wrapper
@retry
def task_a():
return "ok"
@retry(times=5)
def task_b():
return "ok"
当使用@retry(不调用函数),func直接接收task_a函数,if func is None分支不执行。当使用@retry(times=5),func一开始是None,函数返回functools.partial(retry, times=5)——一个等待接收func的函数,在下一步接收task_b。
6. 基于类的装饰器
除了嵌套函数,你也可以用类来实现装饰器,通过实现__call__方法。这种方式在装饰器需要维护复杂状态时比较合适。
import functools
class CountCalls:
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
self.call_count = 0
def __call__(self, *args, **kwargs):
self.call_count += 1
print(f"{self.func.__name__} called {self.call_count} time(s)")
return self.func(*args, **kwargs)
@CountCalls
def process_order(order_id: int) -> None:
print(f"Processing order {order_id}")
process_order(1)
process_order(2)
print(process_order.call_count)
process_order called 1 time(s)
Processing order 1
process_order called 2 time(s)
Processing order 2
2
__init__保存原函数并初始化call_count = 0。functools.update_wrapper(self, func)——类似functools.wraps但用于类实例,把元数据从func复制到对象self。__call__使实例process_order(现在是CountCalls对象而不是函数)能像普通函数一样被调用。
重要提示: 基于类的装饰器在装饰其他类中的方法时效果不佳(因为原类和装饰器的
self容易冲突)。对于方法,优先使用嵌套函数或如果需要dispatch则使用functools.singledispatchmethod。
7. 同时堆叠多个装饰器
你可以在一个函数上应用多个装饰器。应用顺序是从下到上(离函数最近的装饰器先执行)。
@log_call
@retry(times=2, delay=0)
def charge_card(amount: float) -> str:
if amount <= 0:
raise ValueError("Amount must be positive")
return f"Charged {amount}"
print(charge_card(10))
Calling wrapper
Charged 10
这里retry(times=2, delay=0)先应用,把charge_card包装成一个有重试能力的函数;然后log_call再在外层包装。如果你在retry中没有正确使用functools.wraps,日志会打印出wrapper而不是charge_card——这再次证明了第4部分的重要性。
8. 为装饰器编写测试
装饰器仍然是普通的Python函数,所以可以用pytest像测试其他逻辑一样测试它。如果还没安装,先安装库:
pip install pytest
retry装饰器的测试示例:
import pytest
def test_retry_raises_after_exhausting_attempts():
call_log = []
@retry(times=3, delay=0)
def always_fail():
call_log.append("called")
raise ValueError("boom")
with pytest.raises(ValueError):
always_fail()
assert len(call_log) == 3
def test_retry_succeeds_on_second_attempt():
attempts = {"count": 0}
@retry(times=3, delay=0)
def fail_once_then_succeed():
attempts["count"] += 1
if attempts["count"] < 2:
raise ValueError("boom")
return "ok"
result = fail_once_then_succeed()
assert result == "ok"
assert attempts["count"] == 2
运行测试:
pytest test_retry.py -v
test_retry.py::test_retry_raises_after_exhausting_attempts PASSED
test_retry.py::test_retry_succeeds_on_second_attempt PASSED
call_log和attempts充当间谍,用来检查原函数被调用的次数——验证重试行为符合预期,不需要mock外部库。
9. 常见错误
- 忘记
functools.wraps:函数丢失名称/文档字符串,导致调试困难,还会在依赖__name__的框架中出错(比如Flask中的路由)。 - 在wrapper中使用可变的默认参数:同Python的常见错误,避免把
args=[]作为默认值。 - 装饰器在导入时有重操作:如果装饰器在
decorator(func)阶段就执行I/O操作(调用API、读文件),而不是在wrapper中执行,它会在每次导入模块时运行,而不是每次调用函数时运行。 - 滥用装饰器处理复杂逻辑:如果装饰器包含太多分支,代码反而变得难以阅读,这种情况下直接调用函数可能更清楚。逻辑复杂时,考虑用context manager或单独的类。
何时不该用装饰器: 如果包装行为只用一次、无法复用,写装饰器只会让代码更难追踪。装饰器的价值在于为多个不同的函数应用横切关注点(cross-cutting concern),比如日志记录、缓存、重试、认证。
10. 总结
Python中的装饰器没什么神秘的——它只是利用函数是一等对象这一特性,接收函数然后返回函数。从基础装饰器开始,加上functools.wraps保留元数据,添加额外一层函数来接收配置参数,或改用基于类的实现来维持复杂状态。掌握这些模式后,你就能为实际项目编写干净、易测试的日志记录、缓存、重试或验证装饰器。