Chapter 10: Attribute Lookup System
属性访问是 Python 对象系统里最常见的运行时动作。表达式 obj.name 看起来只是从对象上取一个字段,实际会经过类型、实例字典、类字典、MRO、descriptor、访问钩子和解释器缓存共同参与的查找路径。读完本章后,应能追踪一次属性读取从语法到 runtime 对象的路径,判断同名属性由哪一层返回,并解释 property、方法绑定、__getattr__ 兜底和 CPython LOAD_ATTR 优化之间的边界。
本章使用一个贯穿材料:一个 User 对象同时拥有实例属性、类属性、普通方法、property 和访问钩子。后续每一节都会回到这段材料,分别分析读取、覆盖、兜底、descriptor 优先级和方法绑定。
class User:
role = "class role"
def __init__(self, name):
self.name = name
self.role = "instance role"
@property
def label(self):
return f"user:{self.name}"
def greet(self):
return f"hello {self.name}"
u = User("Ada")
这段代码里,u.name、u.role、u.label、u.greet 走出的结果来源不同。u.name 来自实例字典,u.role 命中实例字典里的同名覆盖,u.label 由 property 这个 data descriptor 接管,u.greet 由函数对象的 descriptor 行为转换成 bound method。属性查找系统的核心任务,就是在这些候选位置之间给出稳定顺序。
10.1 Attribute lookup chain
属性查找链回答的问题是:给定一个对象和一个属性名,runtime 按什么顺序寻找候选对象,并在什么位置停止。对于普通实例访问 u.name,入口可以理解为 object.__getattribute__(u, "name")。Python 语言参考把 object.__getattribute__ 描述为实例属性访问时无条件调用的入口,同时说明 __getattr__ 只在常规访问失败后参与兜底;descriptor HOWTO 给出了等价的 Python 伪代码,用来解释默认实例查找顺序:Customizing attribute access、Descriptor HOWTO。
默认实例属性读取的主路径可以压缩成一个判断序列:先在 type(obj).__mro__ 中查找同名类属性;如果找到 data descriptor,立即调用它;随后检查实例 __dict__;再处理 non-data descriptor;再返回普通类属性;全部失败时抛出 AttributeError,由点号表达式或 getattr() 的外层逻辑触发 __getattr__。这里的关键是,类树查找发生得很早,但类树中的候选对象并非一定立即返回;候选对象是否实现 descriptor 协议,会决定它和实例字典之间的优先级。
下面的图只描述普通实例属性读取路径,暂时不展开 super()、metaclass 和特殊方法隐式查找。它的价值在于把“实例上有没有同名属性”和“类上有没有 descriptor”放进同一条决策链。
回到贯穿材料,u.name 的查找会先扫描 User.__mro__,没有发现名为 name 的类属性,然后进入 u.__dict__ 并返回字符串对象。u.role 的查找会先在 User 上发现类属性 role,这个候选对象是普通字符串,没有 __get__,因此继续检查实例字典,最终返回实例字典中的 "instance role"。同样是“类上有同名属性”,返回结果仍可能来自实例,因为普通类属性和实例属性之间允许覆盖。
类对象上的属性读取使用 type.__getattribute__。例如 User.role 的接收者是类对象 User,它自身也是一个对象,类型通常是 type。这条路径会使用类对象的查找规则,并在类的 MRO 中定位属性;如果查到 descriptor,会以 desc.__get__(None, User) 的形式调用。这个差异解释了为什么 User.greet 返回函数相关对象,而 u.greet 返回已经绑定了 u 的方法对象。
源码阅读时,可以把语言层顺序对应到 CPython 的几个入口,但不要把文件行号写死到判断中。descriptor HOWTO 明确把实例路径对应到 Objects/object.c 中的 PyObject_GenericGetAttr(),把类型路径对应到 Objects/typeobject.c 中的 type_getattro() 和 _PyType_Lookup()。不同 CPython 版本可能调整内部函数拆分和生成代码位置,本章结论以语言语义和 3.11+ 时代 CPython 的公开结构为边界。
10.2 Instance dict, class dict, and shadowing
实例字典和类字典的关系决定普通属性覆盖。实例 __dict__ 存储属于某个对象自己的键值,类 __dict__ 存储类命名空间中的键值。类字典会被包成只读视图 mappingproxy 暴露出来,目的是让外部观察类命名空间,同时把类对象的结构修改集中到属性赋值路径上。实例属性读取时,runtime 会把类树和实例字典放到同一条查找链里比较。
先看贯穿材料中的两个键。
u.__dict__
# {'name': 'Ada', 'role': 'instance role'}
User.__dict__["role"]
# 'class role'
u.role 返回 "instance role",因为 User.__dict__["role"] 是普通字符串。普通类属性没有 descriptor 接管能力,实例字典中的同名键会覆盖它。这个覆盖关系只影响从实例出发的读取;直接读取 User.role 时,接收者已经换成类对象,结果来自类命名空间。
这种覆盖通常称为 shadowing。shadowing 的准确含义是:查找链前面某个位置的同名绑定屏蔽了后面某个位置的同名绑定。对于实例读取,实例字典可以屏蔽普通类属性和 non-data descriptor;data descriptor 位于更高优先级,因此能屏蔽实例字典。判断 shadowing 时,先确认候选对象所在位置,再确认候选对象类型是否触发 descriptor 规则。
下面的代码把类属性和实例属性的覆盖关系压缩到一个最小例子里。
class Counter:
value = 0
first = Counter()
second = Counter()
first.value = 10
print(first.value) # 10
print(second.value) # 0
print(Counter.value) # 0
first.value = 10 默认写入 first.__dict__,不会修改 Counter.__dict__["value"]。因此 first.value 命中实例字典,second.value 没有实例键,继续返回类属性。这个例子给出一个工程判断:如果修改一个实例后只有该实例表现改变,应优先检查实例字典;如果所有实例都表现改变,应检查类字典或 descriptor 持有的共享状态。
继承把类字典扩展成 MRO 查找。实例访问 obj.name 时,类侧候选并非只来自 type(obj).__dict__,还会沿 type(obj).__mro__ 查找父类。子类字典中的同名键会先于父类字典命中。这个顺序解释了“子类覆盖父类方法”和“实例覆盖普通类属性”这两个现象背后的统一模型:它们都是同名键在查找链中的先后位置不同。
class Base:
kind = "base"
class Admin(User, Base):
kind = "admin"
admin = Admin("Grace")
print(admin.kind) # admin
admin.kind 的类侧查找会先看 Admin.__dict__,再继续到 User、Base 和 object。由于 Admin 已经提供 kind,父类中的同名候选不会进入结果阶段。这里的判断顺序是:先确认接收者类型,再展开 MRO,再按 descriptor 与实例字典优先级决定返回值。
10.3 getattribute, getattr, setattr, and delattr
四个访问钩子分别接管读取、兜底、写入和删除。__getattribute__ 是读取入口,普通 obj.name 都先进入它;__getattr__ 是读取失败后的兜底;__setattr__ 接收 obj.name = value;__delattr__ 接收 del obj.name。Python data model 对这四个钩子的描述给出一个重要边界:__getattr__ 的触发条件是默认访问失败或 __getattribute__ 抛出 AttributeError,成功命中的属性不会进入 __getattr__。
下面的例子只记录读取路径,并把默认实现交回 object.__getattribute__。这样可以观察钩子位置,同时保留原来的查找规则。
class TracedUser(User):
def __getattribute__(self, name):
print("read", name)
return object.__getattribute__(self, name)
def __getattr__(self, name):
print("fallback", name)
return f"missing:{name}"
tracked = TracedUser("Lin")
print(tracked.name)
print(tracked.unknown)
读取 tracked.name 时,__getattribute__ 打印 read name,随后委托默认实现并返回实例字典里的值。读取 tracked.unknown 时,__getattribute__ 先运行,默认实现找不到属性并抛出 AttributeError,点号表达式的外层逻辑再调用 __getattr__,最终返回兜底结果。这个例子说明 __getattr__ 适合做延迟属性、代理属性或兼容层;它不适合承担普通属性的主查找逻辑。
实现 __getattribute__ 时,内部读取其它属性必须通过基类实现或其它稳定存储完成。原因是 self.name 这样的表达式会再次进入同一个 __getattribute__,形成递归。安全写法通常是 object.__getattribute__(self, "_field"),或者把真实数据放在外部对象、私有字典或已知不会再次触发自定义逻辑的位置。
class SafeRecord:
def __init__(self):
object.__setattr__(self, "_data", {"x": 1})
def __getattribute__(self, name):
if name == "x":
data = object.__getattribute__(self, "_data")
return data[name]
return object.__getattribute__(self, name)
写入和删除有自己的入口。默认 obj.name = value 通常写入实例字典,但类上存在带 __set__ 的 data descriptor 时,写入会交给 descriptor。自定义 __setattr__ 可以检查、转换或转发写入;自定义 __delattr__ 可以控制属性删除行为。两者内部同样应调用 object.__setattr__ 或 object.__delattr__ 完成实际状态更新。
class CheckedUser(User):
def __setattr__(self, name, value):
if name == "name" and not isinstance(value, str):
raise TypeError("name must be str")
object.__setattr__(self, name, value)
checked = CheckedUser("Ada")
checked.name = "Grace"
访问钩子还有一个容易影响调试的边界:某些由语言语法或内置函数隐式触发的 special method lookup 会绕过普通实例上的 __getattribute__。例如 len(obj) 查找 __len__ 时,走的是类型层面的特殊方法查找规则。这个边界让协议调用保持稳定,也让自定义访问代理需要额外处理显式属性读取和隐式协议调用之间的差异。
10.4 Descriptor lookup and precedence
Descriptor 是放在类上的对象,只要它的类型实现了 __get__、__set__ 或 __delete__ 中的某些方法,就能参与属性访问。property、普通函数、classmethod、staticmethod 和 slots 都建立在 descriptor 协议上。descriptor 的作用位置很明确:它必须作为类属性被查到,默认实例查找才会自动调用它;把 descriptor 对象直接放入实例字典,只会得到普通实例属性。
Descriptor 分成 data descriptor 和 non-data descriptor。实现了 __set__ 或 __delete__ 的 descriptor 是 data descriptor;只实现 __get__ 的 descriptor 是 non-data descriptor。descriptor HOWTO 明确给出优先级:data descriptor 高于实例字典,non-data descriptor 可以被实例字典覆盖。下面用两个小 descriptor 观察差异。
class DataValue:
def __get__(self, obj, objtype=None):
return "data descriptor"
def __set__(self, obj, value):
obj.__dict__["value"] = value
class NonDataValue:
def __get__(self, obj, objtype=None):
return "non-data descriptor"
class DataExample:
value = DataValue()
class NonDataExample:
value = NonDataValue()
x = DataExample()
x.__dict__["value"] = "instance value"
print(x.value) # data descriptor
y = NonDataExample()
y.__dict__["value"] = "instance value"
print(y.value) # instance value
x.value 返回 data descriptor 的结果,因为 DataValue 的类型有 __set__,优先级高于 x.__dict__。y.value 返回实例字典的结果,因为 NonDataValue 只有 __get__,实例字典可以屏蔽它。这个规则解释了 property 为什么能持续接管读取:标准 property 是 data descriptor,即便实例字典里存在同名键,读取仍由 property.__get__ 返回。
贯穿材料中的 u.label 就是 data descriptor 路径。label 存在于 User.__dict__,对象是 property 实例。读取 u.label 时,默认查找先发现这个 data descriptor,随后调用 property.__get__(u, User),执行原始 getter 函数并返回 "user:Ada"。实例字典中没有 label 时如此;即便手动把 u.__dict__["label"] 填入其它对象,默认读取仍会命中 property。
u.__dict__["label"] = "shadow attempt"
print(u.label) # user:Ada
Descriptor 的返回值由 __get__(self, obj, objtype) 的两个参数共同决定。从实例读取时,obj 是具体实例,objtype 是实例类型;从类读取时,obj 是 None,objtype 是类。许多 descriptor 会根据 obj is None 返回自身或返回适合类访问的对象。这个约定让同一个类属性可以同时支持 u.label 和 User.label 两类访问。
工程上判断 descriptor 问题,可以使用固定顺序:先看属性是否定义在类上;再看 descriptor 对象的类型是否有 __get__;再看是否有 __set__ 或 __delete__;然后检查实例字典是否存在同名键;最后判断读取来自 descriptor、实例字典还是普通类属性。这个顺序能解释大多数 property、ORM 字段、校验字段和延迟计算字段的行为。
10.5 Method lookup and LOAD_ATTR specialization
方法查找是 descriptor 规则的高频应用。用户定义函数放在类字典里时,函数对象实现了 __get__,因此从实例读取 u.greet 会返回 bound method。bound method 内部记录两件事:底层函数对象和绑定实例。调用这个方法时,实例会作为第一个参数传给底层函数,所以 u.greet() 在语义上等价于把 u 作为 self 调用 User.greet。Python data model 对实例方法对象的描述也采用这个关系:底层函数调用时会把 class instance 插到参数列表前面。
method = u.greet
print(method.__self__ is u) # True
print(method.__func__ is User.greet) # True
print(method()) # hello Ada
这个例子展示了一个重要边界:方法绑定发生在属性读取阶段,函数体执行发生在调用阶段。读取 u.greet 已经创建或取得了一个表示“函数 + 实例”的 callable;随后的 () 才进入函数调用路径。阅读栈、性能问题或 descriptor 代码时,需要把“属性查找成本”和“函数调用成本”分开判断。
直接读取 User.greet 时,接收者是类对象。函数 descriptor 会以类访问方式处理,结果通常表现为底层函数对象本身,调用者需要显式传入实例。这个行为和 classmethod、staticmethod 的差异也来自 descriptor。classmethod 的 __get__ 绑定类对象;staticmethod 的 __get__ 返回原函数,不插入实例或类。它们看起来像装饰器,runtime 位置仍是类字典里的 descriptor。
CPython 为属性访问投入了专门优化,因为 LOAD_ATTR 是高频字节码。以 CPython 3.11+ 为版本边界,PEP 659 引入 specializing adaptive interpreter;官方说明把 LOAD_ATTR 列为可 specialization 的指令族,并说明 hot instruction 可以根据运行时看到的类型和值替换成更快的专门形态,同时在输入形态变化时退回通用路径:PEP 659、What’s New in Python 3.11。
def read_label(obj):
return obj.label
从语义看,read_label(u) 必须遵守前面讲过的 descriptor 优先级,返回 property 计算值。从 CPython 执行看,函数体里的属性读取会对应到属性加载字节码,解释器在多次执行后可能建立 inline cache,把“这个位置经常读取某种类型上的某个属性”记录下来。缓存命中时可以减少重复查找开销;缓存失效时必须回到通用查找路径。这个优化不改变 Python 语义,不赋予程序新的可观察规则。
方法调用也有类似优化空间。u.greet() 的语义需要完成属性查找、方法绑定和调用;CPython 可以把常见方法调用路径拆成更直接的操作,减少临时 bound method 对象和动态查找的成本。具体 opcode 名称、specialization 形态和生成的 C 代码位置会随 CPython 版本变化,阅读源码时应以当前版本的 dis 输出、Python/bytecodes.c、生成的解释器 case 和 specialization 代码为准。稳定结论只有一个:优化依赖属性查找规则,优化失败时回到相同语义的通用路径。
这一节最终给出的判断顺序是:先解释语义来源,再解释 CPython 优化。看到 obj.method(),先判断 method 是否来自类字典中的函数 descriptor,是否被实例字典覆盖,是否被 data descriptor 接管;再判断调用阶段如何传入 self;最后再讨论 CPython 是否通过 LOAD_ATTR、方法加载或 inline cache 降低重复查找成本。语义判断先于性能判断,能让源码阅读和故障定位保持稳定。
最小自检任务
阅读下面代码,判断每一行输出来自哪一层查找,并说明 c.value、c.action、c.missing 的返回路径。
class DataField:
def __get__(self, obj, objtype=None):
return "descriptor value"
def __set__(self, obj, value):
obj.__dict__["value"] = value
class Example:
value = DataField()
action = lambda self: "method value"
def __init__(self):
self.value = "instance value"
self.action = "shadowed action"
def __getattr__(self, name):
return f"fallback:{name}"
c = Example()
print(c.value)
print(c.action)
print(c.missing)
答案要点
c.value 返回 "descriptor value"。Example.__dict__["value"] 是 data descriptor,因为 DataField 实现了 __set__。默认实例查找先命中 data descriptor,并调用 DataField.__get__(c, Example);__init__ 中的 self.value = "instance value" 通过 descriptor 的 __set__ 把同名键写入 c.__dict__,但读取优先级仍由 data descriptor 接管。
c.action 返回 "shadowed action"。Example.__dict__["action"] 是函数对象,函数对象作为 non-data descriptor 支持方法绑定;实例字典中存在同名键 "action",因此实例字典屏蔽 non-data descriptor。读取阶段直接返回字符串,后续如果执行 c.action(),会因为字符串不可调用而抛出 TypeError。
c.missing 返回 "fallback:missing"。默认查找在 data descriptor、实例字典、non-data descriptor 和普通类属性位置都没有找到 missing,于是抛出 AttributeError;点号访问的外层逻辑调用 Example.__getattr__(c, "missing"),返回兜底字符串。这个过程说明 __getattr__ 位于失败兜底层,不参与已经成功的属性读取。
本章知识点总结
- 查找入口:普通实例属性读取先进入
object.__getattribute__的默认路径。 - 类树扫描:实例读取会在
type(obj).__mro__中寻找类侧候选属性。 - 实例字典:普通实例属性存放在对象自己的
__dict__中,并可覆盖普通类属性。 - 类字典:类命名空间提供方法、类属性和 descriptor 的存放位置。
- Shadowing:同名属性的返回结果由查找链位置和 descriptor 类型共同决定。
- 读取钩子:
__getattribute__接管读取主入口,内部应委托稳定的基类实现。 - 兜底钩子:
__getattr__在常规查找失败后触发,适合延迟属性和代理属性。 - 写入钩子:
__setattr__接收属性赋值,默认写入实例状态或转交 data descriptor。 - 删除钩子:
__delattr__接收属性删除,并应维护对象状态一致性。 - Data descriptor:实现
__set__或__delete__的 descriptor 优先于实例字典。 - Non-data descriptor:只实现
__get__的 descriptor 可以被实例字典同名键覆盖。 - 方法绑定:类字典中的函数通过 descriptor 读取生成绑定实例的 method。
- 类访问:从类对象读取 descriptor 时,
__get__接收的实例参数是None。 - LOAD_ATTR:CPython 3.11+ 可以对高频属性加载做 specialization,并在形态变化时回到通用语义。
- 判断顺序:先看类侧 descriptor,再看实例字典,再看普通类属性和兜底钩子。