cover

Python基本原理:Python对象及其内存布局

本文以Python官方文档CPython实现作为参考,示例代码均在3.13.7测试通过,阅读需要一点C语言基础。

注意:本文以64位系统为基准,32位系统的原理一致,但是字节数上会有差异。

概念

虽然大多数人第一次接触Python是写的面向过程的代码,入门之后也是当作脚本语言来用,但是Python语言本身,是完完全全基于面向对象实现的。你在Python层面接触到的东西,包括基本数据类型(intfloat)、基本常量(NoneTrueFalse)、甚至函数和模块等,统统都是对象。

那么对象究竟是个什么东西呢?

从逻辑层面来讲,对象是对事物的抽象。比如一条黑色的狗,就可以抽象成一个“狗对象”,这个狗对象拥有一些属性(狗对象.颜色 = 黑色)和一些方法(狗对象.汪汪叫)。通常情况下,属性是一个值,表征这个对象一些性质;方式是一系列动作,表征这个对象可以做什么。

而在实现层面,对象对应于内存中的数据。黑色可能就是内存中某几个字节的值,汪汪叫可能就是若干条可以执行的机器指令。

所以换句话说,对象就是对内存中数据的抽象,每一个对象的背后,都对应了若干个内存块。

对象的内存布局,就是指对象的内存块有几个、每一块有多大、内存块的每一个字节都是什么意思。

Python中的对象可以分为两类:内置对象(listdict这些)和自定义对象(通过class定义的对象)。接下来我们从最简单的内置对象float开始探索Python对象的内存布局。

float对象

浮点数对象是Python中最简单的对象,只有24字节大小:

1>>> import sys
2>>> sys.getsizeof(1.0)
324

这24个字节被分成3个字段,依次是:

  1. 引用计数,占8个字节,一个整数,记录这个对象有多少个引用(用于垃圾回收)。
  2. 类型指针,占8个字节,指向其类型对象。关于类型对象可以参考这里[Python基本原理:类型对象与元类]。
  3. 实际数值,占8个字节,实际类型为IEEE 754的double。

所以,嗯,它真的很简单,完全就是C语言的double前面套了一个Python对象头。讲它的目的也主要是为了引出Python的对象头。

注意,引用计数和类型指针加起来这个16字节的对象头,是所有Python对象都有的。

在CPython源代码中这两个字段分别叫ob_refcntob_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_itemallocated两个字段。

列表对象的完整内存布局如下图:

列表对象内存布局

其中ob_item是一个数组指针,指向一个动态数组,这个动态数组的元素都是PyObject指针,可以指向任意的Python对象。用因此Python可“保存”任意元素。

由于列表长度是不确定且随时可以改变的,如果每变动一次都重新分配一次动态数组,势必会造成严重的性能损耗。所以Python采用了阶梯式扩容/缩容策略。

当列表元素增多时,会依次使用2的幂大小的动态数组(从1开始,依次2、4、8、16…),这样既可以避免频繁扩容,也不会浪费太多空间。

当列表元素减少时,同样按照2的幂来缩容。同时为了防止缩容后马上有新元素append导致马上又需要扩容,缩容会有一个延迟策略,只有当元素数量相对于动态数组长度过少时,才会触发缩容机制。

这样ob_sizeallocated的作用也就明了了: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 *prevPyObject *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_refcntob_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,也就是空指针,因为这个时候并没有一个字典对象让你指向。