开场
某私募周五下午。研究团队内部的「自动研究 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')
元编程选型规则
| surface | use when | one-line example | failure mode if misused |
|---|---|---|---|
@dataclass | plain data with named fields | @dataclass(frozen=True) class Tick: ... | over-uses inheritance for what plain data needs |
@property | one read/write hook on one attribute | @property\ndef p(self): return self._p | proliferates into a hand-rolled descriptor when more attributes need the same treatment |
__init_subclass__ | do something every time a subclass is defined | def __init_subclass__(cls, **kw): cls._registry[kw['name']] = cls | silently breaks if a subclass forgets to pass kwargs through super() |
hand-rolled descriptor (__get__ / __set__ / __set_name__) | multiple typed-attribute hooks across many subclasses | omega = 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 itself | class 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 处理校验过的参数集合,而非手写描述符;beartype 与 typeguard 这类库是描述符边界校验的运行时检查替代。本课的 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 在描述符自类型上回中均可使用。