先看一组我在自己机器上跑出来的数(CPython 3.13.14,timeit 200 万次取平均):
普通实例属性 22.1 ns/次
非数据描述符(实例字典赢) 57.1 ns/次
数据描述符 137.6 ns/次
__getattr__ 兜底 191.5 ns/次
覆写 __getattribute__ 226.5 ns/次
同样一句 obj.x,最慢和最快差了十倍。差别不在字典查得快不快,而在这一句背后有一条五层的优先级链,Python 得一层层往下试。
更反直觉的是:我把值塞进了 obj.__dict__,取出来却可能不是它。
一、一个「塞进去也白塞」的实验
class DataDesc:
def __get__(self, obj, owner): return 'DATA_DESC'
def __set__(self, obj, v): pass # 关键:有 __set__
class NonDataDesc:
def __get__(self, obj, owner): return 'NONDATA_DESC'
# 没有 __set__
class K:
d = DataDesc()
n = NonDataDesc()
cls_attr = 'CLASS_ATTR'
def __getattr__(self, name):
return 'GETATTR:' + name
k = K()
k.__dict__['d'] = 'INSTANCE_DICT'
k.__dict__['n'] = 'INSTANCE_DICT'
k.__dict__['cls_attr'] = 'INSTANCE_DICT'
三个键都实打实写进了实例字典,取值结果却是:
k.d -> DATA_DESC # 不是 INSTANCE_DICT
k.n -> INSTANCE_DICT # 不是 NONDATA_DESC
k.cls_attr -> INSTANCE_DICT # 不是 CLASS_ATTR
k.missing -> GETATTR:missing # 字典里根本没有
名字一样,命运不同。决定权不在实例字典,而在类那边那个同名对象有没有 __set__。
二、判定标准只有一个:有没有 set
print(hasattr(K.__dict__['d'], '__set__')) # True -> 数据描述符
print(hasattr(K.__dict__['n'], '__set__')) # False -> 非数据描述符
Python 的叫法很直白:
- 数据描述符(data descriptor):定义了
__set__或__delete__。property是最常见的一种。 - 非数据描述符(non-data descriptor):只定义
__get__。普通函数就是这一种,所以self.method才会变成绑定方法。
只有数据描述符能压过实例字典。原因也不神秘:它既能读又能写,如果允许实例字典里的同名键把它盖掉,那它的 __set__ 就永远轮不到执行,赋值语义会自相矛盾。
三、完整的五层链
object.__getattribute__ 内部的查找顺序,一句话版:
1. 类及其 MRO 上找同名属性,若是「数据描述符」 -> 直接调它的 __get__,结束
2. 查实例 __dict__ -> 命中就返回,结束
3. 类上那个属性若是「非数据描述符」 -> 调它的 __get__,结束
4. 类上那个属性是普通值 -> 直接返回,结束
5. 以上全没找到,且类定义了 __getattr__ -> 调 __getattr__,否则抛 AttributeError
注意第 1 步在实例字典之前。这就是 k.d 明明字典里有值,却返回 DATA_DESC 的原因。
也注意第 3 步:普通函数是非数据描述符,所以实例属性可以覆盖方法。
class A:
def f(self): return 'method'
a = A()
a.f = 'shadowed'
print(a.f()) # TypeError: 'str' object is not callable
这不是 bug,是优先级链按设计跑完了。而 property 因为有 __set__(哪怕你没写 setter,property 对象也自带抛 AttributeError 的 __set__),实例属性盖不住它:
class B:
@property
def x(self): return 42
b = B()
b.x = 99 # AttributeError: property 'x' of 'B' object has no setter
四、性能账怎么读
把实测数据按链上的位置重排:
| 路径 | 命中层 | ns/次 |
|---|---|---|
k.plain |
第 2 层:实例字典 | 22.1 |
k.n |
第 2 层:实例字典(先否掉非数据描述符) | 57.1 |
k.d |
第 1 层:数据描述符 __get__ |
137.6 |
k.missing |
第 5 层:__getattr__ 兜底 |
191.5 |
g.x(覆写 __getattribute__) |
Python 层再进一次函数 | 226.5 |
三点值得留意:
k.plain和k.n都命中实例字典,却差了 2.6 倍。因为k.n得先走一遍类 MRO、判断出NonDataDesc不是数据描述符,才能放心去查实例字典。多出来的就是这段判定。- 数据描述符贵在要调 Python 层的
__get__,137.6ns 里绝大部分是一次函数调用。 - 别为了「灵活」去覆写
__getattribute__,226.5ns 已经比普通属性慢十倍,而且类里每一个属性访问都要过你这段 Python 代码。真要兜底,用__getattr__—— 它只在找不到时才被叫到。
五、调试时怎么看穿这条链
inspect.getattr_static 会绕开描述符协议,把「原始找到的那个对象」还给你,不触发 __get__:
import inspect
print(type(inspect.getattr_static(k, 'd')).__name__) # DataDesc
print(type(inspect.getattr_static(k, 'n')).__name__) # str <- 实例字典里那个
一个实用判断:当你怀疑「这个值到底从哪来的」,先 getattr_static,再看 'x' in obj.__dict__,最后翻 type(obj).__mro__ 里各类的 __dict__。比在 __getattribute__ 里打日志干净得多。
六、写下来就三句话
- 优先级:数据描述符 > 实例字典 > 非数据描述符 > 类属性 >
__getattr__。 - 分水岭是
__set__:有它就能压过实例字典,没有就得排在后面。 - 这条链每多走一层,就要多付几十纳秒;热路径上尽量让它停在第 2 层。
我把实验脚本留在 d51-probe.py 里,换台机器、换个 Python 版本可以直接复跑。数值会变,量级和顺序不会。