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

需要记住的三层结构:

  1. retry(times, delay) ——工厂函数,接收配置参数,返回decorator。
  2. decorator(func) ——真正的装饰器,接收原函数。
  3. 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保留元数据,添加额外一层函数来接收配置参数,或改用基于类的实现来维持复杂状态。掌握这些模式后,你就能为实际项目编写干净、易测试的日志记录、缓存、重试或验证装饰器。

pythondecoratorclosurefunctional-programmingclean-code