Python基本原理:Python对象及其内存布局
本文以Python官方文档和CPython实现作为参考,示例代码均在3.13.7测试通过,阅读需要一点C语言基础。
注意:本文以64位系统为基准,32位系统的原理一致,但是字节数上会有差异。
概念
虽然大多数人第一次接触Python是写的面向过程的代码,入门之后也是当作脚本语言来用,但是Python语言本身,是完完全全基于面向对象实现的。你在Python层面接触到的东西,包括基本数据类型(int、float)、基本常量(None、True、False)、甚至函数和模块等,统统都是对象。
那么对象究竟是个什么东西呢?
从逻辑层面来讲,对象是对事物的抽象。比如一条黑色的狗,就可以抽象成一个“狗对象”,这个狗对象拥有一些属性(狗对象.颜色 = 黑色)和一些方法(狗对象.汪汪叫)。通常情况下,属性是一个值,表征这个对象一些性质;方式是一系列动作,表征这个对象可以做什么。
而在实现层面,对象对应于内存中的数据。黑色可能就是内存中某几个字节的值,汪汪叫可能就是若干条可以执行的机器指令。
所以换句话说,对象就是对内存中数据的抽象,每一个对象的背后,都对应了若干个内存块。
对象的内存布局,就是指对象的内存块有几个、每一块有多大、内存块的每一个字节都是什么意思。
Python中的对象可以分为两类:内置对象(list、dict这些)和自定义对象(通过class定义的对象)。接下来我们从最简单的内置对象float开始探索Python对象的内存布局。
float对象
浮点数对象是Python中最简单的对象,只有24字节大小:
1>>> import sys
2>>> sys.getsizeof(1.0)
324
这24个字节被分成3个字段,依次是:
- 引用计数,占8个字节,一个整数,记录这个对象有多少个引用(用于垃圾回收)。
- 类型指针,占8个字节,指向其类型对象。关于类型对象可以参考这里[Python基本原理:类型对象与元类]。
- 实际数值,占8个字节,实际类型为IEEE 754的double。
所以,嗯,它真的很简单,完全就是C语言的double前面套了一个Python对象头。讲它的目的也主要是为了引出Python的对象头。
注意,引用计数和类型指针加起来这个16字节的对象头,是所有Python对象都有的。
在CPython源代码中这两个字段分别叫ob_refcnt和ob_type,本文后续也会沿用这两个字段名。
int对象
我们都知道,一个字节最多可以指示$2^8=256$种状态,所以4字节的C语言int只能表示从-2147483648到2147483647共$2^{32}=4294967296$个数字。而Python的int号称“无限精度”,那么必然不可能只是C语言的int或者long long前面套一个对象头那么简单。
事实上,想要做到无限精度,就不能事先给int分配一个固定大小的内存,必需是用多少分配多少的动态分配。
Python的整数对象使用一个lv_tag的字段记录分配的内存,整个int对象长这样:
1struct _longobject {
2 long long ob_refcnt;
3 PyTypeObject *ob_type;
4 unsigned long long lv_tag;
5 unsigned int ob_digit[1];
6}
如果你还没有阅读[Python基本原理:类型对象与元类],这里的PyTypeObject可以不用管它;ob_digit[1]是C语言的惯用写法,表示这是一个灵活数组成员,其真实大小在运行时动态决定。
lv_tag最低位的两bit被视作一个uint2(取值从0到3)。
- 值为0时,表示这个整数对象是一个正数。
- 值为1时,表示这个整数对象是0。
- 值为2时,表示这个整数对象是一个负数。
- 值为3时,嗯,只能是解释器崩溃了。
lv_tag的第三低位(从一开始数)是一个保留标志位。
lv_tag的高61位被视作一个uint61,表示ob_digit这个数组的长度。特别的,当整数对象是0时,虽然lv_tag的高61位是0,但是ob_digit的长度依然是1,只不过ob_digit[0]的值没有意义。
当整数对象不为0时,ob_digit用来表示整数对象的绝对值(因为lv_tag)中已经有符号位了。
我们都知道一个整数可以写成$\sum_{i=0}^{n}a_iK^i$的形式,其中$K$是进制,$a_i$(取值范围是$[0, K-1]$)则是每一位上的数字。所谓不同进制,就是选择不同的$K$来表示一个数字。
ob_digit也是这个原理,ob_digit[i]的值就是$a_i$。只不过这个$K$稍微大了一点,是$2^{30}$。
一个绝对值小于$2^{30}$的整数对象,它的ob_digit只需要一个元素;大于等于$2^{30}$的对象,ob_digit则需要多个元素,在进行运算时,实际上要遍历这个数组。
可以用下面的代码进行验证:
1>>> from sys import getsizeof as sizeof
2>>> sizeof(0), sizeof(1), sizeof(2 ** 30 - 1), sizeof(2 ** 30)
3(28, 28, 28, 32)
分别是16+8+4*1和16+8+4*2。
list对象
列表毫无疑问是一个变长对象。由于不是所有变长对象都需要像整数对象的lv_tag那样高度定制,所以Python设置了一个变长对象头,也就是在公共对象头后面添加了一个ob_size字段(64位整数),用于记录对象的变长部分。除了变长对象头之外,列表对象还有ob_item和allocated两个字段。
列表对象的完整内存布局如下图:
其中ob_item是一个数组指针,指向一个动态数组,这个动态数组的元素都是PyObject指针,可以指向任意的Python对象。用因此Python可“保存”任意元素。
由于列表长度是不确定且随时可以改变的,如果每变动一次都重新分配一次动态数组,势必会造成严重的性能损耗。所以Python采用了阶梯式扩容/缩容策略。
当列表元素增多时,会依次使用2的幂大小的动态数组(从1开始,依次2、4、8、16…),这样既可以避免频繁扩容,也不会浪费太多空间。
当列表元素减少时,同样按照2的幂来缩容。同时为了防止缩容后马上有新元素append导致马上又需要扩容,缩容会有一个延迟策略,只有当元素数量相对于动态数组长度过少时,才会触发缩容机制。
这样ob_size和allocated的作用也就明了了:ob_size记录动态数组中到底有多少个指针是有用的,它的值总是等于len(ls);allocated则记录整个动态数组的长度。
我们可以用以下代码来验证:
1>>> ls = []
2>>> ls.__sizeof__() # 空列表ob_item是空指针,没有动态数组
340
4>>> ls = [1]
5>>> ls.__sizeof__() # 动态数组长度是1,因此增加了一个指针,8个字节
648
7>>> ls = [1, 2]
8>>> ls.__sizeof__()
956
10>>> ls = [1, 2, 3]
11>>> ls.__sizeof__() # 这里动态数组的长度则是4而不是3
1272
提示
如果使用
sys.getsizeof()计量列表对象的大小,会比ls.__sizeof__()的结果大16。因为列表对象由Python GC管理,前面会有16字节的GC头,sys.getsizeof()会在ls.__sizeof__()的基础上加上这个GC头的开销。
自定义对象
对于用户使用class自定义的对象,Python无法事先获知具体细节,也就不能像内置对象那样做针对性地优化,因此只能定一个通用的结构:
1struct _classobject {
2 PyWeakReference *weakref;
3 PyDictObject *dict;
4 PyObject *prev;
5 PyObject *next;
6 long long ob_refcnt;
7 PyTypeObject *ob_type;
8}
注意,这个结构体只是为了说明自定义对象的内存布局,源码中不是这么简单粗暴定义的。
每个字段的含义也非常清晰:
PyWeakReference *weakref:指向弱引用链表头。可以略过。PyDictObject *dict:指向一个字典,用于存放对象的属性。也就是Python层面的obj.__dict__。PyObject *prev和PyObject *next:前面提到的GC头,用于指向其它被GC管理的对象,这样所有的GC对象就组成了一个双链表。- 剩下两个字段和公共对象头兼容。
如果分别用.__sizeof__()和sys.getsizeof()计量,就会发现后者会计量公共对象头之前的4个字段:
1import sys
2
3
4class MyObject:
5
6 def __init__(self, name, value):
7 self.name = name
8 self.value = value
9
10
11obj = MyObject("foo", "bar")
12
13
14print(obj.__sizeof__(), sys.getsizeof(obj))
15
16# 运行结果:16 48
除了这种最常用的情形,自定义对象还可以使用__slots__进行优化。
__slots__可以固定自定义对象的属性,以便省去.__dict__字典的存储占用,同时加快属性查找的速度。具体实现方式和Python基本原理:名字绑定与命名空间中对局部命名的优化一样,将属性依次放在ob_type后面的空间即可。
1import sys
2
3
4class MyObject2:
5
6 __slots__ = ["name", "value", "desc"]
7
8 def __init__(self, name, value, desc):
9 self.name = name
10 self.value = value
11 self.desc = desc
12
13
14obj2 = MyObject2("foo", "bar", "desc")
15
16print(obj2.__sizeof__(), sys.getsizeof(obj2))
17
18# 运行结果;40 56
可以看到,.__sizeof__()统计了5个字段(公共对象头和3个属性),sys.getsizeof()也只比.__sizeof__()多了两个字段。
这意味着经过__slots__优化的对象没有了.__weakref__和.__dict__,也就不能随意增加属性和进行弱引用。
提示
如果在
__slots__中定义__weakref__和__dict__,对象又可以随意添加属性和进行弱引用了。但是从内存布局的角度,此时对象的内存布局和普通的__slots__一致。从实用角度上,这种行为也没什么意义。
**注意:不同CPython版本对自定义对象的内存布局可能会有改动,上述内容仅针对3.13版本。**不过这些改动只是实现细节上的差异,大致原理上(指__weakref__和__dict__)大差不差。
总结
一个Python对象就是若干个内存块,Python用指针组织这些内存块。不管是内置定长对象、内置变长对象还是自定义对象,都有一个“主内存块”,这个内存块的前16个字节总是ob_refcnt和ob_type。Python对对象的常规操作也总是从这两个字节开始,例如id(obj)返回的就是ob_refcnt的地址,前面的GC头等则视为扩展字段,为Python的内在机制服务。
再次强调,本文涉及许多实现上的细节,这部分内容仅针对CPython 3.13。
扩展:使用ctypes检视内存
本文的内容枯燥且不够直观,配合ctypes检视每一个字节的值会对理解有很大帮助。
我们可以先准备下面的辅助函数:
1from ctypes import POINTER, c_void_p, cast, py_object, string_at
2
3
4def view_pyobj_from_double_pointer(ptr_addr: int):
5 c_pointer = POINTER(c_void_p)
6 ptr = cast(ptr_addr, c_pointer).contents.value
7 return cast(ptr, py_object)
8
9
10def view_binary(addr: int, length: int = 8) -> str:
11 return "_".join(f"{b:08b}" for b in string_at(addr, length)[::-1])
12
13
14def view_as_ull(addr: int) -> int:
15 return int.from_bytes(string_at(addr, 8), "little") # little表示小端序
16
17
18def view_as_uint(addr: int) -> int:
19 return int.from_bytes(string_at(addr, 4), "little")
然后可以用来检视lv_tag以及ob_digit[0]的值:
1a = -42
2
3print(view_binary(id(a) + 16))
4print(view_as_uint(id(a) + 24))
5print(view_binary(id(a) + 24, 4))
6
7# 运行结果:
8# 00000000_00000000_00000000_00000000_00000000_00000000_00000000_00001010
9# 42
10# 00000000_00000000_00000000_00101010
lv_tag的低2位取值为2,表示这个整数是一个负数,高61位的取值为1,表示ob_digit的长度为1。
ob_digit[0]的值为42,也就是整数对象的绝对值,二进制视角32 + 8 + 2也符合预期。
也可以用来查看.__dict__:
1class MyObject:
2
3 def __init__(self, name, value):
4 self.name = name
5 self.value = value
6
7
8obj = MyObject("foo", "bar")
9
10
11print(id(obj.__dict__), view_as_ull(id(obj) - 24))
12print(view_pyobj_from_double_pointer(id(obj) - 24))
13
14# 运行结果:
15# 2217896950784 2217896950784
16# py_object({'name': 'foo', 'value': 'bar'})
注意
如果在
view_pyobj_from_double_pointer(id(obj) - 24)之前没有显式的调用过obj.__dict__,view的结果会是py_object(<NULL>)。这是因为Python会对属性字典进行惰性创建,在显式调用
obj.__dict__之前,会使用键值分离字典进行属性存储优化,-24偏移开始的这8个字节会被赋值为0,也就是空指针,因为这个时候并没有一个字典对象让你指向。