第 9 章 · 装饰器与魔法方法

Python 的「元编程」双雄:装饰器让你不改函数代码就给函数加能力(日志、缓存、计时——Java 课第 11 章 GUI 回调的进阶版),魔法方法让你自定义类融入语言语法(第 6 章鸭子类型的引擎)。这两章合起来,解释了 Python 框架(Flask、pytest、dataclass)的全部魔法,是从「会用 Python」到「看懂 Python 生态」的分水岭。

9.1 装饰器:给函数套壳

先看动机:给一批函数统一加「计时」功能,笨办法每个函数里插计时代码(重复);装饰器把「壳」抽出来:

import time, functools

def timer(func):                          # ① 接收函数
    @functools.wraps(func)                # ③ 保留原函数的名字和文档
    def wrapper(*args, **kwargs):         # ② 定义壳:任意参数
        start = time.perf_counter()
        result = func(*args, **kwargs)    # 调用原函数
        print(f"{func.__name__} 用时 {time.perf_counter() - start:.4f}s")
        return result
    return wrapper                        # 返回壳

@timer                                    # 语法糖:等价 slow_sum = timer(slow_sum)
def slow_sum(n):
    return sum(range(n))

slow_sum(10**7)          # slow_sum 用时 0.2314s

拆解语法糖:@timer 就是「把下面的函数作为参数传给 timer,把返回值贴回原名」(第 3 章「函数是一等公民」的直接应用)。壳里用 *args, **kwargs 透传任意参数(第 3 章)、functools.wraps 保住原函数元信息(不加的话 slow_sum.__name__ 变成 "wrapper",调试灾难)。读第三方框架的装饰器时,永远用这三步拆:收函数 → 定义壳 → 返回壳。

带参数的装饰器(三层套娃):@retry(times=3) 是「先调 retry(times=3) 得到真正的装饰器,再套」——「装饰器工厂」。读法慢一点,结构同上多一层。

9.2 经典装饰器三例

缓存(@functools.cache):

@functools.cache          # 标准库自带,自动 memoize
def fib(n):
    return n if n < 2 else fib(n-1) + fib(n-2)

fib(200)                  # 瞬间出(朴素递归是指数级,数据结构课第 9 章)

一行装饰器把指数递归变线性(记忆化),这是「装饰器改变算法复杂度」的极端案例。

注册器(框架的核心机制):

ROUTES = {}
def route(path):                     # 装饰器工厂
    def deco(func):
        ROUTES[path] = func          # 注册:函数定义时被收进路由表
        return func
    return deco

@route("/hello")
def hello(): return "hi"
# Flask 的 @app.get("/") 就是这个机制:import 时自动收集路由

重试(生产刚需):

def retry(times=3, delay=1):
    def deco(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for i in range(times):
                try:
                    return func(*args, **kwargs)
                except Exception:
                    if i == times - 1: raise
                    time.sleep(delay)
        return wrapper
    return deco

第 10 章爬虫直接用它。三个例子一个结论:装饰器把「横切关注点」(计时、缓存、重试、鉴权)从业务代码里剥离——软件工程课第 5 章(AOP 思想)的 Python 实现。

9.3 魔法方法:让自定义类接入语法

魔法方法(dunder methods)是双下划线包裹的钩子,语言语法在背后调它们。第 6 章见过 __init__,这里补全家常用款:

class Vector:
    def __init__(self, x, y): self.x, self.y = x, y

    def __repr__(self):                    # repr(v) 与 REPL 显示(print 调 __str__)
        return f"Vector({self.x!r}, {self.y!r})"

    def __add__(self, other):              # v1 + v2 → 调这里
        return Vector(self.x + other.x, self.y + other.y)

    def __eq__(self, other):               # v1 == v2
        return (self.x, self.y) == (other.x, other.y)

    def __len__(self):                     # len(v)
        return 2

    def __getitem__(self, i):              # v[0]、v[1](还能切片)
        return (self.x, self.y)[i]

    def __iter__(self):                    # for x in v(第 8 章协议)
        yield from (self.x, self.y)

v = Vector(1, 2) + Vector(10, 20)          # Vector(11, 22):运算符重载!

运算符重载让自定义类型自然融入语法(v1 + v2 而不是 v1.add(v2)),Python 的 str/数字运算全靠它。纪律:重载要符合直觉(+ 就该是「相加」,别用 + 干「合并用户」这种怪事;成语原则:像内建类型一样行事)。

几个高频成员:__repr__ vs __str__(repr 给开发者看(精确),str 给用户看(好看),只写一个就写 repr)、__hash__ 与 __eq__(要当字典键必须成对重写,Java 课第 8 章同款契约)、__enter__/__exit__(with 协议,第 5 章)、__call__(实例可调用,装饰器类的形态)。

dataclass(第 6 章)的真相此刻揭晓:它就是「自动生成 __init__/__repr__/__eq__ 等魔法方法」的装饰器——本章两个主题在标准库里合流。

9.4 property:属性访问的平滑升级

Java 习惯「先写 getter/setter 以备将来」(第 7 章的封装),Python 说:先直接用属性,将来需要拦截时用 property 无缝升级:

class Circle:
    def __init__(self, r):
        self._r = r                 # 单下划线:内部约定

    @property
    def area(self):                 # 读:c.area 像属性,实际是函数
        return 3.14159 * self._r ** 2

    @property
    def r(self): return self._r

    @r.setter
    def r(self, value):             # 写:c.r = 5 会经过这里(可校验)
        if value <= 0: raise ValueError("半径必须为正")
        self._r = value

c = Circle(2)
print(c.area)        # 12.57(没有括号!)
c.r = 5              # 触发 setter 校验

调用方代码零改动(还是 c.area),实现从「属性」换成「计算 + 校验」——封装(Java 课第 7 章)与简洁(Pythonic)的两全。这就是「Python 不需要 getter/setter」的底气:升级路径已经铺好。

常见错误

  • 装饰器忘了返回 wrapper(忘了 return)。@ 之后原函数变成 None,调用报「NoneType is not callable」;三步拆解法逐层检查。
  • 忘 @functools.wraps。被装饰函数的 name/doc 全变 wrapper,日志与调试全废;模板背下来。
  • eq 重写了 hash 没重写。类放进 set/dict 后「失踪」(Java 课第 8 章同款契约);重写 eq 必须配套 hash(或声明 hash = None 表明不可哈希)。
  • 魔法方法返回 NotImplemented 与抛 NotImplementedError 混淆。前者是「我不处理,请你试别的类型」(运算符重载的礼貌拒绝),后者是「此方法必须由子类实现」。

练习

  1. 动手:实现 @count_calls 装饰器:统计函数被调用次数,提供 func.calls 查询;用它包装第 8 章的素数生成器内部函数,观察调用分布。
  2. 动手:实现带参装饰器 @retry(times=3, delay=0.5)(9.2 完整版),构造一个「前两次失败第三次成功」的函数测试;打印每次重试的日志。
  3. 实现:温度类 Temperature:魔法方法全套——支持 t1 + t2(返回新对象)、t1 == t2、abs(t)(取摄氏绝对值)、bool(t)(零度以下 False);写测试验证所有语法糖。
  4. 思考:装饰器和继承都能「扩展函数/类的行为」。从「运行时 vs 定义时」「同一函数叠加多个增强」两个角度,对比两者适用场景,再解释为什么 Flask/Django 的路由、pytest 的 fixture 全选装饰器而不是继承。