CPython属性查找机制曝光 数据描述器优先级凌驾实例字典

【python】你知道描述器是多么重要的东西嘛?你写的所有程序都用到了! — 码农高天

码农高天

Python所有点号属性访问均由LOAD_ATTR触发,只有同时定义__get__与__set__的数据描述器能绕过实例字典优先执行,构成了方法绑定与property等特性的底层支柱。

AI-assisted summary of 【python】你知道描述器是多么重要的东西嘛?你写的所有程序都用到了! — original source. Check the original for full context.

Source published:

Key ideas

  • 描述器是 Python 底层最核心的机制之一,日常使用的对象属性访问、实例方法绑定、@property、@staticmethod 以及 @classmethod 均基于描述器构建。 (0:00)
  • 描述器必须定义在类(Type)上作为类属性才能生效;若直接挂载到实例的 __init__ 中,则仅会被当作普通实例对象,无法触发描述器逻辑。 (3:40)
  • Python 属性查找的执行优先级由 CPython 源码决定:数据描述器(同时具备 __get__ 和 __set__)> 实例字典 __dict__ > 非数据描述器(仅具备 __get__)> 普通类属性。 (6:50)
  • 为仅有 __get__ 的描述器类动态添加 __set__ 方法,会直接将其升级为数据描述器,从而立即绕过实例字典并逆转属性查找结果。 (2:00)

Detailed notes

## Background 该视频由 YouTube 频道“码农高天”于 2024 年 1 月 25 日发布。视频简介指出,描述器(descriptor)虽然平时开发者很少主动编写,但它是 Python 极其重要的底层机制,用户在访问任何对象的属性时,底层都有可能调用了描述器。

## TL;DR 视频通过几个反直觉的代码变体实验,层层拆解 Python 描述器的工作机制与属性查找流程。码农高天从字节码 LOAD_ATTR 切入,深入剖析 CPython 源码中 _PyObject_GenericGetAttrWithDict 的底层实现,揭示描述器必须绑定在类(Type)上才能生效的硬性约束。视频进一步梳理了数据描述器、实例字典、非数据描述器以及普通类属性之间的严格查找优先级,完美解释了动态添加 __set__ 为何能颠覆实例属性的谜题。最后总结指出,包括成员方法绑定、@staticmethod、@classmethod 与 @property 在内的核心语言特性,底层完全建立在描述器机制之上。

## Atmosphere & style 视频整体呈现出硬核、严谨而又抽丝剥茧的技术解谜氛围。高天开篇以几个违背常理的代码现象抛出悬念,随后直切 CPython 的 C 语言底层实现,让观众产生一种“知其然更知其所以然”的豁然开朗感。讲解节奏紧凑、逻辑严密,既有直观的代码运行对照,又有源码级别的精准点拨,带给人扎实沉浸的极客探秘体验。

## Bulletin - [00:00](https://www.youtube.com/watch?v=Fp7aQO3QuS0&t=0s) 描述器是 Python 底层最核心的机制之一,日常使用的对象属性访问、实例方法绑定、@property、@staticmethod 以及 @classmethod 均基于描述器构建。 - [03:40](https://www.youtube.com/watch?v=Fp7aQO3QuS0&t=220s) 描述器必须定义在类(Type)上作为类属性才能生效;若直接挂载到实例的 __init__ 中,则仅会被当作普通实例对象,无法触发描述器逻辑。 - [06:50](https://www.youtube.com/watch?v=Fp7aQO3QuS0&t=410s) Python 属性查找的执行优先级由 CPython 源码决定:数据描述器(同时具备 __get__ 和 __set__)> 实例字典 __dict__ > 非数据描述器(仅具备 __get__)> 普通类属性。 - [02:00](https://www.youtube.com/watch?v=Fp7aQO3QuS0&t=120s) 为仅有 __get__ 的描述器类动态添加 __set__ 方法,会直接将其升级为数据描述器,从而立即绕过实例字典并逆转属性查找结果。

## Key takeaways - [00:00](https://www.youtube.com/watch?v=Fp7aQO3QuS0&t=0s) 只要类中实现了 __get__、__set__ 或 __delete__ 中的任意一个魔术方法,该类就构成了描述器。 - [00:35](https://www.youtube.com/watch?v=Fp7aQO3QuS0&t=35s) 将描述器挂载为类属性后,无论是通过实例访问(o.name)还是通过类访问(A.name),都会触发其 __get__ 方法。 - [01:10](https://www.youtube.com/watch?v=Fp7aQO3QuS0&t=70s) 在 __init__ 中执行 self.name = Name() 会使描述器机制失效,因为描述器必须存在于类型的 MRO 中,无法在实例层级生效。 - [01:35](https://www.youtube.com/watch?v=Fp7aQO3QuS0&t=95s) 如果描述器仅定义了 __get__,对实例进行同名属性赋值(o.name = 'Bob')会将其写入实例字典,从而遮蔽描述器。 - [02:00](https://www.youtube.com/watch?v=Fp7aQO3QuS0&t=120s) 动态向描述器类注入 __set__ 方法会使其升级为数据描述器,即使实例字典中已有同名属性,查找也会重新优先调用 __get__。 - [03:00](https://www.youtube.com/watch?v=Fp7aQO3QuS0&t=180s) 所有形如 a.b 的属性读取操作在字节码层面都会生成 LOAD_ATTR 指令,其核心逻辑由 CPython 的 _PyObject_GenericGetAttrWithDict 函数实现。 - [04:00](https://www.youtube.com/watch?v=Fp7aQO3QuS0&t=240s) CPython 查找属性时首先调用 Py_TYPE(obj) 获取类型指针,并在类型的 MRO 字典中寻找匹配的属性对象(descr)。 - [05:00](https://www.youtube.com/watch?v=Fp7aQO3QuS0&t=300s) 通过 PyDescr_IsData(descr) 检查描述器是否同时包含 tp_descr_set,是 CPython 区分数据描述器与非数据描述器的底层判定依据。 - [05:30](https://www.youtube.com/watch?v=Fp7aQO3QuS0&t=330s) 数据描述器享有最高查找优先级,解释器会无视实例字典的存在,直接执行描述器的 __get__ 并返回结果。 - [06:50](https://www.youtube.com/watch?v=Fp7aQO3QuS0&t=410s) 非数据描述器的查找优先级低于实例自身的 __dict__,只有在实例字典中未查到目标属性时,解释器才会回退调用非数据描述器的 __get__。 - [08:10](https://www.youtube.com/watch?v=Fp7aQO3QuS0&t=490s) 当目标对象既不在实例字典中、也不是描述器时,解释器会将其作为普通类属性直接返回,这也是 Python 类变量继承的基础机制。 - [08:50](https://www.youtube.com/watch?v=Fp7aQO3QuS0&t=530s) 普通方法绑定(Bound Method)、@property、@staticmethod 和 @classmethod 的底层实现均完全依赖描述器机制。

## Detailed walkthrough ### [00:00](https://www.youtube.com/watch?v=Fp7aQO3QuS0&t=0s) 什么是描述器与几个违背直觉的诡异现象

在 Python 编程中,描述器(Descriptor)是一个绝大多数开发者平时很少主动编写、但在底层却无处不在的核心机制。在 CPython 的实现中,大量语言特性都是基于描述器构建的。从概念上讲,只要一个类中定义了 `__get__`、`__set__` 或 `__delete__` 这三个魔术方法中的任意一个,这个类就变成了一个描述器。码农高天首先用一个极简的代码示例展示了描述器的基本用法:定义一个包含 `__get__` 方法的类 `Name`,其 `__get__` 接收 `self`、`obj` 和 `objtype` 三个参数,并直接返回字符串 `'Peter'`;随后在类 `A` 中将其作为类属性挂载(`name = Name()`)。当实例化 `o = A()` 并访问 `o.name` 时,控制台打印出的并不是 `Name` 实例本身,而是 `'Peter'`。甚至当直接访问类属性 `A.name` 时,返回的依然是 `'Peter'`。这表明访问属性的操作实际上触发了 `Name.__get__`。

```python class Name: def __get__(self, obj, objtype): return 'Peter'

class A: name = Name()

o = A() print(o.name) # 输出: Peter print(A.name) # 输出: Peter ```

然而,描述器的行为如果仅止于此,还不足以体现其复杂的内部运作。码农高天随后展示了三个循序渐进且极其反直觉的变体实验: 1. **写进 `__init__` 会失效**:如果把 `name = Name()` 移到 `class A` 的 `__init__` 函数中,写成 `self.name = Name()`,实例化后再打印 `o.name`,输出的结果不再是 `'Peter'`,而是原始的 `<Name object>` 对象。 2. **实例赋值覆盖描述器**:在最初类变量生效的结构下,如果执行了一句实例赋值 `o.name = 'Bob'`,再次打印 `o.name`,输出变成了新赋值的 `'Bob'`,描述器似乎被覆盖失效了。 3. **“放一个大招”——动态改动类定义颠覆结果**:在已经完成 `o.name = 'Bob'` 赋值的前提下,如果通过 `Name.__set__ = lambda self, obj, val: None` 给 `Name` 这个类动态挂载一个毫无意义的 `__set__` 方法,再次打印 `o.name` 时,输出竟然奇迹般地变回了 `'Peter'`。

面对这一连串令人困惑的现象,码农高天直言:“其实所有的故事都要从一个 bytecode,也就是 `LOAD_ATTR` 说起。”只要在 Python 代码中写出形如 `a.b` 的表达式,取值时就会触发 `LOAD_ATTR` 字节码指令,而赋值时则会调用 `STORE_ATTR`。想要彻底弄明白描述器的底层魔力,答案全部藏在 CPython 解释器处理 `LOAD_ATTR` 的 C 语言源码实现之中。

---

[03:40](https://www.youtube.com/watch?v=Fp7aQO3QuS0&t=220s) 从 LOAD_ATTR 字节码深入 CPython 源码

在 CPython 解释器中,执行 `LOAD_ATTR` 字节码的核心是调用 `PyObject_GetAttr`。该函数通过对象的类型结构体查找 `tp_getattro` 指针;在未被开发者自定义重载的情况下,默认调用的基类函数是 `PyObject_GenericGetAttr`,而它最终直接转发给 `_PyObject_GenericGetAttrWithDict`。这个函数正是属性查找所有逻辑与“魔法”集中发生的地方。调用该函数时,目标实例作为 `obj` 传入,要访问的属性名则以字符串对象保存在 `name` 中(例如 `o.name` 中 `obj` 即对象 `o`,`name` 即 `'name'`)。

```c // CPython 属性查找核心机制精简逻辑示意 tp = Py_TYPE(obj); descr = _PyType_Lookup(tp, name); f = descr->ob_type->tp_descr_get; if (f!= NULL && PyDescr_IsData(descr)) { return f(descr, obj, (PyObject *)tp); // 数据描述器优先执行 } ```

深入该源码的行数可以发现两个决定性的查找机制: - **必须挂载在类型(Type)上**:在第 1175 行,函数首先获取对象 `obj` 对应的类型指针 `tp = Py_TYPE(obj)`(在示例中即 `class A`);在第 1195 行,它会直接去该类型的 MRO 字典中查找名为 `name` 的属性,得到一个可能为描述器的对象指针 `descr`。码农高天特别强调了这一关键约束:“要想让这个 descriptor 工作,它必须得是 type 的一个 attribute 或者说一个 member,你在 type 产生的 object 上做手脚是没有用的。”这完美解释了为什么在 `__init__` 中执行 `self.name = Name()` 无法触发描述器,因为此时 `Name` 只是实例属性,类的类型结构体中根本没有该属性。 - **数据描述器(Data Descriptor)的高优先级检查**:在第 1200 行,代码检查获取到的 `descr` 是否具备 `tp_descr_get` 函数(即 Python 层的 `__get__`)。更关键的是紧随其后的条件判断——它还会调用 `PyDescr_IsData(descr)` 检查其是否属于“数据描述器”。判断依据就是看该描述器是否同时定义了 `tp_descr_set`(即 `__set__`)。只有当一个描述器**既有 `__get__` 又有 `__set__`** 时,解释器才会无视实例本身的字典,立即执行 `descr` 的 `tp_descr_get` 函数并将结果返回。

---

[06:50](https://www.youtube.com/watch?v=Fp7aQO3QuS0&t=410s) 属性查找优先级与谜题的最终揭晓

如果描述器不是数据描述器,CPython 便会继续向下执行后续的分支流程。函数会去获取当前实例对象本身的 `__dict__`(实例字典),普通情况下通过 `self.xxx =...` 绑定的所有成员变量都保存在这个字典中。解释器会首先在实例字典中查找是否存在同名属性;如果找到了,便直接返回实例字典中的值。只有当实例字典中没有该属性时,代码才会退回到先前的描述器检查:如果该描述器定义了 `__get__` 函数(即仅有 `__get__` 而没有 `__set__` 的非数据描述器,Non-Data Descriptor),便在此处调用 `f(descr, obj, (PyObject *)tp)` 并返回结果。

```mermaid flowchart TD accTitle: Python Attribute Lookup Priority accDescr: CPython generic attribute lookup flowchart showing data descriptor, instance dict, non-data descriptor, and class attribute fallback.

Start["访问 obj.name (LOAD_ATTR)"] --> FindType["在类 obj.type (MRO) 中查找 name -> descr"] FindType --> CheckGet{"descr 是否有 __get__?"}

subgraph DataDescrBranch ["数据描述器判定"] CheckGet -- 是 --> CheckSet{"descr 是否同时有 __set__?<br>(PyDescr_IsData)"} CheckSet -- 是 --> CallDataGet["直接调用 descr.__get__() 返回<br>(最高优先级)"] end

subgraph DictBranch ["实例字典判定"] CheckSet -- 否 --> SearchDict{"obj.__dict__ 中是否存在 name?"} CheckGet -- 否 --> SearchDict SearchDict -- 是 --> ReturnDict["返回 obj.__dict__['name']"] end

subgraph FallbackBranch ["非数据描述器与类属性回退"] SearchDict -- 否 --> CheckNonData{"descr 是否有 __get__?"} CheckNonData -- 是 --> CallNonDataGet["调用 descr.__get__() 返回"] CheckNonData -- 否 --> CheckDescrNull{"descr 是否非空?"} CheckDescrNull -- 是 --> ReturnClassAttr["直接返回类属性 descr<br>(普通值如 int, str)"] CheckDescrNull -- 否 --> RaiseAttr["抛出 AttributeError"] end ```

通过这一套完整的优先级顺序,前面的代码谜题全部迎刃而解: - **`o.name = 'Bob'` 为何生效**:最开始 `Name` 只定义了 `__get__`,属于非数据描述器。在执行赋值时,`'Bob'` 被写入到了 `o.__dict__['name']` 中。读取时,实例字典优先级高于非数据描述器,因此直接返回了 `'Bob'`。 - **动态增加 `__set__` 为何反转**:在给 `Name` 动态添加了 `__set__` 之后,`Name` 瞬间晋级为数据描述器。在 `LOAD_ATTR` 执行时,数据描述器的优先级凌驾于实例字典之上,解释器根本不会去查 `o.__dict__`,直接调用了 `Name.__get__`,因此返回值重新变回了 `'Peter'`。 - **常规类属性的回退机制**:在源码最后,如果 `descr` 不是 `NULL` 且没有 `__get__`,解释器会将其原样返回。这正是 Python 最基础的属性继承机制——当 `class A` 拥有类属性 `name = 'Alice'` 时,实例 `o` 的字典为空,解释器查不到实例属性也没有描述器拦截,最终便回退返回类属性 `'Alice'`。

码农高天在结尾总结道,描述器绝非冷门的语法糖,而是支撑整门语言运转的基石:“尽管你可能很少自己去写一个 descriptor,但是你的代码是无时无刻不在使用这个机制的,你每一次使用什么点什么的时候都会触发这个机制。”在 Python 体系中,包括普通成员方法绑定(Bound Method)、静态方法(`@staticmethod`)、类方法(`@classmethod`)以及属性装饰器(`@property`),其底层无一例外都是通过描述器来实现的。

## Notable quotes > [06:50](https://www.youtube.com/watch?v=Fp7aQO3QuS0&t=410s) 尽管你可能很少自己去写一个 descriptor,但是你的代码是无时无刻不在使用这个机制的,你每一次使用什么点什么的时候都会触发这个机制。 — 码农高天

## Mentioned 码农高天, Python, CPython, LOAD_ATTR, STORE_ATTR, PyObject_GetAttr, PyObject_GenericGetAttr, _PyObject_GenericGetAttrWithDict, __get__, __set__, __delete__

## Video description 这期视频聊一下描述器,也就是descriptor。尽管我们平时直接用到它的地方不多,但是它是python非常重要的底层机制,在我们使用任何object的attribute的时候,都有可能实际上调用的是描述器。

[Source](https://www.youtube.com/watch?v=Fp7aQO3QuS0)

中文简报

CPython属性查找机制解析:数据描述器优先级高于实例字典 Python所有点号属性访问均由LOAD_ATTR触发,只有同时定义__get__与__set__的数据描述器能绕过实例字典优先执行,构成了方法绑定与property等特性的底层支柱。 视频通过几个反直觉的代码变体实验,层层拆解 Python 描述器的工作机制与属性查找流程。码农高天从字节码 LOAD_ATTR 切入,深入剖析 CPython 源码中 _PyObject_GenericGetAttrWithDict 的底层实现,揭示描述器必须绑定在类(Type)上才能生效的硬性约束。视频进一步梳理了数据描述器、实例字典、非数据描述器以及普通类属性之间的严格查找优先级,解释了动态添加 __set__ 为何能颠覆实例属性的谜题。最后总结指出,包括成员方法绑定、@staticmethod、@classmethod 与 @property 在内的核心语言特性,底层完全建立在描述器机制之上。 描述器是 Python 底层最核心的机制之一,日常使用的对象属性访问、实例方法绑定、@property、@staticmethod 以及 @classmethod 均基于描述器构建。 (0:00) 描述器必须定义在类(Type)上作为类属性才能生效;若直接挂载到实例的 __init__ 中,则仅会被当作普通实例对象,无法触发描述器逻辑。 (3:40) Python 属性查找的执行优先级由 CPython 源码决定:数据描述器(同时具备 __get__ 和 __set__)> 实例字典 __dict__ > 非数据描述器(仅具备 __get__)> 普通类属性。 (6:50) 为仅有 __get__ 的描述器类动态添加 __set__ 方法,会直接将其升级为数据描述器,从而立即绕过实例字典并逆转属性查找结果。 (2:00)

Reading summary

码农高天指出,描述器是支撑 Python 底层属性访问的核心机制;开发者通过点号访问属性时触发的 `LOAD_ATTR` 字节码,其在 CPython 中的多层优先级裁决决定了描述器与实例字典的表现行为。 反直觉现象与描述器的基本定义 (0:00) 在 Python 中,只要类中实现了 `__get__`、`__set__` 或 `__delete__` 任意一个方法,该类就成为描述器。当把只实现 `__get__` 的描述器实例挂载为类属性时,通过实例或类访问该属性均会触发其 `__get__`。然而这种行为存在多个看似反直觉的表现:若将描述器挂载在 `__init__` 中作为实例属性(`self.name = Name()`),描述器会失效并直接暴露原始对象;若对实例执行属性赋值(`o.name = 'Bob'`),访问该属性会优先返回新值;但若在类上动态添加一个无实质操作的 `__set__` 方法,属性返回值又会重新变回描述器 `__get__` 的结果。 LOAD_ATTR 与 CPython 源码的底层约束 (3:40) 这一系列行为源于点号访问触发的 `LOAD_ATTR` 字节码。CPython 解释器通过 `PyObject_GetAttr` 及其底层的 `_PyObject_GenericGetAttrWithDict` 处理属性查找。源码显示,解释器首先通过 `Py_TYPE(obj)` 获取对象所属的类,并在其 MRO 字典中查找属性名称对应的指针 `descr`。因此,描述器必须定义为类属性才能被检索到,挂载在实例本身的对象上则不会触发该检索链条,这解释了为何在 `__init__` 中定义描述器会直接失效。 数据与非数据描述器的查找优先级 (6:50) CPython 对属性解析设定了严格的优先级顺序。在获取到类的 `descr` 后,解释器会通过 `PyDescr_IsData` 检查其是否同时具备 `tp_descr_set`(即 `__set__`)。同时具备 `__get__` 与 `__set__` 的被称为“数据描述器”,其执行优先级最高,会直接绕过实例字典返回值。若仅具备 `__get__`,则属于“非数据描述器”,解释器会优先读取实例自身的 `__dict__`。这也解释了为何最初实例赋值能覆盖非数据描述器,而一旦动态补全 `__set__` 晋级为数据描述器后,解释器便不再查阅实例字典,再次强制执行描述器逻辑。 码农高天总结认为,尽管普通开发者鲜少手动编写描述器,但 Python 的常规实例方法绑定、`@staticmethod`、`@classmethod` 以及 `@property` 等语言特性底层全部依赖描述器与此套查找优先级运行。

精炼阅读摘要

码农高天指出,描述器是支撑 Python 底层属性访问的核心机制,开发者日常使用的点号查找、方法绑定和各类装饰器均依赖于此。通过剖析 CPython 中 LOAD_ATTR 的实现,可以厘清数据描述器、实例字典与非数据描述器之间的严格查找优先级。 描述器定义与反直觉现象 (0:00) 在 Python 中,只要一个类实现了 __get__、__set__ 或 __delete__ 魔术方法中的任意一个,该类即可作为描述器。当描述器作为类属性挂载时,无论是通过实例访问还是通过类本身访问该属性,都会拦截访问并触发其 __get__ 方法返回相应值。然而,若将描述器实例赋值给构造函数中的 self.name,该机制便会失效,实例仅会返回原始描述器对象;若对拥有描述器属性的实例执行赋值操作(如 o.name = 'Bob'),原本生效的 __get__ 行为看似会被覆盖;更反直觉的是,在实例已赋值的情况下,如果动态向描述器类注入一个空的 __set__ 方法,实例属性查找又会立即重新由描述器的 __get__ 接管并输出初始设定值。 LOAD_ATTR 字节码与 CPython 源码 (3:40) 所有的点号访问表达式(如 a.b)在 Python 底层均会编译为 LOAD_ATTR 字节码,并触发 PyObject_GetAttr 调用。在未重载属性获取逻辑的情况下,解释器会调用 PyObject_GenericGetAttr 并最终进入 _PyObject_GenericGetAttrWithDict 函数处理核心查找逻辑。该函数接收访问的目标实例与属性名字符串,首先通过 Py_TYPE 获取目标对象的类型指针,并在该类型的 MRO 字典中执行 _PyType_Lookup 查找对应属性。这确立了描述器生效的前提条件:描述器必须挂载在类型(Type)上作为类成员,直接挂在实例对象上的描述器无法被该逻辑识别。 属性查找优先级与谜题解析 (6:50) CPython 依据描述器类型和实例字典定义了清晰的查找梯队。如果类中查找到的对象定义了 __get__ 且通过 PyDescr_IsData 验证同时定义了 __set__,该对象即被判定为数据描述器,解释器将无视实例自身的 __dict__ 直接执行其 tp_descr_get 并返回结果;若不满足该条件,解释器接下来会转向实例的 __dict__ 查找并优先返回实例属性;如果实例字典中不存在该属性,解释器才会回退去调用仅包含 __get__ 的非数据描述器;若非描述器,则直接返回类属性或抛出 AttributeError。这完整解释了先前的异常行为:仅有 __get__ 的描述器会被实例字典覆盖,而一旦动态添加 __set__ 成为数据描述器后,其优先级便凌驾于实例字典之上。 描述器不仅是一种元编程工具,更是整个 Python 语言对象模型的基础设施。包括普通实例方法的隐式参数绑定、@staticmethod、@classmethod 以及 @property 装饰器,其底层运行逻辑完全建立在描述器协议之上。

Detailed notes — zh

## Background 该视频由技术博主“码农高天”于 2024 年 1 月 25 日发布在 YouTube 平台上。视频简介指出,本期内容深入探讨 Python 的核心底层机制——描述器(Descriptor),解释了即便日常业务开发中很少主动编写,但在访问任何对象的属性(attribute)时背后其实都在调用描述器。 ## TL;DR 视频首先通过三段层层递进、颠覆直觉的代码现象,展示了实例赋值失效、属性被覆盖以及动态注入魔术方法后结果离奇反转的谜题。为了揭开谜底,主讲人深入 CPython 底层源码,剖析了处理 LOAD_ATTR 字节码的核心函数 _PyObject_GenericGetAttrWithDict 的完整执行路径。源码清晰揭示了数据描述器(同时实现 __get__ 和 __set__)、实例字典(__dict__)、非数据描述器以及普通类属性之间的严格查找优先级。最后总结指出,包括普通类属性回退、实例方法绑定、@property 及 @staticmethod 在内的诸多核心语言特性,底层完全建立在描述器机制之上。 ## Atmosphere & style 视频风格硬核严谨却不失趣味,呈现出一种步步揭秘的侦探式硬核技术拆解感。讲者开篇通过精心设计的“反常识”代码给观众制造认知冲突与悬念,随后熟练切换至 C 语言源码逐行推导,将晦涩的 VM 内部机制拆解得条理清晰、抽丝剥茧。整体节奏紧凑自然,语气笃定自信,给观众带来一种“看清底层真相后恍然大悟”的通透与智力快感。 ## Bulletin - [05:00](https://www.youtube.com/watch?v=Fp7aQO3QuS0&t=300s) 描述器必须定义在类层级(Class Level)而非实例的 __init__ 中才能生效,因为 CPython 查找属性时首先从宿主对象的类及其 MRO 中解析描述器。 - [05:00](https://www.youtube.com/watch?v=Fp7aQO3QuS0&t=300s) CPython 对数据描述器(Data Descriptor)的判定极为严格,必须同时实现 __get__ 和 __set__;一旦满足,其属性访问优先级将彻底压倒实例自身的 __dict__ 字典。 - [00:00](https://www.youtube.com/watch?v=Fp7aQO3QuS0&t=0s) 仅定义 __get__ 的非数据描述器优先级低于实例的 __dict__,一旦实例属性被同名覆盖,该描述器的 __get__ 便不会被默认触发。 - [10:00](https://www.youtube.com/watch?v=Fp7aQO3QuS0&t=600s) Python 的常规类属性回退、实例方法绑定以及 @property、@staticmethod、@classmethod 等核心特性,在底层全都是基于描述器查找机制实现的。 ## Key takeaways - [00:00](https://www.youtube.com/watch?v=Fp7aQO3QuS0&t=0s) 在 Python 语法层面,任何实现了 __get__、__set__ 或 __delete__ 协议方法的类都被视为描述器。 - [00:00](https://www.youtube.com/watch?v=Fp7aQO3QuS0&t=0s) 若在宿主类的 __init__ 中将描述器绑定为实例属性(如 self.name = Name()),该描述器将完全失效,直接返回对象本身。 - [00:00](https://www.youtube.com/watch?v=Fp7aQO3QuS0&t=0s) 对非数据描述器绑定的属性进行实例赋值时,新值会写入实例字典并在后续访问中覆盖原有的描述器行为。 - [00:00](https://www.youtube.com/watch?v=Fp7aQO3QuS0&t=0s) 即使在实例属性已被赋值覆盖后,一旦动态给描述器类注入 __set__ 方法,其行为会立刻逆转回调用 __get__。 - [05:00](https://www.youtube.com/watch?v=Fp7aQO3QuS0&t=300s) Python 属性取值 a.b 在底层对应字节码 LOAD_ATTR,其 CPython 底层核心实现入口为 _PyObject_GenericGetAttrWithDict 函数。 - [05:00](https://www.youtube.com/watch?v=Fp7aQO3QuS0&t=300s) LOAD_ATTR 首先扫描宿主对象类及其 MRO,若在类层级找不到相关定义,描述器协议根本不会进入判定流程。 - [05:00](https://www.youtube.com/watch?v=Fp7aQO3QuS0&t=300s) CPython 通过判断是否存在 tp_descr_set 将描述器区分为数据描述器与非数据描述器,前者拥有超越实例字典的最高调用优先级。 - [05:00](https://www.youtube.com/watch?v=Fp7aQO3QuS0&t=300s) 非数据描述器的 __get__ 仅在实例字典查找未命中的情况下作为备用兜底逻辑执行。 - [10:00](https://www.youtube.com/watch?v=Fp7aQO3QuS0&t=600s) CPython 源码中的 descr 变量可承载普通非描述器对象,若实例字典未命中且未定义 __get__,则直接返回类属性本身。 - [10:00](https://www.youtube.com/watch?v=Fp7aQO3QuS0&t=600s) 面向对象中经典的‘实例找不到属性则自动查找类属性’的回退链条,本质上是由描述器属性查找机制统一驱动的。 - [10:00](https://www.youtube.com/watch?v=Fp7aQO3QuS0&t=600s) 日常开发中常见的普通方法调用、@property、@classmethod 和 @staticmethod 底层全由描述器支撑,是 Python 最核心的机制之一。 ## Detailed walkthrough ### [00:00](https://www.youtube.com/watch?v=Fp7aQO3QuS0&t=0s) 描述器的定义与颠覆直觉的代码现象 在 Python 编程中,描述器(Descriptor)是一个绝大多数开发者平时很少主动编写,但在 CPython 底层极其关键的核心机制。在 Python 语法层级,一个类只要定义了 `__get__`、`__set__` 或 `__delete__` 魔术方法中的任意一个,这个类就变成了描述器。 ```mermaid flowchart TD accTitle: Python 描述器分类 accDescr: 展示数据描述器与非数据描述器的判定条件及优先级差异 subgraph Types["描述器分类"] A["定义类"] --> B{"实现了哪些方法?"} B -->|"仅 __get__"| C["非数据描述器 (Non-data Descriptor)"] B -->|"同时实现 __get__ 和 __set__"| D["数据描述器 (Data Descriptor)"] end subgraph Priority["属性访问优先级"] D --> P1["优先级高于实例字典 (instance.__dict__)"] C --> P2["优先级低于实例字典 (instance.__dict__)"] end ``` 码农高天首先用一段极简代码演示了描述器的基本用法: 定义一个 `Name` 类作为描述器,在其中实现 `__get__(self, obj, objtype)` 方法,固定返回字符串 `"Peter"`。接着定义类 `A`,将类变量 `name` 赋值为一个 `Name` 实例对象。当我们实例化 `o = A()` 并打印 `o.name` 时,控制台打印出 `"Peter"`,说明访问 `o.name` 时实际上调用了 `Name` 类的 `__get__` 方法;哪怕直接打印 `A.name`,返回的结果依然是 `"Peter"`。 随后,码农高天展示了三个循序渐进、令人困惑的反直觉现象: 1. **修改为实例属性赋值**:如果在类 `A` 的 `__init__` 构造函数中使用 `self.name = Name()` 赋值,实例化后打印 `o.name`,输出的不再是 `"Peter"`,而变成了原原本本的 `Name` 对象本身,描述器完全失效了。 2. **实例属性覆盖**:恢复最初的类变量定义,在实例化后执行 `o.name = "Bob"`。再次打印 `o.name` 时,输出变成了新赋值的 `"Bob"`。 3. **动态注入 `__set__` 后的神奇反转**:在 `o.name = "Bob"` 赋值已然完成之后,动态给 `Name` 类补上一个无实质意义的空函数作为 `__set__` 方法(例如 `Name.__set__ = lambda self, obj, val: None`)。此时再次打印 `o.name`,输出竟然神不知鬼不觉地变回了 `"Peter"`。 面对这一连串匪夷所思的输出变化,码农高天向观众抛出提问:“做一个小调查,上面这些结果的原理你都知道怎么回事吗?” - 描述器协议方法包含:`__get__(self, obj, objtype)`、`__set__(self, obj, val)` 和 `__delete__(self, obj)`。 - `__get__` 方法的三大形参对应:描述器实例本身(`self`)、访问该属性的宿主对象实例(`obj`)以及宿主对象的类(`objtype`)。 --- ### [05:00](https://www.youtube.com/watch?v=Fp7aQO3QuS0&t=300s) 从字节码 LOAD_ATTR 到 CPython 底层源码剖析 要彻底解释这些行为,秘密全部藏在 Python 虚拟机处理属性访问的核心字节码中。当我们在 Python 中写下 `a.b` 取值时,底层调用的是 `LOAD_ATTR`;当写下 `a.b = val` 存值时,底层调用的则是 `STORE_ATTR`。在 CPython 源码中,`LOAD_ATTR` 与 `STORE_ATTR` 的工作流相似,其底层入口是 `PyObject_GetAttr`。若对象没有重载该逻辑,默认会进入 `tp_getattro` 指针绑定的通用函数 `PyObject_GenericGetAttr`,而该函数内部直接调用了核心魔法发生地:`_PyObject_GenericGetAttrWithDict`。 ```mermaid flowchart TD accTitle: Python 属性查找优先级 (LOAD_ATTR) accDescr: 展示 CPython 底层查找属性时的完整判定流程与优先级分支 Start["访问 o.name (LOAD_ATTR)"] --> FindClass["从 o 的类 (tp) 及其 MRO 中查找属性 'name'"] FindClass --> CheckDescr{"在类中是否找到对象 descr?"} CheckDescr -- 找到 --> CheckGet{"descr 是否有 tp_descr_get (__get__)?"} CheckDescr -- 未找到 --> SearchDict CheckGet -- 有 --> CheckData{"descr 是否有 tp_descr_set (__set__)?<br/>(是否为数据描述器)"} CheckGet -- 无 --> SearchDict CheckData -- 是: 数据描述器 --> ExecDataGet["优先执行 descr 的 __get__ 并返回"] CheckData -- 否: 非数据描述器 --> SearchDict["从实例本身的字典 (o.__dict__) 中查找 'name'"] SearchDict --> FoundInDict{"实例字典中是否存在?"} FoundInDict -- 存在 --> ReturnDictVal["返回实例字典中的值"] FoundInDict -- 不存在 --> CheckFallback{"是否存在先前找出的非数据描述器?"} CheckFallback -- 有 __get__ --> ExecNonDataGet["调用非数据描述器的 __get__ 并返回"] CheckFallback -- 无 __get__ 但 descr 存在 --> ReturnDescr["直接返回类属性 descr (常规类属性兜底)"] CheckFallback -- 均无 --> RaiseAttrError["抛出 AttributeError 异常"] ``` 在 `_PyObject_GenericGetAttrWithDict` 源码中,属性查找遵循着严谨的判定分支: 1. **类及其 MRO 优先扫描**: 在源码约 1175 行,CPython 首先拿到对象的类指针 `tp = Py_TYPE(obj)`,并在约 1195 行通过类的 MRO(方法解析顺序)查找名为 `name` 的属性对象 `descr`。码农高天强调,这是第一个关键点:“你这个 descriptor 必须要定义到 type 上,也就是必须要写到 class 的 definition 里面才是有效的。”这直接解释了为什么在 `__init__` 中将 `self.name` 赋值为描述器实例会失效——因为属性挂在对象实例上,C 源码在宿主类级别根本抓不到这个描述器。 2. **数据描述器判定(Data Descriptor)**: 源码约 1200 行检查找到的 `descr` 是否具备 `tp_descr_get` 函数指针。如果存在,继续通过宏判断它是否为 `is_data`。CPython 对数据描述器的判定标准极为严苛:**只有同时定义了 `__get__` 和 `__set__`,才被认定为数据描述器**。一旦判定为数据描述器,CPython 不管对象实例自己存了什么,都会以最高优先级立刻执行 `tp_descr_get` 并返回结果。 3. **实例字典查找(`__dict__`)**: 若该属性不是数据描述器,查找流程进入对象自身的字典(`__dict__`)。日常开发中通过 `self.xxx =...` 动态绑定的变量全部存放在这里。如果在实例字典中找到了同名 key,则直接返回实例的值。 4. **非数据描述器兜底(Non-data Descriptor)**: 如果实例字典里没有找到,但此前在类级别找到的 `descr` 拥有 `tp_descr_get`(即只有 `__get__` 没有 `__set__` 的非数据描述器),此时才会调用并执行它的 `__get__`。 基于这套完整的源码优先级,前面的所有诡异现象得到了清晰的解释: - 当只有 `__get__` 时,`Name` 是非数据描述器。执行 `o.name = "Bob"` 时,`"Bob"` 被写入 `o.__dict__`。在随后的 `LOAD_ATTR` 中,实例字典的优先级高于非数据描述器,因此输出了 `"Bob"`。 - 一旦动态给 `Name` 注入了 `__set__` 方法,它立刻被 CPython 识别为数据描述器,其取值优先级瞬间超越了 `o.__dict__`。因此 CPython 绕过已有的 `"Bob"`,再次调用 `__get__` 返回 `"Peter"`。 --- ### [10:00](https://www.youtube.com/watch?v=Fp7aQO3QuS0&t=600s) 常规类属性的回退机制与描述器的无处不在 在 `LOAD_ATTR` 源码逻辑的末尾,还存在一个返回分支:如果 `descr` 既不是数据描述器,在对象 `__dict__` 里面也没有找到,且它自身也没有实现 `__get__` 函数,那么源码会直接返回 `descr` 这个对象本身。 码农高天指出,源码中变量命名为 `descr` 略带误导性,因为从类的 MRO 里查出来的对象并不一定真的是描述器,它可以是任何普通对象,比如一个普通的字符串(String)或整数(Integer)。这一段兜底代码实际上在日常开发中触发得极其频繁。例如: 类 `A` 定义了一个普通类属性 `name = "Alice"`,当实例化 `o = A()` 时,`o.__dict__` 是完全为空的字典。当执行 `o.name` 取值时,CPython 首先在类 `A` 中找到了字符串 `"Alice"`;接着检查发现该字符串既没有 `__get__` 也没有 `__set__`,而在 `o.__dict__` 里也查无此人;最终走入兜底分支,原样将类上的 `"Alice"` 返回。 码农高天总结道,我们在面向对象编程中习以为常的“在实例对象中找不到属性,就自动去它的类、乃至父类查找”的属性查找回退机制,本质上就是由这一整套底层的属性与描述器查找代码所统一实现的。 在视频的尾声,码农高天强调了描述器在 Python 生态中举足轻重的地位: “尽管你可能很少自己去写一个 descriptor,但是你的代码是无时无刻不在使用这个机制的,你每一次使用什么点什么的时候都会触发这个机制。”在 Python 内部,诸如类中的实例方法绑定、静态方法(`@staticmethod`)、类方法(`@classmethod`)以及属性装饰器(`@property`),底层全都是基于描述器机制实现的。码农高天表示未来将进一步展开解析这些衍生机制,并号召观众关注与一键三连。 ## Notable quotes > [10:00](https://www.youtube.com/watch?v=Fp7aQO3QuS0&t=600s) 尽管你可能很少自己去写一个 descriptor,但是你的代码是无时无刻不在使用这个机制的,你每一次使用什么点什么的时候都会触发这个机制。 — 码农高天 ## Mentioned 码农高天, Python, CPython ## Video description 这期视频聊一下描述器,也就是descriptor。尽管我们平时直接用到它的地方不多,但是它是python非常重要的底层机制,在我们使用任何object的attribute的时候,都有可能实际上调用的是描述器。 [Source](https://www.youtube.com/watch?v=Fp7aQO3QuS0)

Source quotes

[10:00] 码农高天: 尽管你可能很少自己去写一个 descriptor,但是你的代码是无时无刻不在使用这个机制的,你每一次使用什么点什么的时候都会触发这个机制。

Source quotes

[6:50] 码农高天: 尽管你可能很少自己去写一个 descriptor,但是你的代码是无时无刻不在使用这个机制的,你每一次使用什么点什么的时候都会触发这个机制。

Original transcript

[0:00] 这期视频我们来讲一个大家可能平时主动用的不多但是在python里非常非常重要的一个结构叫做描述器也就是descriptor那Descriptor这个概念大家可能有的听说过有的没有听说过但是在cpython里实际上有大量的机制是通过descript来完成的或许你并不知道 但是实际上你一直在用这个机制好 我们先来简单介绍一下descriptor那在python层级呢 一个descriptor呢就是你定义了一个下方向线get函数的class或者是定义了下方线下方线set或者下方线delete这三个函数定义任何一个都会把这个class变成一个descriptor我们先来看一个descriptor最简单的用法我上面有个class name然后这个name定义了一个get函数这个get函数里面的argument必须得是一个object和一个object type然后这个Gate很简单直接return一个Peter我们在下面定义了一个class a这个class a 里面有一个属于class的member variable 叫做name当然这个name就是一个name object那由于我们的class name是一个descriptor所以我们这个classA里面的这个MemberName就是一个DescriptorObject那我们在下面建立了一个class A的object O然后我们打印 old name我们看一下结果是什么可以看到我们打印出了Peter也就是说当我们使用OderName的时候它实际上是调用了 name这个class的get击数那我们换一种方式,我们这次打印class A 的 name可以看到打印class A的name依然返回的是Peter这里呢其实你应该已经能感受到一点点的神奇了因为这个class A的name variable是被我们复制的name object对吧那你很有可能就会想这个descript是怎么实现的因为如果你不把这个事情搞明白你就不太容易解释下面这个问题我把代码改了一下 现在我给了一个init函数这个init函数里面我做了一个self.name等于name下面我们再建立一个O然后打印O.name可以看到它的结果变了现在它打印出来的就是这个name object了那这是为什么呢如果你觉得刚才那个问题还不令人酷酷的话我们又把刚才的代码稍微改了一下在建立这个object之后我们把o.name复制成了Bob我们再打印O.name会有什么结果呢?可以看到它打印出来的就是我们新负值的这个变量了那如果到这里你还觉得一切理所当然的话我就放一个大招在我们把O.name复制成Bob之后我们动态的把 name这个class的set设置成一个没有意义的函数注意,我们这一步只给这个descriptor增加了一个method。而上面对O.name的复值是已经完成了的此时我们再打印O.name结果就变回了皮特做一个小调查上面这些结果的原理你都知道怎么回事吗其实所有的故事都要从一个bytecode 也就是loadattr说起这个bytecode非常非常常见因为只要你在python代码中写出形如a.b这种形式的代码的时候当你在求他的值就会使用Load ATTR而当你要在这里保存值 就会使用Store APTR在左边第9行我们做O.name=bub的时候 我们就使用了一个storeattr而在第11行print order name的时候呢 我们会用一个loadattr那有关Descript这些秘密都在这两个bycode的原代码里Load ATTR跟Store ATTR有相似的机制所以我们今天说明白load然后store的部分大家如果感兴趣留作家庭作业我们看loadattr的代码比较简单 它的核心是调用了pyobjectgetattr那py object get htr 里面的核心代码是这个是tp getattro 那在你没有重载的情况下BaseObjectGetHTro是这个py object generic get htr 函数点进去可以看到它return是一个下滑线py object generic get atl with dict那这个函数就是我们的目标函数了是所有魔法发生的地方我们descriptor的逻辑就在这里面那我们知道getattr就是一个object上面 [5:00] 这个object是作为obj被传进来的当然这个attribute的名字是保存在name里比如说我们刚才的o.name这个object就是object的o本身这个name里面就保存了一个stringname在1175行他首先把object的type给存到了tp这个pointer里对于我们刚才那段的代码o的type就是classA1195行这是第一个关键点注意他在这个type里在找那个name也就是说你这个descriptor必须要定义到type上也就是必须要写到class的definition里面才是有效的这就解释了为什么我刚才尝试在init这个函数里面复制发现打印出来的东西就是那个object本身而没有触发这个descriptor要想让这个descript工作它必须得是type的一个attribute或者说一个member你在type产生的object上做手脚是没有用的当然我们知道这行代码就是在type里面找到了这个name对应的attribute这个所谓的descr也就是descriptor它不一定是一个真实的descriptor它可能是任何的object当然如果没有这个名字它会返回一个no那就无所谓了所以在1200行它首先去寻找了这个可能是descriptor的object有没有这个tpdescriptor get函数这个函数就是我们刚才定义的下右线下右线get函数这个就是我们最开始说的一个class定义了下方向下方向get函数就成为一个descriptor它的机制就是在loadattr里面才会找一下接下来这个if statement注意了他找到get函数之后没完他还要判断一下这个玩意儿是不是is data而is data的判断就是他有没有set函数只有当这个descriptor既有get函数又有set函数的时候他才会在现在立刻执行这个get的操作大家还记不记得刚才我们的get函数他的三个argument分别是这个descriptor本身然后object和object type那在这里面对应的也是descriptor object跟它的type也就是如果既有get函数也有set函数在这里就会返回get函数的返回值当然他会把调用这个descriptor object和它的type传进get函数好我们接着往下看我们在正常的使用loadattr的时候我们传进来的这个默认dig就是一个no那么这一大段函数呢就是找到这个object本身的下号线下号线dictobject本身的下号线下号线dict保存了这个object里面所有的member variable就是你在正常情况下通过self点什么什么什么东西等于什么复值的这些东西全都保存在这个object的下号线下号线dict里面接下来这个部分非常简单从这个object的dictionary里面找一找有没有你要的这个名字如果有的话返回回去也就是我们所谓的跟这个object绑定的变量下面还记不记得f是什么f是那个get函数对不对这个部分就是如果你的descriptor只有get函数没有set函数就会在这运行这里大家看明白优先级了吗如果get跟set都有优先使用descriptor如果不是的话先在自己的dict里面找如果dict里面还没有就尝试用descriptor的get那我们现在就可以来解释这一段代码了由于我们name的这个descriptor只有get而没有set当我们做older name等于Bob的时候name等于Bob的时候我们是把这个Bob复制到了O这个object上通过打印O.下方下方我们可以看到O这个object里面它的attribute name对应的值是Bob而由于这个descriptor name现在只有get没有set所以在我们第11行打印O.name的时候它会去这个dict里面去也就是打印出了Bob而在第12行我们把这个name.set随便定义了一个函数之后这个descriptor在做load的时候的优先集就跑到了dict上面所以他就会优先用这个get函数也就打印出了Peter那当然在这个loadattr里面还有最后一个部分就是如果descriptor不是not的话直接返回descriptor那这个地方呢你可能会没绕过来说这是什么意思对吧这是因为他的变量明星 [10:00] 有点有误导性还记不记得这个descript是怎么来的实际上就是我们从type里面通过mro拿到的一个值就是一个正常的object 它是什么都有可能 可能就是一个string 一个integer 都有可能而我们在前面已经排除了这个所谓的descriptor里面有下温下温限get是个函数的可能性如果他没有get的话 我们就原样返回其实这段代码是出现的超级频繁的我们看左边就是一个极常见的例子就是我class A 本身有一个 attribute name 叫 Alice然后我通过A新建了一个object这个时候我这个object dict是空的 我没有任何数据绑定到这个object上而当我在运行O.name的时候他就会从他的type里面拿到这个name对应的object是一个string Alice然后他发现这个string既没有get能力也没有set能力同时我在这个Dict里面又没有找到所以我就把它直接返回回来了也就打印出了Alice那这个部分实际上实现的就是我们在一个类的对象里面找东西找不到再返回这个类找甚至它的负类找这么一个功能就这个功能的机制实际上也是刚才那段代码那通过刚才的介绍呢 大家应该可以感觉到descript这个东西在python里面非常非常重要尽管你可能很少自己去写一个descriptor但是你的代码是无时无刻不再使用这个机制的你每一次使用什么点什么的时候都会触发这个机制而在python里有很多我们常见的机制实际上就是基于descript来完成的那这些机制都有什么呢如果大家感兴趣的话我们以后再说好 那这期视频就到这里 如果喜欢的话记得点一下关注 别忘了意见三连哦字幕志愿者 杨茜茜