KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

03 · 描述符 — keel 龙骨

类型:衍生概念(服务于「属性查找」) · 应用课对应:04 章 · 类:属性分界、property 与 dataclass

类型:衍生概念(服务于「属性查找」) · 应用课对应:04 章 · 类:属性分界、property 与 dataclass

这一页只回答四件事:它为什么会出现、它是什么、它长什么样、它怎么工作。

一、它为什么会出现

两个需求,都落在「一个属性上」而不是「整个对象上」:

① 想让 obj.qty = -1 直接报错,但 obj.name = "x" 照常能写
② 想让 obj.method 自动带上 self,而 obj.some_int 就只是一个整数

__setattr__ 能拦住赋值,但它拦的是所有的赋值——用它做①,等于给每个属性都加了同一套规则。

问题在于:「这个属性怎么读、怎么写」这件事,需要一个可以逐属性注册的位置。
而且这个位置必须在实例字典之前被问到,否则实例一赋值就绕过去了。

二、它是什么

一句话:描述符是一个「实现了 __get__ 的、被放在类上的对象」;属性查找走到它时,会把读或写转交给它。

由此有两个直接后果:

它不是这两样东西——混淆会带来误判:

三、它长什么样

以下是 CPython 3.13.14(Windows)上的输出。

一个能拦住负数的属性,只需要三个方法:

class Positive:
    def __set_name__(self, owner, name):     # 类创建时被调用,告诉它自己叫什么
        self.name = name
    def __get__(self, obj, objtype=None):    # 读:obj 为 None 时说明是从类上读
        if obj is None:
            return self
        return obj.__dict__.get(self.name)
    def __set__(self, obj, value):           # 写:这就是「数据描述符」的资格
        if value < 0:
            raise ValueError(f"{self.name} 不能为负: {value}")
        obj.__dict__[self.name] = value

class Order:
    qty = Positive()

这个类用起来是这样:

>>> od = Order(); od.qty = 3
>>> od.qty
3
>>> od.qty = -1
ValueError: qty 不能为负: -1
>>> od.__dict__
{'qty': 3}

两条旁证,说明协议不止用在自定义类上;第三条来自「取消实例字典」:

>>> type(Temp.__dict__["celsius"]).__name__        # @property 产出的就是描述符
'property'
>>> hasattr(type(Temp.celsius.fget), "__get__")    # 普通函数也有 __get__(所以能自动绑定 self)
True
>>> class Slim:
...     __slots__ = ("x",)
>>> sl = Slim(); sl.x = 1; sl.y = 2
AttributeError: 'Slim' object has no attribute 'y' and no __dict__ for setting new attributes
>>> hasattr(sl, "__dict__")                        # 实例字典整个不存在了
False

__slots__ 把描述符用在了另一头:它给每个声明过的名字放一个数据描述符,同时把实例字典整个拿掉——于是没声明过的名字无处可存。

四、它怎么工作

一次 obj.qty 走到描述符,是五个判定点:

① 属性查找在类链上命中了 qty,而且它是一个对象(不是普通值)
② 看它有没有 __set__ 或 __delete__ → 有,它就是「数据描述符」
③ 有 __get__ → 读操作交给 obj.__get__(实例, 类),返回值就是 obj.qty 的结果
④ 赋值时 → 因为它是数据描述符,**先**走 __set__,实例字典排在它后面
⑤ 没有 __set__ 的(非数据描述符)→ 实例字典排在它前面,可以被实例属性遮住

③⑤ 的分界解释了一个日常现象:给实例赋一个同名属性,能遮住方法,却遮不住 property。
因为函数没有 __set__(非数据描述符),而 property 有。

你写的类属性 类型 实例字典能不能遮住它
普通值 retries = 3 不是描述符 能
函数 def who(self) 非数据描述符 能
@property 产出的对象 数据描述符 不能
__slots__ 声明出来的槽 数据描述符 不能(而且没有字典)

五、它会带出哪些概念

六、回到应用课

应用课里的那句话 现在你知道它为什么成立
"property 能拦住赋值并做校验" 它实现了 __set__,属于数据描述符,排在实例字典之前
"方法会自动带上 self" 函数有 __get__,取到的是绑定方法;这一步和非数据描述符走的是同一条路
"__slots__ 能省内存、也会限制属性" 它给每个名字放一个数据描述符,同时取消实例字典——限制是省内存的代价,不是两条规则

这一页改变了你的哪个判断:以后看到「某个属性读出来会做校验 / 会懒加载 / 会带 self」,第一件事是去问它在类上到底存的是什么对象(type(cls.__dict__["name"]).__name__),而不是去找哪里写了赋值拦截——答案几乎总在那一个实现了 __get__ 的属性上。反过来,自己写一个「只对这一个属性加行为」的需求时,也不必再动 __setattr__。

自测题

  1. 为什么给实例赋一个同名属性,能遮住方法,却遮不住 @property?
  2. Positive 里为什么必须有 __set__?如果只有 __get__,od.qty = -1 会怎样?

进入 keel 阅读