Python 装饰器:从语法糖、闭包到 @wraps(上篇)

这是「装饰器」系列的 上篇:只讲装饰器是什么、为什么成立、内部怎么运转。
TL;DR

  • 它是 :一个"接收函数、返回新函数"的函数;对被装饰函数 f 来说,@decorator 等价于 f = decorator(f)。
  • 解决了:重试、缓存、计时、日志这类和业务无关却到处都要写的"横切逻辑",只写一次、到处套用。
  • 核心机制 :1. 不带参数用两层 ,带配置用三层 ;2. wrapper 靠闭包 记住原函数和配置,还能在缓存命中时短路 (不调用原函数);3. @wraps 把原函数的元数据(名字、文档、注解)复制到 wrapper 上,并留下 wrapped 追溯链。
  • 教学 vs 工程 :本文手写的缓存是教学版(无界、仅进程内有效);生产应优先用 functools.lru_cache / functools.cache,并补齐线程安全、过期、容量。
  • 适合谁:想彻底搞懂 Python 装饰器的人。

配套可跑示例:所有示例均在 Python 3.14 实跑;标注"示意"的耗时 / 内存地址会随机器浮动。


前言

我是一名自学 AI Agent 开发的学习者。这篇文章不是搬运教程,而是我啃装饰器时的一份思考笔记 :我不想只记住"装饰器就是套娃",而是从"我到底想解决什么问题"出发,一步步推到语法糖、闭包、@wraps,让我能达到我能使用装饰器实现我想要达到的效果。

但我很清楚,自学最容易出现"局部自洽、整体有坑"。所以我把自己的理解过程、踩过的错误直觉、仍然存疑的点都直接写在文中。如果哪里理解偏了、有更地道的工程写法,恳请路过的大佬指正,当然也希望我的理解能对正在学习装饰器的人有帮助,希望大家能积极评论。


一、先问个问题

你在写 Agent,一个节点要调 LLM。真实环境里 LLM 会超时 、会返回 429 限流、偶尔格式还不对。于是你写了个重试循环。

然后你发现:除了重试,你还想给每个节点计时 、把重复的 embedding / 响应缓存 、把每次调用记日志。

问题来了------这些动作和"节点到底干什么"毫无关系,却要在十几个节点里各抄一遍。能不能只写一次,然后像贴标签一样贴到每个函数上?

这就是装饰器存在的理由。


二、没有装饰器会怎样

2.1 困境:横切逻辑污染业务代码

"重试、计时、日志、缓存"在术语上叫横切关注点(cross-cutting concerns)------它们横切在所有业务函数上。如果手写:

python 复制代码
import time

def call_llm(prompt):
    start = time.time()                  # 计时
    for attempt in range(3):             # 重试
        try:
            result = do_request(prompt)  # do_request:示意的底层请求函数
            break
        except Exception:
            continue
    log(...)                             # log:示意的日志函数
    return result

每个函数都塞这么一坨,业务逻辑被淹没;想把重试从 3 次改成 5 次,得改十几处。这违反开闭原则 :对扩展开放、对修改关闭。

当有一些重复的业务逻辑想要在对应的函数上实现,我认为可以考虑用装饰器解决(缓存,重试,计数,计时等),当然这只是业务选型的一环,没有说一定要用装饰器,只是会了多一个参考点。

2.2 关键前提:函数是"一等公民"

要造上面那台"增强机器",我先确认了一件事:我能不能把一个函数,像普通数据一样交给另一段代码?

这里有个我以前混淆过的点。Python 常说"一切皆对象",但"是对象"还不够,我真正需要的是函数成为一等公民(first-class citizen)------它能和数字、字符串一样享有完整"权利":

  • 赋值给变量;
  • 当作参数传进另一个函数;
  • 当作另一个函数的返回值;
  • 放进列表、字典等容器。

我特意把两个说法分开:

  • 一切皆对象讲"形态"------运行时几乎所有东西都是对象,有 id、有属性、有地址;
  • 一等公民讲"权利"------能不能被自由地传递、返回;
  • 对装饰器真正起作用的是后者。

反例帮我记住:C 语言里函数不是完整一等公民------不能在运行时创建、不能当返回值、没有闭包,只能靠函数指针做有限传递,所以写不出 Python 这种装饰器。

确认这个前提成立,才有后面那个自然的想法:写一个函数,把原函数喂给它,让它返回一个"增强过的新函数"。 这个"增强函数的函数",就是装饰器。

我的理解 :装饰器能成立,靠的不是"函数是对象",而是"函数能被传进去、再被吐出来"。

延伸问题:除了函数,Python 里还有哪些东西看起来是一等公民、其实有限制?我隐约觉得类方法 / 绑定方法(bound method)这里有微妙差别,但还没完全理清,求指点。


三、核心机制

3.1 最小本质:装饰器 = 函数替换语法糖

python 复制代码
# my_decorator:任意"接收函数、返回函数"的可调用对象;这里只演示等价关系
@my_decorator
def say_hello():
    print("原函数逻辑")

# 100% 等价于:
def say_hello():
    print("原函数逻辑")
say_hello = my_decorator(say_hello)     # 这一行赋值是 @ 自动做的

我一开始觉得 @ 很神秘,把它拆开后发现没有任何魔法,只做两步:调用装饰器 → 把返回值重新绑到函数名上。

3.2 装饰器结构1:不带参数:两层结构

两层结构:decorator 接收原函数 → 创建并返回 wrapper。

示例1:计时装饰器(实验demo)

python 复制代码
import time
from functools import wraps

def timing(func):                        # 外层:收原函数
    @wraps(func)
    def wrapper(*args, **kwargs):        # 内层:真正替换原函数
        start = time.perf_counter()      #   【前】
        result = func(*args, **kwargs)   #   【中】调用闭包里的原函数
        print(f"[timing] {func.__name__} 耗时 {(time.perf_counter()-start)*1000:.2f} ms")  # 【后】
        return result
    return wrapper

@timing
def llm_call(prompt):
    time.sleep(0.05)
    return f"LLM 回复:{prompt}"

if __name__ == "__main__":
    print(llm_call("你好"))

【已实测】 运行 llm_call("你好")(耗时为示意,随机器浮动):

text 复制代码
# 示意输出,实际耗时随机器浮动
[timing] llm_call 耗时 50.22 ms
LLM 回复:你好

注意 wrapper 的结构是 前 / 中(调原函数)/ 后 。原函数不是在 wrapper "之后"跑,而是在 wrapper 体内被调用------所以你能在它执行之后拿到结果、算耗时。

我自己有过的问题:我最初以为"装饰器逻辑先跑完,才轮到原函数",后来才发现我把模型想反了,即原函数不是在 wrapper 之后接力跑,而是 wrapper 在自己体内调用它,所以我能在调用前看一次表、调用后再看一次表,两个时间点一减就是耗时。

⚠️ 关键注意点 :wrapper 里调用原函数时一定要写 return func(*args, **kwargs) 。 如果只写 func(...) 不 return,原函数的返回值会被"吃掉",调用方拿到 None。

3.3 装饰器结构2:带配置:三层结构

装饰器本身需要参数时,得在外面再套一层 ,变成三层嵌套:

示例2:重试装饰器(实验demo)

python 复制代码
import time
from functools import wraps

def retry(max_attempts=3, backoff=0.1):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            last_exc = None
            for attempt in range(1, max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as exc:
                    last_exc = exc
                    print(f"[retry] {func.__name__} 第 {attempt} 次失败:{exc}")
                    if attempt < max_attempts:
                        wait = backoff * 2 ** (attempt - 1)
                        print(f"        等待 {wait:.2f}s 后重试")
                        time.sleep(wait)
            raise RuntimeError("重试耗尽") from last_exc
        return wrapper
    return decorator

_state = {"n": 0}

@retry(max_attempts=3, backoff=0.05)
def flaky_task():
    """前两次失败、第三次成功的不稳定任务。"""
    _state["n"] += 1
    if _state["n"] < 3:
        raise TimeoutError("LLM 超时")
    return "任务成功"

if __name__ == "__main__":
    print(flaky_task())

三层各自的职责:

层 接收什么 干什么
工厂(最外) 装饰器配置 通过闭包把配置传给内层,返回 decorator
decorator(中) 被装饰的原函数 定义 wrapper,把配置和原函数一起闭包进去,返回 wrapper
wrapper(内) 调用时的参数 写附加逻辑、调用原函数、返回结果

【已实测】 一个前两次超时、第三次成功的任务,@retry(max_attempts=3, backoff=0.05):

text 复制代码
[retry] flaky_task 第 1 次失败:LLM 超时
        等待 0.05s 后重试
[retry] flaky_task 第 2 次失败:LLM 超时
        等待 0.10s 后重试
任务成功

3.4 闭包:wrapper 靠什么"记住"原函数和配置

我的理解 :内层函数不管外层函数的生命周期如何------哪怕外层已经执行完、返回了------内层都能直接拿到外层函数的参数。原因是这些被内层引用的外层变量(叫自由变量 )没有随外层栈帧一起销毁,而是被装进一个单元格(cell)由内层持有,这就是闭包 ,记录在 wrapper.__closure__ 里。

放到装饰器里,wrapper 正是靠闭包拿到它需要的两样东西:

  • 三层结构:从最外层工厂拿配置形参(重试次数、退避),从中层 decorator 拿原函数;
  • 两层结构:直接从 decorator 拿原函数。

【已实测】打印闭包内容(真实输出含 cell / 对象内存地址、每次不同,这里做示意):

text 复制代码
# 示意输出:真实打印形如 <cell at 0x...: function object at 0x...>,地址随进程变化
两层 wrapper 的闭包:[real 函数对象]        # 只有原函数
三层 wrapper 的闭包:[real 函数对象, 3]     # 原函数 + 配置

这就是为什么 wrapper 在原函数定义结束之后依然能调用它------原函数被闭包留住了。

闭包的两个细节,我特意理清过:

  1. 是引用,不是复制出新对象:外层那个变量改了,内层看到的也跟着改------闭包绑定的是变量本身,不是当时的值(这也是循环里创建闭包会拿到"最终值"而不是"当时值"的原因)。

  2. 内层若直接给这个变量"赋值",它会被判定为内层自己的局部变量 ,再去读就抛 UnboundLocalError,并不会改到外层。

    产生原因是一条编译期 就定好的规则:Python 在函数定义时就决定哪些名字是局部变量------只要函数体里存在对某个变量的赋值,这个变量就被当成局部变量,不管你在那行赋值之前有没有读过它。 所以 count += 1(等价 count = count + 1)里的 count 被当成局部变量,右边读它时还没赋值,就报错了。

    要让内层真正改外层变量,需要显式写 nonlocal 变量名;如果只是改 list / dict 这类可变对象的内容(append、改键),因为没有重新绑定变量,则不需要 nonlocal。

3.5 wrapper 还能"短路":不调用原函数

前 / 中 / 后之外,wrapper 还有一个能力:直接返回,根本不调用原函数。缓存命中、权限拒绝、熔断时都用得到。

python 复制代码
from functools import wraps

def memoize(func):
    store = {}

    @wraps(func)
    def wrapper(*args, **kwargs):
        # 位置参数 + 排序后的关键字参数,拼成可哈希 key,避免 kwargs 顺序 / 冲突
        key = (args, tuple(sorted(kwargs.items())))
        if key in store:                 # 命中 -> 短路,不调用原函数
            print(f"[cache] 命中 {args},直接返回,不调用 {func.__name__}")
            return store[key]
        print(f"[cache] 未命中 {args},调用 {func.__name__} 并缓存")
        # 打印只展示位置参数,实际 key 里包含排序后的 kwargs
        result = func(*args, **kwargs)   # 未命中才调用原函数
        store[key] = result
        return result
    return wrapper

@memoize
def embed(text):
    return f"embedding({text})"
if __name__ == "__main__":
    embed("hello")    # 未命中
    embed("hello")    # 命中
    embed("world")    # 未命中

【已实测】:

text 复制代码
[cache] 未命中 ('hello',),调用 embed 并缓存
[cache] 命中 ('hello',),直接返回,不调用 embed
[cache] 未命中 ('world',),调用 embed 并缓存

教学版 vs 工程版的差距(别拿教学版直接上生产):

  • 上面是教学版:用 dict 做无界缓存,只在进程内有效、重启即失;
  • 标准库已有现成实现:functools.lru_cache 做有界 缓存(可限容量),functools.cache 做无界缓存(3.9+);
  • 参数若不可哈希(如 list、dict),上面的 key 会直接报错,需要先把参数序列化成字符串(如 JSON)再当 key;
  • 真上生产还要补齐:线程安全、缓存过期(TTL)、最大容量淘汰------这些正是我实战篇与"依赖抽象"专题要解决的问题。

3.6 @wraps:让 wrapper "看起来像"原函数

为什么需要它

我学装饰器时的切入点其实是好奇:明明调用中间隔了一个函数,为什么调用方完全"没感觉"?搞懂函数替换后,我顺着这个问题注意到一个细节------wrapper 毕竟是手动新建的独立函数对象,它和原函数是两个对象,名字、文档字符串默认都是 wrapper 自己的。也就是说,不处理的话,"透明"只做到了行为层,身份层并不透明。为了确认这一点,我把 3.2 的 timing 内部那行 @wraps 暂时去掉,再跑一遍:

python 复制代码
# 沿用 3.2 定义的 timing,仅去掉它内部的 @wraps
@timing
def llm_call(prompt):
    """这是文档字符串"""
...
print(llm_call.__name__)   # 得到 'wrapper',而不是 'llm_call'
print(llm_call.__doc__)    # 得到 None

所以 wraps 并不是"让装饰器能跑"------不写它,装饰器照样工作;它负责把"透明"补完整。

更准确地说:

@wraps 补的是元数据透明 ,不是对象透明 。它让 wrapper 在名字、文档、注解、签名展示上像原函数,并用 __wrapped__ 留下追溯链;但 wrapper 仍是独立对象,执行时调用的是闭包里的 func。

它到底做了什么(update_wrapper 源码)

【已实测,Python 3.14】默认要复制的元数据:

python 复制代码
WRAPPER_ASSIGNMENTS = (
    '__module__',      # 所属模块
    '__name__',        # 函数名
    '__qualname__',    # 完整限定名(类里的方法带类名)
    '__doc__',         # 文档字符串
    '__annotations__', # 类型注解
    '__type_params__', # 泛型类型参数(3.12+ 才有,见参考资料 PEP 695)
)
WRAPPER_UPDATES = ('__dict__',)

update_wrapper 的伪代码:

python 复制代码
def update_wrapper(wrapper, wrapped):
    for attr in WRAPPER_ASSIGNMENTS:
        try:
            value = getattr(wrapped, attr)
        except AttributeError:
            pass                          # 原函数没这属性就跳过
        else:
            setattr(wrapper, attr, value)

    for attr in WRAPPER_UPDATES:
        getattr(wrapper, attr).update(getattr(wrapped, attr, {}))

    wrapper.__wrapped__ = wrapped
    return wrapper

代码理解:

1.遍历 WRAPPER_ASSIGNMENTS,用 setattr 把原函数的标量元数据逐个覆盖到 wrapper 上; 2.遍历 WRAPPER_UPDATES,用 wrapper.__dict__.update(wrapped.__dict__) 做浅合并;

3.最后单独设 wrapper.wrapped = wrapped。

举例说明 dict 的浅合并(不是递归合并):

text 复制代码
wrapper 原有:keep=..., shared={'nested':'wrapper值'}
原函数带来: added=..., shared={'nested':'原函数值'}
update 后:  keep 保留 / added 新增 / shared 整个变成 {'nested':'原函数值'}(不递归)

3.7 装饰器在什么时候执行:三个时点

搞清楚"对象什么时候被创建、名字什么时候被替换",能避免很多困惑。我按时间顺序拆成三个时点:

text 复制代码
阶段1:模块加载执行期,def 逐行创建函数对象(此时装饰器尚未触发)
   只创建最外层的工厂函数,比如@retry的最外层是def retry(...)(两层结构则最外层是 decorator)
   函数体内部的 decorator / wrapper 一律不创建

阶段2:装饰器执行时(@ 语法糖触发,只执行一次)
   以 @retry(max_attempts=3) 为例:
     1. 求值 retry(max_attempts=3) -> 创建 decorator(闭包持有配置)
     2. decorator(原函数) -> 创建 wrapper
        wrapper 上的 @wraps(原函数) 立刻执行 update_wrapper:
          拷元数据(setattr)/ __dict__ 浅合并 / wrapper.__wrapped__ = 原函数
     3. 返回 wrapper 这个函数对象

   两层结构:直接 decorator(原函数) -> 创建 wrapper -> 返回
   【原函数体不执行,只是生成 wrapper】

阶段3:外层 @ 收尾
   原函数名 = wrapper(重绑),透明替换原函数名

------ 整个过程原函数体一次都没执行
之后每次 原函数():才真正跑 wrapper

记忆规则:def 写在谁的函数体里,就只有谁被调用时,这个 def 才执行、才创建对象。


四、误解与适用边界

误解 1:wrapper 是靠 wrapped 来执行原函数的

这是我之前学习搞错的一点,后来复习的时候发现矛盾点纠正了:wrapper 执行原函数靠的是闭包里的 func,运行时根本不读 wrapped。 wrapped 只是挂在明面上的标签,用途是给外部(人、inspect、测试)追溯、解包,拿到没被装饰过的原函数。

【已实测 · 诱饵实验】故意让 wrapped 指向另一个函数:

python 复制代码
def dec(func):
    def wrapper(*a, **kw):
        return func(*a, **kw)        # 调用闭包 func
    def decoy():
        return "我是诱饵"
    wrapper.__wrapped__ = decoy      # 标签故意挂到别处
    return wrapper

def real():
    return "我是真正被执行的原函数"

f = dec(real)

结果:

text 复制代码
调用 f()             -> 我是真正被执行的原函数   # 走闭包
调用 f.__wrapped__() -> 我是诱饵               # 读标签

两者可以指向完全不同的函数,证明执行只认闭包。正常用 wraps 时 wrapped 恰好也指向原函数,所以容易误以为"执行的是它"。

误解 2:把"原函数名 = wrapper"和"wrapper.wrapped = 原函数"当成同一个赋值

我第一次学这个概念以为这两句是一回事,后来复习才分清:它们是方向相反、位置不同的两个赋值。

赋值 谁做的 方向 含义
原函数名 = wrapper 外层 @ 名字 f -> wrapper 让外部名字改指向 wrapper
wrapper.wrapped = 原函数 内层 @wraps wrapper.wrapped -> 原函数 在 wrapper 身上存好原函数,方便追溯
  • 外层 @ 只负责重绑名字,它不把原函数往 wrapper 身上存;
  • 但要分清 wrapper "持有原函数"的两条路径:执行时能调用它,靠的是闭包里的 func------这跟 wraps 无关,不写 wraps 也照样执行 ;__wrapped__ 则是 wraps 额外在明面上挂的一个引用,只给外部追溯用;
  • 所以不写 wraps,闭包里照样留着 func、行为正常,只是少了 __wrapped__ 这个标签,名字 / doc 也还是 wrapper 的。

适用边界

  • 装饰器解决的是横切逻辑复用,不要用它包装复杂的业务分支判断。
  • 一个函数叠太多层装饰器会难调试;每层职责要单一,必要时用 wrapped 解包。
  • 装饰器是实现细节:它让"装饰器的增删改"和"调用方代码"完全解耦,调用方不该感知到它的存在------这正是"透明替换"的意义。

五、参考资料


相关推荐
高频因子挖掘机2 小时前
股票池一大就请求缓慢?量化系统批量获取行情的设计与优化
后端·github·api
郑州光合科技余经理2 小时前
本地生活平台搭建:跨业态用户标识怎么贯通
java·开发语言·前端·后端·uni-app·php·ai编程
Thneonl2 小时前
故意弄坏生产:混沌工程不是乱砸,是实验设计
后端·程序员
Thneonl2 小时前
别上来就 strace:60 秒十条命令看清一台病机
后端·程序员
马剑威(威哥爱编程)2 小时前
【AI全栈后端12-11】Spring Boot 把 AI 接口真正上线扛量:限流 / 降级 / 可观测
人工智能·spring boot·后端
tachibana23 小时前
Golang 中数组和切片的区别?
开发语言·后端·golang·数组·切片
我是人✓3 小时前
Spring IOC注解全解析和Spring整合测试单元
java·后端·spring
Sam_Deep_Thinking3 小时前
CountDownLatch实现原理
java·后端·面试·程序员