基础

Python 装饰器:是什么、如何工作,以及如何编写自己的装饰器

更新于: · Skillaria.Top

装饰器是接收一个函数并返回新函数(通常是包装器)的函数,它在调用前后添加行为,例如日志、计时、缓存或权限检查。写在 def 上方的 @decorator 等价于 func = decorator(func);为了让包装器保留原函数的名字和文档字符串,使用 functools.wraps。

Python 中的装饰器是一个函数:它接收另一个函数,并返回一个新的函数,通常是对原函数的包装。借助它,可以在不修改函数代码的情况下为其添加日志、计时、缓存、重试等行为。装饰器写成 @名字,放在函数声明的上方,在 Flask、FastAPI、pytest 以及标准库中随处可见。

装饰器通俗地讲是什么

可以把它想象成礼物和包装纸。礼物(函数)保持不变,外面多了一层包装,它会先于礼物起作用。装饰器就是这样一层包装:调用函数时,先执行包装器的代码,包装器再调用原函数,并且可以对结果做些处理。

之所以能这样做,是因为在 Python 中函数是普通对象:可以把它们传给其他函数,从函数中返回,也可以在函数内部定义。装饰器正是建立在这三个特性之上。

装饰器如何工作:@ 只是语法糖

来看一个最简单的装饰器,它在调用前后打印消息:

def log_calls(func):
    def wrapper():
        print(f"正在调用 {func.__name__}")
        result = func()
        print(f"{func.__name__} 执行完毕")
        return result
    return wrapper

@log_calls
def hello():
    print("你好!")

hello()
正在调用 hello
你好!
hello 执行完毕

写在 def 上方的 @log_calls 与在声明之后写下面这行完全等价:

def hello():
    print("你好!")

hello = log_calls(hello)

现在名字 hello 指向的是 wrapper,原函数则保存在闭包中——wrapper 记住了外层函数中的变量 func。需要注意一个细节:装饰器只在 def 执行时应用一次,也就是模块导入时;而包装器会在每次调用时运行。

闭包与装饰器

闭包是一个内部函数,即使外层函数已经结束,它仍然记得外层函数的变量。没有闭包,装饰器就不可能实现:正是通过闭包,wrapper 才能访问 func。闭包中也可以保存状态,例如调用次数计数器:

def count_calls(func):
    calls = 0
    def wrapper(*args, **kwargs):
        nonlocal calls
        calls += 1
        print(f"{func.__name__}: 第 {calls} 次调用")
        return func(*args, **kwargs)
    return wrapper

如何编写 Python 装饰器:通用模板

前面第一个例子有两个缺点:包装器不接收参数,而且会“吞掉”原函数的名字。下面是一个可靠的模板,编写任何装饰器都可以从它开始:

import functools

def my_decorator(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        # 调用之前的代码
        result = func(*args, **kwargs)
        # 调用之后的代码
        return result
    return wrapper

模板的三条规则:

  1. *args, **kwargs — 包装器接收任意参数,并原样传递下去。
  2. return result — 别忘了返回结果,否则被装饰的函数总会返回 None。
  3. @functools.wraps(func) — 把原函数的元数据复制到包装器上。

下面是一个测量执行时间的装饰器示例:

import functools
import time

def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} 用时 {elapsed:.3f} 秒")
        return result
    return wrapper

@timer
def slow_sum(n):
    return sum(range(n))

slow_sum(10_000_000)   # slow_sum 用时 0.142 秒

为什么需要 functools.wraps

如果不使用 wraps,被装饰函数的名字、文档字符串等属性都会被替换,外部看到的是 wrapper:

def plain(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@plain
def add(a, b):
    """计算两个数的和。"""
    return a + b

print(add.__name__, add.__doc__)   # wrapper None

使用 @functools.wraps(func) 之后,包装器会获得原函数的 __name__、__doc__、__module__、__qualname__ 和 __annotations__,同时 __wrapped__ 属性中保存着原函数的引用。这对调试和 traceback、help()、按名字注册函数的框架,以及显示真实签名的 inspect.signature 都很重要。

带参数的装饰器

有时装饰器需要配置,例如 @retry(times=3) 或 @app.route("/")。这时需要再增加一层嵌套:外层函数接收参数,并返回真正的装饰器——这种结构称为装饰器工厂。

import functools

def repeat(times):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            result = None
            for _ in range(times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(times=3)
def ping():
    print("ping")

ping()   # ping, ping, ping — 三行输出

调用顺序是:repeat(times=3) 返回 decorator,它再应用到 ping 上并返回 wrapper。换句话说,@repeat(times=3) 等价于 ping = repeat(times=3)(ping)。

一个实用的例子是出错后重试:

import functools
import time

def retry(times=3, delay=1.0, exceptions=(Exception,)):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, times + 1):
                try:
                    return func(*args, **kwargs)
                except exceptions:
                    if attempt == times:
                        raise
                    time.sleep(delay)
        return wrapper
    return decorator

@retry(times=5, delay=0.5, exceptions=(ConnectionError,))
def fetch_rate():
    ...

在同一个函数上使用多个装饰器

装饰器可以叠加使用。它们从下往上应用:离 def 最近的先被应用。

@timer
@log_calls
def report():
    ...

# 等价于:
report = timer(log_calls(report))

调用时,最外层(timer)先执行,因此它也会计入 log_calls 的运行时间。顺序很重要:例如,一个装饰器检查访问权限,另一个缓存结果,二者的先后会影响结果。

Python 的标准及内置装饰器

语言和标准库中已经包含许多实用的装饰器:

装饰器作用
@property把方法变成只读属性;配合 @x.setter 可以变成受控的可写属性
@staticmethod没有 self 的类方法,本质上是类命名空间中的普通函数
@classmethod方法接收类 cls 而不是实例——常用于编写替代构造函数
@functools.cache、@functools.lru_cache按参数缓存结果
@functools.wraps把函数的元数据复制到包装器上
@dataclasses.dataclass为类生成 __init__、__repr__、__eq__
@contextlib.contextmanager把生成器变成可用于 with 的上下文管理器

缓存:functools.cache 与 lru_cache

缓存装饰器会记住每组参数对应的结果,再次调用时直接返回,而不再计算。经典例子是递归计算斐波那契数,不加缓存时耗时呈指数增长:

import functools

@functools.cache
def fib(n):
    return n if n < 2 else fib(n - 1) + fib(n - 2)

print(fib(100))   # 354224848179261915075

functools.cache(自 Python 3.9 起可用)保存所有结果,没有上限。@functools.lru_cache(maxsize=128) 只保留最近使用的若干条记录,并淘汰旧的,对长期运行的服务更安全。统计信息用 fib.cache_info() 查看,用 fib.cache_clear() 清空。这类函数的参数必须是可哈希的:传入列表会得到 TypeError: unhashable type: 'list'。而且只应缓存纯函数——即相同参数总是返回相同结果、没有副作用的函数。

property 装饰器的作用

@property 让你可以像访问属性一样调用方法(不加括号),并在赋值时进行控制:

class Account:
    def __init__(self, balance):
        self._balance = balance

    @property
    def balance(self):
        return self._balance

    @balance.setter
    def balance(self, value):
        if value < 0:
            raise ValueError("余额不能为负数")
        self._balance = value

acc = Account(100)
print(acc.balance)   # 100
acc.balance = -5     # ValueError

staticmethod 与 classmethod

class Money:
    def __init__(self, kopecks):
        self.kopecks = kopecks

    @classmethod
    def from_rubles(cls, rubles):
        return cls(round(rubles * 100))

    @staticmethod
    def is_valid(value):
        return value >= 0

m = Money.from_rubles(12.5)
print(m.kopecks, Money.is_valid(-1))   # 1250 False

正是 @classmethod 让方法可以作用于类本身,而不是实例:from_rubles 也能正确地创建子类的对象。

类装饰器与用类实现的装饰器

装饰器也可以应用于类:它接收一个类,并返回一个类(同一个或修改过的)。@dataclass 就是这样工作的。下面是一个注册类的简单例子:

registry = {}

def register(cls):
    registry[cls.__name__] = cls
    return cls

@register
class CsvExporter:
    pass

print(registry)   # {'CsvExporter': <class '__main__.CsvExporter'>}

反过来,也可以用类充当装饰器。任何带有 __call__ 方法的对象都可以,状态很适合保存在实例属性中:

import functools

class CountCalls:
    def __init__(self, func):
        functools.update_wrapper(self, func)
        self.func = func
        self.calls = 0

    def __call__(self, *args, **kwargs):
        self.calls += 1
        return self.func(*args, **kwargs)

@CountCalls
def tick():
    pass

tick(); tick()
print(tick.calls)   # 2

异步装饰器

对于 async def 函数,包装器本身也必须是协程,并且用 await 等待结果,否则返回的只是一个尚未执行的协程对象:

import functools

def log_async(func):
    @functools.wraps(func)
    async def wrapper(*args, **kwargs):
        print(f"开始 {func.__name__}")
        result = await func(*args, **kwargs)
        print(f"完成 {func.__name__}")
        return result
    return wrapper

装饰器有什么用

  • 横切关注点 — 日志、指标、计时,无需把代码复制到每个函数中。
  • 缓存 — 用 @functools.cache 处理昂贵的纯计算。
  • 检查 — 访问权限、参数验证、Web 处理函数中的鉴权。
  • 注册 — Flask 和 FastAPI 中的路由(@app.get("/")),pytest 中的 fixture。
  • 可靠性 — 重试、超时、错误处理。

主要优点是职责分离:函数专注于自己的任务,辅助行为只需一行代码即可添加,也同样容易移除。

常见错误

  • 包装器没有返回结果 — 被装饰的函数返回 None。
  • 没有 *args, **kwargs — 带参数调用时出现 TypeError。
  • 忘记 wraps — __name__ 和文档字符串错误,日志难以理解。
  • 工厂缺少括号 — 写成 @repeat 而不是 @repeat(3),函数会被当作 times 参数传入。
  • 装饰器主体中有繁重的代码 — 它在导入模块时执行,而不是在调用时执行。

小结

Python 装饰器是高阶函数:接收一个函数,返回包装器;@ 只是赋值语句的简写。编写时从包含 *args, **kwargs、return result 和 functools.wraps 的模板开始;需要配置时,增加一层工厂函数。装饰器所依赖的函数、闭包和注解,在课程中都有实践讲解。

常见问题

能否取消函数上的装饰器?

如果装饰器使用了 functools.wraps,原函数可以通过 __wrapped__ 属性访问:func.__wrapped__(...) 会直接调用未包装的函数。否则只能从代码中删除带 @ 的那一行。

装饰器的代码什么时候执行?

装饰器函数本身在声明被装饰的函数时执行一次,通常是在导入模块期间。wrapper 内部的代码则在每次调用被装饰的函数时执行。

装饰器可以用于类的方法吗?

可以。普通的 *args, **kwargs 装饰器同样适用于方法,self 只是 args 的第一个元素。

装饰器和继承有什么区别?

继承通过类的层次结构改变行为;装饰器从外部包装具体的函数或类,不需要修改代码,并且可以方便地与其他装饰器组合。

通过视频学习本主题

课程“Python 3.15: от новичка до профи”结合实践详细讲解了这一主题。

前往课程