Skip to content

03 · 装饰器与闭包:不改代码,给函数加功能 ​

学完你能说出:

  • 闭包是什么:内层函数「记住」了外层函数的变量。
  • 装饰器是什么:接收一个函数、返回一个增强版函数;@ 只是语法糖。
  • 为什么要用 functools.wraps,带参数的装饰器为什么要三层。

先看地图 ​

Python 模块第三篇。做 AI 应用经常要给调用大模型的函数加「失败重试」「计时」「缓存」「打日志」,这些都靠装饰器。FastAPI 的 @app.get("/") 也是装饰器。

本篇讲 4 个点:① 函数可以当参数传 → ② 闭包 → ③ 装饰器 → ④ 带参数的装饰器。

一句话本质 ​

装饰器 = 一个「包装函数的函数」:把原函数包起来,在它执行前后加点事,再把包好的新函数还给你。

生活类比:给手机套手机壳 ​

  • 手机(原函数)本身不用改。
  • 套上手机壳(装饰器),就多了防摔、支架的功能。
  • 套了壳,手机还是那部手机,打电话照样打。

一步步看 ​

1. 前置知识:函数也是一种值 ​

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

f = hello      # 把函数赋给变量(注意没有括号,不是调用)
f()            # → 你好

函数能赋值、能当参数传、能当返回值,这是装饰器的基础。

2. 闭包:内层函数记住外层的变量 ​

python
def make_counter():
    count = 0                 # 外层函数的局部变量
    def inc():
        nonlocal count        # nonlocal = 非本地的:声明要改外层的 count
        count += 1
        return count
    return inc                # 返回内层函数本身

c = make_counter()
c()  # → 1
c()  # → 2      ← make_counter 早就执行完了,count 却还「活着」

inc 带着它需要的外层变量 count 一起被返回了,这就叫闭包(Closure):函数 + 它记住的外部变量。

3. 装饰器:给函数计时 ​

python
import time, functools

def timer(func):                          # 接收原函数
    @functools.wraps(func)                # wraps = 包装:保留原函数的名字和文档
    def wrapper(*args, **kwargs):         # wrapper = 包装器;*args, **kwargs = 接收任意参数
        start = time.time()
        result = func(*args, **kwargs)    # 调用原函数
        print(f"{func.__name__} 用时 {time.time() - start:.2f} 秒")
        return result                     # 原函数的返回值照样返回
    return wrapper                        # 返回增强版函数

@timer                                    # 等价于:ask_llm = timer(ask_llm)
def ask_llm(question):
    ...

@timer 就是 ask_llm = timer(ask_llm) 的简写(语法糖:写起来更甜、功能不变的写法)。之后调用 ask_llm(...),实际执行的是 wrapper,wrapper 内部再调用原来的函数。

这里的 wrapper 就是一个闭包:它记住了外层传进来的 func。

4. 带参数的装饰器:失败重试 N 次 ​

python
def retry(times):                          # 第 1 层:接收装饰器自己的参数
    def decorator(func):                   # 第 2 层:接收原函数
        @functools.wraps(func)
        def wrapper(*args, **kwargs):      # 第 3 层:真正执行
            for i in range(times):
                try:
                    return func(*args, **kwargs)
                except Exception:
                    if i == times - 1:     # 最后一次还失败,就把错误抛出去
                        raise
        return wrapper
    return decorator

@retry(times=3)                            # 先执行 retry(3) 得到 decorator,再用它装饰函数
def call_api():
    ...

为什么要三层? @retry(times=3) 是先调用 retry(3),它的返回值(decorator)才是真正的装饰器。所以比普通装饰器多一层。

为什么这么设计 ​

为什么要用装饰器,而不是直接在函数里加代码? ​

  • 问题:10 个调用 API 的函数都要加「重试 + 计时 + 日志」,每个函数里复制一遍,代码重复;要改重试次数还得改 10 处。
  • 方案:把这些通用逻辑写成装饰器,需要的函数上面加一行 @。
  • 好处:业务逻辑和通用功能分开;改一处,处处生效;不需要修改原函数。

一句话答法

装饰器把日志、计时、重试、权限等通用逻辑从业务代码里抽出来复用,不改原函数就能增加功能,改一处处处生效。

为什么要加 functools.wraps? ​

  • 问题:装饰后,ask_llm 实际上变成了 wrapper,它的名字(__name__)、文档字符串都变成了 wrapper 的。日志里全显示「wrapper」,调试、生成文档都会出错。
  • 方案:用 @functools.wraps(func) 把原函数的名字、文档等信息复制到 wrapper 上。
  • 好处:装饰后的函数看起来仍然是原函数。

一句话答法

不加 wraps,被装饰函数的名字和文档会变成内部 wrapper 的,影响调试和文档;wraps 会把原函数的元信息复制过来。

英文术语拆解 ​

术语拆开看一句话意思
Decoratordecorate 装饰 + -or装饰器:包装函数、增加功能
Closureclose 关闭 + -ure闭包:函数 + 它记住的外部变量
wrapperwrap 包裹 + -er包装器:装饰器内部的那个新函数
nonlocalnon 非 + local 本地的声明要修改外层函数的变量
*args / **kwargsarguments 参数 / keyword arguments 关键字参数接收任意数量的位置参数 / 关键字参数
Syntactic Sugarsyntactic 语法的 + sugar 糖语法糖:更方便、但功能不变的写法
functoolsfunction 函数 + tools 工具函数相关的标准库

面试怎么问 ​

Q:什么是装饰器?手写一个计时装饰器。

装饰器是接收一个函数并返回新函数的可调用对象,用于在不修改原函数的情况下扩展功能。@deco 等价于 f = deco(f)。手写时内层 wrapper 用 *args, **kwargs 接收任意参数、调用原函数并返回结果,再用 functools.wraps 保留元信息。

Q:什么是闭包?

内层函数引用了外层函数的变量,外层函数返回后,这些变量仍被内层函数保存,函数加上这些被记住的变量就是闭包。装饰器里的 wrapper 就是闭包,它记住了 func。

Q:带参数的装饰器怎么写?为什么是三层?

最外层接收装饰器参数并返回真正的装饰器,中间层接收被装饰函数,最内层是 wrapper。因为 @retry(3) 会先调用 retry(3),它的返回值才会被用来装饰函数。

容易混的地方 ​

  • @timer 和 @timer() 不一样:前者把函数直接交给 timer;后者先调用 timer(),再用它的返回值去装饰。用错了会报错。
  • 装饰器在定义函数时就执行了:@timer 那一行,在 def 执行时就把函数替换掉了,不是在调用时才执行。
  • 改外层变量要用 nonlocal:只读不用写,要修改(比如 count += 1)就得声明,否则会报错。

和其他知识的连接 ​

自测 3 问 ​

Q1:@timer 放在 def ask_llm 上面,等价于什么代码?

点开看答案

等价于 ask_llm = timer(ask_llm):把原函数交给 timer,用 timer 返回的新函数(wrapper)替换掉原来的名字。

Q2:什么是闭包?用 make_counter 的例子说明。

点开看答案

内层函数 inc 用到了外层函数的变量 count。make_counter() 执行完、返回 inc 之后,count 并没有消失,而是被 inc 记住了,每次调用都在累加。函数加上它记住的外部变量就是闭包。

Q3:为什么写装饰器要加 functools.wraps?

点开看答案

不加的话,被装饰函数的 __name__、文档字符串都会变成内部 wrapper 的,日志、调试、文档生成都会出错。wraps 会把原函数的这些元信息复制到 wrapper 上。

✅ 验收清单 ​

  • 能用「手机壳」类比解释装饰器
  • 能不看资料写出 make_counter 闭包,并解释 count 为什么还「活着」
  • 能手写一个带 functools.wraps 的计时装饰器
  • 能手写带参数的重试装饰器,并解释为什么要三层
  • 能讲清装饰器和 wraps 各自解决什么问题(问题 → 方案 → 好处)

下一步该懂什么 ​