K 的一隅

Python Python 语言核心

@ 只是替换:装饰器语法背后发生了什么

@decorator 是语法糖,本质是函数调用后把返回值重新绑定到原名字;functools.wraps 保留元数据;带参装饰器多包一层工厂。

8 分钟阅读 更新于 2026-07-28
本文目录

看到 @timer 贴在 def fetch(): 上面,容易以为 Python 在函数体里「插入」了一段计时逻辑。实际上解释器只做一件事:先定义函数,再用装饰器的返回值替换掉原来的名字。没有魔法插入,没有改字节码,只有名字重新绑定——和写 fetch = timer(fetch) 一模一样。

装饰器在标准库和第三方库里无处不在:@property 把方法变成描述符,@staticmethod 改调用约定,Flask 的 @app.route("/") 把函数登记进路由表。把 @ 还原成等价的后置调用,读源码、写包装器、排查「为什么 __name__ 变了」都会稳很多。

装饰器解决的是横切关注点:计时、鉴权、缓存、重试、注册——同一段逻辑要套在很多函数或类上,又不想在每个函数体里复制粘贴。弄懂「替换」语义后,你可以自己实现轻量版 lru_cache 或路由表,而不必死记每个框架的魔法。

语法糖:@ 等价于后置调用

装饰器本质上是高阶函数:输入一个 callable,输出一个 callable(或任意对象,但常见是函数)。@ 只是让这一步写在定义旁边,读起来更紧凑。

python
def shout(func):
    def wrapper(*args, **kwargs):
        print(">>>")
        result = func(*args, **kwargs)
        print("<<<")
        return result
    return wrapper


@shout
def greet(name: str) -> str:
    return f"Hello, {name}"


# 与下面完全等价:
# def greet(name: str) -> str:
#     return f"Hello, {name}"
# greet = shout(greet)

展开对照:

写法实际步骤
@shout + def greet1. 创建函数对象,临时绑到 greet;2. greet = shout(greet)
@手动写第 2 步

装饰器必须是可调用对象(函数、带 __call__ 的类实例等);它接收被装饰的可调用对象,返回新的可调用对象(通常包装原函数,闭包持有原函数引用)。

运行:

python
print(greet("Ada"))
# >>>
# Hello, Ada
# <<<

注意:装饰在定义时执行一次,不是每次调用 greet 时都执行 shout。若装饰器里有注册逻辑,副作用发生在 import/定义阶段。这也是「import 某模块就自动注册路由/信号处理器」的实现基础——模块被加载,函数被定义,装饰器立刻跑完注册。

functools.wraps:别让包装函数「丢身份」

裸写 wrapper 会把 __name____doc____module____qualname__ 等元数据弄丢。调试器、文档生成、help() 都会显示 wrapper 的名字,栈追踪也不直观。

python
def bare(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper


@bare
def add(a: int, b: int) -> int:
    """两数之和。"""
    return a + b


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

functools.wraps 把原函数的元数据复制到 wrapper 上——写装饰器时的常规写法,相当于「语法糖之上的语法糖」:

python
from functools import wraps


def logged(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f"call {func.__name__}")
        return func(*args, **kwargs)
    return wrapper


@logged
def mul(a: int, b: int) -> int:
    """乘法。"""
    return a * b


print(mul.__name__)  # mul
print(mul.__doc__)   # 乘法。
print(mul(3, 4))     # call mul → 12

若还要保留函数签名(用于 inspect),可叠加 functools.wrapsinspect.signature 或第三方 decorator 包;标准库场景 wraps 通常够用。

带参数的装饰器:多一层工厂

@retry(times=3) 这种形式,Python 会先求值 retry(times=3),再把结果当作装饰器去装饰函数。因此需要三层嵌套:外层接收装饰器参数,中层接收被装饰函数,内层才是实际调用。

python
from functools import wraps


def repeat(times: int):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            last: object = None
            for _ in range(times):
                last = func(*args, **kwargs)
            return last
        return wrapper
    return decorator


@repeat(times=2)
def ping() -> str:
    print("ping")
    return "pong"


# 等价于:ping = repeat(times=2)(ping)
ping()
# ping
# ping

展开顺序:repeat(2) 先返回 decoratordecorator(ping) 再返回 wrapper;最终名字 ping 指向 wrapper。写错缩进层级(少一层 def)是初学者最常踩的坑:若 @repeat(2) 后面直接跟函数,说明 repeat(2) 必须返回一个「单参数 callable」。

可选参数装饰器可以用 *args, **kwargs 或「无参时直接当 decorator 用」的分支实现,但核心模型不变——多一层工厂

装饰类、叠放与 import 时机

类也可以被装饰——@dataclass 接收类对象,返回增强后的类。原理仍是「类语句执行完,名字绑到装饰器返回值」。

装饰器可以叠放:

python
@outer
@inner
def f(): ...

等价于 f = outer(inner(f))从下往上应用:inner 先包 fouter 再包结果。顺序影响行为(例如外层先记日志还是内层先校验权限)。

因为装饰在定义时运行,模块被 import 时就会执行。重量级初始化(连数据库、读大文件)放在装饰器里要慎重,必要时改成懒执行——第一次调用再注册或再加载配置,避免 import 副作用拖慢启动。

无参装饰器与带参装饰器的区别,纯粹是「装饰器本身要不要先吃参数」。写 @cache 时,cache 直接是 (func) -> wrapper;写 @cache(maxsize=128) 时,cache(maxsize=128) 必须先返回真正的 decorator。IDE 里把 @ 行折叠成赋值语句,是验证理解的好办法。

和元类的边界

上一篇的 metaclass 在 class 语句执行过程中改造「如何造类」;装饰器在类或函数对象已经创建之后包装它。轻量横切逻辑(日志、缓存、权限检查、路由注册)优先装饰器;要改类成员表、阻止某些属性进类、统一 metaclass 行为时,才轮到 metaclass。

类装饰器与 metaclass 偶尔重叠(都能在类定义时改类),但装饰器不介入 namespace 构建阶段,通常更好懂、更好测。选哪个可以问:我需要在类体还没执行完时就改 namespace 吗?需要 → metaclass;类已经造好,只想包一层或改 __annotations__ → 装饰器。

用类实现装饰器

函数不够用时,可以用类的 __call__ 当装饰器——尤其需要跨调用保存状态时:

python
from functools import wraps


class CountCalls:
    def __init__(self, func):
        wraps(func)(self)
        self.func = func
        self.count = 0

    def __call__(self, *args, **kwargs):
        self.count += 1
        print(f"#{self.count}")
        return self.func(*args, **kwargs)


@CountCalls
def work() -> None:
    print("done")


work()
work()
# #1 done
# #2 done

这里 @CountCalls 等价于 work = CountCalls(work):装饰器本身是可调用类,实例持有原函数和计数器。带参类装饰器则在 __init__ 里收参数,在 __call__ 里收被装饰对象——模式与三层函数相同。

保留签名与 typing

类型检查器看的是最外层 callable 的签名。若 wrapper 写成 (*args, **kwargs),mypy/pyright 会丢失参数信息。Python 3.10+ 可用 ParamSpec 转发:

python
from collections.abc import Callable
from functools import wraps
from typing import ParamSpec, TypeVar

P = ParamSpec("P")
R = TypeVar("R")


def trace(func: Callable[P, R]) -> Callable[P, R]:
    @wraps(func)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        print("enter", func.__name__)
        return func(*args, **kwargs)
    return wrapper

运行时行为不变,静态分析能跟上。工程里公开 API 的装饰器值得多花这一行。

写装饰器时还有一个细节:wrapper 若需转发 *args, **kwargs,要保证原函数接受的参数都能透传;若 wrapper 签名写死,会挡住仅关键字参数或 **kwargs 调用。*args, **kwargs 是最稳妥的默认,配合 wrapsParamSpec 兼顾运行时与类型检查。

常见坑

现象原因
help(f) 显示 wrapper忘了 @wraps
带参装饰器报「callable expected」少包一层,把 repeat(times=2) 的返回值当函数体写了
装饰后类型检查对不上静态类型看的是 wrapper 签名,需 ParamSpec / TypeVar 标注
循环 import 时注册表是空的装饰器在 import 时跑,依赖模块尚未加载完

异步代码里也有 @asyncio.coroutine 时代遗留模式;现代写法用 async def 本身。若写 async 装饰器,wrapper 需是 async def,并在内部 await func(...)——同步装饰器包 async 函数仍可行,但 wrapper 返回 coroutine,调用方要记得 await。

标准库里的装饰器长什么样

functools.lru_cache 是带参装饰器:@lru_cache(maxsize=128) 先调 lru_cache(maxsize=128) 得到 decorator,再装饰目标函数。源码里能看到典型的三层结构与 wraps 用法——读标准库实现是写装饰器的最佳习题。

contextlib.contextmanager 把生成器变成上下文管理器,也是装饰器:装饰后的函数在 with 语句里执行。不同装饰器返回类型可以不是函数(如 property 返回描述符),但「定义时替换名字」这一点不变。

写自己的装饰器时,先在纸上展开成 f = deco(f) 再还原 @,能避免大多数结构错误。带参版本多写一行「工厂返回 decorator」, mentally 把两层括号 () 分开看。

团队规范可要求:对外暴露的装饰器必须带 @wraps,并在 docstring 里说明是否缓存、是否线程安全。读代码的人不必点开 wrapper 才知道语义。

和 property、staticmethod 的关系

@staticmethod@classmethod 也是装饰器:它们接收函数对象,返回描述符(下一篇)。@property 把函数变成 property 实例。标准库大量用法都符合「定义完 → callable 包装 → 名字重绑」同一模型,只是返回值不一定是函数。

装饰器可以嵌套在类定义里装饰方法——class 体中的 @classmethod 同样是先 def 再替换。方法与函数用的是同一套装饰器机制,没有单独的「方法装饰器语法」。

测试装饰器时,用无 @ 的展开形式 wrapped = deco(fn) 断言行为,比 import 整模块更隔离。副作用(注册、计数)也应在测试里验证「定义时发生一次」,而不是每次调用重复注册。

装饰器与类型标注:@overload、某些框架 stub 也使用 @ 语法,但语义仍是替换——不要与 TypeScript 的装饰器实验特性混为一谈,Python 装饰器没有独立的 stage 概念。弄混时记住:Python 的 @ 永远在定义完成后立刻执行一次。

记住:@decorator 不是注解处理器,只是替换——把原名字重新绑到 decorator(原对象) 的返回值;wraps 保留可追溯的元数据;带参装饰器多包一层工厂函数。把这三点内化,装饰器就从「符号魔法」变回普通的函数调用链。