本节目标:把「属性查找」从一条规则表还原成两个 C 函数的协作——
object.__getattribute__管实例、type.__getattribute__管类对象,说清描述符三件套、元类层的优先级,以及描述符「只在类字典里生效」这条硬约束。
适用版本:Python 3.12+(实测 3.14.6)
8.1 描述符协议与属性查找
本书 属性查找、描述符协议与 __getattr__
已经给出可观测的结论:数据描述符 → 实例字典 → 非数据描述符 → __getattr__。本节不再重复那张表,而是往下钻一层:这套顺序是谁执行的、类对象的属性访问走的是不是同一条路、描述符协议到底有几个方法、以及各条路径的实测代价。
一、属性查找由两个不同的 C 函数执行
CPython 里没有一个叫「属性查找」的统一函数。实例访问和类对象访问走的是两个不同的 tp_getattro 槽:
import sys
print("python", sys.version.split()[0])
class Plain:
x = 1
p = Plain()
print("type(p).__getattribute__ is object.__getattribute__ :",
type(p).__getattribute__ is object.__getattribute__)
print("type(Plain).__getattribute__ is type.__getattribute__ :",
type(Plain).__getattribute__ is type.__getattribute__)
print("object.__getattribute__ :", object.__getattribute__)
print("type.__getattribute__ :", type.__getattribute__)
输出(本机 3.14.6):
type(p).__getattribute__ is object.__getattribute__ : True
type(Plain).__getattribute__ is type.__getattribute__ : True
object.__getattribute__ : <slot wrapper '__getattribute__' of 'object' objects>
type.__getattribute__ : <slot wrapper '__getattribute__' of 'type' objects>
两个都是「slot wrapper」,但分别属于 object 和 type。它们的 C 实现是 PyObject_GenericGetAttr(对象槽)与 type_getattro(类型槽)。这条事实解释了一个常见困惑:为什么在实例上重写 __getattribute__ 拦不到 Plain.x 这种类属性访问——Plain.x 走的是 type.__getattribute__,与你定义在 Plain 里的实例方法无关。
二、描述符协议的三件套:__get__ / __set__ / __delete__
描述符协议不是「有一个 __get__ 就算」,而是三个可选方法组成的整体:
| 方法 | 签名 | 触发时机 |
|---|---|---|
__get__ | (self, obj, owner) | 读属性;obj is None 表示从类上读 |
__set__ | (self, obj, value) | 写属性 obj.attr = v |
__delete__ | (self, obj) | 删属性 del obj.attr |
把三个都实现一遍,并在 __get__ 里打印它收到的 obj 与 owner:
log = []
class Desc:
def __get__(self, obj, owner):
log.append(("get", obj, owner.__name__))
return f"get(obj={obj!r})"
def __set__(self, obj, val):
log.append(("set", obj, val))
def __delete__(self, obj):
log.append(("del", obj))
class Holder:
d = Desc()
h = Holder()
print("h.d ->", h.d, "| log:", log); log.clear()
print("Holder.d ->", Holder.d, "| log:", log); log.clear()
del h.d
print("del h.d 后 log:", log)
输出:
h.d -> get(obj=<__main__.Holder object at 0x109c7e900>) | log: [('get', <__main__.Holder object ...>, 'Holder')]
Holder.d -> get(obj=None) | log: [('get', None, 'Holder')]
del h.d 后 log: [('del', <__main__.Holder object at 0x109c7e900>)]
两个细节值得记牢:
- 从类上读时
obj是None。这正是staticmethod.__get__能「什么都不绑」、classmethod.__get__能「绑cls」、普通函数__get__能「绑self」的实现基础——它们拿到的是同一个(obj, owner)二元组,区别只在怎么用。 del h.d调的是描述符的__delete__,不是删实例字典里的键。如果描述符只实现了__get__(非数据描述符)而实例字典里又没有同名键,del会直接抛AttributeError,因为协议里没有可以调的__delete__。
三、描述符只在「类字典」里生效
这是描述符最容易被忽略的约束:协议只在查找命中类型对象(及其 MRO)的字典时触发。同一个描述符对象放进实例字典,__get__ 根本不会被调用:
class D:
def __get__(self, obj, owner):
return "描述符 __get__ 被调用"
def __set__(self, obj, val):
pass
class C:
x = D()
c = C()
print("类上 c.x ->", c.x) # 触发 __get__
c.__dict__["y"] = D() # 描述符塞进实例字典
print("实例字典里 c.y ->", c.y) # 原样返回,未触发 __get__
输出:
类上 c.x -> 描述符 __get__ 被调用
实例字典里 c.y -> <__main__.D object at 0x105b9c910>
原因是查找算法只在「沿 MRO 命中的类属性」上做描述符判定。实例字典里的值被当作普通数据直接返回,无论它是不是描述符。这条约束也解释了 cached_property 的缓存策略为什么可行:它第一次算完后把结果(不是它自己)写进实例字典,之后命中的是普通值。
四、类对象的属性访问:元类层是同一套规则
类对象也是对象,它的「类型」是元类。所以 Cls.attr 的查找链在元类层同样跑一遍描述符优先级:
class MetaData: # 数据描述符(有 __set__)
def __get__(self, obj, owner):
return f"<元类数据描述符 owner={obj.__name__}>"
def __set__(self, obj, val):
pass
class MetaNonData: # 非数据描述符(只有 __get__)
def __get__(self, obj, owner):
return "<元类非数据描述符>"
class Meta(type):
data = MetaData()
nondata = MetaNonData()
class Cls(metaclass=Meta):
data = "类字典里的 data"
nondata = "类字典里的 nondata"
print("Cls.data ->", Cls.data)
print("Cls.nondata ->", Cls.nondata)
输出:
Cls.data -> <元类数据描述符 owner=Cls>
Cls.nondata -> 类字典里的 nondata
于是 Cls.attr 的完整链条是:元类的 MRO 里找数据描述符 → 类自身的 MRO 字典 → 元类 MRO 里的非数据描述符 → 元类的 __getattr__。实例那套规则原样上移了一层。注意在类层查找时,描述符收到的是 obj=None,owner 则是被访问的那个类,而不是定义描述符的基类:
log = []
class D:
def __get__(self, obj, owner):
log.append((obj, owner.__name__)); return "v"
class Base:
d = D()
class Sub(Base):
pass
print("Sub.d ->", Sub.d, "| owner:", log); log.clear()
print("Base.d ->", Base.d, "| owner:", log)
输出:
Sub.d -> v | owner: [(None, 'Sub')]
Base.d -> v | owner: [(None, 'Base')]
五、__get__ 抛 AttributeError 会落回 __getattr__
查找链的兜底不止服务「找不到属性」,也服务「找到了但取不出来」。只要描述符的 __get__ 抛出 AttributeError,__getattr__ 就会被调用:
class Boom:
def __get__(self, obj, owner):
raise AttributeError("描述符内部炸了")
class E:
x = Boom()
def __getattr__(self, name):
return f"__getattr__ 兜底 {name}"
print("E().x ->", E().x)
输出:
E().x -> __getattr__ 兜底 x
这一点在写「惰性属性」时很有用:属性暂时取不到时抛 AttributeError,就能自然回退到 __getattr__ 的统一处理,而不必把逻辑塞进描述符里。
六、实测:谁把属性访问拖慢了
很多人以为「定义了 __getattr__ 就会拖慢所有属性访问」。实测并不成立——CPython 3.11 之后 LOAD_ATTR 有内联快速路径,命中实例字典时根本不进 Python 层:
import timeit
class Fast:
def __init__(self): self.a = 1
class WithGetattr:
def __init__(self): self.a = 1
def __getattr__(self, name): return 99
class WithGetattribute:
def __init__(self): self.a = 1
def __getattribute__(self, name):
return object.__getattribute__(self, name)
f, wg, wga = Fast(), WithGetattr(), WithGetattribute()
def ns(stmt, n=2_000_000):
return timeit.timeit(stmt, globals=globals(), number=n) / n * 1e9
print(f"普通类 f.a : {ns('f.a'):6.1f} ns")
print(f"有 __getattr__ wg.a : {ns('wg.a'):6.1f} ns")
print(f"有 __getattribute__ wga.a : {ns('wga.a'):6.1f} ns")
print(f"缺失属性 wg.missing : {ns('wg.missing'):6.1f} ns")
输出(本机 3.14.6,三次独立测量):
普通类 f.a : 8.4 ns
有 __getattr__ wg.a : 8.2 ns
有 __getattribute__ wga.a : 71.9 ns
缺失属性 wg.missing : 49.0 ns
结论与直觉相反:定义 __getattr__ 不会拖慢已有属性的读取(8.2 对 8.4 ns,同一量级),因为它只在快速路径失败后才被调用。真正贵的是重写 __getattribute__——它把每次访问都变成一次 Python 层调用,约 72 ns,慢 8 倍以上。热路径上做代理请优先考虑 __getattr__,而不是 __getattribute__。
小结
- 属性访问由两个 C 函数分管:实例走
object.__getattribute__(PyObject_GenericGetAttr),类对象走type.__getattribute__(type_getattro)。 - 描述符协议是
__get__/__set__/__delete__三件套;从类上读时__get__收到obj=None,owner是被访问的类。 - 描述符只在类字典里生效;放进实例字典的描述符不会被调用。
- 类对象的属性访问在元类层重复同一套优先级:元类数据描述符 > 类字典 > 元类非数据描述符。
__get__抛AttributeError会触发__getattr__兜底,可用于惰性属性。- 实测:定义
__getattr__几乎不增加已有属性的读取成本,而重写__getattribute__会把每次访问拖慢约 8 倍。
下一节 typing 的静态与运行时边界
会换一条线索:把注解里的类型写成运行时对象时,list[int]、int | str 这些「类型」在解释器里到底是什么。
阅读导航:上一节:命名空间包、zip 导入与冻结模块 · 下一节:typing 的静态与运行时边界 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。