KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

03 · 闭包与装饰器:声明式写法的全部秘密 — keel 龙骨

## 这一章解决什么问题

这一章解决什么问题

装饰器是 Python 里「用的人多、懂的人少」的头号选手。@app.get(...)、@retry(3)、@lru_cache 都见过,但自己写就卡壳。这一章从闭包一步步推出装饰器——你会发现装饰器没有任何新语法,它只是 02 章「嵌套函数 + 返回函数」的一个固定用法。

从闭包到装饰器:一步之遥

02 章末尾的 make_greeter 展示了「函数造函数」。现在换一个角度想:如果内层函数的参数是函数呢?

def with_logging(fn):
    def wrapper(*args, **kwargs):
        print(f"-> calling {fn.__name__}")
        result = fn(*args, **kwargs)
        print(f"<- {fn.__name__} done")
        return result
    return wrapper

def add(a, b):
    return a + b

add = with_logging(add)   # "包装":用新函数顶替原函数的名字

>>> add(1, 2)
-> calling add
<- add done
3

add = with_logging(add) 这个动作太常见了,Python 给了它语法糖:

@with_logging
def add(a, b):
    return a + b

装饰器就是一个「接收函数、返回函数」的函数。@ 只是把赋值那行挪到定义处。没有魔法。

为什么要 functools.wraps

上面的写法有个隐患:add.__name__ 现在是 'wrapper'——原函数的元信息被顶替了。调试、文档生成、序列化工具都会被误导。修复只差一行:

from functools import wraps

def with_logging(fn):
    @wraps(fn)                      # 把 fn 的 __name__/__doc__ 等拷到 wrapper 上
    def wrapper(*args, **kwargs):
        ...
    return wrapper

@wraps(fn) 自己也是个装饰器——你现在能读懂这行递归的写法了。写装饰器必带 functools.wraps。

带参数的装饰器:多包一层

@with_logging 没有参数。但 @retry(times=3) 明明带参数——怎么办?答案是:再包一层函数。装饰器工厂接收参数,返回真正的装饰器:

import time, random
from functools import wraps

def retry(times, delay=0.5):
    def decorator(fn):                      # 真正的装饰器
        @wraps(fn)
        def wrapper(*args, **kwargs):
            last_exc = None
            for attempt in range(1, times + 1):
                try:
                    return fn(*args, **kwargs)
                except Exception as exc:
                    last_exc = exc
                    print(f"attempt {attempt}/{times} failed: {exc}")
                    time.sleep(delay)
            raise last_exc                  # 重试用尽,抛最后一次的异常
        return wrapper
    return decorator

@retry(times=3, delay=0.2)
def fetch(url):
    ...

读 @retry(times=3) 时心里要能「降糖」:它是 fetch = retry(times=3)(fetch)——先调工厂拿到装饰器,再用装饰器包装函数。数括号:retry(times=3) 一个括号是工厂调用,后面那个隐含括号才是包装。

带参装饰器所以要多一层,本质上是因为装饰器语法糖只能接收一个参数(被装饰对象)。参数越多,层数越多,这是语法糖的成本,不是设计缺陷。

一个必会内置:lru_cache

标准库里最常用的装饰器,缓存函数结果:

from functools import lru_cache

@lru_cache(maxsize=128)
def fib(n):
    return n if n < 2 else fib(n - 1) + fib(n - 2)

fib(100)   # 瞬间完成。没有缓存要算到天荒地老

使用前提必须刻在脑子里:参数必须可哈希(内部用 dict 存缓存),且结果应该确定。给 fetch(url) 加 lru_cache 意味着永远拿旧数据——缓存装饰器改变行为,不是免费的。

装饰器为什么属于「声明式」

对比两种写法:

# 命令式:每个函数开头都写一遍
def delete_user(user_id):
    check_auth("admin")
    ...

def update_config(cfg):
    check_auth("admin")
    ...

# 声明式:规则写在函数头顶,一眼看到
@require_auth("admin")
def delete_user(user_id): ...

@require_auth("admin")
def update_config(cfg): ...

命令式把「这件事需要什么」藏在函数体内第 N 行;声明式把它放在签名旁边,读代码的人和写代码的人都不用翻。后续课的「声明式鉴权」就是这个模式的工业级版本——装饰器工厂按需造出带权限参数的 wrapper,注册进路由表。

自检三题

  1. @deco 和 @deco() 的区别是什么?后者的 deco 必须是什么?
  2. 忘写 functools.wraps 会坏掉哪些具体场景?说出两个。
  3. @lru_cache 装饰的方法在「每次调用参数相同的实例方法」时有什么隐患?(提示:self 也会进缓存键。)

下一章

04 章讲类:类属性与实例属性的分界、property、dataclass——以及 frozen dataclass 如何呼应 01 章的「不可变性」。

进入 keel 阅读