本节目标:把
obj.attr还原成一条可复述的查找流水线——先沿type(obj).__mro__找数据描述符,再查实例__dict__,最后才是非数据描述符;能凭__set__的有无判断一个对象是数据还是非数据描述符,并说清__getattr__与__getattribute__的分工边界。
适用版本:Python 3.12+(实测 3.14.6)
1.2 属性查找、描述符协议与 getattr
上一节我们确认了实例属性就存在 __dict__ 里,属性访问只是「查字典」。但真相比这复杂:property 能拦截赋值、方法会绑定 self、cached_property 首次计算后又能被缓存——如果属性访问只是查字典,这些现象都无从解释。答案是 描述符协议:CPython 在查字典之前和之后,会额外询问「类字典里找到的那个对象,是不是描述符」。本节把这套顺序彻底拆开。
一、一次 obj.attr 的完整查找路径
读属性时,CPython 按固定优先级依次尝试,命中即返回:
- 沿
type(obj).__mro__查找类字典。找到的值v,若v定义了__get__且定义了__set__(或__delete__),它是数据描述符,直接调用v.__get__(obj, type(obj))并返回——优先级最高。 - 查实例
obj.__dict__。若命中,返回值。 - 回到步骤 1 找到的值
v。若v是非数据描述符(只有__get__,没有__set__),调用v.__get__(obj, type(obj))返回。 - 都失败,调用
type(obj).__getattr__(obj, name)(若定义了),否则抛AttributeError。
一句话记忆:数据描述符 > 实例字典 > 非数据描述符 > __getattr__。用一段可运行代码把前三级实测出来:
log = []
class DataDesc: # 有 __set__ -> 数据描述符
def __get__(self, obj, owner):
log.append("data.__get__"); return "来自数据描述符"
def __set__(self, obj, val):
log.append("data.__set__")
class NonDataDesc: # 只有 __get__ -> 非数据描述符
def __get__(self, obj, owner):
log.append("nondata.__get__"); return "来自非数据描述符"
class Demo:
data = DataDesc()
nondata = NonDataDesc()
d = Demo()
d.__dict__["data"] = "实例字典里的 data" # 手动往实例字典塞同名键
d.__dict__["nondata"] = "实例字典里的 nondata"
print("d.data =", d.data, "| 日志:", log); log.clear()
print("d.nondata =", d.nondata, "| 日志:", log)
输出:
d.data = 来自数据描述符 | 日志: ['data.__get__']
d.nondata = 实例字典里的 nondata | 日志: []
data 是数据描述符,即使实例字典里有同名键也被它盖过;nondata 是非数据描述符,被实例字典直接遮蔽,__get__ 根本没被调用。这就是「实例属性可以覆盖方法(函数是非数据描述符),却覆盖不了 property」的根本原因。
二、用 __set__ 的有无给描述符分类
判断一个对象属于哪一类,不需要读源码——看它有没有 __set__(或 __delete__):
import functools
for name, obj in [("property", property), ("staticmethod", staticmethod),
("classmethod", classmethod),
("function", type(lambda: 0)),
("cached_property", functools.cached_property)]:
has_get, has_set = hasattr(obj, "__get__"), hasattr(obj, "__set__")
kind = "数据" if (has_get and has_set) else ("非数据" if has_get else "非描述符")
print(f"{name:>16}: __get__={has_get} __set__={has_set} -> {kind}")
输出(本机 3.14.6):
property: __get__=True __set__=True -> 数据
staticmethod: __get__=True __set__=False -> 非数据
classmethod: __get__=True __set__=False -> 非数据
function: __get__=True __set__=False -> 非数据
cached_property: __get__=True __set__=False -> 非数据
这张表把前面几章散落的结论串了起来:
| 对象 | 类别 | 关键后果 |
|---|---|---|
property | 数据描述符 | 实例字典无法遮蔽,赋值必走 setter |
staticmethod | 非数据描述符 | 实例可覆盖;__get__ 返回裸函数 |
classmethod | 非数据描述符 | __get__ 把 cls 绑进方法 |
| 普通函数 | 非数据描述符 | 正是它让 obj.method 变成绑定方法 |
cached_property | 非数据描述符 | 首次计算后写回实例字典,之后被字典遮蔽 |
三、cached_property 为什么能把结果缓存住
cached_property 的设计巧妙之处,正是「非数据描述符」这个身份。它只在 __get__ 里算一次,然后把结果写进实例 __dict__;下次访问时,实例字典先于非数据描述符被命中,于是不再重算:
calls = []
class Model:
@functools.cached_property
def heavy(self):
calls.append(1)
return 42
m = Model()
print("首次 m.heavy =", m.heavy, "| 计算次数:", len(calls))
print("计算后 m.__dict__ =", m.__dict__)
print("再次 m.heavy =", m.heavy, "| 计算次数:", len(calls))
输出:
首次 m.heavy = 42 | 计算次数: 1
计算后 m.__dict__ = {'heavy': 42}
再次 m.heavy = 42 | 计算次数: 1
如果 cached_property 是数据描述符(带 __set__),实例字典就永远遮不住它,缓存无从生效。它刻意只实现 __get__,就是在利用查找顺序里「实例字典 > 非数据描述符」这条规则。理解这点后,你也能预判它的已知局限:cached_property 需要实例有可写的 __dict__,所以用了 __slots__ 的类不能直接用它(除非把 __dict__ 也加进 slots)。
四、__getattr__ 与 __getattribute__ 的分工
查找路径的最后一站是 __getattr__,但它在整条链上的位置常被误解。真正的总入口是 __getattribute__:obj.attr 首先调用的就是它,默认实现会走完上面的一到三级;只有当它抛出 AttributeError,__getattr__ 才作为兜底被调用:
class Proxy:
def __init__(self):
self.real = 1
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 = Proxy()
print("p.real ->", p.real)
print("p.missing ->", p.missing)
输出:
[__getattribute__] 拦截 real
p.real -> 1
[__getattribute__] 拦截 missing
[__getattr__] 兜底 missing
p.missing -> <默认 missing>
对照表:
| 方法 | 触发时机 | 覆盖面 | 典型用途 |
|---|---|---|---|
__getattribute__ | 每次属性访问 | 全部 | 全局审计、代理(慎用,易递归) |
__getattr__ | 正常查找失败后 | 仅缺失属性 | 默认值、延迟加载、__getattr__ 代理 |
__setattr__ | 每次赋值 | 全部 | 校验、不可变、审计 |
__delattr__ | 每次删除 | 全部 | 禁止删除 |
一个容易踩的坑:在 __getattribute__ 里访问 self.xxx 会再次触发它自己,造成无限递归,必须走 object.__getattribute__(self, ...) 绕过。而 __getattr__ 天然安全——它只在查找失败后运行,里面再访问已有属性不会再触发它。
五、__slots__ 的成员描述符
__slots__ 常被说成「省掉 __dict__」,但它内部到底怎么存属性?答案是:__slots__ 里每个名字都会在类上生成一个 member_descriptor,而它是数据描述符:
class Slotted:
__slots__ = ("x",)
def __init__(self, x):
self.x = x
s = Slotted(1)
desc = Slotted.__dict__["x"]
print("type(Slotted.x) =", type(desc))
print("有 __get__:", hasattr(desc, "__get__"), " 有 __set__:", hasattr(desc, "__set__"))
print("实例有 __dict__ 吗:", hasattr(s, "__dict__"))
输出:
type(Slotted.x) = <class 'member_descriptor'>
有 __get__: True 有 __set__: True
实例有 __dict__ 吗: False
成员描述符把值存在对象内固定偏移的槽位里,而不是字典——这正是 __slots__ 省内存、访问更快的机制。因为它是数据描述符,赋值会走 __set__,写入未声明的属性名会直接报 AttributeError。下一章 类装饰器、__set_name__ 与属性工厂
会讲到描述符如何通过 __set_name__ 自动获知自己的属性名,从而省掉手写 name = "x" 的样板。
六、一次属性访问的字节码
属性访问在字节码层面是单个 LOAD_ATTR 指令:
import dis
def get(o):
return o.attr
dis.dis(get)
输出(本机 3.14.6):
LOAD_FAST_BORROW 0 (o)
LOAD_ATTR 0 (attr)
RETURN_VALUE
LOAD_ATTR 就是本节讲的整条查找路径的入口——解释器把它交给类型对象上的属性查找逻辑,C 层依次做「数据描述符、实例字典、非数据描述符」的判定。注意 3.14 的字节码里出现了 LOAD_FAST_BORROW 这样的指令(借用引用的优化形式),它属于较新的实现细节。反汇编输出随版本变化,不要把它当成跨版本稳定的接口——本书只保证 3.14.6 上的实测输出,其他版本请以本机 dis 为准。
七、各条查找路径的性能量级
查找顺序不仅决定「谁赢」,也决定「多慢」。同一个 obj.attr,命中不同的层级,耗时相差数倍。用 timeit 在 300 万次下实测:
import timeit
class Plain:
cls_attr = 1
def __init__(self): self.inst_attr = 1
@property
def prop(self): return 1
def __getattr__(self, name): return 1
class Slotted:
__slots__ = ("inst_attr",)
def __init__(self): self.inst_attr = 1
p, s = Plain(), Slotted()
def ns(stmt, n=3_000_000):
return timeit.timeit(stmt, globals=globals(), number=n) / n * 1e9
for label, stmt in [("实例字典属性", "p.inst_attr"), ("类属性(MRO)", "p.cls_attr"),
("property", "p.prop"), ("__getattr__兜底", "p.missing"),
("__slots__槽位", "s.inst_attr")]:
print(f"{label:>14} {stmt:>12} : {ns(stmt):.1f} ns/次")
输出(本机 3.14.6,单次测量,数值有少量波动):
实例字典属性 p.inst_attr : 8.3 ns/次
类属性(MRO) p.cls_attr : 8.0 ns/次
property p.prop : 48.7 ns/次
__getattr__兜底 p.missing : 47.8 ns/次
__slots__槽位 s.inst_attr : 7.9 ns/次
读法:实例字典、类属性、__slots__ 槽位都在同一个量级(约 8 ns),因为 LOAD_ATTR 有内联的快速路径;而 property 与 __getattr__ 要走一次 Python 层的方法调用,慢了约 6 倍。结论很直接——热路径上少用 property 和 __getattr__ 做属性代理;property 的价值在于「校验 + 可读」,不在于「快」。
想继续看对象在内存里的真实排布与 __slots__ 的收益量级,对象布局、__slots__ 与内存占用测量
会用 tracemalloc 给出实测数据。
小结
obj.attr的查找顺序是 数据描述符 → 实例__dict__→ 非数据描述符 →__getattr__;数据描述符能盖过实例字典,非数据描述符不能。- 用
__set__的有无给描述符分类:property是数据描述符,staticmethod、classmethod、普通函数、cached_property都是非数据描述符。 cached_property能把结果缓存住,正是因为它刻意只做非数据描述符,从而让首次计算结果写回实例字典后遮蔽自己。__getattribute__是每次访问的总入口,__getattr__只在查找失败后兜底;在__getattribute__里访问self.xxx会递归,须用object.__getattribute__绕过。__slots__为每个名字生成member_descriptor(数据描述符),值存在对象内固定槽位而非字典,这是它省内存的机制。- 属性访问编译为
LOAD_ATTR;反汇编指令名随版本变化(3.14 出现LOAD_FAST_BORROW),不跨版本稳定。 - 性能上,实例字典、类属性、
__slots__槽位约 8 ns/次,而property与__getattr__因多一次 Python 层调用约 48 ns/次——热路径应避免用它们做纯转发。
下一节 魔术方法驱动的协议设计
会顺着描述符再走一步:当 __get__、__eq__、__iter__ 这些 dunder 被语言当作协议钩子时,CPython 是在哪个对象上找它们、又是如何决定用谁的。
阅读导航:上一节:1.1 一切皆对象:类型、标识与引用 · 下一节:1.3 魔术方法驱动的协议设计 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。