KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

02 · 属性查找 — keel 龙骨

类型:主题概念 · 应用课对应:04 章 · 类:属性分界、property 与 dataclass

类型:主题概念 · 应用课对应:04 章 · 类:属性分界、property 与 dataclass

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

一、它为什么会出现

两个需求,方向相反:

① 类上写 retries = 3 当默认值,某个实例把它改成 5 —— 别的实例还是 3
② obj.some_missing 本该报错,但框架想在这里接管:记一笔、或者现造一个值出来

同一个 obj.x,既要有「类级默认 + 实例级覆盖」这种静态的分层,又要在找不到时留一个可编程的出口。
如果只有一张平铺的表,两件事都做不到。

问题在于:能问的地方不止一个,所以必须定一个确定的顺序——
否则「实例上有一个、类上也有一个」时,答案会随实现变。

二、它是什么

一句话:属性查找是按固定顺序问几个地方的一次搜索——问的对象包括类、它的继承链、实例自己的字典,以及可选的兜底回调。

由此有两个直接后果:

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

三、它长什么样

以下是 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 能拦住赋值" 数据描述符排在实例字典之前,赋值也必须先经过它的 __set__

这一页改变了你的哪个判断:以后看到「一个实例改了、另一个也跟着变了」,第一件事是问这个属性到底存在谁身上(obj.__dict__ 里有没有这一项),而不是去怀疑继承或缓存——④没命中时,你改的很可能就是类上那个共享对象。看到 __getattr__ 报错,也要先想到它只在②到⑤全落空之后才会被调用。

自测题

  1. 类上写了 retries = 3,一个实例把它改成 5。此时 obj.__dict__ 里有什么?第二个实例读到的是几?
  2. 读一个已存在的属性和读一个不存在的属性,__getattribute__ 与 __getattr__ 各被调用几次?

进入 keel 阅读