← 返回模块
3.3.2.4beta 可读 · 未来付费内容校验中内容版本 2026-05-24

描述符、元类与反射

3.3.2 · 设计模式与工具 · 编程

开场

某私募周五下午。研究团队内部的「自动研究 UI」——一个小网页,列出库里每个定价器并渲染一个表单可以调它的参数——挂了。一位初级开发上周新加了一个 GARCH 波动率定价器,可这个定价器的表单从未出现在页面上。这页本来不需要维护,注册表应该是在 import 时被填好的。资深开发打开定价器模块一看,发现忘了注册:新类从未进 _PRICERS。半年前同一位资深开发写了第二课那个装饰器驱动的注册表,现在意识到「需要你记得去贴的装饰器」本身就是一类 bug,而语言里其实已经有现成的修法——__init_subclass__,每当子类被定义时自动触发。第一课讲的是「语言会消费你写的」那些 dunder。这一课讲的是类机制它自己用来组装实例与查找属性所用的那些 dunder:__get__ / __set__ / __set_name__(描述符)、__init_subclass__(子类钩子)、__new__ 与元类钩子(类的创建),以及 inspect(反向:在运行时反问一个类「你是什么」)。四种表面拼起来构成一个迷你声明式 Pricer 框架——本课的 capstone,也正是研究 UI 实际跑在上面的那套机制。

描述符:@property 之下的一层

描述符是任何定义了 __get__(可选 __set____delete__)的对象。每个 Python 开发者其实都用过的典型例子是 @property——它是一个描述符,__get__ 调装饰的方法、__set__ 抛异常(只读)或调 setter(可读写)。从零搭一个 Parameter 描述符:

class Parameter:
    def __init__(self, lo: float, hi: float) -> None: self.lo, self.hi = lo, hi
    def __set_name__(self, owner, name: str) -> None: self.name = '_' + name
    def __get__(self, obj, objtype=None):
        if obj is None: return self
        return getattr(obj, self.name)
    def __set__(self, obj, value: float) -> None:
        if not (self.lo <= value <= self.hi):
            raise ValueError(f'{self.name[1:]}={value!r} outside [{self.lo}, {self.hi}]')
        setattr(obj, self.name, value)

__set_name__(self, owner, name) 是现代钩子(Python 3.6+),让描述符在类定义时获知自己被绑定到的属性名——解决了历史上那个「你写的是 omega = Parameter(...),存储属性只能叫 _omega,但描述符看不到自己名字」的难题。让这一切成立的规则是属性查找 MRO。当你写 obj.attr,Python 先在 type(obj).__mro__ 里找数据描述符(__get__ + __set__);找到则数据描述符胜出。否则查 obj.__dict__;在那里找到则实例属性胜出。再否则沿 MRO 找非数据描述符(只有 __get__)或类属性。所以类上的 @property(数据描述符)打败了 obj.foo = ... 这种实例赋值;而实例方法(非数据描述符——__get__ 是它绑定 self 的那一步)会输给 obj.method = lambda ... 的实例赋值。

__slots__:内存退订

类级元组 __slots__ = ('x', 'y') 用一个固定形状、描述符驱动的属性存储替换掉每实例 __dict__。在小类上每实例节省约 40 字节,同时禁止在声明槽外创建新属性。典型用例是「你正要实例化几百万个小类」——tick 记录、仿真路径、3.2.2 里的 candle bar。__slots__ 内存测量演示是用 3.3.1 L1 的 tracemalloc 流程对一个 TickRecord(code: str, price: float, ts: int) 跑 1,000,000 实例,对比有 __slots__ 与无 __slots__ 的每实例占用,差距在总量上稳定在 40-60 MB。

两个坑。(1) 子类只有定义了 __slots__ 才会继承「掉 __dict__」的纪律。子类写 __slots__ = ()(空元组)就保留节省;不写 __slots__ 就悄悄又把 __dict__ 加回来、节省全没。(2) @dataclass(slots=True)(Python 3.10+)是从 dataclass 字段自动合成 __slots__ 的现代捷径——除非有具体理由,否则用它。

__init_subclass__:轻量级现代元类

__init_subclass__(cls, **kwargs) 是父类上的一个类方法,每当一个子类被定义时执行一次,子类作为第一个参数。用例 1 是自动注册——第二课那个注册表模式不用装饰器、只靠继承 Pricer 基类就能重建:

class Pricer:
    __slots__ = ()
    _registry: dict[str, type] = {}
    def __init_subclass__(cls, name: str | None = None, **kwargs) -> None:
        super().__init_subclass__(**kwargs)
        if name is not None:
            cls._registry[name] = cls

class GarchVolPricer(Pricer, name='garch'):
    __slots__ = ('_omega', '_alpha', '_beta')
    omega = Parameter(1e-8, 1e-3)
    alpha = Parameter(0.0, 1.0)
    beta = Parameter(0.0, 1.0)
    def __init__(self, omega: float, alpha: float, beta: float) -> None:
        self.omega = omega; self.alpha = alpha; self.beta = beta

针对沪深300 ETF(510300.SH)日对数收益的典型拟合:g = GarchVolPricer(omega=1e-6, alpha=0.08, beta=0.91)alpha + beta < 1 保证平稳性。资深开发那个研究 UI 的 bug 就消失了:没有装饰器可以忘——继承 Pricer(..., name='...') 这件事本身就是注册。用例 2:校验子类是否定义了必需属性——__init_subclass__ 可以断言子类 override 了 price,未做到就在类定义时抛错。规则:__init_subclass__ 覆盖了「在类定义时做点什么」这类需求中历史上需要元类的约九成。

元类,简要说

class Meta(type): 定义了一个自定义类型,其 __new__(mcs, name, bases, ns, **kwargs) 在类创建时执行,可以改写类体——注入 __slots__、把每个方法都改成带日志的版本、像 abc.ABCMeta 那样加抽象方法强制。语法是 class Foo(Bar, metaclass=Meta, **extra_kwargs): ...。同样的注册表模式写成元类版本:

class RegistryMeta(type):
    def __new__(mcs, name, bases, ns, register_as: str | None = None, **kw):
        cls = super().__new__(mcs, name, bases, ns)
        if register_as is not None:
            Pricer._registry[register_as] = cls
        return cls
# __init_subclass__ above is preferred over this metaclass for the registry use case; the metaclass is shown here only to make the contrast explicit.

优先用 __init_subclass__;只有当你必须以 __init_subclass__ 做不到的方式控制类创建过程本身时才上元类——需要在类存在之前改写命名空间字典,或要通过 __instancecheck__ / __subclasscheck__ 控制 isinstance / issubclass 语义。剩下那 1% 的用例包括 abc.ABCMeta(标准库;你不会自己写)、Django 的 models.Model 元类(你也不会自己写),以及 Pydantic v1 的 ModelMetaclass——Pydantic v2 把它改成了 __init_subclass__,因为原来的元类过度工程。

inspect 反射

有了上面这套机制,就能搭它的逆向:在运行时反问一个类「你是什么」。inspect.signature(fn) 返回一个 Signature 对象,其 .parameters 把参数名映射到 Parameter 对象(带 .annotation.default.kind);这是每一个量化团队自动生成研究 UI 的基础。策略一注册,UI 就反射其构造器签名,按每个参数渲染一个输入框——无需另外维护一份 schema 文件。inspect.getmembers(cls, predicate=inspect.isfunction) 走每个方法;inspect.getsource(obj) 返回源码;inspect.getfile(obj) 返回文件路径:

import inspect
def describe(name: str) -> None:
    cls = Pricer._registry[name]
    sig = inspect.signature(cls.__init__)
    params = [p for p in sig.parameters.values() if p.name != 'self']
    print(f'pricer: {name} ({cls.__name__})')
    print(f'  defined in: {inspect.getfile(cls)}')
    print(f'  parameters:')
    for p in params:
        print(f'    {p.name}: {p.annotation}')
describe('garch')

元编程选型规则

surfaceuse whenone-line examplefailure mode if misused
@dataclassplain data with named fields@dataclass(frozen=True) class Tick: ...over-uses inheritance for what plain data needs
@propertyone read/write hook on one attribute@property\ndef p(self): return self._pproliferates into a hand-rolled descriptor when more attributes need the same treatment
__init_subclass__do something every time a subclass is defineddef __init_subclass__(cls, **kw): cls._registry[kw['name']] = clssilently breaks if a subclass forgets to pass kwargs through super()
hand-rolled descriptor (__get__ / __set__ / __set_name__)multiple typed-attribute hooks across many subclassesomega = Parameter(1e-8, 1e-3)error traces point to descriptor code, not user code — readers six months later have to learn the descriptor protocol
metaclass (class Meta(type))only when you must control class-creation itselfclass Meta(type): def __new__(mcs, name, bases, ns, **kw): ...error messages point to the metaclass, not your class — ten new readers need to learn metaclasses before they can debug

五行口诀:数据 -> @dataclass;一对属性钩子 -> @property;子类钩子 -> __init_subclass__;多个属性都要类型化钩子 -> 自写描述符;只有元类能做的事 -> 元类。元编程有故障隔离成本:报错指向类机制而不指向你的代码,半年后的读者会追到 Parameter.__set__ 然后问「这是什么时候跑的」——给每个描述符和元类钩子写 docstring,把元编程当快刀对待:不是日常下厨用的工具。

脚注一句:生产中大多数团队会用 pydantic 处理校验过的参数集合,而非手写描述符;beartypetypeguard 这类库是描述符边界校验的运行时检查替代。本课的 Parameter 描述符是手工/教学形式。通过 super().__init_subclass__(**kwargs) 的协作多重继承之所以让 **kwargs 重要,是因为链上下一个需要类创建 kwarg 的子类会因为没透传而坏。《Fluent Python》第 2 版 第 14 章是规范的指引。

练习(capstone)

Exercise

扩展 capstone Pricer 框架。(a) 定义第二个具体子类 class BlackScholesPricer(Pricer, name="bs-call"):,带 __slots__ = ("_s", "_k", "_r", "_sigma", "_t") 以及五个 Parameter 字段:s = Parameter(0.0, 1e6)k = Parameter(0.0, 1e6)r = Parameter(-0.05, 0.20)sigma = Parameter(0.0, 5.0)t = Parameter(0.0, 10.0)。(b) 给它一个 __init__(self, s: float, k: float, r: float, sigma: float, t: float) -> None,逐字段赋值。(c) 验证 BlackScholesPricer._registry["bs-call"] is BlackScholesPricer。(d) 验证构造 BlackScholesPricer(s=-1.0, k=100.0, r=0.05, sigma=0.2, t=1.0) 抛出 ValueError,因为 s=-1.0[0.0, 1e6] 之外。(e) 调用 describe("bs-call"),确认打印的参数列表按顺序包含五项:s, k, r, sigma, t。(f) 用一句话说明:把 _registry 这一行从 Pricer 搬到 BlackScholesPricer 之后,为什么其他所有子类的自动注册都会失效。

提示
继承 Pricer(name="bs-call") 在类定义时触发 __init_subclass__,正是那一行把类填进 _registry;不需要装饰器。
提示
每个 Parameter.__set__ 在每次属性赋值时都跑;负的 s 会在 __init__ 里就触发边界校验,定价计算还没开始就抛。

收尾这条 Python 通道

你现在见过了语言从你那里消费的 dunder(第一课)、那些 dunder 撑起的设计模式(第二课)、让它们并行跑起来的协调原语(第三课),以及类机制它自己用来组装实例与查找属性的 dunder(本课)。Python 通道在此关闭。再往下一层——当 Python 元编程本身成为瓶颈而不是解药时——通往 C++ for Quant(3.4),那里你用描述符风格的灵活换显式内存布局与可预测延迟;或者通往 Rust 通道(3.5),那里类型系统强制描述符仅靠文档维护的契约。无论走哪边,本模块学的那些表面——值类、协议、注册表、锁、描述符——都会翻译到新语言的对应物:Rust 的 trait 与 trait object,C++ 的 templates 与 concepts。

阅读清单:《Fluent Python》第 2 版 中译 第 23 章 (属性描述符) 与 第 24 章 (类元编程, 包含 init_subclass 与 metaclass 段落); CPython 官方 Data model 中文文档 (描述符协议节是权威参考) 与 inspect 模块中文文档; Raymond Hettinger 的 Descriptors and Metaclasses 系列博客 (Python.org 官方教程); David Beazley 的 Python 3 Metaprogramming PyCon 2013 演讲 (英文; 国内有中文翻译) 是元类章节的源头; PEP 487 (init_subclass) 与 PEP 412 (key-sharing dict, slots 性能背景) 的中文社区导读; 一句话提示: 国内 私募 / 资管 团队 2026 年大多以 Python 3.11+ 为基线, @dataclass(slots=True) 与 PEP 673 typing.Self 在描述符自类型上回中均可使用。