Python 的 obj.x 到底按什么顺序找?拆开数据描述符、实例字典和 __getattr__ 那条优先级链

先看一组我在自己机器上跑出来的数(CPython 3.13.14,timeit 200 万次取平均):

text 复制代码
普通实例属性             22.1 ns/次
非数据描述符(实例字典赢) 57.1 ns/次
数据描述符               137.6 ns/次
__getattr__ 兜底         191.5 ns/次
覆写 __getattribute__    226.5 ns/次

同样一句 obj.x,最慢和最快差了十倍。差别不在字典查得快不快,而在这一句背后有一条五层的优先级链,Python 得一层层往下试。

更反直觉的是:我把值塞进了 obj.__dict__,取出来却可能不是它。

一、一个「塞进去也白塞」的实验

python 复制代码
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'

三个键都实打实写进了实例字典,取值结果却是:

text 复制代码
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

python 复制代码
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__ 内部的查找顺序,一句话版:

text 复制代码
1. 类及其 MRO 上找同名属性,若是「数据描述符」 -> 直接调它的 __get__,结束
2. 查实例 __dict__ -> 命中就返回,结束
3. 类上那个属性若是「非数据描述符」 -> 调它的 __get__,结束
4. 类上那个属性是普通值 -> 直接返回,结束
5. 以上全没找到,且类定义了 __getattr__ -> 调 __getattr__,否则抛 AttributeError

注意第 1 步在实例字典之前 。这就是 k.d 明明字典里有值,却返回 DATA_DESC 的原因。

也注意第 3 步:普通函数是非数据描述符,所以实例属性可以覆盖方法。

python 复制代码
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__),实例属性盖不住它:

python 复制代码
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

三点值得留意:

  1. k.plain 和 k.n 都命中实例字典,却差了 2.6 倍 。因为 k.n 得先走一遍类 MRO、判断出 NonDataDesc 不是数据描述符,才能放心去查实例字典。多出来的就是这段判定。
  2. 数据描述符贵在要调 Python 层的 __get__,137.6ns 里绝大部分是一次函数调用。
  3. 别为了「灵活」去覆写 __getattribute__ ,226.5ns 已经比普通属性慢十倍,而且类里每一个属性访问都要过你这段 Python 代码。真要兜底,用 __getattr__ ------ 它只在找不到时才被叫到。

五、调试时怎么看穿这条链

inspect.getattr_static 会绕开描述符协议,把「原始找到的那个对象」还给你,不触发 __get__:

python 复制代码
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 版本可以直接复跑。数值会变,量级和顺序不会。

相关推荐
用户11481867894841 小时前
水印的实现方式逻辑
前端·python
贾公子1 小时前
从单条内容到账号维度——AIGC 风险评分的工程实现
后端·python
木头左3 小时前
【Python】不点浏览器发博客:逆向 CSDN 创作中心接口做 md-publish 技能
python·自动化·api
Tt有了t3 小时前
设计模式速查
java·python·设计模式
deepseek234 小时前
Python日志拖慢请求?QueueHandler排队、丢失与退出实测
python·性能优化·logging·后端开发·queuehandler
用户91294046848424 小时前
Codex 从零到能交付(05):第一次改代码:生成一个文件清单脚本
python
Dmz丶4 小时前
前端转 AI 100 天 Day 38:Token 管理与成本优化——别让 API 账单爆炸
python
用户298698530145 小时前
Python 自动化处理 Word 页眉页脚的几种典型场景
后端·python·api
終不似少年遊*5 小时前
【安全业务Agent】从场景到编排
python·react.js·ai·langchain·agent·安全架构·工作流