码农高天
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)