KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
02 · 属性查找 — keel 龙骨
类型:主题概念 · 应用课对应:04 章 · 类:属性分界、property 与 dataclass
类型:主题概念 · 应用课对应:04 章 · 类:属性分界、property 与 dataclass
这一页只回答四件事:它为什么会出现、它是什么、它长什么样、它怎么工作。
一、它为什么会出现
两个需求,方向相反:
① 类上写 retries = 3 当默认值,某个实例把它改成 5 —— 别的实例还是 3
② obj.some_missing 本该报错,但框架想在这里接管:记一笔、或者现造一个值出来
同一个 obj.x,既要有「类级默认 + 实例级覆盖」这种静态的分层,又要在找不到时留一个可编程的出口。
如果只有一张平铺的表,两件事都做不到。
问题在于:能问的地方不止一个,所以必须定一个确定的顺序——
否则「实例上有一个、类上也有一个」时,答案会随实现变。
二、它是什么
一句话:属性查找是按固定顺序问几个地方的一次搜索——问的对象包括类、它的继承链、实例自己的字典,以及可选的兜底回调。
由此有两个直接后果:
- 顺序里有一处反直觉:定义了
__set__的数据描述符排在实例字典之前。
所以property能拦住self.x = v,用户写obj.x = 5也不会在实例字典里留下x。 - 「找不到」和「找到了但不是你要的」是两件事,分别由两个方法负责。
它不是这两样东西——混淆会带来误判:
- 不是「先看实例、再看类」这么简单。 那只是顺序里的中段;中段之前还有数据描述符这一档,
之后还有__getattr__这一档,两头都不能省。 - 不是「
__getattr__和__getattribute__是同一个东西的两种写法」。 前者只在正常查找失败之后被调用,
后者是每一次读属性都会走的入口——把它写错会把整个对象的所有属性访问拖慢甚至拖坏。
三、它长什么样
以下是 CPython 3.13.14(Windows)上的输出。
第一种形态是「东西存在哪」——两个字典加一条继承链:
>>> class Base: kind = "base"
>>> class Mid(Base): pass
>>> class Leaf(Mid): kind = "leaf"
>>> o = Leaf()
>>> o.__dict__
{}
>>> o.kind
'leaf'
>>> o.kind = "instance-kind"
>>> o.__dict__
{'kind': 'instance-kind'}
>>> [k.__name__ for k in Leaf.__mro__]
['Leaf', 'Mid', 'Base', 'object']
o.kind 一开始走的是类(实例字典是空的);赋过一次值之后,实例字典里就有了同名项,从此遮住类上那个——这就是「属性遮蔽」的真身,只是两个字典的先后。
第二种形态是兜底回调的调用轨迹:
>>> class Probe:
... def __getattribute__(self, name):
... print(f" __getattribute__({name})")
... return object.__getattribute__(self, name)
... def __getattr__(self, name):
... print(f" __getattr__({name})")
... return f"<兜底:{name}>"
>>> p = Probe(); p.tag = "x"
>>> p.tag
__getattribute__(tag)
'x'
>>> p.missing
__getattribute__(missing)
__getattr__(missing)
'<兜底:missing>'
读已存在的属性时只走一次 __getattribute__;读不存在的属性时多走一次 __getattr__。
两个方法各出现几次,就是顺序里「正常路径」与「兜底路径」的分界。
四、它怎么工作
一次 obj.x 是六个判定点:
① 入口 obj.x 交给 type(obj).__getattribute__(这是每次都会走的那一层)
② 类链找 x 在「类本身 + 它的 MRO」里找名为 x 的项
③ 数据描述符 若找到的项定义了 __set__ 或 __delete__ → 直接用它,**实例字典都不看**
④ 实例字典 否则看 obj.__dict__ 里有没有 x
⑤ 类上的项 还没有 → 直接用②里找到的那个(非数据描述符或普通类属性)
⑥ 兜底 全都落空 → 调用 __getattr__("x");它也不存在 → AttributeError
③ 是这一页最该记住的一档:它的可观察后果是数据描述符会「拦住」实例字典。(下一页那个实现里,od.qty = 3 之后 od.__dict__ 里确实有 qty——值最终仍要存下来,但访问路径先经过描述符,所以校验有机会生效。)
再看三个日常结论,都由这张顺序表直接推出来:
| 现象 | 在哪一档定的 |
|---|---|
@property 能校验、能懒加载 |
③:数据描述符排在实例字典之前 |
obj.method 会带 self |
⑤:函数是非数据描述符,它在实例字典里找不到,于是走了描述符协议去绑定 |
| 类上写个可变对象,所有实例一起变 | ④:实例字典没有它时,拿到的是类上那一个对象本身,不是副本 |
五、它会带出哪些概念
- 描述符:③⑤两档凭什么「知道」该怎么处理函数和
property——靠的就是__get__/__set__。→ 03 页 __slots__:它做的事情是取消实例字典(④这一档整个消失),所以只能存在声明过的属性上。→ 应用课 04 章- 可变类属性的共享:类上放一个 list,所有实例读到的是同一个 list——④没命中时返回的是类上那个对象。→ 应用课 04 章
dataclass的默认值:field(default_factory=...)与直接写= []的差别,落点也在④。→ 应用课 04 章
六、回到应用课
| 应用课里的那句话 | 现在你知道它为什么成立 |
|---|---|
| "类属性和实例属性是两回事" | 它们就是两个字典;查找按固定顺序问,实例字典命中就遮住类上那个 |
| "改实例属性不会影响别的实例" | 赋值写进的是这个实例自己的字典,别人的字典里没这一项,仍回到类上 |
"property 能拦住赋值" |
数据描述符排在实例字典之前,赋值也必须先经过它的 __set__ |
这一页改变了你的哪个判断:以后看到「一个实例改了、另一个也跟着变了」,第一件事是问这个属性到底存在谁身上(obj.__dict__ 里有没有这一项),而不是去怀疑继承或缓存——④没命中时,你改的很可能就是类上那个共享对象。看到 __getattr__ 报错,也要先想到它只在②到⑤全落空之后才会被调用。
自测题
- 类上写了
retries = 3,一个实例把它改成5。此时obj.__dict__里有什么?第二个实例读到的是几? - 读一个已存在的属性和读一个不存在的属性,
__getattribute__与__getattr__各被调用几次?