码农高天
类内定义的函数本质上均为普通函数对象,仅在通过实例访问触发描述器协议时才会封装为自动填充首位参数的方法对象,若直接动态挂载到实例字典则会因绕过该协议而报错。
AI-assisted summary of 【python】class里定义的函数是怎么变成方法的?函数里的self有什么特殊意义么? — original source. Check the original for full context.
Source published:
Key ideas
- Python 类中定义的函数本质上只是普通的函数对象,之所以通过实例访问时会变成“绑定方法”(Bound Method),核心驱动力是函数对象本身实现了描述器协议(Descriptor Protocol)。 (5:00)
- CPython 源码中,通过实例访问方法会触发描述器的 get 函数并调用 PyMethod_New 创建 method 对象;调用该对象时底层通过 method_vectorcall 自动将绑定的实例注入参数列表首位。 (5:00)
- 动态给类添加函数(A.f = f)可在实例访问时正常转换为绑定方法;但若直接赋值给实例(o.f = f),因直接命中实例字典而不触发描述器,调用时必须显式手动传入实例参数。 (8:31)
- 参数名 self 在 Python 语法和底层实现中没有任何特殊魔力,仅仅是社区约定俗成用于占位第一个位置参数的普通标识符。 (8:31)
Detailed notes
## Background 该视频由技术博主“码农高天”于 2024 年 2 月 3 日发布在 YouTube 平台。视频围绕 Python 面向对象编程中的经典疑问展开,系统剖析了类中定义的函数如何转变为绑定方法,以及为什么定义时存在的 self 参数在调用时无需显式传递的底层运作机制。
## TL;DR 视频从普通函数(function)与绑定方法(bound method)的对比切入,揭示了 Python 中方法调用的核心驱动力是函数对象自带的描述器(Descriptor)协议。高天结合 CPython 底层源码剖析指出,当通过实例访问属性时,解释器会触发函数的描述器 get 方法并调用 PyMethod_New 生成绑定当前实例的方法对象,在执行时底层借由 method_vectorcall 自动将实例塞入首个参数位置。视频进一步通过动态给类或实例赋值函数、自定义描述器等边界场景验证了属性查找与描述器触发规则,并最终阐明 self 纯粹是社区约定的占位符,免传实参的机制完全归功于描述器的暗中转换。
## Atmosphere & style 视频整体呈现出严谨扎实而又通俗透彻的硬核技术研读风格,讲解节奏平稳流畅、条理分明。高天以日常代码中的直观现象作为引子,层层剥茧至 CPython 源码与底层实现,并借助多组对比实验现场击破认知误区,营造出一种拨开迷雾、让底层黑盒彻底透明化的技术爽感与启发感。
## Bulletin - [05:00](https://www.youtube.com/watch?v=Le-vZ6ueh0E&t=300s) Python 类中定义的函数本质上只是普通的函数对象,之所以通过实例访问时会变成“绑定方法”(Bound Method),核心驱动力是函数对象本身实现了描述器协议(Descriptor Protocol)。 - [05:00](https://www.youtube.com/watch?v=Le-vZ6ueh0E&t=300s) CPython 源码中,通过实例访问方法会触发描述器的 get 函数并调用 PyMethod_New 创建 method 对象;调用该对象时底层通过 method_vectorcall 自动将绑定的实例注入参数列表首位。 - [08:31](https://www.youtube.com/watch?v=Le-vZ6ueh0E&t=511s) 动态给类添加函数(A.f = f)可在实例访问时正常转换为绑定方法;但若直接赋值给实例(o.f = f),因直接命中实例字典而不触发描述器,调用时必须显式手动传入实例参数。 - [08:31](https://www.youtube.com/watch?v=Le-vZ6ueh0E&t=511s) 参数名 self 在 Python 语法和底层实现中没有任何特殊魔力,仅仅是社区约定俗成用于占位第一个位置参数的普通标识符。
## Key takeaways - [00:00](https://www.youtube.com/watch?v=Le-vZ6ueh0E&t=0s) 类定义在执行时只是在局部命名空间中运行,类中定义的函数作为普通 function object 保存在类的 __dict__ 字典中。 - [00:00](https://www.youtube.com/watch?v=Le-vZ6ueh0E&t=0s) 直接通过类访问(如 A.f)得到的是普通函数,而通过实例对象访问(如 o.f)得到的则是绑定方法(Bound Method)。 - [00:00](https://www.youtube.com/watch?v=Le-vZ6ueh0E&t=0s) Bound Method 对象内部记住了所绑定的具体实例,即使将其赋值给独立变量脱离原对象调用,也能自动访问实例属性且无需传入 self。 - [05:00](https://www.youtube.com/watch?v=Le-vZ6ueh0E&t=300s) 函数转为绑定方法的核心机制在于函数对象自带描述器协议,其实现了 CPython 底层的 __get__ 方法。 - [05:00](https://www.youtube.com/watch?v=Le-vZ6ueh0E&t=300s) CPython 源码中,函数对象的描述器 get 函数通过调用 PyMethod_New(func, obj) 实例化出一个持有原函数与目标实例的 method 对象。 - [05:00](https://www.youtube.com/watch?v=Le-vZ6ueh0E&t=300s) 在底层 method_vectorcall 调用逻辑中,解释器会自动将持有的实例作为首个位置参数拼入参数列表,再调用原始函数。 - [05:00](https://www.youtube.com/watch?v=Le-vZ6ueh0E&t=300s) 基于属性查找协议(LOAD_ATTR),访问 o.f 时若在实例自身的 __dict__ 找不到,会向上查找到类 A 并识别出描述器,从而触发 __get__ 转换。 - [08:31](https://www.youtube.com/watch?v=Le-vZ6ueh0E&t=511s) 动态将函数赋值给类(A.f = f)时,实例查找依然能回溯到类并触发描述器,表现与常规类方法一致。 - [08:31](https://www.youtube.com/watch?v=Le-vZ6ueh0E&t=511s) 动态将函数直接挂载到实例(o.f = f)时,函数被直接存入实例的 __dict__,查找过程不会触发描述器,o.f 依旧是普通函数。 - [08:31](https://www.youtube.com/watch?v=Le-vZ6ueh0E&t=511s) 直接调用实例字典中的普通函数若不显式传递自身(如 o.f("hello")),会因缺少必要位置参数而直接触发 TypeError。 - [08:31](https://www.youtube.com/watch?v=Le-vZ6ueh0E&t=511s) 若自定义描述器的 __get__ 返回一个普通函数对象,Python 不会对该返回值递归触发描述器,返回给调用者的依然是普通函数。 - [08:31](https://www.youtube.com/watch?v=Le-vZ6ueh0E&t=511s) 方法中 self 的名称完全可由开发者自定义替换,免显式传参的全过程纯粹是由描述器与底层参数补全机制协作完成的。
## Detailed walkthrough ### [00:00](https://www.youtube.com/watch?v=Le-vZ6ueh0E&t=0s) 类方法中的 `self` 谜题与绑定的方法(Bound Method)
在日常 Python 编程中,大家在类(`class`)内部定义方法(method)时,普遍习惯将第一个参数命名为 `self`。然而真正实例化一个对象(object)并调用该方法时,开发者却只需要传递 `self` 之后的实参。例如在类 `A` 中定义函数 `f(self, data)`,实例化 `o = A()` 之后调用 `o.f("hello")`,代码便会正常打印出 `"hello"`。进一步地,如果给实例对象 `o` 动态绑定一个属性 `o.name = "Bob"`,并将函数 `f` 修改为先打印 `self.name` 再打印 `data`,调用 `o.f("hello")` 就会依次输出 `"Bob"` 和 `"hello"`。
许多人知其然不知其所以然:这个 `self` 到底去哪了?从类的底层创建机制来看,类在定义时实际上只是在一个局部命名空间中执行代码块,随后将产生的局部变量全部存入该类的 `__dict__` 中。因此,类 `A` 中保存的 `f` 本质上只是一个普普通通的函数(function),它明确要求接收两个参数(`self` 和 `data`)。
关键的分水岭在于通过类访问还是通过对象实例访问。如果分别打印 `A.f` 与 `o.f`: - `A.f` 显示为一个非常普通的函数(`<function A.f at...>`); - `o.f` 则变成了一个绑定方法(`<bound method A.f of <__main__.A object at...>>`)。
所谓的 bound method,本质上是一个绑定了实例对象 `o` 的方法对象(method object)。为了证明这一点,可以将 `o.f` 赋值给一个独立变量 `my_func = o.f`,随后直接调用 `my_func("hello")`。此时调用过程表面上已经与变量 `o` 无关,但调用时依然只需传入一个参数,且依然能准确打印出 `o.name`(即 `"Bob"`)。这证明 `o.f` 返回的并不是原先定义在类中的那个普通函数,而是一个记住了所绑定对象的全新 method 对象;当调用该 method 时,它会在底层自动把自己绑定的对象塞入第一个参数位置。码农高天指出:“换言之 O.f 并不是你在 class A 定义的这个 F 了。”
```mermaid flowchart TD accTitle: 普通函数转化为绑定方法的流程 accDescr: 展示了通过类访问函数与通过实例访问函数时,函数如何通过描述器转换为绑定的方法对象。
subgraph Definition["定义阶段"] A["Class A 定义 f(self, data)"] --> DictA["A.__dict__['f'] = function object"] end
subgraph Access["属性访问阶段"] DictA --> DirectAccess["通过类访问:A.f"] DirectAccess --> FuncOut["返回普通 Function<br/>调用需显式传参:A.f(o, 'hello')"]
DictA --> InstanceAccess["通过实例访问:o.f"] InstanceAccess --> DescCall["触发 Function 的 __get__(o, A)"] DescCall --> MethodOut["返回 Bound Method<br/>绑定实例 o 到 self"] end
subgraph Execution["执行阶段"] MethodOut --> Invoke["调用 o.f('hello')<br/>(底层通过 method_vectorcall 补全 self)"] end ```
---
[05:00](https://www.youtube.com/watch?v=Le-vZ6ueh0E&t=300s) 描述器机制(Descriptor)与 CPython 源码剖析
同一个属性名在类和对象上访问会产生截然不同的对象类型,这一行为背后的核心驱动力就是描述器(Descriptor)机制。在 Python 中,所有定义的函数本质上都是一个函数对象(function object),而函数对象本身实现了解释器底层的描述器协议,自带 `__get__` 方法(在 CPython 源码中对应描述器的 get 函数)。
在 CPython 源码实现中,函数对象的 descriptor get 函数极其简单直白:它直接调用并返回了 `PyMethod_New(func, obj)`。这个函数会创建一个专门的 `method` 对象,该对象内部持有两个核心要素: 1. 原始函数对象 `func`; 2. 触发调用的实例对象 `object`(即方法内的 `self`)。
当这个 `method` 对象被调用时,它会走底层预设的调用函数 `method_vectorcall`。在该调用逻辑中,解释器会先获取绑定的 `self` 和原始 `func`,在各种分支处理下,“在各种 case 里面 它都会把 self 给放到 argument 的第一个”,随后将用户实际传入的其余参数紧随其后拼接成一个新的参数列表,最终以这个合并后的参数列表去调用原始的 `func`。这就是一个 function 转化为 method 的核心底层机制。
结合属性查找协议(`LOAD_ATTR`),整个查找与转换流程如下: - 当定义 `class A` 时,生成的普通 function object 存入了 `A.__dict__`。如果直接执行 `A.f(o, "hello")`,用户必须手工传入实例 `o` 与字符串参数; - 当实例化 `o = A()` 并执行 `o.f` 时,Python 先在 `o.__dict__` 中查找名为 `f` 的属性。由于找不到,查找逻辑顺着 MRO 链向上查找其类型对象 `A`; - 在 `A` 的命名空间中找到属性 `f` 后,解释器发现它是一个普通的 function object,但该 object 拥有描述器 get 函数(实现了 `__get__`); - 解释器因而不会直接返回该函数,而是调用该函数的 descriptor get 方法(传入实例 `o` 与类型 `A`),从而生成并返回绑定了实例 `o` 的 method object; - 最终,调用 `o.f("hello")` 时,method 对象内部自动把 `o` 补齐为第一个参数,实现了免显式传递 `self` 的体验。
---
[08:31](https://www.youtube.com/watch?v=Le-vZ6ueh0E&t=511s) 动态绑定与自定义描述器的边界情况
理解上述机制具有极强的实际应用价值,尤其是在需要动态为类或实例添加方法与函数时。许多人在动态增加函数时往往会困惑:究竟要不要在函数中写 `self`?调用时到底需不需要显式传入 `self`?
#### 情况一:动态添加到类对象 如果将一个普通的独立函数 `f(self, data)` 赋值给类本身: ```python A.f = f ``` 当后续新建的实例 `o` 调用 `o.f` 时,由于实例自身的字典中没有 `f`,查找逻辑会落到类 `A` 上并触发函数对象的描述器协议。该函数会被自动转化为 bound method,因此调用时无需显式传 `self`,使用 `o.f("hello")` 即可。
#### 情况二:动态添加到实例对象 如果将函数直接赋值到具体实例对象上: ```python o.f = f ``` 此时函数对象被直接写进了 `o.__dict__` 字典中。码农高天强调:“由于这个函数定义到了对象上 对象的 dict 里面 当你再使用 O.f 的时候它就不会再调用你拿到的这个 function object 的 descriptor get 函数了你拿到的这个 O.f 就是一个完整的 function object”。因此,如果直接调用 `o.f("hello")`,Python 会直接抛出缺少必要位置参数的 TypeError 异常(`missing required positional argument`)。此时正确的调用方式必须手动显式传入实例自身:`o.f(o, "hello")`。
#### 情况三:自定义描述器返回函数 若在类 `A` 中定义一个自定义的描述器对象,其 `__get__` 函数在被调用时直接返回一个普通的函数对象 `f`: ```python class FuncDescriptor: def __get__(self, instance, owner): return f # 直接返回普通函数
class A: f = FuncDescriptor() ``` 在此场景下调用 `o.f`,查找路径如下: 1. 实例 `o` 本身字典无 `f`,在类 `A` 中找到 `f`; 2. 类 `A` 中的 `f` 是自定义描述器实例,解释器检测到其具有 `__get__`,因而执行其 `__get__` 方法; 3. 自定义描述器的 `__get__` 直接返回了普通的 function object; 4. 解释器直接将该返回值交给调用方,而**不会**针对返回的函数对象再次递归触发描述器机制。
因此,`o.f` 拿到的是纯粹的普通函数而不是 bound method,调用时同样必须显式传参:`o.f(o, "hello")`。
| 绑定/定义场景 | `o.f` 实际拿到的对象 | 正确调用语法 | 底层原因 | |:--- |:--- |:--- |:--- | | **常规类内定义方法** (`class A: def f(self):...`) | `Bound Method` | `o.f("hello")` | 类字典中的函数在实例查找时触发自身的描述器,封装为 bound method | | **动态赋值给类** (`A.f = f`) | `Bound Method` | `o.f("hello")` | 查找回溯到类对象,触发函数自带的描述器逻辑 | | **动态赋值给实例** (`o.f = f`) | `Function` | `o.f(o, "hello")` | 直接命中实例自身的 `__dict__`,不触发描述器机制 | | **自定义描述器返回普通函数** | `Function` | `o.f(o, "hello")` | 自定义描述器运行后直接吐出普通函数,底层不会二次触发该函数的描述器 |
码农高天总结道,在类中定义常规方法时产生的本质上就是一个普通的函数对象,参数名 `self` 纯粹只是社区约定俗成的占位符,“它只是把第一个 argument 这个位置占了个坑而已 你叫它什么对于 Python 来说是一样的”。开发者在面向对象调用中感受到的所谓“方法绑定”,实际上全靠访问实例属性时由函数对象自带的 descriptor get 函数暗中协助,构造出绑定实例的 method 对象,从而免去了在参数列表中手动传递实例自身的繁琐操作。
## Mentioned Python, CPython, 码农高天, PyMethod_New, method_vectorcall
## Video description 这期视频讲一下大家常用但是未必知道背后原理的class里的method机制。明明定义的函数里有个self参数,怎么使用的时候不需要给呢?看完视频,全都明白了!
[Source](https://www.youtube.com/watch?v=Le-vZ6ueh0E)