码农高天
__slots__机制在类创建期将属性映射为C结构体的固定内存偏移量,借由描述器协议实现直接指针寻址,在彻底消除实例字典内存开销的同时省去哈希查找步骤。
AI-assisted summary of 【python】__slots__是什么东西?什么?它还能提升性能?它是如何做到的!? — original source. Check the original for full context.
Source published:
Key ideas
- 在 Python 类定义中使用 __slots__ 可以构建属性白名单,不仅能防止动态绑定意外属性,还能在基准测试中让属性赋值速度提升约 10%。 (0:00)
- CPython 在类创建阶段(type_new)会为声明的每个 slot 计算相对于对象基地址的固定内存偏移量(offset),并将槽指针连续追加在 C 结构体末尾。 (2:37)
- 在 PyType_Ready 阶段,CPython 会在类字典中为每个 slot 生成 Member Descriptor,使得属性访问直接转化为基地址加偏移量的快速指针寻址,完全省去实例哈希表的检索开销。 (6:50)
- 使用 __slots__ 的类默认不再为实例创建 __dict__ 字典,彻底消除了动态哈希表的内存预留与冗余开销,实现“一个萝卜一个坑”的内存紧凑布局。 (9:56)
Detailed notes
## Background 该视频由技术博主“码农高天”于 2024 年 2 月 12 日发布在 YouTube 平台上。视频简介提到,__slots__ 在国内技术社区(如 B 站)讲解较少,但它不仅易于使用,还能显著提升程序的性能和稳定性,本期视频旨在深入剖析其背后的底层运行原理。
## TL;DR 视频全面解析了 Python 中 __slots__ 的核心机制与三大优势:限制属性种类的白名单、加快约 10% 的属性访问速度以及大幅压缩内存。博主结合 CPython 源码,层层拆解了类创建阶段 type_new 中内存偏移量(offset)的计算与分配,以及 PyType_Ready 阶段如何生成并注册 Member Descriptor。通过原理对比与代码实测,视频清晰论证了 __slots__ 为何能省去普通实例维护 __dict__ 哈希表的巨大内存开销,并凭借“基地址 + 偏移量”的直接指针运算实现高效寻址。
## Atmosphere & style 视频呈现出严谨扎实且极具探索欲的技术极客风格。码农高天以温和从容、抽丝剥茧的语调,带领观众直接下潜至 C 语言层面的 CPython 源码世界,将原本抽象隐晦的底层内存布局拆解得一清二楚。生动的“一个萝卜一个坑”比喻与严谨的基准测试、交互式终端验证相互映衬,既有硬核源码调试的张力,又有解开底层黑盒后的豁然开朗与自信从容。
## Bulletin - [00:00](https://www.youtube.com/watch?v=FqBKqYzaRaQ&t=0s) 在 Python 类定义中使用 __slots__ 可以构建属性白名单,不仅能防止动态绑定意外属性,还能在基准测试中让属性赋值速度提升约 10%。 - [02:37](https://www.youtube.com/watch?v=FqBKqYzaRaQ&t=157s) CPython 在类创建阶段(type_new)会为声明的每个 slot 计算相对于对象基地址的固定内存偏移量(offset),并将槽指针连续追加在 C 结构体末尾。 - [06:50](https://www.youtube.com/watch?v=FqBKqYzaRaQ&t=410s) 在 PyType_Ready 阶段,CPython 会在类字典中为每个 slot 生成 Member Descriptor,使得属性访问直接转化为基地址加偏移量的快速指针寻址,完全省去实例哈希表的检索开销。 - [09:56](https://www.youtube.com/watch?v=FqBKqYzaRaQ&t=596s) 使用 __slots__ 的类默认不再为实例创建 __dict__ 字典,彻底消除了动态哈希表的内存预留与冗余开销,实现“一个萝卜一个坑”的内存紧凑布局。
## Key takeaways - [00:00](https://www.youtube.com/watch?v=FqBKqYzaRaQ&t=0s) __slots__ 的三大核心优势是限制属性种类(白名单提高鲁棒性)、加快属性访问速度以及大幅压缩内存占用。 - [00:00](https://www.youtube.com/watch?v=FqBKqYzaRaQ&t=0s) 向带有 __slots__ 的类实例赋予白名单之外的属性时,Python 会直接抛出 AttributeError 异常。 - [00:00](https://www.youtube.com/watch?v=FqBKqYzaRaQ&t=0s) 基准测试表明,使用 __slots__ 相比普通类在循环属性赋值操作中能获得大约 10% 的运行速度提升。 - [02:37](https://www.youtube.com/watch?v=FqBKqYzaRaQ&t=157s) __slots__ 的底层实现核心发生在 CPython 的 type_new 函数中,负责解析 slot 声明并改造类的内存布局。 - [02:37](https://www.youtube.com/watch?v=FqBKqYzaRaQ&t=157s) CPython 解析 __slots__ 时会统一将其转换为元组,滤除 __dict__ 与 __weakref__ 等特殊名字后保存到 ht_slots。 - [02:37](https://www.youtube.com/watch?v=FqBKqYzaRaQ&t=157s) Python 对象在底层本质是 C 结构体,声明 slots 时会在原本对象的 tp_basicsize 基础上连续追加固定大小的内存空间。 - [02:37](https://www.youtube.com/watch?v=FqBKqYzaRaQ&t=157s) 每个 slot 在底层都会构建一个 member 结构体,其类型标记为 T_OBJECT_EX,偏移量则按 sizeof(PyObject*) 递增累加。 - [06:50](https://www.youtube.com/watch?v=FqBKqYzaRaQ&t=410s) 类就绪阶段的 PyType_Ready 函数通过 add_members 为每个 slot 创建 Member Descriptor 并注册到类的 __dict__ 中。 - [06:50](https://www.youtube.com/watch?v=FqBKqYzaRaQ&t=410s) 根据 Python 描述器协议,访问实例属性时会优先命中类级描述器,从而跳过对实例字典的检索。 - [06:50](https://www.youtube.com/watch?v=FqBKqYzaRaQ&t=410s) 底层读取函数 PyMember_GetOne 通过实例基地址加上固定偏移量直接定位内存,避免了运行时的动态哈希查找。 - [09:56](https://www.youtube.com/watch?v=FqBKqYzaRaQ&t=596s) 普通 Python 对象的属性读取需要先查类 MRO 描述器,未命中后再查实例 __dict__,多出了一次哈希表查询开销。 - [09:56](https://www.youtube.com/watch?v=FqBKqYzaRaQ&t=596s) 字典为了保证 O(1) 性能需保留大量冗余空间,而 __slots__ 采用“一个萝卜一个坑”的紧凑结构,极大节省了内存。 - [09:56](https://www.youtube.com/watch?v=FqBKqYzaRaQ&t=596s) 定义了 __slots__ 的实例默认完全不创建 __dict__ 属性,直接访问实例的 __dict__ 会抛出异常。
## Detailed walkthrough ### [00:00](https://www.youtube.com/watch?v=FqBKqYzaRaQ&t=0s) \_\_slots\_\_ 的基本概念与三大核心优势
在 Python 编程中,`__slots__` 是一个可以在类定义(class definition)中声明的特殊变量,其类型通常是一个元组(tuple)或者列表(list)。它的最直观作用是限制该类的实例对象能够使用的属性种类。码农高天通过一个直观的代码示例进行了演示:定义一个类 `A` 并在其中声明 `__slots__ = ('f', 'g')`,随后实例化一个对象 `o = A()`。当尝试执行常规的动态属性赋值 `o.b = 2` 时,程序会立刻抛出异常,提示 `AttributeError: 'A' object has no attribute 'b'`;而如果将赋值语句改为 `o.f = 2`,代码就能顺利执行。这是因为开发者通过 `__slots__` 显式设置了一份属性白名单,明确告知 Python 该类的实例仅允许绑定名为 `f` 和 `g` 的属性。
然而,白名单机制带来的代码规范性只是 `__slots__` 的冰山一角。码农高天随后展示了一个对比基准测试(benchmark):定义包含 slots 的类 `A` 与完全不包含 slots 的普通类 `B`,分别生成其实例 `o` 和 `p`,并在高频循环中执行相同次数的属性赋值操作(`o.f = 2` 与 `p.f = 2`)。基准测试结果表明,带有 `__slots__` 的类在属性赋值性能上显著优于普通类,速度大约提升了 10%。不仅如此,在内存占用方面它也展现出了惊人的压缩效果。
码农高天总结道:“所以说 slots 这个机制实际上至少给我们带来了三件事:第一,通过限制属性的种类,利用白名单这个制度让我们的代码更有鲁棒性;第二,在 access 这些属性的时候速度更快;第三,实际上它是更省内存的。”
- **属性白名单**:通过元组或列表形式限制类实例可绑定的属性名,防止动态添加预期之外的属性。 - **性能提升**:基准测试中,属性赋值与访问速度通常能获得约 10% 的性能优化。 - **内存优化**:消除了每个实例默认附带的字典结构,大幅降低大规模对象实例化时的内存占用。
---
[02:37](https://www.youtube.com/watch?v=FqBKqYzaRaQ&t=157s) CPython 源码剖析:类创建阶段的 type_new 与 Member 内存偏移
为了探究底层性能提升的根本原因,必须深入 CPython 的源码实现。码农高天指出,`__slots__` 绝大多数底层的“魔法”都在类型创建阶段发生,其核心逻辑位于 CPython 源码的 `type_new` 函数(即创建新 class 或新 type 时触发的函数)中。当解析到类的定义包含 `__slots__` 时,CPython 首先将属性值存入局部变量,统一转换为元组格式,并做基本检查和特殊处理——如滤除 `__dict__` 与 `__weakref__` 这两个特殊 slot 并保留普通 slot 名字,对其排序后再保存到堆类型对象的 `ht_slots` 中。
接下来便进入了最关键的内存布局改造阶段。在底层实现中,所有的 Python 对象(包括类对象本身)在 C 语言层面本质上都是一个 C 结构体(C Struct)。CPython 通过宏 `PyHeapType_GET_MEMBERS` 获取该类型结构体尾部的指针。普通对象的类型具有一个基准内存大小(`tp_basicsize`),而每当类定义中声明了一个额外的 slot,Python 在计算并申请内存时就会在原本的 C 结构体末尾额外追加一段连续空间。宏最终返回的指针正是指向这片追加区域,用于保存该类型的成员元数据(member 信息)。
对于每一个 slot,底层都会建立一个 `member` 结构体,将其 `name`、`type` 与 `offset` 逐一赋值。其中,`type` 统一标记为 `T_OBJECT_EX`,表明槽内存储的是一个 `PyObject` 指针;而 `offset`(偏移量)则初始设定为对象的基准大小 `tp_basicsize`。随后,每遍历一个 slot,`slot_offset` 就会向后累加一个 `sizeof(PyObject*)` 的字节长度。码农高天举例解释道:假设 `class A` 正常情况下需要 100 个字节来保存,其初始 `slot_offset` 就是 100;第一个 slot `f` 的偏移量就固定在 100,紧随其后的第二个 slot `g` 则位于 108。这就相当于为每个新创建的实例划分好了专属的连续内存空间,最后将包含这些偏移量信息的结构体数组指针保存到类型的 `tp_members` 中。
```mermaid flowchart TD accTitle: CPython 类创建阶段 slots 内存布局处理流程 accDescr: 展示从读取 slots 到计算内存偏移量并存入 tp_members 的完整步骤
subgraph Phase1["1. 解析 slots 定义"] A["读取 __slots__ 声明"] --> B["转换为 Tuple 并过滤 __dict__ / __weakref__"] B --> C["对 slots 属性名排序并存入 ht_slots"] end
subgraph Phase2["2. 计算连续内存偏移量"] D["基准大小 tp_basicsize (如 100 bytes)"] --> E["Slot 'f': offset = 100"] E -->|"累加 sizeof(PyObject*)"| F["Slot 'g': offset = 108"] end
subgraph Phase3["3. 构建 Member 结构体"] G["构建 member 结构体 (Type: T_OBJECT_EX)"] H["记录各属性名称与绝对偏移量"] I["保存结构体数组至类的 tp_members"] end
Phase1 --> Phase2 Phase2 --> G G --> H H --> I ```
- **C 结构体内存扩展**:对象在内存中是定长的 C 结构体,每个 slot 都会直接在其后方顺延开辟一个指针长度的固定存储单元。 - **偏移量计算机制**:首个 slot 的偏移值直接对齐类型的 `tp_basicsize`,后续 slot 依序递增 `sizeof(PyObject*)`(64 位系统下通常递增 8 字节)。 - **`T_OBJECT_EX`**:标识 slot 内存储的值是一个 Python 对象指针。
---
[06:50](https://www.youtube.com/watch?v=FqBKqYzaRaQ&t=410s) PyType_Ready 与 Member Descriptor:属性读写的底层运作
在 `type_new` 函数中完成属性名与偏移量(offset)的记录后,类本身的数据结构已经完备,但实例运行时(runtime)的读写行为还没有改变。底层的后续动作发生在类初始化的收尾函数 `PyType_Ready` 中。在类型就绪的过程中,CPython 会调用 `add_members` 函数。该函数遍历先前保存在 `tp_members` 中的所有成员元数据,并执行两个核心操作:首先为每一个 member 创建一个专门的“描述器”(Member Descriptor),随后将这个描述器直接注册到类的字典(class `__dict__`)中。
当我们在 Python 代码中访问实例属性(例如调用 `o.f`)时,Python 的属性解析顺序(MRO)规定了必须首先检查该对象所属类的 MRO 列表中是否存在同名描述器。由于 `add_members` 已经将 `f` 的 Member Descriptor 预先放入了 `A.__dict__` 中,解释器便会优先命中该描述器,直接调用描述器绑定的获取方法(getter)和设置方法(setter)。
描述器的核心读取逻辑由底层 C 函数 `PyMember_GetOne` 实现。该函数接收实例对象 `o` 的绝对内存地址以及当初记录的成员结构体 `DMember`。函数内部通过将实例对象的内存基地址直接加上此前计算好的固定偏移量(如基地址 `ADDR + 100`),即可毫无阻碍地定位到 slot 所指的那片内存,将其强制转换为 `PyObject*` 指针并返回给上层代码。
码农高天强调道:“在这里把这个 address 加了一个 offset……这个 ADDR 呢保存的是这个 object 本身的绝对地址,绝对地址加上 offset 就是我们要找的这个 slot 里面保存的这个 object 地址。”这一设计彻底避开了运行时的动态解析,使得属性访问转化为了纯粹的高效指针偏移运算。
- **Member Descriptor 生成**:在 `PyType_Ready` 阶段,类字典中自动填充对应 slot 的描述器对象。 - **属性查找优先级**:基于 Python 描述器协议,实例查找属性时首先命中类层级的描述器,跳过了对普通实例字典的查询。 - **指针寻址(Pointer Arithmetic)**:底层读取直接执行 `(PyObject **)((char *)addr + offset)`,耗时极低。
---
[09:56](https://www.youtube.com/watch?v=FqBKqYzaRaQ&t=596s) 为什么更快更省内存:原理解析与代码验证
通过对底层机制的全景还原,`__slots__` 能够提升运行速度并节省内存的本质原因变得十分清晰。在没有使用 `__slots__` 的普通 Python 对象中,查找属性的操作必须经历两步:第一步,依然需要遍历类及其父类的 MRO 字典以确认是否存在描述器(这一步是不可避免的开销);第二步,在未找到对应描述器的情况下,解释器被迫再去该实例对象专有的 `__dict__` 字典中执行一次哈希查找。而使用了 `__slots__` 之后,属性查找在第一步检查类级描述器时就已精准命中目标,描述器凭借内置的固定偏移量直接把变量的绝对内存地址反馈给解释器,完全省去了第二阶段访问实例哈希表的开销。
在内存分配方面,差距更为悬殊。码农高天用生动的比喻指出:“如果你用 slots 的话,在每一个 object 上都是一个萝卜一个坑;如果你不用 slots 的话,你需要用一个 dictionary 来保存这些萝卜。”字典在底层是一种动态扩容的哈希表结构,为了维持平均 $O(1)$ 的查找性能,哈希表内部必须预留可观的冗余空间(低装载因子),这意味着存储普通字典所需的内存远高于所存数据本身的体积。更激进的是,使用 `__slots__` 的类创建其实例时,默认甚至压根不会为实例创建 `__dict__` 属性,实例结构体仅包含基础对象头和紧挨着的预留槽指针。
在视频的最后,码农高天通过实际交互代码对上述理论进行了验证: 1. 打印 `A.__dict__` 与 `B.__dict__`:带有 slots 的类 `A` 的字典中赫然存在 `f` 与 `g` 两个特殊的 `member_descriptor`,而普通类 `B` 中则没有。 2. 打印实例自身的 `__dict__`:普通实例 `p` 的 `p.__dict__` 输出了 `{'f': 2}` 键值对;而使用了 slots 的实例 `o` 根本没有 `__dict__` 属性,直接访问 `o.__dict__` 会报错,证明其属性值确实没有落入动态字典,而是安全保存在预先分配的连续空间中。
码农高天在结尾充满信心地总结道:“这可能是整个 B 站讲的最详细的 slots 教程了,希望对大家有所帮助。”
| 对比维度 | 普通类(未定义 `__slots__`) | 使用 `__slots__` 的类 | |:--- |:--- |:--- | | **属性白名单限制** | 无限制,可动态追加任意属性 | 严格受限于定义的 slots 元组/列表 | | **实例属性存储载体** | 实例私有的动态哈希表 `__dict__` | 结构体尾部连续的直接指针空间(“一个萝卜一个坑”) | | **实例 `__dict__` 存在性** | 默认必然存在并占用空间 | 默认完全不创建,零字典开销 | | **属性查找流程** | 1. 查找 MRO 描述器(未命中)<br>2. 检索实例自身 `__dict__` 哈希表 | 1. 查找 MRO 描述器(命中 Member Descriptor)<br>2. 基于基址+偏移量直接寻址返回 | | **性能与内存表现** | 存在哈希开销、冗余内存分配,速度相对较慢 | 速度快约 10%,显著压缩内存占用 |
## Notable quotes > [09:56](https://www.youtube.com/watch?v=FqBKqYzaRaQ&t=596s) 如果你用 slots 的话,在每一个 object 上都是一个萝卜一个坑;如果你不用 slots 的话,你需要用一个 dictionary 来保存这些萝卜。 — 码农高天
> [00:00](https://www.youtube.com/watch?v=FqBKqYzaRaQ&t=0s) slots 这个机制实际上至少给我们带来了三件事:第一,通过限制属性的种类,利用白名单这个制度让我们的代码更有鲁棒性;第二,在 access 这些属性的时候速度更快;第三,实际上它是更省内存的。 — 码农高天
## Mentioned Python, CPython, 码农高天, B站, type_new, PyType_Ready, add_members, PyMember_GetOne, Member Descriptor, PyHeapType_GET_MEMBERS, PyObject
## Video description 这期视频我们来讲一下__slots__。这个内容在B站上讲的人很少,但是还蛮有趣的,因为它是可以提升我们的程序性能和稳定性的,而且并不难使用。我们的视频当然和往常一样,主要是要介绍它背后的原理啦!
[Source](https://www.youtube.com/watch?v=FqBKqYzaRaQ)