第 3 章 装饰器:给函数穿衣服的艺术
这一章解决你写过无数遍的「重复包裹逻辑」——每个接口都要鉴权、每个慢函数都要计时、每个易错调用都要重试。Go 里你用中间件一层套一层,Python 里这层壳叫「装饰器」,它是把横切逻辑收口、还不污染业务主体的核心武器。
墨叔:老哥,我问你个事儿。你 Go 里写 HTTP 服务,是不是有个 AuthMiddleware,所有路由外面套一圈,进去前先验 token,验不过直接 401?业务 handler 本身干干净净只管干活。
老哥:对啊,中间件嘛,洋葱模型。
墨叔:那在 Python 里,同一套「在函数外面套层逻辑、里面函数本身不动」的诉求,对应的武器叫装饰器(decorator)。它不是新语法糖,而是 Python 「函数是一等公民 + 闭包」这俩底座上长出来的自然结果。今天我就把这层「衣服」从里到外扒给你看。
一、函数是一等公民:能当变量传的「值」
装饰器的地基,是你从 Go 过来第一个要扭过来的观念:在 Python 里,函数不是「一段代码」,而是一个「值」——能赋值、能当参数传、能当返回值返回。Go 里函数是一等公民你也懂(func 能当参数),但这事在 Python 里走得更彻底,且是装饰器的前提。
def greet(name: str) -> str:
return f"你好, {name}"
# 函数能赋值给变量,变量调用等效
shout = greet
print(shout("老哥")) # 你好, 老哥
# 函数能当参数传
def run_twice(fn, arg):
return fn(arg), fn(arg)
print(run_twice(greet, "世界")) # ('你好, 世界', '你好, 世界')老哥:这不就是 Go 的 func(string) string 当参数传吗?
墨叔:对,方向一样。但 Python 更松:函数能直接返回另一个新函数,这就引出闭包——装饰器的真正心脏。
二、闭包:函数记住了它出生时的环境
闭包(closure)听着玄,其实一句话:内层函数能记住外层函数的变量,哪怕外层已经返回了。
def make_multiplier(factor: int):
def inner(x: int) -> int:
return x * factor # inner 记住了外层传进来的 factor
return inner
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5)) # 10
print(triple(5)) # 15make_multiplier(2) 返回 inner,inner 把 factor=2 这个环境「缝」在自己身上一直带着。double(5) 时用的就是当年缝进去的 2。这就是闭包:函数 + 它捕获的变量 = 一个带电的状态包。
墨叔:你拿它类比餐厅——
make_multiplier是个「打包台」,factor 是秘方。double 这盘菜打包时秘方(2)就封进去了,端到哪儿都按 2 倍炒,跟打包台早就下班没关系。
装饰器就是用「闭包返回新函数」这招,把原函数换成一个「加了料」的新函数。
三、装饰器本质:返回新函数的函数
最朴素的装饰器长这样:
def log_calls(fn):
def wrapper(*args, **kwargs):
print(f"[调用] {fn.__name__} 参数={args}, {kwargs}")
result = fn(*args, **kwargs)
print(f"[返回] {result}")
return result
return wrapper
def add(a: int, b: int) -> int:
return a + b
add = log_calls(add) # 关键:把 add 替换成 wrapper
print(add(2, 3))看穿本质了没?log_calls(add) 返回 wrapper,然后 add = ... 把名字重新绑到 wrapper。从此你调 add(2,3),实际跑的是 wrapper,它先打印、再调真 add、再打印。原函数本体一行没动,外面却套了层日志——这就是「给函数穿衣服」。
Python 给了你语法糖 @,等价于上面那句赋值:
@log_calls
def add(a: int, b: int) -> int:
return a + b老哥:所以 @ 就是个「把下面函数传进去、把返回值绑回同名」的语法糖?
墨叔:一语中的。@log_calls 完全等价于 add = log_calls(add)。理解这点,你就再也不会被装饰器吓到——它没魔法,就是高阶函数 + 赋值。
四、functools.wraps:别丢了原函数的「身份证」
刚才那个朴素装饰器有个坑:add 现在其实是 wrapper,于是:
print(add.__name__) # "wrapper",不是 "add"
print(add.__doc__) # None,原函数文档丢了这在调试、生成 API 文档、用 inspect 排查时全是坑。Python 用 functools.wraps 把原函数的元信息「复印」到 wrapper 上:
import functools
def log_calls(fn):
@functools.wraps(fn) # 关键:把 fn 的 __name__/__doc__ 等拷给 wrapper
def wrapper(*args, **kwargs):
print(f"[调用] {fn.__name__}")
return fn(*args, **kwargs)
return wrapper
@log_calls
def add(a: int, b: int) -> int:
"""两数相加"""
return a + b
print(add.__name__) # "add",身份证回来了
print(add.__doc__) # "两数相加"墨叔:每条装饰器都该加
@functools.wraps(fn),这是铁律,不是可选项。忘了它,你的函数就丢了名字和文档,排查问题时栈帧里全是wrapper,你会骂娘的。
五、类装饰器与多装饰器叠加
装饰器不一定返回函数,也能装饰类,用来给类批量加能力:
def add_repr(cls):
@functools.wraps(cls)
def __repr__(self):
fields = ", ".join(f"{k}={v!r}" for k, v in vars(self).items())
return f"{cls.__name__}({fields})"
cls.__repr__ = __repr__
return cls
@add_repr
class Point:
def __init__(self, x: int, y: int) -> None:
self.x, self.y = x, y
print(Point(1, 2)) # Point(x=1, y=2)多个装饰器能叠在一块,顺序是从下往上包,从上往下执行(离函数最近的先包,等于先当最里层):
@auth # 最外层,最后执行
@timing # 中间层
@log_calls # 最里层,先执行
def handler(): ...等价于 handler = auth(timing(log_calls(handler)))。调用时顺序是 auth → timing → log_calls → 真 handler → 原路返回。这不正就是你 Go 中间件的洋葱模型吗?log_calls 是芯,auth 是最外皮。
老哥:这洋葱和 Go 的 r.Use(auth, timing, logging) 一模一样啊。
墨叔:形同神近。Go 中间件是「把 handler 包进 http.Handler 链」,Python 装饰器是「把函数包进 wrapper 链」——都是装饰模式的落地,都是把横切逻辑和主业解耦。区别是 Go 靠接口+显式链式调用,Python 靠 @ 语法糖 + 闭包,写起来更贴函数本身。
六、真实场景:缓存 / 计时 / 鉴权 / 重试
光讲原理没劲,给你四个天天用得上的实战款。
1. 计时:
import time, functools
def timing(fn):
@functools.wraps(fn)
def wrapper(*a, **kw):
t0 = time.perf_counter()
result = fn(*a, **kw)
print(f"{fn.__name__} 耗时 {time.perf_counter()-t0:.4f}s")
return result
return wrapper2. 简单缓存(当然生产用 functools.lru_cache,见下章):
def memoize(fn):
cache: dict = {}
@functools.wraps(fn)
def wrapper(*args):
if args not in cache:
cache[args] = fn(*args)
return cache[args]
return wrapper
@memoize
def fib(n: int) -> int:
return n if n < 2 else fib(n-1) + fib(n-2)3. 鉴权(对照 Go 中间件验 token):
def require_role(role: str):
def decorator(fn):
@functools.wraps(fn)
def wrapper(user, *a, **kw):
if user.get("role") != role:
raise PermissionError(f"需要 {role} 权限")
return fn(user, *a, **kw)
return wrapper
return decorator
@require_role("admin")
def delete_user(user, uid: int) -> None:
print(f"删除 {uid}")注意这里 require_role 是「带参数的装饰器」——它返回真正的 decorator,形成三层嵌套。这是装饰器最常见的进阶形态,记住套路:外层收参数 → 中层收函数 → 内层是 wrapper。
4. 重试(网络调用抖动时极实用):
import time
def retry(times: int = 3, delay: float = 0.5):
def decorator(fn):
@functools.wraps(fn)
def wrapper(*a, **kw):
last = None
for i in range(times):
try:
return fn(*a, **kw)
except Exception as e: # 真实项目应限定异常类型
last = e
time.sleep(delay)
raise last
return wrapper
return decorator
@retry(times=3)
def call_remote() -> str:
...六·补、带状态的类装饰器
前面全是「函数式装饰器」(用闭包存状态)。但当你要的装饰器要持久状态(比如「统计这个函数被调用了多少次」「限流:每分钟最多 N 次」),用类写更直白——让类实现 __call__,实例本身就是个可调用对象:
import time, functools
class RateLimit:
def __init__(self, max_calls: int, window: float) -> None:
self.max_calls = max_calls
self.window = window
self.calls: list[float] = []
@functools.wraps(lambda fn: fn) # 仅为占位,下面 wrap 才是真装饰
def __call__(self, fn):
@functools.wraps(fn)
def wrapper(*a, **kw):
now = time.monotonic()
self.calls = [t for t in self.calls if now - t < self.window]
if len(self.calls) >= self.max_calls:
raise RuntimeError("调用过于频繁")
self.calls.append(now)
return fn(*a, **kw)
return wrapper
@RateLimit(max_calls=3, window=10.0)
def hit() -> str:
return "ok"这里 RateLimit(...) 实例化后,因为类定义了 __call__,实例能当函数用,于是 @RateLimit(...) 等价于 hit = RateLimit(...)(hit)——实例夹在中间持有 calls 列表当跨调用状态。注意它本质是「带参装饰器的另一种写法」:RateLimit(...) 返回实例,实例 __call__ 收函数返回 wrapper。对照 Go:你在 Go 里通常用一个「带字段的 struct + 方法」做中间件(如 type Limiter struct{...} 然后 func (l *Limiter) Middleware(next) handler),思路同源——结构体/类持有状态,方法充当包装逻辑。
六·再补、装饰类方法时的 self 陷阱
这是新手必踩的坑。当你用装饰器包装一个类方法,wrapper 的第一个参数其实是 self:
def log_call(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
print(f"调 {fn.__name__}, 参数 {args}")
return fn(*args, **kwargs)
return wrapper
class Service:
@log_call
def do(self, x: int) -> int:
return x * 2wrapper(*args) 里 args[0] 就是实例 self,装饰器不用、也不该去碰它,原样传给 fn 即可。坑在于:如果你手写的 wrapper 签名写成了 def wrapper(self, *args) 去显式接收 self,再用 @functools.wraps 复印了原方法的签名,mypy/IDE 会以为这方法签名变了,且 self 被多收一次导致错位。正确纪律永远是 def wrapper(*args, **kwargs) 全透明转发,让 self 自然落在 args[0]。这跟你 Go 里 func (s *Service) Do(x int) 的 receiver 自动就位是一个道理——装饰器只是「在 receiver 外面再套一层」,别去重新声明 receiver。
六·三补、functools.lru_cache:标准库最香的装饰器
别总自己写 memoize,标准库 functools.lru_cache 才是生产级缓存装饰器,带**容量上限(LRU 淘汰)**和命中统计:
from functools import lru_cache
import time
@lru_cache(maxsize=128)
def fetch_user(uid: int) -> dict:
time.sleep(0.1) # 模拟一次昂贵的 DB/网络查询
return {"uid": uid}
fetch_user(1); fetch_user(1) # 第二次命中缓存,sleep 不执行
print(fetch_user.cache_info()) # CacheInfo(hits=1, misses=1, maxsize=128, currsize=1)maxsize 控制保留最近多少个不同参数组合,超出按 LRU 淘汰,防止内存无限涨——这正是 Go 里你得自己引 golang-lru 或手搓 map+链表才有的能力,Python 一行装饰器搞定。注意两个限制:被缓存的参数必须可哈希(list/dict 不行,得转 tuple/frozenset);且它缓存的是返回值,若函数有副作用(如写日志、改全局状态)就不该缓存。这和你 Go 里用 sync.Map 做 memo 前要确认「函数纯」是同一准则。
七、用装饰器实现事务与资源获取
缓存、计时、鉴权、重试之外,装饰器还有一个特别贴工程的项目用法:把「资源获取+释放」或「事务开关」包在函数外,业务函数内只写核心逻辑。对照 Go,这活儿你常放在中间件或 defer 里干;Python 里用一个装饰器就收口了。
import sqlite3, functools
def db_transaction(fn):
@functools.wraps(fn)
def wrapper(conn: sqlite3.Connection, *a, **kw):
try:
result = fn(conn, *a, **kw)
conn.commit() # 成功就提交
return result
except Exception:
conn.rollback() # 出错就回滚
raise
return wrapper
@db_transaction
def transfer(conn, src: int, dst: int, amount: int) -> None:
conn.execute("UPDATE accounts SET bal=bal-? WHERE id=?", (amount, src))
conn.execute("UPDATE accounts SET bal=bal+? WHERE id=?", (amount, dst))
conn = sqlite3.connect(":memory:")
transfer(conn, 1, 2, 100) # commit 自动发生;内部抛异常则自动 rollback这里 transfer 本体只管两条 SQL,提交/回滚被「穿」在外面。这正对应 Go 里你常写的「开事务 → defer tx.Rollback() → 成功则 Commit」模式——Go 靠 defer 兜底回滚,Python 靠装饰器的 try/except 兜底,殊途同归。好处一眼可见:十个业务函数都 @db_transaction,谁都不用再重复写 commit/rollback,且「忘了回滚」这种低级 bug 从根上被消灭。
老哥:那这和把 commit 直接写进函数体有啥区别?
墨叔:区别在于横切关注点和主业的解耦。写进函数体,每个函数都得记着「最后要 commit、出事要 rollback」,十个函数复制十遍,漏一个就是隐性 bug;用装饰器,这层逻辑只有一处,且不可被业务函数绕过。这是装饰模式最值钱的地方——它把「所有函数都必须遵守的约定」从「靠每个人自觉」升级成「框架强制」。你 Go 里把鉴权、日志、恢复(recover)放中间件,也是同一个念头。
九、收益与代价的总账
装饰器甜,但不是免费的:
- 收益:横切逻辑(日志/鉴权/缓存/重试/事务)从每个业务函数抽离,主业干净;一处定义处处复用;对照 Go 中间件心智模型,迁移无痛;
@语法让「分层」肉眼可见。 - 代价:叠加多了栈深、调试时栈帧嵌套(务必
functools.wraps否则更晕);带参装饰器三层嵌套,新手易写错;装饰器在导入时就执行(函数定义即包裹),如果有副作用或重逻辑,可能影响启动;过度装饰会让函数真实行为「藏」在好几层衣服下,读代码要扒皮。 - 设计意图:Python 把「函数是值 + 闭包」做成一等能力,装饰器只是顺势而为的语法糖,而非新机制。它的哲学和 Go 中间件同源——关注点分离。
再补一句和 Go 的终极对照,帮你把位置摆正:Go 的中间件是语言加标准库(net/http 的 Handler 接口)硬撑出来的统一约定,所有人照这套写,风格收敛;Python 的装饰器更「草根」,靠社区约定加 @ 语法糖,灵活但风格容易飘。所以团队里最好立几条铁律:装饰器内层 wrapper 必带 @functools.wraps;带参装饰器统一用「外层收参数、中层收函数、内层是 wrapper」的三层结构,别各写各的;绝不在装饰器里偷偷写重副作用(如发起网络请求、写库),装饰器应当是「轻量横切」,重逻辑请放进业务或独立服务。规矩立住了,装饰器是利器;规矩没有,它就是藏 bug 的迷雾。你从 Go 过来最该带走的,就是这种「约定优于混乱」的工程纪律——语言给了你灵活,纪律决定你走多远。
顺带一提执行时机这个隐蔽点:装饰器是在模块被 import 的那一刻就执行包裹的(函数定义即被装饰),不是等你调用才包。所以若装饰器顶层(不在 wrapper 里)写了重逻辑或副作用,只要有人 import 这个模块就会跑——写单测、做懒加载、或依赖「import 零副作用」的框架时要特别当心,别把「该运行时才做的事」误放进装饰器顶层。wrapper 内部才是「每次调用才执行」的安全区。
十、贴近项目的一个例子
拼一个真实 API handler:先计时、再鉴权、再干活,全靠装饰器收口,业务函数本身只剩核心逻辑:
import functools, time
def timing(fn):
@functools.wraps(fn)
def wrapper(*a, **kw):
t0 = time.perf_counter()
try:
return fn(*a, **kw)
finally:
print(f"{fn.__name__} 耗时 {time.perf_counter()-t0:.4f}s")
return wrapper
def require_role(role: str):
def decorator(fn):
@functools.wraps(fn)
def wrapper(user, *a, **kw):
if user.get("role") != role:
raise PermissionError(f"需要 {role}")
return fn(user, *a, **kw)
return wrapper
return decorator
@timing
@require_role("admin")
def create_order(user, payload: dict) -> dict:
# 真正的业务逻辑,干干净净
return {"order_id": 123, "status": "created"}
admin = {"role": "admin"}
print(create_order(admin, {"sku": "x"}))看到没?create_order 本体只关心「创建订单」,计时和鉴权被「穿」在外面。哪天要加缓存,再叠一个 @memoize 即可,业务代码纹丝不动。这正是装饰模式在工程里最值钱的地方——开闭原则:对扩展开放,对修改封闭。
【思考题】
- 不带参数的
@log_calls和带参数的@require_role("admin"),在「函数调用层数」上差几层?为什么带参版本一定是三层嵌套?能不能用闭包少写一层? - 你说装饰器像 Go 中间件。但 Go 的中间件链是运行时由
r.Use(...)动态组装的,Python 的@是在导入时就定死的。这个「导入时绑定」和「运行时组装」的差异,在哪些项目场景下会让你吃亏(或反而方便)? - 有个装饰器忘记写
@functools.wraps(fn),下游用inspect.signature(handler)拿参数签名、或用handler.__name__做日志。会发生什么?怎么定位这种 bug?
【参考答案】
- 差一层。不带参的装饰器是两层:
log_calls(fn)直接返回wrapper,wrapper里调fn。带参的是三层:require_role("admin")先返回一个「decorator」,decorator 再收fn返回wrapper。为什么必须三层?因为@require_role("admin")在语法上等价于create_order = require_role("admin")(create_order)——第一对括号收参数返回 decorator,第二对括号收函数返回 wrapper。参数和「被装饰的函数」是两个不同的输入,必须分两层接收,所以必然是 三层(外层收参、中层收函数、内层是 wrapper)。能不能少写?可以用functools.partial或直接把参数做成闭包变量省掉一层命名,但可读性反而差;标准写法就是三层,清晰优于少打几行。 - 「导入时绑定」意味着装饰器链在模块加载那一刻就焊死,运行期不能按请求动态增减层。吃亏场景:你想像 Go 那样「按路由配置动态挂不同中间件」(比如
/public不鉴权、/admin才鉴权),Python 的@做不到按运行时条件切换,得在每个装饰器内部用if判断,或改用显式包装(返回函数后手动组合)。方便场景:链是静态的、编译期(导入期)可见,IDE 和 mypy 能静态分析出「这个函数被哪些装饰器包了」,可读性和可检查性反而比 Go 运行时组装更强;而且装饰结果在导入时算一次,运行期零额外组装成本。一句话:动态路由策略用 Go 中间件灵活,固定横切关注点用@更清晰。 - 没写
@functools.wraps,wrapper不会复印原函数的__name__/__doc__/__wrapped__等。后果:handler.__name__变成"wrapper"(日志里全显示 wrapper,定位不到真函数);inspect.signature(handler)拿到的是 wrapper 的签名(*args, **kwargs),而不是原函数的(user, payload)——如果你的框架靠签名做参数注入或文档生成(如 FastAPI 靠签名解析请求体),会直接出错或拿不到正确参数。定位方法:①看日志函数名是否都是wrapper;②print(handler.__wrapped__)若 AttributeError 说明没 wraps;③用inspect.unwrap(handler)尝试剥开。根治:每条装饰器内层wrapper上都加@functools.wraps(fn),并写单测断言handler.__name__ == "create_order"。