《Python高级编程》8.1 描述符协议与属性查找

本书 1.2 节给过查找顺序的可观测结论,本节下沉到 C 实现层:object 与 type 两个 __getattribute__、元类上的描述符优先级、__get__/__set__/__delete__ 三件套,并用实测数据说明 __getattr__ 与 __getattribute__ 各把属性访问拖慢多少。

本节目标:把「属性查找」从一条规则表还原成两个 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 的静态与运行时边界 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时