《Python高级编程》2.2 类装饰器、__set_name__ 与属性工厂

本节讲透类装饰器与 __set_name__ 的执行时机:类装饰器在元类 __init__ 之后运行,看到的是已经完成 __set_name__ 的类对象;__set_name__ 按定义顺序为每个描述符调用一次,且 3.12 起异常不再被包装成 RuntimeError。再用属性工厂与 __init_subclass__ 组合实测一个字段收集框架,并对照标准库 cached_property。

本节目标:讲清类装饰器与 __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 已经是完整的类对象了。

由此得到两个实用结论:

  1. 装饰器运行时,所有 __set_name__ 都已经执行完毕,描述符已经知道自己绑定的属性名。这是「装饰器里直接读描述符名字」能成立的前提。
  2. 装饰器可以返回一个全新的类替换原类。这在 @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 变换 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时