Python · 描述符与装饰器 (元编程)

property/classmethod/ORM 字段的共同底层 — 框架"魔法"的全部原料: 装饰器改函数, 描述符改属性访问, metaclass 改类创建

裁决 装饰器 — 函数级增强 @deco 是语法糖: f = deco(f) wrapper 在调用前后插逻辑 日志 / 重试 / 缓存 / 注册表 / 权限 functools.wraps 保住签名与文档 带参装饰器 = 再包一层工厂 描述符 — 属性级接管 实现 __get__ / __set__ / __delete__ 的类, 作为另一个类的类属性生效 data 描述符: 含 __set__ (可拦截赋值) non-data: 仅 __get__ (可被实例覆盖) obj.x 的读写被你的代码接管 obj.x 的裁决顺序 1. data 描述符 (类上) 2. obj.__dict__ 3. non-data 描述符 / 类属性 4. __getattr__ 兜底 面试题: 为什么实例字典盖不住 property? 你天天在用的描述符 — 只是被包装好了 property getter/setter 转发 data 描述符 obj.x 走你的函数 classmethod / staticmethod 绑定 cls / 解除绑定 non-data 描述符 函数本身也是描述符! Django/SQLA Field 字段类型 + 校验 + SQL 映射 data 描述符 Model.amount 的魔法来源 自定义: 类型校验字段 class Order: amount = Typed(int) o.amount = 'x' → __set__ 拦截报错 声明处一行, 全类字段都受保护 pydantic/dataclass 校验同源思想 元编程三件套 — 框架魔法的全部原料 (按介入深度排列) 装饰器 — 增强函数/类 日志 · 重试 · 缓存 · 路由注册 描述符 — 接管属性访问 类型校验 · ORM 字段 · 惰性计算 metaclass — 接管类创建 单例 · 插件注册表 · ABC 强制实现 Legend 装饰器 描述符 查找顺序 实战示意 机制/容器

装饰器 = 语法糖

  • • @deco 等价 f = deco(f), 仅此而已
  • • 万能三件套: 日志、计时、缓存 (lru_cache)
  • • @wraps 保住 __name__/签名, 否则调试地狱

描述符 = 属性钩子

  • • 必须定义在类上才生效 (实例上无效)
  • • data(有 __set__)优先级压过实例 __dict__
  • • property/classmethod/ORM 字段全是它

元编程介入深度

  • • 装饰器: 改函数行为 → 日常首选
  • • 描述符: 改属性语义 → 框架级基建
  • • metaclass: 改类生灭 → 最后一招, 慎用

💡 一句话理解

装饰器回答"怎么增强函数", 描述符回答"怎么接管属性的读写"。你在框架里见过的所有"魔法" — Django 的 Model.name 能校验能生成 SQL、property 让方法像字段、@classmethod 自动传 cls — 底层都是同一套协议: 把"读写属性"变成"执行你的代码"。看懂这两件, Python 框架的源码对你就是透明的。

🧠 必知必会 必考 & 必会

@deco 的真身
@deco\ndef f(): ... 就是 f = deco(f)。装饰器是"接收可调用并返回可调用"的高阶函数, 类也可以当装饰器(实现 __call__)。
def deco(fn):
    return lambda: fn() + 1      # 关键: 收函数, 返回新可调用
def f(): return 41
f = deco(f)                # 等价于在 f 头上写 @deco
f()                        # → 42
带参装饰器
@retry(3) 是三层: 最外层收参数 → 中间层收函数 → 最内层收调用参数。记住"参数越外层越多一层嵌套"。
def retry(times):              # 第 1 层: 收参数
    def deco(fn):              # 第 2 层: 收函数
        def wrap(*a): return fn(*a)  # 第 3 层: 收调用
        return wrap
    return deco                # 关键: @retry(3) 先吃参数再吃函数
描述符触发条件
只有把描述符类的实例放在宿主类的类属性上, obj.x 才会触发 __get__(obj, Owner)。放实例 __dict__ 里、或直接挂在实例上都不会生效。
class D:
    def __get__(self, obj, owner): return 42
class C:
    d = D()                # 关键: 必须放宿主类的类属性位
C().d                      # → 42
c = C(); c.d = D(); c.d    # → D 对象本身, __get__ 不触发
data vs non-data
定义了 __set__/__delete__ = data, 优先级高于实例字典(所以 property 拦得住赋值); 只有 __get__ = non-data, 会被同名的实例属性盖住(普通函数就是 non-data, 所以实例能遮蔽方法)。
class Data:
    def __get__(s, o, t): return "data"
    def __set__(s, o, v): pass     # 关键: 带 __set__ = data
c = type("C", (), {"x": Data()})(); c.__dict__["x"] = "inst"
c.x                        # → data, 压过实例字典
# 若删掉 __set__ (non-data): c.x → inst, 被实例属性盖住
属性查找顺序
obj.x: 类上的 data 描述符 → obj.__dict__ → 类上 non-data 描述符/类属性 → __getattr__ 兜底。这条顺序是解释 attr 行为的"物理定律"。
class C:
    def __getattr__(self, k): return "fallback"
class P(C):
    @property                  # 关键: data 描述符排第 1
    def x(self): return 1
p = P(); p.__dict__["x"] = 99    # 实例字典蓄意覆盖
p.x                        # → 1, property 赢 __dict__
p.y                        # → fallback, 找不到才兜底
函数即描述符
普通函数实现了 __get__, 被访问时返回 bound method(自动绑定 self) — "方法绑定"不是编译器魔法, 就是描述符协议。
class C:
    def m(self): return self
C.m                        # → 普通函数, 未绑定
c = C()
c.m() is c                # → True
C.m.__get__(c, C)          # 关键: 手动触发描述符 = 绑定方法
metaclass 何时用
类创建时强制校验/注册(ABC、ORM 基类、单例)。99% 场景用 __init_subclass__(3.6+) 就够, 不必直接写 metaclass。
class Base:
    def __init_subclass__(cls, **kw):  # 关键: 3.6+ 替代 metaclass
        assert "name" in cls.__dict__
        super().__init_subclass__(**kw)
class Sub(Base): name = "s"  # 忘写 name → 启动即 AssertionError

🏭 生产实战 real world

场景 1 · 重试装饰器 (调下游的标配)

def retry(times=3, delay=0.5, exc=(TimeoutError, ConnectionError)):
    def deco(fn):
        @functools.wraps(fn)                     # 保住函数名/签名/文档
        def wrapper(*args, **kw):
            for i in range(times, 0, -1):
                try: return fn(*args, **kw)
                except exc as e:
                    if i == 1: raise
                    time.sleep(delay * (times - i + 1))   # 递增退避
        return wrapper
    return deco

@retry(times=3)
def call_payment(api): ...                     # 幂等接口才可重试!

注意重试的前提是幂等; 支付下单这类非幂等操作要用幂等键, 而不是裸重试。

场景 2 · 类型校验描述符 (ORM 风格基建)

配置/模型字段上线前就拦住脏数据, 而不是等线上报错:

class Typed:
    def __init__(self, typ, default=None): self.typ, self.name = typ, None
    def __set_name__(self, owner, name): self.name = name    # 3.6+: 自动拿字段名
    def __set__(self, obj, val):
        if not isinstance(val, self.typ):
            raise TypeError(f"{self.name} 需要 {self.typ.__name__}, 得到 {val!r}")
        obj.__dict__[self.name] = val          # 值仍存实例字典, 描述符只做守门
    __get__ = None  # 简化: 读直接走实例字典 (跳过 __get__)

class Order:
    amount = Typed((int, float));  status = Typed(str)

Order().amount = "1"   # → TypeError: amount 需要 int/float, 得到 '1'

场景 3 · property 做派生字段 + 缓存

class Sku:
    @property
    def price_with_tax(self):              # 读起来像字段, 每次实时算
        return round(self.price * (1 + TAX), 2)

    @functools.cached_property            # 3.8+: 算一次缓存进 __dict__
    def heavy_report(self): ...

接口序列化时 property 直接当字段输出 (dataclasses.asdict 前先 asdict() 转换), 派生字段不必落库。

场景 4 · 路由注册装饰器(框架的骨架)

Flask 风格 @app.get 的本质: 装饰器把 (path, handler) 登记进注册表并原样返回函数:

ROUTES: dict[str, callable] = {}
def route(path):
    def deco(fn):
        ROUTES[path] = fn                  # 注册表登记
        @functools.wraps(fn)
        def wrapper(*a, **kw):
            return fn(*a, **kw)            # 原样透传, 也可加前置/后置
        return wrapper
    return deco

@route("/orders")                       # 业务函数零侵入接入
def list_orders(): ...

场景 5 · 依赖注入描述符

服务对象的懒初始化与共享用描述符最顺: 业务类只见声明, 组装逻辑集中在容器:

class Inject:
    def __init__(self, cls): self.cls = cls
    def __set_name__(self, owner, name): self.name = name
    def __get__(self, obj, owner=None):
        if obj is None: return self
        svc = container.resolve(self.cls)          # 首次访问时按类型解析
        obj.__dict__[self.name] = svc              # 缓存进实例, 之后直读
        return svc

class OrderService:
    db = Inject(Database)          # 声明即注入; 单测时 container.override(Database, FakeDB)

场景 6 · 强类型配置类(pydantic 思想)

配置字段赋值即校验, 错误在启动第 1 秒爆出而不是运行第 3 天:

class Range:
    def __init__(self, typ, lo, hi): self.typ, self.lo, self.hi = typ, lo, hi
    def __set_name__(self, o, name): self.name = name
    def __set__(self, obj, val):
        if not isinstance(val, self.typ) or not (self.lo <= val <= self.hi):
            raise ValueError(f"{self.name} 需 {self.typ.__name__} 且在 [{self.lo},{self.hi}]")
        obj.__dict__[self.name] = val

class AppConfig:
    port = Range(int, 1, 65535)
    workers = Range(int, 1, 128)
AppConfig().port = 99999   # → ValueError: 启动即失败

场景 7 · 大对象惰性派生字段

报告生成贵且多数请求用不到: cached_property 延迟到首次访问, 失效可精确控制:

class SkuView:
    def __init__(self, sku): self.sku = sku
    @functools.cached_property              # 算一次缓存进实例 __dict__
    def heavy_report(self):
        return build_expensive_report(self.sku)
    def invalidate(self):
        self.__dict__.pop("heavy_report", None)   # 删缓存即重新生成

场景 8 · ORM 关系字段与 N+1 查询

order.user 首次访问触发查询并缓存 —— 看懂描述符就懂 N+1 的成因与解法:

class ForeignKey:
    def __init__(self, model): self.model = model
    def __set_name__(self, o, name): self.name = name
    def __get__(self, obj, owner=None):
        if obj is None: return self
        if self.name not in obj._cache:
            obj._cache[self.name] = self.model.get(obj._fk[self.name])  # 惰性查询!
        return obj._cache[self.name]
# 循环里逐个 order.user = 每次一条 SQL = N+1; 解法: select_related 一次预填 _cache

场景 9 · 认证与授权的装饰器编排

请求从上往下穿: 先认证(身份)再授权(权限), 顺序即语义:

def login_required(fn):
    @functools.wraps(fn)
    def wrapper(request, *a, **kw):
        if not request.user: raise Unauthorized()
        return fn(request, *a, **kw)
    return wrapper

def role_required(role):
    def deco(fn):
        @functools.wraps(fn)
        def wrapper(request, *a, **kw):
            if request.user.role != role: raise Forbidden()
            return fn(request, *a, **kw)
        return wrapper
    return deco

@login_required                      # 外层: 有身份
@role_required("admin")             # 内层: 是管理员
def delete_order(request): ...

场景 10 · 插件自动注册体系

基类 __init_subclass__(3.6+) 子类定义即注册, 不需要 metaclass:

REGISTRY: dict[str, type] = {}
class Plugin:
    name: str
    def __init_subclass__(cls, **kw):          # 每定义一个子类自动调用
        super().__init_subclass__(**kw)
        REGISTRY[cls.name] = cls             # 注册!无需显式登记

class WechatPay(Plugin): name = "wechat"    # import 即生效
class Alipay(Plugin):  name = "alipay"
handler = REGISTRY["wechat"](cfg)             # 按名实例化, 新增支付方式零改分发代码

⚠️ 编码注意与常见坑 pitfalls

坑 1 · 装饰器不加 @wraps — 函数名变 wrapper、签名丢失, 日志/AP 排查全乱。正解: 一律 functools.wraps; 团队 lint 加规则。
def deco(fn):
    def w(): return fn()
    return w               # 错: w.__name__ → 'w', 排查全乱
def deco(fn):
    @functools.wraps(fn)        # 对: 名字/签名/文档全保住
    def w(): return fn()
    return w
坑 2 · 描述符放在实例上 — obj.desc = MyDesc() 毫无效果, 协议只在类属性位触发。正解: 声明在 class body 里。
class D:
    def __get__(self, o, t): return 42
class C: d = D()            # 对: 类属性位 → C().d → 42
c = C(); c.d = D()           # 错: 覆盖到实例上
c.d                          # → D 对象本身, 协议不触发
坑 3 · 装饰器在 import 时执行 — 装饰逻辑(如注册表写入)在模块导入时就发生, import 副作用导致"没调用也报错"。正解: 包装逻辑放 wrapper 内, 注册类装饰器除外(那正是目的)。
# 错: 连接在 import 时就执行, 没人调用也跑
def deco(fn):
    DB.connect(); return fn
# 对: 副作用收进 wrapper, 首次调用才发生
def deco(fn):
    @functools.wraps(fn)
    def w(): DB.connect(); return fn()
    return w
坑 4 · 类属性可变默认值 — class C: items = [] 被所有实例共享 append。正解: __init__ 里初始化实例属性; 或用 dataclass field(default_factory=list)。
class Cart:
    items = []               # 错: 所有实例共享一个 list
a, b = Cart(), Cart(); a.items.append(1)
b.items                      # → [1], b 也被污染
class Cart:                   # 对: 实例级初始化
    def __init__(self): self.items = []
坑 5 · 描述符自身存状态 — 把字段值存到 self.value(描述符实例上)会让所有宿主对象共享同一个值。正解: 值存宿主 obj.__dict__(配合 __set_name__ 定 key), 描述符自身只放元信息。
class Bad:
    def __set__(s, o, v): s.v = v        # 错: 值存自己身上
    def __get__(s, o, t): return s.v      # 所有宿主共享一个 v
class Good:
    def __set_name__(s, o, n): s.n = n
    def __set__(s, o, v): o.__dict__[s.n] = v  # 对: 值存宿主
坑 6 · 滥用 metaclass — 团队读不懂、IDE 支持差、多重继承冲突。正解: 优先 __init_subclass__ / 装饰器; metaclass 留给框架作者。
class Meta(type):
    def __new__(m, n, b, ns):
        REGISTRY[n] = super().__new__(m, n, b, ns)
        return REGISTRY[n]      # 错: 注册也要上 metaclass
# 对: __init_subclass__ 两行等价、零魔法
class Plugin:
    def __init_subclass__(cls, **kw): REGISTRY[cls.__name__] = cls
坑 7 · 装饰器叠放顺序语义 — @A @B def f 是 f = A(B(f)), 请求先穿 A 再到 B; 认证/缓存/重试的顺序不同结果完全不同(缓存放认证前 = 缓存未鉴权数据)。正解: 注释明确每层职责, 团队固定顺序。
@cache                       # 错: 缓存了未鉴权的数据
@login_required
def view(req): ...
@login_required              # 对: 先鉴权后缓存
@cache                       # f = login(cache(view))
def view(req): ...
坑 8 · wraps 丢失后的连锁反应 — 不写 functools.wraps, inspect.signature 拿到的是 wrapper 签名, 文档/序列化框架/调试器全错位。正解: 一律 @wraps; 已污染的老代码用 decorator 库重包。
def deco(fn):
    def w(*a, **k): return fn(*a, **k)
    return w                # 错: 签名变成 (*a, **k)
@deco
def add(a, b=1): ...
inspect.signature(add)       # → (*a, **k), 框架全错位
# 对: wrapper 上加 @functools.wraps(fn) → (a, b=1)
坑 9 · 带参装饰器忘了调用 — @retry 直接用在支持参数的装饰器上, fn 被当成了 times 参数, 报错晚且难懂。正解: 带参装饰器写好后测试"两种用法"(@retry 与 @retry(3))分别行为正确。
@retry                       # 错: 没加括号, fn 顶替了 times
def call(): ...
call()                       # → TypeError: 缺少参数 'fn'
@retry(3)                   # 对: 工厂先吃参数再吃函数
def call(): ...
坑 10 · 两个属性共用一个描述符实例 — a = Typed(); b = Typed() 是两个实例没问题; 但 a = b = Typed() 或工厂函数返回同一个实例时, 两个字段互相覆盖状态。正解: 每个字段 new 一个描述符; 状态存宿主 __dict__ 见坑 5。
shared = Typed()
class C:
    a = shared; b = shared   # 错: 两字段共用状态互踩
class C:                     # 对: 每字段各 new 一个
    a = Typed(); b = Typed()
坑 11 · __set_name__ 与继承 — 子类继承父类描述符属性, __set_name__ 只在定义时触发一次; 子类里重新声明才会再触发。正解: 需要按子类区分元数据时, 在 __init_subclass__ 里显式重扫。
# 错: 子类不会再触发 __set_name__, 沿用父类元数据
class Sub(Base): pass          # Base.x 的名字还是 'x'
# 对: 在 __init_subclass__ 里对子类命名空间重扫
class Base:
    def __init_subclass__(cls, **kw):
        for k, v in vars(cls).items():
            if isinstance(v, Typed): v.__set_name__(cls, k)
坑 12 · property 只读但子类想写 — 父类只有 @property 没有 setter, 子类同名赋值报 AttributeError。正解: 子类显式声明 setter, 或父类预留 @x.setter 钩子; 契约变化要显式。
class A:
    @property
    def x(self): return self._x
class B(A):
    @x.setter               # 错: NameError, 子类没有 x
    def x(self, v): ...
# 对: 子类重写完整 property(getter + setter)
坑 13 · 实例调用 classmethod/staticmethod 混淆 — self.method() 上静态/类方法都能调, 但隐式 cls 绑定差异让重构(改方法类型)影响面不可见。正解: 类方法统一 ClassName.method 调用风格, review 时类型一目了然。
class Order:
    @classmethod
    def from_dict(cls, d): return cls(**d)
Order.from_dict({"id": 1})    # 对: 类名调用, cls 一目了然
Order().from_dict({"id": 1})  # 错: 能跑但 cls 来源含糊
坑 14 · __getattr__ 与 __getattribute__ 用错 — __getattr__ 只在"找不到时"触发(适合兜底/代理); 覆写 __getattribute__ 则每次属性访问都进(忘了 super(). 调用 = 无限递归)。正解: 99% 场景用 __getattr__; 动手覆写 __getattribute__ 前先三思。
class Proxy:
    def __getattr__(self, k):        # 对: 找不到才兜底
        return getattr(self.target, k)
class Bad:
    def __getattribute__(self, k):   # 错: 每次都进
        return self.target[k]         # → RecursionError
坑 15 · property 抛异常被序列化层吞掉 — 派生字段在脏数据下抛 ValueError, DRF/json 序列化统一 500, 定位半天。正解: property 内部返回安全默认 + 记日志, 校验错误留在显式方法。
@property
def ratio(self):
    return self.a / self.b     # 错: b=0 → ZeroDivisionError → 500
@property
def ratio(self):
    return self.a / self.b if self.b else 0.0  # 对: 安全默认
坑 16 · 装饰器把生成器函数包没了 — wrapper 直接 return fn(*args) 看似通用, 对生成器函数返回的是生成器对象没问题; 但 wrapper 里做了 return value 之外的处理(如取 .result())就破坏了惰性。正解: 通用装饰器只透传返回值, 不做类型假设。
def deco(fn):
    def w(*a): return fn(*a).result()  # 错: 生成器没有 .result
    return w
def deco(fn):
    def w(*a): return fn(*a)         # 对: 只透传返回值
    return w
坑 17 · __slots__ 与描述符打架 — 声明 __slots__ 后实例没有 __dict__, 描述符若把值存 obj.__dict__ 就失效。正解: slots 类中的描述符把值存实例的 slot 名(同名 slot + 描述符), 或干脆放弃 slots。
# 错: __set__ 里写 obj.__dict__[key] → AttributeError(no __dict__)
class D:
    def __set_name__(s, o, n): s.n = "_" + n
    def __get__(s, o, t): return getattr(o, s.n)
    def __set__(s, o, v): setattr(o, s.n, v)   # 对: 值存独立 slot
class C:
    __slots__ = ("_x",); x = D()
坑 18 · 多继承 metaclass 冲突 — 两个基类 metaclass 不同, 直接 TypeError: metaclass conflict。正解: 写一个共同派生 metaclass 显式合并; 或一边改用 __init_subclass__ 方案规避 metaclass。
class A(metaclass=M1): ...
class B(metaclass=M2): ...
class C(A, B): ...             # 错: TypeError: metaclass conflict
class M12(M1, M2): type        # 对: 先合并再指定
class C(A, B, metaclass=M12): ...
坑 19 · 运行时动态打补丁 vs 描述符 — monkey patch 改方法散落各处且时机敏感; 同样的横切需求(日志/计时)用装饰器+描述符在定义期解决, 可测试且可发现。正解: 补丁只留给测试与应急。
OrderService.save = patched_save   # 错: 补丁散落, 时机敏感
from patches import *            # 谁都说不清改了什么
@timed                             # 对: 定义期声明横切逻辑
def save(self): ...               # 可 grep 可单测; 补丁只留测试/应急
坑 20 · 元编程过度导致"只有作者看得懂" — 三层装饰器 + 描述符 + metaclass 叠加, 新人接手即瘫痪。正解: 每加一层魔法, 配一段 docstring 与最小示例; 团队约定"同一文件最多一层元编程"。
@feature_flag("x")          # 错: 三层装饰器+描述符+metaclass
@register
@validated
class Task(Foundation): ...       # 新人接手即瘫痪
class Task(Base):                 # 对: 一层足矣 + docstring
    """普通类, 魔法可 grep 可调试"""