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__ 的、被放在类上的对象」;属性查找走到它时,会把读或写转交给它。
由此有两个直接后果:
- 放在类上才生效。 它靠的是属性查找的第②档(在类链上找),所以定义在实例上不会触发。
- 实现了
__set__的还叫「数据描述符」,优先级高于实例字典。 这是@property能拦住赋值的全部原因。
它不是这两样东西——混淆会带来误判:
- 不是「
@property的别名」。@property是一个装饰器,它产出的那个property对象才是描述符;同一套协议还同时解释了方法绑定、staticmethod、classmethod、cached_property。 - 不是「和
__setattr__同一层的东西」。__setattr__是对象级的全量钩子,描述符是逐属性的局部钩子;
两者都写着的时候,触发顺序也不是你想的那样。
三、它长什么样
以下是 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__ 声明出来的槽 |
数据描述符 | 不能(而且没有字典) |
五、它会带出哪些概念
- 属性查找:描述符是第②⑤档的实现细节,顺序表在那一页。→ 02 页
@property/@staticmethod/@classmethod:三个装饰器产出的都是描述符,差别只在__get__怎么处理obj。→ 应用课 04 章__slots__:用数据描述符取代实例字典,所以省内存,也所以长不出新属性。→ 应用课 04 章cached_property:它在__get__里把算好的值写进实例字典,之后就被字典遮住了——只在非数据描述符上成立。→ 应用课 04 章
六、回到应用课
| 应用课里的那句话 | 现在你知道它为什么成立 |
|---|---|
"property 能拦住赋值并做校验" |
它实现了 __set__,属于数据描述符,排在实例字典之前 |
"方法会自动带上 self" |
函数有 __get__,取到的是绑定方法;这一步和非数据描述符走的是同一条路 |
"__slots__ 能省内存、也会限制属性" |
它给每个名字放一个数据描述符,同时取消实例字典——限制是省内存的代价,不是两条规则 |
这一页改变了你的哪个判断:以后看到「某个属性读出来会做校验 / 会懒加载 / 会带 self」,第一件事是去问它在类上到底存的是什么对象(type(cls.__dict__["name"]).__name__),而不是去找哪里写了赋值拦截——答案几乎总在那一个实现了 __get__ 的属性上。反过来,自己写一个「只对这一个属性加行为」的需求时,也不必再动 __setattr__。
自测题
- 为什么给实例赋一个同名属性,能遮住方法,却遮不住
@property? Positive里为什么必须有__set__?如果只有__get__,od.qty = -1会怎样?