1. 先从一次普通属性访问开始,搞清 Python 默认查找顺序
__getattr__、__getattribute__、__get__ 这三个方法,在 Python 面试里经常被放在一起问,好像它们是一条链路上的三个关卡。但实际上它们的分工差异非常大,而且调用时机完全不是“依次执行”的关系。如果只是记住结论,遇到真实项目里的组合场景,很容易写出递归爆栈或者行为诡异的代码。所以我一直觉得,与其背“优先级列表”,不如自己动手跑一遍最基础的查找顺序,把 Python 在访问一个实例属性时到底做了什么彻底看明白。
先做一个观念上的铺垫:平时写的 obj.x,并不是简单地从 obj.__dict__ 里取一个键。真正被调用的,是 type(obj) 上继承下来的 __getattribute__ 方法。默认情况下它由 object 提供,而它内部会依次处理类属性、数据描述符、实例字典、非数据描述符等对象。只有这一整套默认查找都失败并抛出 AttributeError 时,解释器才会和 __getattr__ 打交道。
1.1 obj.x 背后到底走多少步
我见过很多同学直接背这样一段结论:数据描述符优先于实例字典,实例字典优先于非数据描述符,最后才轮到 __getattr__。这个结论在多数场景下没问题,但真正去读一段带描述符的代码时,还是会犯晕,因为描述符本身也是类属性,可它又会影响属性访问的最终结果。
下面这个例子是最直观的验证方式。我定义两个类属性,一个是数据描述符,另一个是非数据描述符,然后在 __init__ 里强行往实例字典塞同名键:
python复制class DataDesc:
def __get__(self, instance, owner):
return "data descriptor value"
def __set__(self, instance, value):
print("DataDesc.__set__ called:", value)
class NonDataDesc:
def __get__(self, instance, owner):
return "non-data descriptor value"
class Box:
data = DataDesc()
non_data = NonDataDesc()
def __init__(self):
self.__dict__["data"] = "instance dict data"
self.__dict__["non_data"] = "instance dict non_data"
box = Box()
print(box.data) # data descriptor value
print(box.non_data) # instance dict non_data
输出告诉我们一个很容易混淆的事实:同样是类属性,data 因为定义了 __set__,成了数据描述符,所以哪怕实例字典里有同名键,它仍然赢过了实例字典;而 non_data 只有 __get__,是非数据描述符,实例字典里的键就能把它覆盖掉。
完整顺序其实可以整理成一张表:
| 查找阶段 | 命中的对象 | 后续动作 |
|---|---|---|
| 1 | 类 MRO 中的数据描述符 | 调用描述符的 __get__(instance, owner),不再看实例字典 |
| 2 | 实例 __dict__ |
直接返回对应值 |
| 3 | 类 MRO 中的非数据描述符 | 调用描述符的 __get__(instance, owner) |
| 4 | 普通类属性 | 直接返回类属性值 |
| 5 | 都不存在 | 如果定义了 __getattr__,调用 __getattr__(name) |
| 6 | __getattr__ 也没有或继续抛错 |
抛出 AttributeError |
注意第 2 步到第 4 步之间,还混着普通类属性和函数。函数在 Python 里也是非数据描述符,所以允许 obj.method = something 这种实例级覆盖;如果函数是数据描述符,那就没法给单个实例动态绑定方法了。这算是一个隐藏彩蛋,理解了非数据描述符,你就知道为什么方法可以那么灵活。
