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,注册进路由表。
自检三题
@deco和@deco()的区别是什么?后者的deco必须是什么?- 忘写
functools.wraps会坏掉哪些具体场景?说出两个。 @lru_cache装饰的方法在「每次调用参数相同的实例方法」时有什么隐患?(提示:self 也会进缓存键。)
下一章
04 章讲类:类属性与实例属性的分界、property、dataclass——以及 frozen dataclass 如何呼应 01 章的「不可变性」。