《Python编程入门》8.3 装饰器原理与实战

装饰器本质是函数到函数的高阶函数,@deco 只是 f = deco(f) 的语法糖。本节从最朴素的例子讲起,强调必须用 functools.wraps 保留元信息;再讲带参数的装饰器、类装饰器与实例方法装饰器的差异、缓存装饰器的实现思路与可哈希约束、singledispatch 的类型分派,以及多个装饰器的执行顺序,最后落到鉴权、重试、计时、注册表等真实工程场景。

本节目标:看穿 @deco 只是 f = deco(f) 的语法糖,掌握 functools.wraps、带参数装饰器、类装饰器与方法装饰器,并能用 lru_cache、singledispatch 解决真实问题。
适用版本:Python 3.12+(实测 3.14.6)

8.3 装饰器原理与实战

第 4 章讲过「函数是一等对象」——能赋值、能传参、能返回。装饰器就是这条性质的直接产物:它是一个「接收函数、返回函数」的高阶函数,用来在不改动原函数体的前提下,给它追加行为。

装饰器就是「函数 → 函数」

先不写 @,看最朴素的形态:

def logged(func):
    def wrapper(*args, **kwargs):
        print(f"  call {func.__name__}{args}")
        return func(*args, **kwargs)
    return wrapper

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

add = logged(add)          # 用 wrapper 替换掉原函数
print("add(2,3) =", add(2, 3))
#   call add(2, 3)
# add(2,3) = 5

logged 拿到 add,返回一个新的 wrapper,并把它绑定回同名变量。从此 add 这个名字指向的就是带日志的版本。@ 语法只是把「传进去、再赋值回来」这一步写得更简洁:

@logged
def add(a, b):
    return a + b
# 完全等价于 add = logged(add)

一句话总结:@deco 等价于 f = deco(f)。理解这一点,后面所有花样都是它的组合。

为什么必须用 functools.wraps

wrapper 返回后,原函数的身份信息(__name__、__doc__、参数签名)都会丢失——因为外部看到的已经是 wrapper 了。看对比:

import functools, inspect

def bad_deco(func):
    def wrapper(*a, **k):
        return func(*a, **k)
    return wrapper

def good_deco(func):
    @functools.wraps(func)
    def wrapper(*a, **k):
        return func(*a, **k)
    return wrapper

@bad_deco
def greet(name):
    """打招呼"""
    return f"hi {name}"

@good_deco
def greet2(name):
    """打招呼"""
    return f"hi {name}"

print(greet.__name__, greet.__doc__)        # wrapper None
print(greet2.__name__, greet2.__doc__)      # greet2 打招呼
print(inspect.signature(greet))             # (*a, **k)
print(inspect.signature(greet2))            # (name)

不加 wraps 时,__name__ 变成 wrapper、__doc__ 变成 None、签名变成 (*a, **k)——这会破坏 IDE 提示、文档生成、pickle 乃至依赖签名的框架。@functools.wraps(func) 是装饰器的强制项,不是可选项。

带参数的装饰器

如果装饰器本身要接收参数(比如「重复几次」),就需要三层嵌套:最外层收参数,中间层收函数,最内层是真正的 wrapper:

def repeat(times):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*a, **k):
            return [func(*a, **k) for _ in range(times)]
        return wrapper
    return decorator

@repeat(3)
def say(msg):
    return msg

print(say("hi"))    # ['hi', 'hi', 'hi']

@repeat(3) 的执行顺序是:先算 repeat(3) 得到 decorator,再用 decorator 装饰 say。所以 @ 后面那个表达式会先被求值。理解了三层结构,鉴权、重试、超时这类「带配置」的装饰器就都能写了。

类装饰器与实例方法装饰器

装饰器的目标不只是普通函数,也可能是类或方法。类装饰器接收一个类、返回一个类:

def add_repr(cls):
    cls.describe = lambda self: f"I am {type(self).__name__}"
    return cls

@add_repr
class Point:
    def __init__(self, x, y):
        self.x, self.y = x, y

print(Point(1, 2).describe())   # I am Point

dataclass(第 6 章)本质上就是一个功能强大的类装饰器——它在类定义完成后动态补上 __init__、__repr__、__eq__。

实例方法装饰器则要注意:被装饰的方法第一个参数是 self,wrapper 必须原样转发:

def trace_method(func):
    @functools.wraps(func)
    def wrapper(self, *a, **k):
        print(f"  call {func.__name__} on {type(self).__name__}")
        return func(self, *a, **k)
    return wrapper

class Calc:
    @trace_method
    def double(self, x):
        return x * 2

print(Calc().double(5))
#   call double on Calc
# 10

如果 wrapper 只写 def wrapper(*a, **k),self 会被当作普通参数塞进 a,调用 func(self, ...) 时就会错位——所以方法装饰器的 wrapper 必须显式声明 self。

functools.lru_cache / cache:装饰器解决真实问题

标准库里最常用的装饰器是 functools.lru_cache(带容量上限)和 functools.cache(无上限,等价于 lru_cache(maxsize=None))。它们把「函数调用 → 结果」缓存起来,重复调用直接命中:

calls = []

@functools.lru_cache(maxsize=None)
def fib(n):
    calls.append(n)
    if n < 2:
        return n
    return fib(n - 1) + fib(n - 2)

print("fib(30) =", fib(30))          # fib(30) = 832040
print("实际计算次数:", len(calls))    # 实际计算次数: 31
print(fib.cache_info())
# CacheInfo(hits=28, misses=31, maxsize=None, currsize=31)

朴素的递归 fib(30) 要算上百万次,加了缓存后只真正计算了 31 次——这就是**记忆化(memoization)**的威力。但缓存有一个硬约束:参数必须可哈希,因为它要用参数做字典的 key:

@functools.cache
def total(items):
    return sum(items)

print(total((1, 2, 3)))   # 6
try:
    total([1, 2, 3])
except TypeError as e:
    print(e)              # unhashable type: 'list'

列表不可哈希,所以传列表会直接报错;要缓存列表参数,得先转成元组。此外要小心:缓存会一直持有结果和参数引用,无限增长可能吃光内存——长跑服务里优先用带 maxsize 的 lru_cache。

functools.singledispatch:按类型分派

functools.singledispatch 把「根据第一个参数的类型选择实现」包装成装饰器,避免一长串 isinstance 判断:

@functools.singledispatch
def render(value):
    return f"unknown: {value!r}"

@render.register
def _(value: int):
    return f"int -> {value}"

@render.register
def _(value: list):
    return "list -> " + ", ".join(map(str, value))

print(render(42))         # int -> 42
print(render([1, 2, 3]))  # list -> 1, 2, 3
print(render("hi"))       # unknown: 'hi'

没有匹配类型时走被 @singledispatch 装饰的那个「默认实现」。这是多分派在标准库里最轻量的落地,做序列化、格式化、AST 访问时非常好用。

多个装饰器的执行顺序

当多个装饰器叠在一起,记住一句话:包装自下而上,执行自上而下。

def deco_a(func):
    print("  apply A")
    def wrapper(*a, **k):
        print("  enter A")
        r = func(*a, **k)
        print("  exit A")
        return r
    return wrapper

def deco_b(func):
    print("  apply B")
    def wrapper(*a, **k):
        print("  enter B")
        r = func(*a, **k)
        print("  exit B")
        return r
    return wrapper

@deco_a
@deco_b
def hello():
    print("  hello")

hello()
#   apply B
#   apply A
#   enter A
#   enter B
#   hello
#   exit B
#   exit A

应用阶段(装饰时)从下往上:先 apply B 再 apply A,因为 @deco_a 装饰的是 deco_b(hello) 的结果。运行阶段(调用时)从上往下:A 先进入、B 后进入,退出时反序。想清楚这条顺序,堆叠装饰器就不会搞反。

装饰器在真实工程里的位置

装饰器不是语法玩具,它是「横切关注点」的标准载体:

场景装饰器做什么
鉴权 / 权限调用前检查用户角色,不满足直接抛异常
重试捕获异常后按策略重试,成功即返回
计时 / 监控用 time.perf_counter 包住调用,记录耗时
注册表把函数按名字登记进全局字典,供后续按名分派
缓存lru_cache / 自定义缓存
日志 / 追踪记录入参、出参、异常

重试是一个典型例子——它把「失败后重来」这段控制流从业务代码里抽走:

def retry(times=3):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*a, **k):
            for attempt in range(1, times + 1):
                try:
                    return func(*a, **k)
                except Exception as e:
                    print(f"  attempt {attempt} failed: {e}")
            raise                      # 重试耗尽,抛出最后一次异常
        return wrapper
    return decorator

state = {"n": 0}

@retry(times=3)
def flaky():
    state["n"] += 1
    if state["n"] < 3:
        raise RuntimeError("not yet")
    return "ok"

print("flaky ->", flaky())
#   attempt 1 failed: not yet
#   attempt 2 failed: not yet
# flaky -> ok

注册表则利用「装饰器在导入时就会执行」这一点,把函数自动收集起来:

HANDLERS = {}

def register(name):
    def decorator(func):
        HANDLERS[name] = func
        return func
    return decorator

@register("add")
def do_add(a, b):
    return a + b

@register("mul")
def do_mul(a, b):
    return a * b

print(sorted(HANDLERS))          # ['add', 'mul']
print(HANDLERS["add"](2, 3))     # 5

这种「注册表 + 装饰器」的模式在 Web 框架(路由表)、命令分发、插件系统里随处可见。计时装饰器同理:用 time.perf_counter 包住调用,并放进 try/finally,这样即使被装饰的函数抛异常,耗时也会照常记录。

小结

  1. @deco 只是 f = deco(f) 的语法糖;装饰器就是「接收函数、返回函数」的高阶函数。
  2. 必须用 @functools.wraps(func),否则 __name__、__doc__、签名全部丢失。
  3. 带参数的装饰器需要三层嵌套;类装饰器接收并返回类,方法装饰器的 wrapper 必须显式带 self。
  4. functools.lru_cache/cache 做记忆化,参数必须可哈希,且要警惕缓存无限增长。
  5. functools.singledispatch 按第一个参数类型分派,替代冗长的 isinstance 分支。
  6. 多个装饰器包装自下而上、执行自上而下。
  7. 鉴权、重试、计时、注册表、缓存是装饰器最常见的工程落点。

到这里,你已经掌握了 Python 函数式编程的两大支柱:生成器(惰性数据流)与装饰器(行为组合)。但它们有一个共同的短板——类型信息在运行时几乎不被检查。下一节 9.1 类型注解语法与 pyright / mypy 进入第 9 章,讲如何用类型注解把「参数与返回值是什么」写进代码,并让工具在运行前替你抓错。

阅读导航:上一节:8.2 生成器进阶:send / yield from 与惰性管道 · 下一节:9.1 类型注解语法与 pyright / mypy 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时