本节目标:讲清类装饰器与
__set_name__的调用时机与协议,并能用属性工厂 +__init_subclass__搭出字段框架。
适用版本:Python 3.12+(实测 3.14.6)
2.2 类装饰器、__set_name__ 与属性工厂
上一节我们确认了类的创建时序:类体 → 元类 __new__ → __set_name__ → __init_subclass__ → 元类 __init__。这一节接着往下看两个「类建成之后」才登场的主角:类装饰器和描述符的 __set_name__。站内专题 Python 元编程与动态特性深度解析
用一张表对比过类装饰器与元类「该选谁」,但没有回答两个更硬的问题:装饰器到底在时序的哪一步跑?__set_name__ 又是谁、在什么时候替你调用的?这两点只能靠实测。
2.2.1 类装饰器在时序的哪一步
把类装饰器、元类和描述符的钩子放在同一个类上,跑一次就看到了全貌:
class Meta(type):
def __new__(mcs, name, bases, ns, **kw):
print("A. metaclass.__new__")
return super().__new__(mcs, name, bases, ns)
def __init__(cls, name, bases, ns, **kw):
print("B. metaclass.__init__")
super().__init__(name, bases, ns)
def deco(cls):
print("C. class decorator")
return cls
class Desc:
def __set_name__(self, owner, name):
print(" __set_name__", name)
@deco
class C(metaclass=Meta):
print(" (类体执行)")
a = Desc()
b = Desc()
c = Desc()
真实输出:
(类体执行)
A. metaclass.__new__
__set_name__ a
__set_name__ b
__set_name__ c
B. metaclass.__init__
C. class decorator
类装饰器排在最后,晚于元类的 __new__ 和 __init__。原因是装饰器的语义就是「对已经构造好的类对象再套一层函数」——@deco 展开后是 C = deco(C),而这里的 C 已经是完整的类对象了。
由此得到两个实用结论:
- 装饰器运行时,所有
__set_name__都已经执行完毕,描述符已经知道自己绑定的属性名。这是「装饰器里直接读描述符名字」能成立的前提。 - 装饰器可以返回一个全新的类替换原类。这在
@dataclass里就是真实行为——dataclass返回的可能不是传入的那个类。而元类无法做「返回另一个类」这件事,它决定的是类本身怎么造。
2.2.2 __set_name__ 的调用协议
__set_name__ 是 CPython 在类对象创建时自动调用的钩子,签名固定为 (self, owner, name)。它不是 Python 语法糖,而是 type.__new__ 里的一段 C 代码:遍历命名空间,凡是定义了 __set_name__ 的值,就调用一次。
几条协议细节,逐条实测:
(1)按定义顺序调用,每个描述符只调一次。 上面输出里 a, b, c 的顺序就是源码书写顺序。
(2)只为「本类定义」的描述符调用,不处理继承来的。 这一点最容易被误解:
calls = []
class Track:
def __set_name__(self, owner, name):
calls.append((owner.__name__, name))
class Base:
a = Track()
class Sub(Base): # 没有重新定义 a
b = Track()
print("__set_name__ 调用记录:", calls)
真实输出:
__set_name__ 调用记录: [('Base', 'a'), ('Sub', 'b')]
Sub 继承了 a,但不会为 a 再调用一次 __set_name__——记录里没有 ('Sub', 'a')。所以描述符里通过 __set_name__ 缓存的 self.name,是所有子类共享的同一个值,别指望它为每个子类重算。
(3)__set_name__ 抛异常时的行为,3.12 起变了。 这是本节最值得记住的版本差异:
class Bad:
def __set_name__(self, owner, name):
raise ValueError("boom")
try:
class C:
x = Bad()
except BaseException as e:
print("类型:", type(e).__name__)
print("内容:", e)
print("__notes__:", getattr(e, "__notes__", None))
真实输出(3.14.6):
类型: ValueError
内容: boom
__notes__: ["Error calling __set_name__ on 'Bad' instance 'x' in 'C'"]
抛出的是原始的 ValueError,上下文以 PEP 678 的 __notes__ 附加。而在 3.12 之前,CPython 会把它包成 RuntimeError,原始异常退到 __cause__。所以如果你的代码里有 except RuntimeError: 来捕获「__set_name__ 失败」,在 3.12+ 上会失效——应该直接捕获原始异常类型。
2.2.3 描述符工厂:让字段自己知道名字
__set_name__ 最大的价值是消除冗余。不用它时,字段工厂必须让使用者写两遍名字:
class Field:
def __init__(self, name, type_): # 名字要手写传入
self.name = name
self.type = type_
class User:
name = Field("name", str) # "name" 写了两遍
age = Field("age", int) # 一旦改属性名忘了改字符串,就是隐藏 bug
用 __set_name__ 后,名字由解释器自动注入:
class Field:
def __init__(self, type_, *, default=None, validator=None):
self.type = type_
self.default = default
self.validator = validator
def __set_name__(self, owner, name):
self.name = name
self.storage = "_" + name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.storage, self.default)
def __set__(self, obj, value):
if not isinstance(value, self.type):
raise TypeError(f"{self.name} 需要 {self.type.__name__}")
if self.validator and not self.validator(value):
raise ValueError(f"{self.name} 校验失败: {value!r}")
setattr(obj, self.storage, value)
class User:
name = Field(str)
age = Field(int, validator=lambda v: 0 <= v <= 150)
u = User()
u.name = "Alice"
u.age = 30
print("name/age:", u.name, u.age, "| 存储键:", u.__dict__)
真实输出:
name/age: Alice 30 | 存储键: {'_name': 'Alice', '_age': 30}
注意 u.__dict__ 里的键是 _name / _age——__set_name__ 里算出的 storage = "_" + name 生效了。这就是描述符工厂模式:工厂函数/类只描述「字段的规则」,具体名字由 __set_name__ 在类创建时补全。SQLAlchemy 的 Column、Pydantic 的内部字段、dataclasses.field 都建立在这个机制上。
2.2.4 __init_subclass__ + __set_name__ 组合
有了「字段自动绑定名」,再让基类在子类创建时把字段收集起来,一个极简的模型框架就成型了:
class Model:
_fields = {}
def __init_subclass__(cls, **kw):
super().__init_subclass__(**kw)
cls._fields = {k: v for k, v in vars(cls).items() if isinstance(v, Field)}
class Product(Model):
title = Field(str)
price = Field(float)
print("Product._fields:", list(Product._fields))
print("自动绑定名:", Product._fields['title'].name, Product._fields['price'].name)
真实输出:
Product._fields: ['title', 'price']
自动绑定名: title price
这里两个钩子的分工非常清楚:
__set_name__:让每个Field知道自己叫什么(名字注入,发生在类创建的第 4 步)。__init_subclass__:让Model知道子类有哪些字段(收集,发生在第 5 步)。
因为 __set_name__ 先于 __init_subclass__(见 2.1.2 的时序表),当 __init_subclass__ 执行 vars(cls) 时,每个字段的 .name 已经填好了。顺序反过来就得不到这个名字——这也是为什么这套组合能稳定工作。注意用 vars(cls) 而不是遍历 cls.__dict__:两者等价,但 vars() 更直观。
2.2.5 标准库里的同类实现:cached_property
functools.cached_property 就是「非数据描述符 + __set_name__」的标准库范本:
import functools, inspect
class Data:
def __init__(self):
self.n = 0
@functools.cached_property
def expensive(self):
self.n += 1
return 42
d = Data()
print("首次:", d.expensive, "| 第二次:", d.expensive, "| 计算次数:", d.n)
print("有 __set_name__:", hasattr(functools.cached_property, "__set_name__"))
print("签名:", inspect.signature(functools.cached_property.__set_name__))
真实输出:
首次: 42 | 第二次: 42 | 计算次数: 1
有 __set_name__: True
签名: (self, owner, name)
它靠 __set_name__ 拿到属性名,首次求值后把结果直接写进实例的 __dict__,从而「盖住」类上的描述符。而它只定义 __get__、不定义 __set__,所以是非数据描述符——这正是实例 __dict__ 能覆盖它的原因。如果用数据描述符实现缓存,写入就会永远被拦截,缓存反而失效。
2.2.6 四个钩子的职责边界
把本节和上一节的钩子放一张表里对比,选型就不会错:
| 钩子 | 何时运行 | 能看到什么 | 典型用途 |
|---|---|---|---|
元类 __new__ / __init__ | 类创建中 | 原始命名空间、类对象 | 改类本身、拦截实例创建 |
__set_name__ | 类创建时(描述符) | 所属类、属性名 | 字段自动绑定名 |
__init_subclass__ | 子类创建后 | 已完成的子类 | 注册、校验、收集字段 |
| 类装饰器 | 类创建后(最后) | 完整类对象 | 横切增强、返回新类 |
小结
- 类装饰器在元类
__new__/__init__之后运行,看到的是已经完成__set_name__的完整类对象,且可以返回一个全新的类。 __set_name__按定义顺序为每个描述符调用一次,只为本类定义的描述符调用,继承来的不重复调用。- 3.12 起
__set_name__抛出的异常不再被包装成RuntimeError,而是原样抛出并附 PEP 678 注记——旧代码里的except RuntimeError会失效。 - 描述符工厂用
__set_name__让字段自动获知属性名,消除了「名字写两遍」的冗余;cached_property是非数据描述符 +__set_name__的标准库范例。 __init_subclass__(收集字段)必须晚于__set_name__(绑定名字),这个顺序由 CPython 保证。
到这里,我们处理的都还是「用 Python 写代码来操作类」。下一节换一种更底层的方式——直接在语法树层面改写代码,把一个 assert 变成显式检查,并让改动在 -O 下依然生效。
阅读导航:上一节:2.1 元类与 init_subclass · 下一节:2.3 动态代码生成与 AST 变换 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。