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

本节用实测把 obj.attr 的查找顺序拆开:沿 type.__mro__ 找数据描述符、实例 __dict__、非数据描述符的优先级,逐一验证 property/staticmethod/cached_property 各自是哪一类描述符,并用 dis 的 LOAD_ATTR 与 member_descriptor 说明 __slots__ 的底层实现。

本节目标:把 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 按固定优先级依次尝试,命中即返回:

  1. 沿 type(obj).__mro__ 查找类字典。找到的值 v,若 v 定义了 __get__ 且定义了 __set__(或 __delete__),它是数据描述符,直接调用 v.__get__(obj, type(obj)) 并返回——优先级最高。
  2. 查实例 obj.__dict__。若命中,返回值。
  3. 回到步骤 1 找到的值 v。若 v 是非数据描述符(只有 __get__,没有 __set__),调用 v.__get__(obj, type(obj)) 返回。
  4. 都失败,调用 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 魔术方法驱动的协议设计 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「python」更多文章

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